Skip to content

Commit 47c3a2b

Browse files
cmnemoisjrl
andauthored
feat: SentenceWindowRetriever now supports run_async (#9895)
* feat: `SentenceWindowRetriever` now supports `run_async` * refactor: Reduce duplicated code * refactor: Remove unused import * docs: Add release notes * style: ignore type error from missing `run_async` in `DocumentStore` protocol * docs: Precise the ignored mypy error and add a comment explaining why * style: update mypy ignore type for filter_documents_async in SentenceWindowRetriever --------- Co-authored-by: Sebastian Husch Lee <10526848+sjrl@users.noreply.github.com>
1 parent fe57b6d commit 47c3a2b

3 files changed

Lines changed: 342 additions & 35 deletions

File tree

haystack/components/retrievers/sentence_window_retriever.py

Lines changed: 104 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -204,10 +204,56 @@ def run(self, retrieved_documents: list[Document], window_size: Optional[int] =
204204
205205
"""
206206
window_size = window_size or self.window_size
207+
self._raise_if_windows_size_is_negative(window_size)
208+
self._raise_if_documents_do_not_have_expected_metadata(retrieved_documents)
207209

210+
context_text = []
211+
context_documents = []
212+
for doc in retrieved_documents:
213+
text, docs = self._retrieve_context_for_document(doc, window_size)
214+
context_text.append(text)
215+
context_documents.extend(docs)
216+
217+
return {"context_windows": context_text, "context_documents": context_documents}
218+
219+
@component.output_types(context_windows=list[str], context_documents=list[Document])
220+
async def run_async(self, retrieved_documents: list[Document], window_size: Optional[int] = None):
221+
"""
222+
Based on the `source_id` and on the `doc.meta['split_id']` get surrounding documents from the document store.
223+
224+
Implements the logic behind the sentence-window technique, retrieving the surrounding documents of a given
225+
document from the document store.
226+
227+
:param retrieved_documents: List of retrieved documents from the previous retriever.
228+
:param window_size: The number of documents to retrieve before and after the relevant one. This will overwrite
229+
the `window_size` parameter set in the constructor.
230+
:returns:
231+
A dictionary with the following keys:
232+
- `context_windows`: A list of strings, where each string represents the concatenated text from the
233+
context window of the corresponding document in `retrieved_documents`.
234+
- `context_documents`: A list `Document` objects, containing the retrieved documents plus the context
235+
document surrounding them. The documents are sorted by the `split_idx_start`
236+
meta field.
237+
238+
"""
239+
window_size = window_size or self.window_size
240+
self._raise_if_windows_size_is_negative(window_size)
241+
self._raise_if_documents_do_not_have_expected_metadata(retrieved_documents)
242+
243+
context_text = []
244+
context_documents = []
245+
for doc in retrieved_documents:
246+
text, docs = await self._retrieve_context_for_document_async(doc, window_size)
247+
context_text.append(text)
248+
context_documents.extend(docs)
249+
250+
return {"context_windows": context_text, "context_documents": context_documents}
251+
252+
def _raise_if_windows_size_is_negative(self, window_size: int) -> None:
208253
if window_size < 1:
209254
raise ValueError("The window_size parameter must be greater than 0.")
210255

256+
def _raise_if_documents_do_not_have_expected_metadata(self, retrieved_documents: list[Document]) -> None:
211257
if (
212258
not all(self.split_id_meta_field in doc.meta for doc in retrieved_documents)
213259
and self.raise_on_missing_meta_fields
@@ -220,38 +266,61 @@ def run(self, retrieved_documents: list[Document], window_size: Optional[int] =
220266
):
221267
raise ValueError(f"The retrieved documents must have '{self.source_id_meta_field}' in their metadata.")
222268

223-
context_text = []
224-
context_documents = []
225-
for doc in retrieved_documents:
226-
source_ids = [doc.meta.get(field) for field in self._source_id_meta_fields]
227-
split_id = doc.meta.get(self.split_id_meta_field)
228-
229-
if any(source_id is None for source_id in source_ids) or split_id is None:
230-
logger.warning(
231-
"Document {doc_id} is missing required metadata fields to be used with "
232-
"SentenceWindowRetriever: {source_id} or {split_id}. Skipping context retrieval for this document.",
233-
doc_id=doc.id,
234-
source_id=self._source_id_meta_fields,
235-
split_id=self.split_id_meta_field,
236-
)
237-
context_text.append(doc.content or "")
238-
context_documents.append(doc)
239-
continue
240-
241-
min_before = split_id - window_size
242-
max_after = split_id + window_size
243-
source_id_filters = [
244-
{"field": f"meta.{source_id_meta_field}", "operator": "==", "value": source_id}
245-
for source_id_meta_field, source_id in zip(self._source_id_meta_fields, source_ids)
246-
]
247-
conditions = [
248-
{"field": f"meta.{self.split_id_meta_field}", "operator": ">=", "value": min_before},
249-
{"field": f"meta.{self.split_id_meta_field}", "operator": "<=", "value": max_after},
250-
*source_id_filters,
251-
]
252-
context_docs = self.document_store.filter_documents({"operator": "AND", "conditions": conditions})
253-
context_text.append(self.merge_documents_text(context_docs))
254-
context_docs_sorted = sorted(context_docs, key=lambda doc: doc.meta[self.split_id_meta_field])
255-
context_documents.extend(context_docs_sorted)
256-
257-
return {"context_windows": context_text, "context_documents": context_documents}
269+
def _retrieve_context_for_document(self, doc: Document, window_size: int) -> tuple[str, list[Document]]:
270+
source_ids = [doc.meta.get(field) for field in self._source_id_meta_fields]
271+
split_id = doc.meta.get(self.split_id_meta_field)
272+
273+
if any(source_id is None for source_id in source_ids) or split_id is None:
274+
logger.warning(
275+
"Document {doc_id} is missing required metadata fields to be used with "
276+
"SentenceWindowRetriever: {source_id} or {split_id}. Skipping context retrieval for this document.",
277+
doc_id=doc.id,
278+
source_id=self._source_id_meta_fields,
279+
split_id=self.split_id_meta_field,
280+
)
281+
return (doc.content or "", [doc])
282+
283+
assert split_id is not None
284+
filter_conditions = self._build_filter_conditions(split_id, window_size, source_ids)
285+
context_docs = self.document_store.filter_documents(filter_conditions)
286+
context_text = self.merge_documents_text(context_docs)
287+
context_docs_sorted = sorted(context_docs, key=lambda doc: doc.meta[self.split_id_meta_field])
288+
289+
return context_text, context_docs_sorted
290+
291+
async def _retrieve_context_for_document_async(self, doc: Document, window_size: int) -> tuple[str, list[Document]]:
292+
source_ids = [doc.meta.get(field) for field in self._source_id_meta_fields]
293+
split_id = doc.meta.get(self.split_id_meta_field)
294+
295+
if any(source_id is None for source_id in source_ids) or split_id is None:
296+
logger.warning(
297+
"Document {doc_id} is missing required metadata fields to be used with "
298+
"SentenceWindowRetriever: {source_id} or {split_id}. Skipping context retrieval for this document.",
299+
doc_id=doc.id,
300+
source_id=self._source_id_meta_fields,
301+
split_id=self.split_id_meta_field,
302+
)
303+
return (doc.content or "", [doc])
304+
305+
assert split_id is not None
306+
filter_conditions = self._build_filter_conditions(split_id, window_size, source_ids)
307+
# Ignoring type error because DocumentStore protocol doesn't define filter_documents_async
308+
context_docs = await self.document_store.filter_documents_async(filter_conditions) # type: ignore[attr-defined]
309+
context_text = self.merge_documents_text(context_docs)
310+
context_docs_sorted = sorted(context_docs, key=lambda doc: doc.meta[self.split_id_meta_field])
311+
312+
return context_text, context_docs_sorted
313+
314+
def _build_filter_conditions(self, split_id: int, window_size: int, source_ids: list[Any]) -> dict[str, Any]:
315+
min_before = split_id - window_size
316+
max_after = split_id + window_size
317+
source_id_filters = [
318+
{"field": f"meta.{source_id_meta_field}", "operator": "==", "value": source_id}
319+
for source_id_meta_field, source_id in zip(self._source_id_meta_fields, source_ids)
320+
]
321+
conditions = [
322+
{"field": f"meta.{self.split_id_meta_field}", "operator": ">=", "value": min_before},
323+
{"field": f"meta.{self.split_id_meta_field}", "operator": "<=", "value": max_after},
324+
*source_id_filters,
325+
]
326+
return {"operator": "AND", "conditions": conditions}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
features:
3+
- |
4+
Added async support to `SentenceWindowRetriever` with a new `run_async()` method, allowing the retriever to be used in
5+
async pipelines and workflows.
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
import random
6+
import re
7+
8+
import pytest
9+
10+
from haystack import Document, Pipeline
11+
from haystack.components.preprocessors import DocumentSplitter
12+
from haystack.components.retrievers import InMemoryBM25Retriever
13+
from haystack.components.retrievers.sentence_window_retriever import SentenceWindowRetriever
14+
from haystack.core.pipeline.async_pipeline import AsyncPipeline
15+
from haystack.document_stores.in_memory import InMemoryDocumentStore
16+
17+
18+
class TestSentenceWindowRetrieverAsync:
19+
async def test_document_without_split_id(self):
20+
docs = [
21+
Document(content="This is a text with some words. There is a ", meta={"id": "doc_0"}),
22+
Document(content="some words. There is a second sentence. And there is ", meta={"id": "doc_1"}),
23+
]
24+
with pytest.raises(ValueError, match="The retrieved documents must have 'split_id_test' in their metadata."):
25+
retriever = SentenceWindowRetriever(
26+
document_store=InMemoryDocumentStore(), window_size=3, split_id_meta_field="split_id_test"
27+
)
28+
await retriever.run_async(retrieved_documents=docs)
29+
30+
@pytest.mark.asyncio
31+
async def test_document_without_source_id(self):
32+
docs = [
33+
Document(content="This is a text with some words. There is a ", meta={"id": "doc_0", "split_id": 0}),
34+
Document(
35+
content="some words. There is a second sentence. And there is ",
36+
meta={"id": "doc_1", "split_id": 1, "source_id_test": "source1"},
37+
),
38+
]
39+
with pytest.raises(ValueError, match="The retrieved documents must have 'source_id_test' in their metadata."):
40+
retriever = SentenceWindowRetriever(
41+
document_store=InMemoryDocumentStore(), window_size=3, source_id_meta_field="source_id_test"
42+
)
43+
await retriever.run_async(retrieved_documents=docs)
44+
45+
@pytest.mark.asyncio
46+
async def test_document_without_all_source_ids(self):
47+
docs = [
48+
Document(
49+
content="These are words from the first section",
50+
meta={"id": "doc_1", "split_id": 0, "section_id": "section1"},
51+
),
52+
Document(
53+
content="These are words from the second section, but missing section_id",
54+
meta={"id": "doc_0", "split_id": 0},
55+
),
56+
]
57+
with pytest.raises(
58+
ValueError, match=re.escape("The retrieved documents must have '['id', 'section_id']' in their metadata.")
59+
):
60+
retriever = SentenceWindowRetriever(
61+
document_store=InMemoryDocumentStore(), window_size=3, source_id_meta_field=["id", "section_id"]
62+
)
63+
await retriever.run_async(retrieved_documents=docs)
64+
65+
@pytest.mark.asyncio
66+
async def test_run_async_invalid_window_size(self):
67+
docs = [Document(content="This is a text with some words. There is a ", meta={"id": "doc_0", "split_id": 0})]
68+
with pytest.raises(ValueError):
69+
retriever = SentenceWindowRetriever(document_store=InMemoryDocumentStore(), window_size=0)
70+
await retriever.run_async(retrieved_documents=docs)
71+
72+
@pytest.mark.asyncio
73+
async def test_constructor_parameter_does_not_change(self):
74+
retriever = SentenceWindowRetriever(InMemoryDocumentStore(), window_size=5)
75+
assert retriever.window_size == 5
76+
77+
doc = {
78+
"id": "doc_0",
79+
"content": "This is a text with some words. There is a ",
80+
"source_id": "c5d7c632affc486d0cfe7b3c0f4dc1d3896ea720da2b538d6d10b104a3df5f99",
81+
"page_number": 1,
82+
"split_id": 0,
83+
"split_idx_start": 0,
84+
"_split_overlap": [{"doc_id": "doc_1", "range": (0, 23)}],
85+
}
86+
87+
await retriever.run_async(retrieved_documents=[Document.from_dict(doc)], window_size=1)
88+
assert retriever.window_size == 5
89+
90+
@pytest.mark.asyncio
91+
async def test_context_documents_returned_are_ordered_by_split_idx_start(self):
92+
docs = []
93+
accumulated_length = 0
94+
for sent in range(10):
95+
content = f"Sentence {sent}."
96+
docs.append(
97+
Document(
98+
content=content,
99+
meta={
100+
"id": f"doc_{sent}",
101+
"split_idx_start": accumulated_length,
102+
"source_id": "source1",
103+
"split_id": sent,
104+
},
105+
)
106+
)
107+
accumulated_length += len(content)
108+
109+
random.shuffle(docs)
110+
111+
doc_store = InMemoryDocumentStore()
112+
doc_store.write_documents(docs)
113+
retriever = SentenceWindowRetriever(document_store=doc_store, window_size=3)
114+
115+
# run the retriever with a document whose content = "Sentence 4."
116+
result = await retriever.run_async(retrieved_documents=[doc for doc in docs if doc.content == "Sentence 4."])
117+
118+
# assert that the context documents are in the correct order
119+
assert len(result["context_documents"]) == 7
120+
assert [doc.meta["split_idx_start"] for doc in result["context_documents"]] == [11, 22, 33, 44, 55, 66, 77]
121+
122+
@pytest.mark.asyncio
123+
async def test_run_async_custom_fields(self):
124+
docs = []
125+
accumulated_length = 0
126+
for sent in range(10):
127+
content = f"Sentence {sent}."
128+
docs.append(
129+
Document(
130+
content=content,
131+
meta={
132+
"id": f"doc_{sent}",
133+
# Missing split_idx_start
134+
"source_id_test": "source1",
135+
"split_id_test": sent,
136+
},
137+
)
138+
)
139+
accumulated_length += len(content)
140+
141+
random.shuffle(docs)
142+
143+
doc_store = InMemoryDocumentStore()
144+
doc_store.write_documents(docs)
145+
retriever = SentenceWindowRetriever(
146+
document_store=doc_store,
147+
window_size=3,
148+
source_id_meta_field="source_id_test",
149+
split_id_meta_field="split_id_test",
150+
)
151+
152+
# run the retriever with a document whose content = "Sentence 4."
153+
result = await retriever.run_async(retrieved_documents=[doc for doc in docs if doc.content == "Sentence 4."])
154+
assert len(result["context_documents"]) == 7
155+
156+
@pytest.mark.asyncio
157+
async def test_run_async_with_multiple_source_ids(self):
158+
docs = [
159+
Document(content="This is the first chunk.", meta={"section": "1", "split_id": 0, "source_id": "source1"}),
160+
Document(content="This is the second chunk.", meta={"section": "1", "split_id": 1, "source_id": "source1"}),
161+
Document(content="This is the third chunk.", meta={"section": "1", "split_id": 2, "source_id": "source1"}),
162+
Document(
163+
content="This is a chunk from section 2.", meta={"section": "2", "split_id": 3, "source_id": "source1"}
164+
),
165+
]
166+
doc_store = InMemoryDocumentStore()
167+
doc_store.write_documents(docs)
168+
169+
retriever = SentenceWindowRetriever(
170+
document_store=doc_store, window_size=5, source_id_meta_field=["section", "source_id"]
171+
)
172+
result = await retriever.run_async(
173+
retrieved_documents=[
174+
Document(
175+
content="This is the second chunk.", meta={"section": "1", "split_id": 1, "source_id": "source1"}
176+
)
177+
]
178+
)
179+
180+
assert len(result["context_windows"]) == 1
181+
assert len(result["context_documents"]) == 3
182+
assert all(doc.meta["section"] == "1" for doc in result["context_documents"])
183+
184+
@pytest.mark.asyncio
185+
@pytest.mark.integration
186+
async def test_run_async_with_pipeline(self):
187+
splitter = DocumentSplitter(split_length=1, split_overlap=0, split_by="period")
188+
text = (
189+
"This is a text with some words. There is a second sentence. And there is also a third sentence. "
190+
"It also contains a fourth sentence. And a fifth sentence. And a sixth sentence. And a seventh sentence"
191+
)
192+
doc = Document(content=text)
193+
docs = splitter.run([doc])
194+
doc_store = InMemoryDocumentStore()
195+
doc_store.write_documents(docs["documents"])
196+
197+
pipe = AsyncPipeline()
198+
pipe.add_component("bm25_retriever", InMemoryBM25Retriever(doc_store, top_k=1))
199+
pipe.add_component(
200+
"sentence_window_retriever", SentenceWindowRetriever(document_store=doc_store, window_size=2)
201+
)
202+
pipe.connect("bm25_retriever", "sentence_window_retriever")
203+
result = await pipe.run_async({"bm25_retriever": {"query": "third"}})
204+
205+
assert result["sentence_window_retriever"]["context_windows"] == [
206+
"This is a text with some words. There is a second sentence. And there is also a third sentence. "
207+
"It also contains a fourth sentence. And a fifth sentence."
208+
]
209+
assert len(result["sentence_window_retriever"]["context_documents"]) == 5
210+
211+
result = await pipe.run_async(
212+
{"bm25_retriever": {"query": "third"}, "sentence_window_retriever": {"window_size": 1}}
213+
)
214+
assert result["sentence_window_retriever"]["context_windows"] == [
215+
" There is a second sentence. And there is also a third sentence. It also contains a fourth sentence."
216+
]
217+
assert len(result["sentence_window_retriever"]["context_documents"]) == 3
218+
219+
@pytest.mark.asyncio
220+
@pytest.mark.integration
221+
async def test_serialization_deserialization_in_pipeline(self):
222+
doc_store = InMemoryDocumentStore()
223+
pipe = AsyncPipeline()
224+
pipe.add_component("bm25_retriever", InMemoryBM25Retriever(doc_store, top_k=1))
225+
pipe.add_component(
226+
"sentence_window_retriever", SentenceWindowRetriever(document_store=doc_store, window_size=2)
227+
)
228+
pipe.connect("bm25_retriever", "sentence_window_retriever")
229+
230+
serialized = pipe.to_dict()
231+
deserialized = AsyncPipeline.from_dict(serialized)
232+
233+
assert deserialized == pipe

0 commit comments

Comments
 (0)