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
322 lines
17 KiB
Python
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}
|