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

322 lines
17 KiB
Python

# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from typing import Any
from haystack import Document, component, default_from_dict, default_to_dict, logging
from haystack.document_stores.types import DocumentStore
logger = logging.getLogger(__name__)
@component
class SentenceWindowRetriever:
"""
Retrieves neighboring documents from a DocumentStore to provide context for query results.
This component is intended to be used after a Retriever (e.g., BM25Retriever, EmbeddingRetriever).
It enhances retrieved results by fetching adjacent document chunks to give
additional context for the user.
The documents must include metadata indicating their origin and position:
- `source_id` is used to group sentence chunks belonging to the same original document.
- `split_id` represents the position/order of the chunk within the document.
The number of adjacent documents to include on each side of the retrieved document can be configured using the
`window_size` parameter. You can also specify which metadata fields to use for source and split ID
via `source_id_meta_field` and `split_id_meta_field`.
The SentenceWindowRetriever is compatible with the following DocumentStores:
- [Astra](https://docs.haystack.deepset.ai/docs/astradocumentstore)
- [Elasticsearch](https://docs.haystack.deepset.ai/docs/elasticsearch-document-store)
- [OpenSearch](https://docs.haystack.deepset.ai/docs/opensearch-document-store)
- [Pgvector](https://docs.haystack.deepset.ai/docs/pgvectordocumentstore)
- [Pinecone](https://docs.haystack.deepset.ai/docs/pinecone-document-store)
- [Qdrant](https://docs.haystack.deepset.ai/docs/qdrant-document-store)
### Usage example
```python
from haystack import Document, Pipeline
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
from haystack.components.retrievers import SentenceWindowRetriever
from haystack.components.preprocessors import DocumentSplitter
from haystack.document_stores.in_memory import InMemoryDocumentStore
splitter = DocumentSplitter(split_length=10, split_overlap=5, split_by="word")
text = (
"This is a text with some words. There is a second sentence. And there is also a third sentence. "
"It also contains a fourth sentence. And a fifth sentence. And a sixth sentence. And a seventh sentence"
)
doc = Document(content=text)
docs = splitter.run([doc])
doc_store = InMemoryDocumentStore()
doc_store.write_documents(docs["documents"])
rag = Pipeline()
rag.add_component("bm25_retriever", InMemoryBM25Retriever(doc_store, top_k=1))
rag.add_component("sentence_window_retriever", SentenceWindowRetriever(document_store=doc_store, window_size=2))
rag.connect("bm25_retriever", "sentence_window_retriever")
rag.run({'bm25_retriever': {"query":"third"}})
# >> {'sentence_window_retriever': {'context_windows': ['some words. There is a second sentence.
# >> And there is also a third sentence. It also contains a fourth sentence. And a fifth sentence. And a sixth
# >> sentence. And a'], 'context_documents': [[Document(id=..., content: 'some words. There is a second sentence.
# >> And there is ', meta: {'source_id': '...', 'page_number': 1, 'split_id': 1, 'split_idx_start': 20,
# >> '_split_overlap': [{'doc_id': '...', 'range': (20, 43)}, {'doc_id': '...', 'range': (0, 30)}]}),
# >> Document(id=..., content: 'second sentence. And there is also a third sentence. It ',
# >> meta: {'source_id': '74ea87deb38012873cf8c07e...f19d01a26a098447113e1d7b83efd30c02987114', 'page_number': 1,
# >> 'split_id': 2, 'split_idx_start': 43, '_split_overlap': [{'doc_id': '...', 'range': (23, 53)}, {'doc_id': '.',
# >> 'range': (0, 26)}]}), Document(id=..., content: 'also a third sentence. It also contains a fourth sentence. ',
# >> meta: {'source_id': '...', 'page_number': 1, 'split_id': 3, 'split_idx_start': 73, '_split_overlap':
# >> [{'doc_id': '...', 'range': (30, 56)}, {'doc_id': '...', 'range': (0, 33)}]}), Document(id=..., content:
# >> 'also contains a fourth sentence. And a fifth sentence. And ', meta: {'source_id': '...', 'page_number': 1,
# >> 'split_id': 4, 'split_idx_start': 99, '_split_overlap': [{'doc_id': '...', 'range': (26, 59)},
# >> {'doc_id': '...', 'range': (0, 26)}]}), Document(id=..., content: 'And a fifth sentence. And a sixth sentence.
# >> And a ', meta: {'source_id': '...', 'page_number': 1, 'split_id': 5, 'split_idx_start': 132,
# >> '_split_overlap': [{'doc_id': '...', 'range': (33, 59)}, {'doc_id': '...', 'range': (0, 24)}]})]]}}}}
```
"""
def __init__(
self,
document_store: DocumentStore,
window_size: int = 3,
*,
source_id_meta_field: str | list[str] = "source_id",
split_id_meta_field: str = "split_id",
raise_on_missing_meta_fields: bool = True,
) -> None:
"""
Creates a new SentenceWindowRetriever component.
:param document_store: The Document Store to retrieve the surrounding documents from.
:param window_size: The number of documents to retrieve before and after the relevant one.
For example, `window_size: 2` fetches 2 preceding and 2 following documents.
:param source_id_meta_field: The metadata field that contains the source ID of the document.
This can be a single field or a list of fields. If multiple fields are provided, the retriever will
consider the document as part of the same source if all the fields match.
:param split_id_meta_field: The metadata field that contains the split ID of the document.
:param raise_on_missing_meta_fields: If True, raises an error if the documents do not contain the required
metadata fields. If False, it will skip retrieving the context for documents that are missing
the required metadata fields, but will still include the original document in the results.
"""
if window_size < 1:
raise ValueError("The window_size parameter must be greater than 0.")
self.window_size = window_size
self.document_store = document_store
self.source_id_meta_field = source_id_meta_field
# Use this to have an attribute that is always a list of source id meta fields.
self._source_id_meta_fields = (
source_id_meta_field if isinstance(source_id_meta_field, list) else [source_id_meta_field]
)
self.split_id_meta_field = split_id_meta_field
self.raise_on_missing_meta_fields = raise_on_missing_meta_fields
@staticmethod
def merge_documents_text(documents: list[Document]) -> str:
"""
Merge a list of document text into a single string.
This functions concatenates the textual content of a list of documents into a single string, eliminating any
overlapping content.
:param documents: List of Documents to merge.
"""
if any("split_idx_start" not in doc.meta for doc in documents):
# If any of the documents is missing the 'split_idx_start' metadata we just concatenate their content.
return "".join(doc.content for doc in documents if doc.content)
sorted_docs = sorted(documents, key=lambda doc: doc.meta["split_idx_start"])
merged_text = ""
last_idx_end = 0
for doc in sorted_docs:
if doc.content is None:
continue
start = doc.meta.get("split_idx_start", 0) # start of the current content
# if the start of the current content is before the end of the last appended content, adjust it
start = max(start, last_idx_end)
# append the non-overlapping part to the merged text
merged_text += doc.content[start - int(doc.meta["split_idx_start"]) :]
# update the last end index
last_idx_end = int(doc.meta["split_idx_start"]) + len(doc.content)
return merged_text
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,
window_size=self.window_size,
source_id_meta_field=self.source_id_meta_field,
split_id_meta_field=self.split_id_meta_field,
raise_on_missing_meta_fields=self.raise_on_missing_meta_fields,
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "SentenceWindowRetriever":
"""
Deserializes the component from a dictionary.
:returns:
Deserialized component.
"""
return default_from_dict(cls, data)
@component.output_types(context_windows=list[str], context_documents=list[Document])
def run(self, retrieved_documents: list[Document], window_size: int | None = None) -> dict[str, Any]:
"""
Based on the `source_id` and on the `doc.meta['split_id']` get surrounding documents from the document store.
Implements the logic behind the sentence-window technique, retrieving the surrounding documents of a given
document from the document store.
:param retrieved_documents: List of retrieved documents from the previous retriever.
:param window_size: The number of documents to retrieve before and after the relevant one. This will overwrite
the `window_size` parameter set in the constructor.
:returns:
A dictionary with the following keys:
- `context_windows`: A list of strings, where each string represents the concatenated text from the
context window of the corresponding document in `retrieved_documents`.
- `context_documents`: A list `Document` objects, containing the retrieved documents plus the context
document surrounding them. The documents are sorted by the `split_idx_start`
meta field.
"""
window_size = window_size or self.window_size
SentenceWindowRetriever._raise_if_windows_size_is_negative(window_size)
self._raise_if_documents_do_not_have_expected_metadata(retrieved_documents)
context_text = []
context_documents = []
for doc in retrieved_documents:
text, docs = self._retrieve_context_for_document(doc, window_size)
context_text.append(text)
context_documents.extend(docs)
return {"context_windows": context_text, "context_documents": context_documents}
@component.output_types(context_windows=list[str], context_documents=list[Document])
async def run_async(self, retrieved_documents: list[Document], window_size: int | None = None) -> dict[str, Any]:
"""
Based on the `source_id` and on the `doc.meta['split_id']` get surrounding documents from the document store.
Implements the logic behind the sentence-window technique, retrieving the surrounding documents of a given
document from the document store.
:param retrieved_documents: List of retrieved documents from the previous retriever.
:param window_size: The number of documents to retrieve before and after the relevant one. This will overwrite
the `window_size` parameter set in the constructor.
:returns:
A dictionary with the following keys:
- `context_windows`: A list of strings, where each string represents the concatenated text from the
context window of the corresponding document in `retrieved_documents`.
- `context_documents`: A list `Document` objects, containing the retrieved documents plus the context
document surrounding them. The documents are sorted by the `split_idx_start`
meta field.
"""
window_size = window_size or self.window_size
SentenceWindowRetriever._raise_if_windows_size_is_negative(window_size)
self._raise_if_documents_do_not_have_expected_metadata(retrieved_documents)
context_text = []
context_documents = []
for doc in retrieved_documents:
text, docs = await self._retrieve_context_for_document_async(doc, window_size)
context_text.append(text)
context_documents.extend(docs)
return {"context_windows": context_text, "context_documents": context_documents}
@staticmethod
def _raise_if_windows_size_is_negative(window_size: int) -> None:
if window_size < 1:
raise ValueError("The window_size parameter must be greater than 0.")
def _raise_if_documents_do_not_have_expected_metadata(self, retrieved_documents: list[Document]) -> None:
if (
not all(self.split_id_meta_field in doc.meta for doc in retrieved_documents)
and self.raise_on_missing_meta_fields
):
raise ValueError(f"The retrieved documents must have '{self.split_id_meta_field}' in their metadata.")
if (
not all(field in doc.meta for doc in retrieved_documents for field in self._source_id_meta_fields)
and self.raise_on_missing_meta_fields
):
raise ValueError(f"The retrieved documents must have '{self.source_id_meta_field}' in their metadata.")
def _retrieve_context_for_document(self, doc: Document, window_size: int) -> tuple[str, list[Document]]:
source_ids = [doc.meta.get(field) for field in self._source_id_meta_fields]
split_id = doc.meta.get(self.split_id_meta_field)
if any(source_id is None for source_id in source_ids) or split_id is None:
logger.warning(
"Document {doc_id} is missing required metadata fields to be used with "
"SentenceWindowRetriever: {source_id} or {split_id}. Skipping context retrieval for this document.",
doc_id=doc.id,
source_id=self._source_id_meta_fields,
split_id=self.split_id_meta_field,
)
return doc.content or "", [doc]
assert split_id is not None
filter_conditions = self._build_filter_conditions(split_id, window_size, source_ids)
context_docs = self.document_store.filter_documents(filter_conditions)
context_text = self.merge_documents_text(context_docs)
context_docs_sorted = sorted(context_docs, key=lambda doc: doc.meta[self.split_id_meta_field])
return context_text, context_docs_sorted
async def _retrieve_context_for_document_async(self, doc: Document, window_size: int) -> tuple[str, list[Document]]:
source_ids = [doc.meta.get(field) for field in self._source_id_meta_fields]
split_id = doc.meta.get(self.split_id_meta_field)
if any(source_id is None for source_id in source_ids) or split_id is None:
logger.warning(
"Document {doc_id} is missing required metadata fields to be used with "
"SentenceWindowRetriever: {source_id} or {split_id}. Skipping context retrieval for this document.",
doc_id=doc.id,
source_id=self._source_id_meta_fields,
split_id=self.split_id_meta_field,
)
return doc.content or "", [doc]
assert split_id is not None
filter_conditions = self._build_filter_conditions(split_id, window_size, source_ids)
# Ignoring type error because DocumentStore protocol doesn't define filter_documents_async
context_docs = await self.document_store.filter_documents_async(filter_conditions) # type: ignore[attr-defined]
context_text = self.merge_documents_text(context_docs)
context_docs_sorted = sorted(context_docs, key=lambda doc: doc.meta[self.split_id_meta_field])
return context_text, context_docs_sorted
def _build_filter_conditions(self, split_id: int, window_size: int, source_ids: list[Any]) -> dict[str, Any]:
min_before = split_id - window_size
max_after = split_id + window_size
source_id_filters = [
{"field": f"meta.{source_id_meta_field}", "operator": "==", "value": source_id}
for source_id_meta_field, source_id in zip(self._source_id_meta_fields, source_ids, strict=True)
]
conditions = [
{"field": f"meta.{self.split_id_meta_field}", "operator": ">=", "value": min_before},
{"field": f"meta.{self.split_id_meta_field}", "operator": "<=", "value": max_after},
*source_id_filters,
]
return {"operator": "AND", "conditions": conditions}