Coverage for haystack/components/retrievers/auto_merging_retriever.py: 100%
86 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2#
3# SPDX-License-Identifier: Apache-2.0
5from collections import defaultdict
6from typing import Any
8from haystack import Document, component, default_from_dict, default_to_dict
9from haystack.document_stores.types import DocumentStore
12@component
13class AutoMergingRetriever:
14 """
15 A retriever which returns parent documents of the matched leaf nodes documents, based on a threshold setting.
17 The AutoMergingRetriever assumes you have a hierarchical tree structure of documents, where the leaf nodes
18 are indexed in a document store. See the HierarchicalDocumentSplitter for more information on how to create
19 such a structure. During retrieval, if the number of matched leaf documents below the same parent is
20 higher than a defined threshold, the retriever will return the parent document instead of the individual leaf
21 documents.
23 The rational is, given that a paragraph is split into multiple chunks represented as leaf documents, and if for
24 a given query, multiple chunks are matched, the whole paragraph might be more informative than the individual
25 chunks alone.
27 Currently the AutoMergingRetriever can only be used by the following DocumentStores:
28 - [AstraDB](https://haystack.deepset.ai/integrations/astradb)
29 - [ElasticSearch](https://haystack.deepset.ai/docs/latest/documentstore/elasticsearch)
30 - [OpenSearch](https://haystack.deepset.ai/docs/latest/documentstore/opensearch)
31 - [PGVector](https://haystack.deepset.ai/docs/latest/documentstore/pgvector)
32 - [Qdrant](https://haystack.deepset.ai/docs/latest/documentstore/qdrant)
34 ```python
35 from haystack import Document
36 from haystack.components.preprocessors import HierarchicalDocumentSplitter
37 from haystack.components.retrievers.auto_merging_retriever import AutoMergingRetriever
38 from haystack.document_stores.in_memory import InMemoryDocumentStore
40 # create a hierarchical document structure with 3 levels, where the parent document has 3 children
41 text = "The sun rose early in the morning. It cast a warm glow over the trees. Birds began to sing."
42 original_document = Document(content=text)
43 builder = HierarchicalDocumentSplitter(block_sizes={10, 3}, split_overlap=0, split_by="word")
44 docs = builder.run([original_document])["documents"]
46 # store level-1 parent documents and initialize the retriever
47 doc_store_parents = InMemoryDocumentStore()
48 for doc in docs:
49 if doc.meta["__children_ids"] and doc.meta["__level"] in [0,1]: # store the root document and level 1 documents
50 doc_store_parents.write_documents([doc])
52 retriever = AutoMergingRetriever(doc_store_parents, threshold=0.5)
54 # assume we retrieved 2 leaf docs from the same parent, the parent document should be returned,
55 # since it has 3 children and the threshold=0.5, and we retrieved 2 children (2/3 > 0.66(6))
56 leaf_docs = [doc for doc in docs if not doc.meta["__children_ids"]]
57 retrieved_docs = retriever.run(leaf_docs[4:6])
58 print(retrieved_docs["documents"])
59 # [Document(id=538..),
60 # content: 'warm glow over the trees. Birds began to sing.',
61 # meta: {'block_size': 10, 'parent_id': '835..', 'children_ids': ['c17...', '3ff...', '352...'], 'level': 1, 'source_id': '835...',
62 # 'page_number': 1, 'split_id': 1, 'split_idx_start': 45})]}
63 ```
64 """ # noqa: E501
66 def __init__(self, document_store: DocumentStore, threshold: float = 0.5) -> None:
67 """
68 Initialize the AutoMergingRetriever.
70 :param document_store: DocumentStore from which to retrieve the parent documents
71 :param threshold: Threshold to decide whether the parent instead of the individual documents is returned
72 """
74 if not 0 < threshold < 1:
75 raise ValueError("The threshold parameter must be between 0 and 1.")
77 self.document_store = document_store
78 self.threshold = threshold
80 def to_dict(self) -> dict[str, Any]:
81 """
82 Serializes the component to a dictionary.
84 :returns:
85 Dictionary with serialized data.
86 """
87 return default_to_dict(self, document_store=self.document_store, threshold=self.threshold)
89 @classmethod
90 def from_dict(cls, data: dict[str, Any]) -> "AutoMergingRetriever":
91 """
92 Deserializes the component from a dictionary.
94 :param data:
95 Dictionary with serialized data.
96 :returns:
97 An instance of the component.
98 """
99 return default_from_dict(cls, data)
101 @staticmethod
102 def _check_valid_documents(matched_leaf_documents: list[Document]) -> None:
103 # check if the matched leaf documents have the required meta fields
104 if not all(doc.meta.get("__parent_id") for doc in matched_leaf_documents):
105 raise ValueError("The matched leaf documents do not have the required meta field '__parent_id'")
107 if not all(doc.meta.get("__level") for doc in matched_leaf_documents):
108 raise ValueError("The matched leaf documents do not have the required meta field '__level'")
110 if not all(doc.meta.get("__block_size") for doc in matched_leaf_documents):
111 raise ValueError("The matched leaf documents do not have the required meta field '__block_size'")
113 @component.output_types(documents=list[Document])
114 def run(self, documents: list[Document]) -> dict[str, list[Document]]:
115 """
116 Run the AutoMergingRetriever.
118 Recursively groups documents by their parents and merges them if they meet the threshold,
119 continuing up the hierarchy until no more merges are possible.
121 :param documents: List of leaf documents that were matched by a retriever
122 :returns:
123 List of documents (could be a mix of different hierarchy levels)
124 """
126 AutoMergingRetriever._check_valid_documents(documents)
128 def _get_parent_doc(parent_id: str) -> Document:
129 parent_docs = self.document_store.filter_documents({"field": "id", "operator": "==", "value": parent_id})
130 if len(parent_docs) != 1:
131 raise ValueError(f"Expected 1 parent document with id {parent_id}, found {len(parent_docs)}")
133 parent_doc = parent_docs[0]
134 if not parent_doc.meta.get("__children_ids"):
135 raise ValueError(f"Parent document with id {parent_id} does not have any children.")
137 return parent_doc
139 def _try_merge_level(docs_to_merge: list[Document], docs_to_return: list[Document]) -> list[Document]:
140 parent_doc_id_to_child_docs: dict[str, list[Document]] = defaultdict(list) # to group documents by parent
142 for doc in docs_to_merge:
143 if doc.meta.get("__parent_id"): # only docs that have parents
144 parent_doc_id_to_child_docs[doc.meta["__parent_id"]].append(doc)
145 else:
146 docs_to_return.append(doc) # keep docs that have no parents
148 # Process each parent group
149 merged_docs = []
150 for parent_doc_id, child_docs in parent_doc_id_to_child_docs.items():
151 parent_doc = _get_parent_doc(parent_doc_id)
153 # Calculate merge score
154 score = len(child_docs) / len(parent_doc.meta["__children_ids"])
155 if score > self.threshold:
156 merged_docs.append(parent_doc) # Merge into parent
157 else:
158 docs_to_return.extend(child_docs) # Keep children separate
160 # if no new merges were made, we're done
161 if not merged_docs:
162 return merged_docs + docs_to_return
164 # Recursively try to merge the next level
165 return _try_merge_level(merged_docs, docs_to_return)
167 return {"documents": _try_merge_level(documents, [])}
169 @component.output_types(documents=list[Document])
170 async def run_async(self, documents: list[Document]) -> dict[str, list[Document]]:
171 """
172 Asynchronously run the AutoMergingRetriever.
174 Recursively groups documents by their parents and merges them if they meet the threshold,
175 continuing up the hierarchy until no more merges are possible.
177 :param documents: List of leaf documents that were matched by a retriever
178 :returns:
179 List of documents (could be a mix of different hierarchy levels)
180 """
182 AutoMergingRetriever._check_valid_documents(documents)
184 async def _get_parent_doc(parent_id: str) -> Document:
185 # 'ignore' since filter_documents_async is not defined in the Protocol but exists in the implementations
186 parent_docs = await self.document_store.filter_documents_async( # type: ignore[attr-defined]
187 {"field": "id", "operator": "==", "value": parent_id}
188 )
189 if len(parent_docs) != 1:
190 raise ValueError(f"Expected 1 parent document with id {parent_id}, found {len(parent_docs)}")
192 parent_doc = parent_docs[0]
193 if not parent_doc.meta.get("__children_ids"):
194 raise ValueError(f"Parent document with id {parent_id} does not have any children.")
196 return parent_doc
198 async def _try_merge_level(docs_to_merge: list[Document], docs_to_return: list[Document]) -> list[Document]:
199 parent_doc_id_to_child_docs: dict[str, list[Document]] = defaultdict(list) # to group documents by parent
201 for doc in docs_to_merge:
202 if doc.meta.get("__parent_id"): # only docs that have parents
203 parent_doc_id_to_child_docs[doc.meta["__parent_id"]].append(doc)
204 else:
205 docs_to_return.append(doc) # keep docs that have no parents
207 # Process each parent group
208 merged_docs = []
209 for parent_doc_id, child_docs in parent_doc_id_to_child_docs.items():
210 parent_doc = await _get_parent_doc(parent_doc_id)
212 # Calculate merge score
213 score = len(child_docs) / len(parent_doc.meta["__children_ids"])
214 if score > self.threshold:
215 merged_docs.append(parent_doc) # Merge into parent
216 else:
217 docs_to_return.extend(child_docs) # Keep children separate
219 # if no new merges were made, we're done
220 if not merged_docs:
221 return merged_docs + docs_to_return
223 # Recursively try to merge the next level
224 return await _try_merge_level(merged_docs, docs_to_return)
226 return {"documents": await _try_merge_level(documents, [])}
228 def close(self) -> None:
229 """
230 Release the synchronous resources of the underlying Document Store.
231 """
232 if hasattr(self.document_store, "close"):
233 self.document_store.close()
235 async def close_async(self) -> None:
236 """
237 Release the asynchronous resources of the underlying Document Store.
238 """
239 if hasattr(self.document_store, "close_async"):
240 await self.document_store.close_async()