Files
wehub-resource-sync c56bef871b
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
Docker image release / Build base image (push) Has been cancelled
Sync docs with Docusaurus / sync (push) Has been cancelled
Tests / Check if changed (push) Has been cancelled
Tests / format (push) Has been cancelled
Tests / check-imports (push) Has been cancelled
Tests / Unit / macos-latest (push) Has been cancelled
Tests / Unit / ubuntu-latest (push) Has been cancelled
Tests / Unit / windows-latest (push) Has been cancelled
Tests / mypy (push) Has been cancelled
Tests / Integration / ubuntu-latest (push) Has been cancelled
Tests / Integration / macos-latest (push) Has been cancelled
Tests / Integration / windows-latest (push) Has been cancelled
Tests / notify-slack-on-failure (push) Has been cancelled
Tests / Mark tests as completed (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:28 +08:00

227 lines
10 KiB
Python

# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from collections import defaultdict
from typing import Any
from haystack import Document, component, default_from_dict, default_to_dict
from haystack.document_stores.types import DocumentStore
@component
class AutoMergingRetriever:
"""
A retriever which returns parent documents of the matched leaf nodes documents, based on a threshold setting.
The AutoMergingRetriever assumes you have a hierarchical tree structure of documents, where the leaf nodes
are indexed in a document store. See the HierarchicalDocumentSplitter for more information on how to create
such a structure. During retrieval, if the number of matched leaf documents below the same parent is
higher than a defined threshold, the retriever will return the parent document instead of the individual leaf
documents.
The rational is, given that a paragraph is split into multiple chunks represented as leaf documents, and if for
a given query, multiple chunks are matched, the whole paragraph might be more informative than the individual
chunks alone.
Currently the AutoMergingRetriever can only be used by the following DocumentStores:
- [AstraDB](https://haystack.deepset.ai/integrations/astradb)
- [ElasticSearch](https://haystack.deepset.ai/docs/latest/documentstore/elasticsearch)
- [OpenSearch](https://haystack.deepset.ai/docs/latest/documentstore/opensearch)
- [PGVector](https://haystack.deepset.ai/docs/latest/documentstore/pgvector)
- [Qdrant](https://haystack.deepset.ai/docs/latest/documentstore/qdrant)
```python
from haystack import Document
from haystack.components.preprocessors import HierarchicalDocumentSplitter
from haystack.components.retrievers.auto_merging_retriever import AutoMergingRetriever
from haystack.document_stores.in_memory import InMemoryDocumentStore
# create a hierarchical document structure with 3 levels, where the parent document has 3 children
text = "The sun rose early in the morning. It cast a warm glow over the trees. Birds began to sing."
original_document = Document(content=text)
builder = HierarchicalDocumentSplitter(block_sizes={10, 3}, split_overlap=0, split_by="word")
docs = builder.run([original_document])["documents"]
# store level-1 parent documents and initialize the retriever
doc_store_parents = InMemoryDocumentStore()
for doc in docs:
if doc.meta["__children_ids"] and doc.meta["__level"] in [0,1]: # store the root document and level 1 documents
doc_store_parents.write_documents([doc])
retriever = AutoMergingRetriever(doc_store_parents, threshold=0.5)
# assume we retrieved 2 leaf docs from the same parent, the parent document should be returned,
# since it has 3 children and the threshold=0.5, and we retrieved 2 children (2/3 > 0.66(6))
leaf_docs = [doc for doc in docs if not doc.meta["__children_ids"]]
retrieved_docs = retriever.run(leaf_docs[4:6])
print(retrieved_docs["documents"])
# [Document(id=538..),
# content: 'warm glow over the trees. Birds began to sing.',
# meta: {'block_size': 10, 'parent_id': '835..', 'children_ids': ['c17...', '3ff...', '352...'], 'level': 1, 'source_id': '835...',
# 'page_number': 1, 'split_id': 1, 'split_idx_start': 45})]}
```
""" # noqa: E501
def __init__(self, document_store: DocumentStore, threshold: float = 0.5) -> None:
"""
Initialize the AutoMergingRetriever.
:param document_store: DocumentStore from which to retrieve the parent documents
:param threshold: Threshold to decide whether the parent instead of the individual documents is returned
"""
if not 0 < threshold < 1:
raise ValueError("The threshold parameter must be between 0 and 1.")
self.document_store = document_store
self.threshold = threshold
def to_dict(self) -> dict[str, Any]:
"""
Serializes the component to a dictionary.
:returns:
Dictionary with serialized data.
"""
return default_to_dict(self, document_store=self.document_store, threshold=self.threshold)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "AutoMergingRetriever":
"""
Deserializes the component from a dictionary.
:param data:
Dictionary with serialized data.
:returns:
An instance of the component.
"""
return default_from_dict(cls, data)
@staticmethod
def _check_valid_documents(matched_leaf_documents: list[Document]) -> None:
# check if the matched leaf documents have the required meta fields
if not all(doc.meta.get("__parent_id") for doc in matched_leaf_documents):
raise ValueError("The matched leaf documents do not have the required meta field '__parent_id'")
if not all(doc.meta.get("__level") for doc in matched_leaf_documents):
raise ValueError("The matched leaf documents do not have the required meta field '__level'")
if not all(doc.meta.get("__block_size") for doc in matched_leaf_documents):
raise ValueError("The matched leaf documents do not have the required meta field '__block_size'")
@component.output_types(documents=list[Document])
def run(self, documents: list[Document]) -> dict[str, list[Document]]:
"""
Run the AutoMergingRetriever.
Recursively groups documents by their parents and merges them if they meet the threshold,
continuing up the hierarchy until no more merges are possible.
:param documents: List of leaf documents that were matched by a retriever
:returns:
List of documents (could be a mix of different hierarchy levels)
"""
AutoMergingRetriever._check_valid_documents(documents)
def _get_parent_doc(parent_id: str) -> Document:
parent_docs = self.document_store.filter_documents({"field": "id", "operator": "==", "value": parent_id})
if len(parent_docs) != 1:
raise ValueError(f"Expected 1 parent document with id {parent_id}, found {len(parent_docs)}")
parent_doc = parent_docs[0]
if not parent_doc.meta.get("__children_ids"):
raise ValueError(f"Parent document with id {parent_id} does not have any children.")
return parent_doc
def _try_merge_level(docs_to_merge: list[Document], docs_to_return: list[Document]) -> list[Document]:
parent_doc_id_to_child_docs: dict[str, list[Document]] = defaultdict(list) # to group documents by parent
for doc in docs_to_merge:
if doc.meta.get("__parent_id"): # only docs that have parents
parent_doc_id_to_child_docs[doc.meta["__parent_id"]].append(doc)
else:
docs_to_return.append(doc) # keep docs that have no parents
# Process each parent group
merged_docs = []
for parent_doc_id, child_docs in parent_doc_id_to_child_docs.items():
parent_doc = _get_parent_doc(parent_doc_id)
# Calculate merge score
score = len(child_docs) / len(parent_doc.meta["__children_ids"])
if score > self.threshold:
merged_docs.append(parent_doc) # Merge into parent
else:
docs_to_return.extend(child_docs) # Keep children separate
# if no new merges were made, we're done
if not merged_docs:
return merged_docs + docs_to_return
# Recursively try to merge the next level
return _try_merge_level(merged_docs, docs_to_return)
return {"documents": _try_merge_level(documents, [])}
@component.output_types(documents=list[Document])
async def run_async(self, documents: list[Document]) -> dict[str, list[Document]]:
"""
Asynchronously run the AutoMergingRetriever.
Recursively groups documents by their parents and merges them if they meet the threshold,
continuing up the hierarchy until no more merges are possible.
:param documents: List of leaf documents that were matched by a retriever
:returns:
List of documents (could be a mix of different hierarchy levels)
"""
AutoMergingRetriever._check_valid_documents(documents)
async def _get_parent_doc(parent_id: str) -> Document:
# 'ignore' since filter_documents_async is not defined in the Protocol but exists in the implementations
parent_docs = await self.document_store.filter_documents_async( # type: ignore[attr-defined]
{"field": "id", "operator": "==", "value": parent_id}
)
if len(parent_docs) != 1:
raise ValueError(f"Expected 1 parent document with id {parent_id}, found {len(parent_docs)}")
parent_doc = parent_docs[0]
if not parent_doc.meta.get("__children_ids"):
raise ValueError(f"Parent document with id {parent_id} does not have any children.")
return parent_doc
async def _try_merge_level(docs_to_merge: list[Document], docs_to_return: list[Document]) -> list[Document]:
parent_doc_id_to_child_docs: dict[str, list[Document]] = defaultdict(list) # to group documents by parent
for doc in docs_to_merge:
if doc.meta.get("__parent_id"): # only docs that have parents
parent_doc_id_to_child_docs[doc.meta["__parent_id"]].append(doc)
else:
docs_to_return.append(doc) # keep docs that have no parents
# Process each parent group
merged_docs = []
for parent_doc_id, child_docs in parent_doc_id_to_child_docs.items():
parent_doc = await _get_parent_doc(parent_doc_id)
# Calculate merge score
score = len(child_docs) / len(parent_doc.meta["__children_ids"])
if score > self.threshold:
merged_docs.append(parent_doc) # Merge into parent
else:
docs_to_return.extend(child_docs) # Keep children separate
# if no new merges were made, we're done
if not merged_docs:
return merged_docs + docs_to_return
# Recursively try to merge the next level
return await _try_merge_level(merged_docs, docs_to_return)
return {"documents": await _try_merge_level(documents, [])}