chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {
|
||||
"auto_merging_retriever": ["AutoMergingRetriever"],
|
||||
"filter_retriever": ["FilterRetriever"],
|
||||
"in_memory": ["InMemoryBM25Retriever", "InMemoryEmbeddingRetriever"],
|
||||
"multi_retriever": ["MultiRetriever"],
|
||||
"multi_query_embedding_retriever": ["MultiQueryEmbeddingRetriever"],
|
||||
"multi_query_text_retriever": ["MultiQueryTextRetriever"],
|
||||
"sentence_window_retriever": ["SentenceWindowRetriever"],
|
||||
"text_embedding_retriever": ["TextEmbeddingRetriever"],
|
||||
}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .auto_merging_retriever import AutoMergingRetriever as AutoMergingRetriever
|
||||
from .filter_retriever import FilterRetriever as FilterRetriever
|
||||
from .in_memory import InMemoryBM25Retriever as InMemoryBM25Retriever
|
||||
from .in_memory import InMemoryEmbeddingRetriever as InMemoryEmbeddingRetriever
|
||||
from .multi_query_embedding_retriever import MultiQueryEmbeddingRetriever as MultiQueryEmbeddingRetriever
|
||||
from .multi_query_text_retriever import MultiQueryTextRetriever as MultiQueryTextRetriever
|
||||
from .multi_retriever import MultiRetriever as MultiRetriever
|
||||
from .sentence_window_retriever import SentenceWindowRetriever as SentenceWindowRetriever
|
||||
from .text_embedding_retriever import TextEmbeddingRetriever as TextEmbeddingRetriever
|
||||
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,226 @@
|
||||
# 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, [])}
|
||||
@@ -0,0 +1,104 @@
|
||||
# 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
|
||||
from haystack.document_stores.types import DocumentStore
|
||||
|
||||
|
||||
@component
|
||||
class FilterRetriever:
|
||||
"""
|
||||
Retrieves documents that match the provided filters.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.retrievers import FilterRetriever
|
||||
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
||||
|
||||
docs = [
|
||||
Document(content="Python is a popular programming language", meta={"lang": "en"}),
|
||||
Document(content="python ist eine beliebte Programmiersprache", meta={"lang": "de"}),
|
||||
]
|
||||
|
||||
doc_store = InMemoryDocumentStore()
|
||||
doc_store.write_documents(docs)
|
||||
retriever = FilterRetriever(doc_store, filters={"field": "lang", "operator": "==", "value": "en"})
|
||||
|
||||
# if passed in the run method, filters override those provided at initialization
|
||||
result = retriever.run(filters={"field": "lang", "operator": "==", "value": "de"})
|
||||
|
||||
print(result["documents"])
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, document_store: DocumentStore, filters: dict[str, Any] | None = None) -> None:
|
||||
"""
|
||||
Create the FilterRetriever component.
|
||||
|
||||
:param document_store:
|
||||
An instance of a Document Store to use with the Retriever.
|
||||
:param filters:
|
||||
A dictionary with filters to narrow down the search space.
|
||||
"""
|
||||
self.document_store = document_store
|
||||
self.filters = filters
|
||||
|
||||
def _get_telemetry_data(self) -> dict[str, Any]:
|
||||
"""
|
||||
Data that is sent to Posthog for usage analytics.
|
||||
"""
|
||||
return {"document_store": type(self.document_store).__name__}
|
||||
|
||||
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, filters=self.filters)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "FilterRetriever":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary to deserialize from.
|
||||
:returns:
|
||||
The deserialized component.
|
||||
"""
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(self, filters: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Run the FilterRetriever on the given input data.
|
||||
|
||||
:param filters:
|
||||
A dictionary with filters to narrow down the search space.
|
||||
If not specified, the FilterRetriever uses the values provided at initialization.
|
||||
:returns:
|
||||
A list of retrieved documents.
|
||||
"""
|
||||
return {"documents": self.document_store.filter_documents(filters=filters or self.filters)}
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
async def run_async(self, filters: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Asynchronously run the FilterRetriever on the given input data.
|
||||
|
||||
:param filters:
|
||||
A dictionary with filters to narrow down the search space.
|
||||
If not specified, the FilterRetriever uses the values provided at initialization.
|
||||
:returns:
|
||||
A list of retrieved documents.
|
||||
"""
|
||||
# 'ignore' since filter_documents_async is not defined in the Protocol but exists in the implementations
|
||||
out_documents = await self.document_store.filter_documents_async(filters=filters or self.filters) # type: ignore[attr-defined]
|
||||
return {"documents": out_documents}
|
||||
@@ -0,0 +1,17 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from lazy_imports import LazyImporter
|
||||
|
||||
_import_structure = {"bm25_retriever": ["InMemoryBM25Retriever"], "embedding_retriever": ["InMemoryEmbeddingRetriever"]}
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .bm25_retriever import InMemoryBM25Retriever as InMemoryBM25Retriever
|
||||
from .embedding_retriever import InMemoryEmbeddingRetriever as InMemoryEmbeddingRetriever
|
||||
|
||||
else:
|
||||
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
|
||||
@@ -0,0 +1,196 @@
|
||||
# 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
|
||||
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
||||
from haystack.document_stores.types import FilterPolicy
|
||||
|
||||
|
||||
@component
|
||||
class InMemoryBM25Retriever:
|
||||
"""
|
||||
Retrieves documents that are most similar to the query using keyword-based algorithm.
|
||||
|
||||
Use this retriever with the InMemoryDocumentStore.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.retrievers.in_memory import InMemoryBM25Retriever
|
||||
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
||||
|
||||
docs = [
|
||||
Document(content="Python is a popular programming language"),
|
||||
Document(content="python ist eine beliebte Programmiersprache"),
|
||||
]
|
||||
|
||||
doc_store = InMemoryDocumentStore()
|
||||
doc_store.write_documents(docs)
|
||||
retriever = InMemoryBM25Retriever(doc_store)
|
||||
|
||||
result = retriever.run(query="Programmiersprache")
|
||||
|
||||
print(result["documents"])
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
document_store: InMemoryDocumentStore,
|
||||
filters: dict[str, Any] | None = None,
|
||||
top_k: int = 10,
|
||||
scale_score: bool = False,
|
||||
filter_policy: FilterPolicy = FilterPolicy.REPLACE,
|
||||
) -> None:
|
||||
"""
|
||||
Create the InMemoryBM25Retriever component.
|
||||
|
||||
:param document_store:
|
||||
An instance of InMemoryDocumentStore where the retriever should search for relevant documents.
|
||||
:param filters:
|
||||
A dictionary with filters to narrow down the retriever's search space in the document store.
|
||||
:param top_k:
|
||||
The maximum number of documents to retrieve.
|
||||
:param scale_score:
|
||||
When `True`, scales the score of retrieved documents to a range of 0 to 1, where 1 means extremely relevant.
|
||||
When `False`, uses raw similarity scores.
|
||||
:param filter_policy: The filter policy to apply during retrieval.
|
||||
Filter policy determines how filters are applied when retrieving documents. You can choose:
|
||||
- `REPLACE` (default): Overrides the initialization filters with the filters specified at runtime.
|
||||
Use this policy to dynamically change filtering for specific queries.
|
||||
- `MERGE`: Combines runtime filters with initialization filters to narrow down the search.
|
||||
:raises TypeError: If the document_store is not an instance of InMemoryDocumentStore.
|
||||
:raises ValueError:
|
||||
If the specified `top_k` is not > 0.
|
||||
"""
|
||||
if not isinstance(document_store, InMemoryDocumentStore):
|
||||
raise TypeError("document_store must be an instance of InMemoryDocumentStore")
|
||||
|
||||
self.document_store = document_store
|
||||
|
||||
if top_k <= 0:
|
||||
raise ValueError(f"top_k must be greater than 0. Currently, the top_k is {top_k}")
|
||||
|
||||
self.filters = filters
|
||||
self.top_k = top_k
|
||||
self.scale_score = scale_score
|
||||
self.filter_policy = filter_policy
|
||||
|
||||
def _get_telemetry_data(self) -> dict[str, Any]:
|
||||
"""
|
||||
Data that is sent to Posthog for usage analytics.
|
||||
"""
|
||||
return {"document_store": type(self.document_store).__name__}
|
||||
|
||||
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,
|
||||
filters=self.filters,
|
||||
top_k=self.top_k,
|
||||
scale_score=self.scale_score,
|
||||
filter_policy=self.filter_policy.value,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "InMemoryBM25Retriever":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary to deserialize from.
|
||||
:returns:
|
||||
The deserialized component.
|
||||
"""
|
||||
init_params = data.get("init_parameters", {})
|
||||
if "filter_policy" in init_params:
|
||||
init_params["filter_policy"] = FilterPolicy.from_str(init_params["filter_policy"])
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(
|
||||
self,
|
||||
query: str,
|
||||
filters: dict[str, Any] | None = None,
|
||||
top_k: int | None = None,
|
||||
scale_score: bool | None = None,
|
||||
) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Run the InMemoryBM25Retriever on the given input data.
|
||||
|
||||
:param query:
|
||||
The query string for the Retriever.
|
||||
:param filters:
|
||||
A dictionary with filters to narrow down the search space when retrieving documents.
|
||||
:param top_k:
|
||||
The maximum number of documents to return.
|
||||
:param scale_score:
|
||||
When `True`, scales the score of retrieved documents to a range of 0 to 1, where 1 means extremely relevant.
|
||||
When `False`, uses raw similarity scores.
|
||||
:returns:
|
||||
The retrieved documents.
|
||||
|
||||
:raises ValueError:
|
||||
If the specified DocumentStore is not found or is not a InMemoryDocumentStore instance.
|
||||
"""
|
||||
if self.filter_policy == FilterPolicy.MERGE and filters:
|
||||
filters = {**(self.filters or {}), **filters}
|
||||
else:
|
||||
filters = filters or self.filters
|
||||
if top_k is None:
|
||||
top_k = self.top_k
|
||||
if scale_score is None:
|
||||
scale_score = self.scale_score
|
||||
|
||||
docs = self.document_store.bm25_retrieval(query=query, filters=filters, top_k=top_k, scale_score=scale_score)
|
||||
return {"documents": docs}
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
async def run_async(
|
||||
self,
|
||||
query: str,
|
||||
filters: dict[str, Any] | None = None,
|
||||
top_k: int | None = None,
|
||||
scale_score: bool | None = None,
|
||||
) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Run the InMemoryBM25Retriever on the given input data.
|
||||
|
||||
:param query:
|
||||
The query string for the Retriever.
|
||||
:param filters:
|
||||
A dictionary with filters to narrow down the search space when retrieving documents.
|
||||
:param top_k:
|
||||
The maximum number of documents to return.
|
||||
:param scale_score:
|
||||
When `True`, scales the score of retrieved documents to a range of 0 to 1, where 1 means extremely relevant.
|
||||
When `False`, uses raw similarity scores.
|
||||
:returns:
|
||||
The retrieved documents.
|
||||
|
||||
:raises ValueError:
|
||||
If the specified DocumentStore is not found or is not a InMemoryDocumentStore instance.
|
||||
"""
|
||||
if self.filter_policy == FilterPolicy.MERGE and filters:
|
||||
filters = {**(self.filters or {}), **filters}
|
||||
else:
|
||||
filters = filters or self.filters
|
||||
if top_k is None:
|
||||
top_k = self.top_k
|
||||
if scale_score is None:
|
||||
scale_score = self.scale_score
|
||||
|
||||
docs = await self.document_store.bm25_retrieval_async(
|
||||
query=query, filters=filters, top_k=top_k, scale_score=scale_score
|
||||
)
|
||||
return {"documents": docs}
|
||||
@@ -0,0 +1,236 @@
|
||||
# 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
|
||||
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
||||
from haystack.document_stores.types import FilterPolicy
|
||||
|
||||
|
||||
@component
|
||||
class InMemoryEmbeddingRetriever:
|
||||
"""
|
||||
Retrieves documents that are most semantically similar to the query.
|
||||
|
||||
Use this retriever with the InMemoryDocumentStore.
|
||||
|
||||
When using this retriever, make sure it has query and document embeddings available.
|
||||
In indexing pipelines, use a DocumentEmbedder to embed documents.
|
||||
In query pipelines, use a TextEmbedder to embed queries and send them to the retriever.
|
||||
|
||||
### Usage example
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.embedders import OpenAIDocumentEmbedder, OpenAITextEmbedder
|
||||
from haystack.components.retrievers.in_memory import InMemoryEmbeddingRetriever
|
||||
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
||||
|
||||
docs = [
|
||||
Document(content="Python is a popular programming language"),
|
||||
Document(content="python ist eine beliebte Programmiersprache"),
|
||||
]
|
||||
doc_embedder = OpenAIDocumentEmbedder()
|
||||
docs_with_embeddings = doc_embedder.run(docs)["documents"]
|
||||
|
||||
doc_store = InMemoryDocumentStore()
|
||||
doc_store.write_documents(docs_with_embeddings)
|
||||
retriever = InMemoryEmbeddingRetriever(doc_store)
|
||||
|
||||
query="Programmiersprache"
|
||||
text_embedder = OpenAITextEmbedder()
|
||||
query_embedding = text_embedder.run(query)["embedding"]
|
||||
|
||||
result = retriever.run(query_embedding=query_embedding)
|
||||
|
||||
print(result["documents"])
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
document_store: InMemoryDocumentStore,
|
||||
filters: dict[str, Any] | None = None,
|
||||
top_k: int = 10,
|
||||
scale_score: bool = False,
|
||||
return_embedding: bool = False,
|
||||
filter_policy: FilterPolicy = FilterPolicy.REPLACE,
|
||||
) -> None:
|
||||
"""
|
||||
Create the InMemoryEmbeddingRetriever component.
|
||||
|
||||
:param document_store:
|
||||
An instance of InMemoryDocumentStore where the retriever should search for relevant documents.
|
||||
:param filters:
|
||||
A dictionary with filters to narrow down the retriever's search space in the document store.
|
||||
:param top_k:
|
||||
The maximum number of documents to retrieve.
|
||||
:param scale_score:
|
||||
When `True`, scales the score of retrieved documents to a range of 0 to 1, where 1 means extremely relevant.
|
||||
When `False`, uses raw similarity scores.
|
||||
:param return_embedding:
|
||||
When `True`, returns the embedding of the retrieved documents.
|
||||
When `False`, returns just the documents, without their embeddings.
|
||||
:param filter_policy: The filter policy to apply during retrieval.
|
||||
Filter policy determines how filters are applied when retrieving documents. You can choose:
|
||||
- `REPLACE` (default): Overrides the initialization filters with the filters specified at runtime.
|
||||
Use this policy to dynamically change filtering for specific queries.
|
||||
- `MERGE`: Combines runtime filters with initialization filters to narrow down the search.
|
||||
:raises TypeError: If the document_store is not an instance of InMemoryDocumentStore.
|
||||
:raises ValueError:
|
||||
If the specified top_k is not > 0.
|
||||
"""
|
||||
if not isinstance(document_store, InMemoryDocumentStore):
|
||||
raise TypeError("document_store must be an instance of InMemoryDocumentStore")
|
||||
|
||||
self.document_store = document_store
|
||||
|
||||
if top_k <= 0:
|
||||
raise ValueError(f"top_k must be greater than 0. Currently, top_k is {top_k}")
|
||||
|
||||
self.filters = filters
|
||||
self.top_k = top_k
|
||||
self.scale_score = scale_score
|
||||
self.return_embedding = return_embedding
|
||||
self.filter_policy = filter_policy
|
||||
|
||||
def _get_telemetry_data(self) -> dict[str, Any]:
|
||||
"""
|
||||
Data that is sent to Posthog for usage analytics.
|
||||
"""
|
||||
return {"document_store": type(self.document_store).__name__}
|
||||
|
||||
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,
|
||||
filters=self.filters,
|
||||
top_k=self.top_k,
|
||||
scale_score=self.scale_score,
|
||||
return_embedding=self.return_embedding,
|
||||
filter_policy=self.filter_policy.value,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "InMemoryEmbeddingRetriever":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
The dictionary to deserialize from.
|
||||
:returns:
|
||||
The deserialized component.
|
||||
"""
|
||||
init_params = data.get("init_parameters", {})
|
||||
if "filter_policy" in init_params:
|
||||
init_params["filter_policy"] = FilterPolicy.from_str(init_params["filter_policy"])
|
||||
return default_from_dict(cls, data)
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(
|
||||
self,
|
||||
query_embedding: list[float],
|
||||
filters: dict[str, Any] | None = None,
|
||||
top_k: int | None = None,
|
||||
scale_score: bool | None = None,
|
||||
return_embedding: bool | None = None,
|
||||
) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Run the InMemoryEmbeddingRetriever on the given input data.
|
||||
|
||||
:param query_embedding:
|
||||
Embedding of the query.
|
||||
:param filters:
|
||||
A dictionary with filters to narrow down the search space when retrieving documents.
|
||||
:param top_k:
|
||||
The maximum number of documents to return.
|
||||
:param scale_score:
|
||||
When `True`, scales the score of retrieved documents to a range of 0 to 1, where 1 means extremely relevant.
|
||||
When `False`, uses raw similarity scores.
|
||||
:param return_embedding:
|
||||
When `True`, returns the embedding of the retrieved documents.
|
||||
When `False`, returns just the documents, without their embeddings.
|
||||
:returns:
|
||||
The retrieved documents.
|
||||
|
||||
:raises ValueError:
|
||||
If the specified DocumentStore is not found or is not an InMemoryDocumentStore instance.
|
||||
"""
|
||||
if self.filter_policy == FilterPolicy.MERGE and filters:
|
||||
filters = {**(self.filters or {}), **filters}
|
||||
else:
|
||||
filters = filters or self.filters
|
||||
if top_k is None:
|
||||
top_k = self.top_k
|
||||
if scale_score is None:
|
||||
scale_score = self.scale_score
|
||||
if return_embedding is None:
|
||||
return_embedding = self.return_embedding
|
||||
|
||||
docs = self.document_store.embedding_retrieval(
|
||||
query_embedding=query_embedding,
|
||||
filters=filters,
|
||||
top_k=top_k,
|
||||
scale_score=scale_score,
|
||||
return_embedding=return_embedding,
|
||||
)
|
||||
|
||||
return {"documents": docs}
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
async def run_async(
|
||||
self,
|
||||
query_embedding: list[float],
|
||||
filters: dict[str, Any] | None = None,
|
||||
top_k: int | None = None,
|
||||
scale_score: bool | None = None,
|
||||
return_embedding: bool | None = None,
|
||||
) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Run the InMemoryEmbeddingRetriever on the given input data.
|
||||
|
||||
:param query_embedding:
|
||||
Embedding of the query.
|
||||
:param filters:
|
||||
A dictionary with filters to narrow down the search space when retrieving documents.
|
||||
:param top_k:
|
||||
The maximum number of documents to return.
|
||||
:param scale_score:
|
||||
When `True`, scales the score of retrieved documents to a range of 0 to 1, where 1 means extremely relevant.
|
||||
When `False`, uses raw similarity scores.
|
||||
:param return_embedding:
|
||||
When `True`, returns the embedding of the retrieved documents.
|
||||
When `False`, returns just the documents, without their embeddings.
|
||||
:returns:
|
||||
The retrieved documents.
|
||||
|
||||
:raises ValueError:
|
||||
If the specified DocumentStore is not found or is not an InMemoryDocumentStore instance.
|
||||
"""
|
||||
if self.filter_policy == FilterPolicy.MERGE and filters:
|
||||
filters = {**(self.filters or {}), **filters}
|
||||
else:
|
||||
filters = filters or self.filters
|
||||
if top_k is None:
|
||||
top_k = self.top_k
|
||||
if scale_score is None:
|
||||
scale_score = self.scale_score
|
||||
if return_embedding is None:
|
||||
return_embedding = self.return_embedding
|
||||
|
||||
docs = await self.document_store.embedding_retrieval_async(
|
||||
query_embedding=query_embedding,
|
||||
filters=filters,
|
||||
top_k=top_k,
|
||||
scale_score=scale_score,
|
||||
return_embedding=return_embedding,
|
||||
)
|
||||
|
||||
return {"documents": docs}
|
||||
@@ -0,0 +1,237 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, component, default_from_dict, default_to_dict
|
||||
from haystack.components.embedders.types.protocol import TextEmbedder
|
||||
from haystack.components.retrievers.types import EmbeddingRetriever
|
||||
from haystack.core.serialization import component_to_dict
|
||||
from haystack.utils.async_utils import _execute_component_async
|
||||
from haystack.utils.misc import _deduplicate_documents
|
||||
|
||||
|
||||
@component
|
||||
class MultiQueryEmbeddingRetriever:
|
||||
"""
|
||||
A component that retrieves documents using multiple queries in parallel with an embedding-based retriever.
|
||||
|
||||
This component takes a list of text queries, converts them to embeddings using a query embedder,
|
||||
and then uses an embedding-based retriever to find relevant documents for each query in parallel.
|
||||
The results are combined and sorted by relevance score.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
||||
from haystack.document_stores.types import DuplicatePolicy
|
||||
from haystack.components.embedders import OpenAITextEmbedder
|
||||
from haystack.components.embedders import OpenAIDocumentEmbedder
|
||||
from haystack.components.retrievers import InMemoryEmbeddingRetriever
|
||||
from haystack.components.writers import DocumentWriter
|
||||
from haystack.components.retrievers import MultiQueryEmbeddingRetriever
|
||||
|
||||
documents = [
|
||||
Document(content="Renewable energy is energy that is collected from renewable resources."),
|
||||
Document(content="Solar energy is a type of green energy that is harnessed from the sun."),
|
||||
Document(content="Wind energy is another type of green energy that is generated by wind turbines."),
|
||||
Document(content="Geothermal energy is heat that comes from the sub-surface of the earth."),
|
||||
Document(content="Biomass energy is produced from organic materials, such as plant and animal waste."),
|
||||
Document(content="Fossil fuels, such as coal, oil, and natural gas, are non-renewable energy sources."),
|
||||
]
|
||||
|
||||
# Populate the document store
|
||||
doc_store = InMemoryDocumentStore()
|
||||
doc_embedder = OpenAIDocumentEmbedder()
|
||||
doc_writer = DocumentWriter(document_store=doc_store, policy=DuplicatePolicy.SKIP)
|
||||
documents = doc_embedder.run(documents)["documents"]
|
||||
doc_writer.run(documents=documents)
|
||||
|
||||
# Run the multi-query retriever
|
||||
in_memory_retriever = InMemoryEmbeddingRetriever(document_store=doc_store, top_k=1)
|
||||
query_embedder = OpenAITextEmbedder()
|
||||
|
||||
multi_query_retriever = MultiQueryEmbeddingRetriever(
|
||||
retriever=in_memory_retriever,
|
||||
query_embedder=query_embedder,
|
||||
max_workers=3
|
||||
)
|
||||
|
||||
queries = ["Geothermal energy", "natural gas", "turbines"]
|
||||
result = multi_query_retriever.run(queries=queries)
|
||||
for doc in result["documents"]:
|
||||
print(f"Content: {doc.content}, Score: {doc.score}")
|
||||
# >> Content: Geothermal energy is heat that comes from the sub-surface of the earth., Score: 0.8509603046266574
|
||||
# >> Content: Renewable energy is energy that is collected from renewable resources., Score: 0.42763211298893034
|
||||
# >> Content: Solar energy is a type of green energy that is harnessed from the sun., Score: 0.40077417016494354
|
||||
# >> Content: Fossil fuels, such as coal, oil, and natural gas, are non-renewable energy sources., Score: 0.3774863680
|
||||
# >> Content: Wind energy is another type of green energy that is generated by wind turbines., Score: 0.30914239725622
|
||||
# >> Content: Biomass energy is produced from organic materials, such as plant and animal waste., Score: 0.25173074243
|
||||
```
|
||||
""" # noqa E501
|
||||
|
||||
def __init__(self, *, retriever: EmbeddingRetriever, query_embedder: TextEmbedder, max_workers: int = 3) -> None:
|
||||
"""
|
||||
Initialize MultiQueryEmbeddingRetriever.
|
||||
|
||||
:param retriever: The embedding-based retriever to use for document retrieval.
|
||||
:param query_embedder: The query embedder to convert text queries to embeddings.
|
||||
:param max_workers: Maximum number of worker threads for parallel processing.
|
||||
"""
|
||||
self.retriever = retriever
|
||||
self.query_embedder = query_embedder
|
||||
self.max_workers = max_workers
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the query embedder and the retriever.
|
||||
"""
|
||||
for inner in (self.query_embedder, self.retriever):
|
||||
if hasattr(inner, "warm_up"):
|
||||
inner.warm_up()
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""
|
||||
Warm up the query embedder and the retriever on the serving event loop.
|
||||
"""
|
||||
for inner in (self.query_embedder, self.retriever):
|
||||
if hasattr(inner, "warm_up_async"):
|
||||
await inner.warm_up_async()
|
||||
elif hasattr(inner, "warm_up"):
|
||||
inner.warm_up()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Release the query embedder's and the retriever's resources.
|
||||
"""
|
||||
for inner in (self.query_embedder, self.retriever):
|
||||
if hasattr(inner, "close"):
|
||||
inner.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Release the query embedder's and the retriever's async resources.
|
||||
"""
|
||||
for inner in (self.query_embedder, self.retriever):
|
||||
if hasattr(inner, "close_async"):
|
||||
await inner.close_async()
|
||||
elif hasattr(inner, "close"):
|
||||
inner.close()
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(self, queries: list[str], retriever_kwargs: dict[str, Any] | None = None) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Retrieve documents using multiple queries in parallel.
|
||||
|
||||
:param queries: List of text queries to process.
|
||||
:param retriever_kwargs: Optional dictionary of arguments to pass to the retriever's run method.
|
||||
:returns:
|
||||
A dictionary containing:
|
||||
- `documents`: List of retrieved documents sorted by relevance score.
|
||||
"""
|
||||
docs: list[Document] = []
|
||||
retriever_kwargs = retriever_kwargs or {}
|
||||
|
||||
self.warm_up()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||
queries_results = executor.map(lambda query: self._run_on_thread(query, retriever_kwargs), queries)
|
||||
for result in queries_results:
|
||||
if not result:
|
||||
continue
|
||||
docs.extend(result)
|
||||
|
||||
# de-duplicate and sort
|
||||
docs = _deduplicate_documents(docs)
|
||||
docs.sort(key=lambda x: x.score or 0.0, reverse=True)
|
||||
return {"documents": docs}
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
async def run_async(
|
||||
self, queries: list[str], retriever_kwargs: dict[str, Any] | None = None
|
||||
) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Retrieve documents using multiple queries concurrently.
|
||||
|
||||
Uses each component's `run_async` method if available, otherwise falls back to running `run`
|
||||
in a thread executor. Queries are processed concurrently using asyncio.gather.
|
||||
|
||||
:param queries: List of text queries to process.
|
||||
:param retriever_kwargs: Optional dictionary of arguments to pass to the retriever's run method.
|
||||
:returns:
|
||||
A dictionary containing:
|
||||
- `documents`: List of retrieved documents sorted by relevance score.
|
||||
"""
|
||||
retriever_kwargs = retriever_kwargs or {}
|
||||
|
||||
await self.warm_up_async()
|
||||
|
||||
results = await asyncio.gather(*[self._run_one_async(q, retriever_kwargs) for q in queries])
|
||||
docs: list[Document] = [doc for result in results if result for doc in result]
|
||||
docs = _deduplicate_documents(docs)
|
||||
docs.sort(key=lambda x: x.score or 0.0, reverse=True)
|
||||
return {"documents": docs}
|
||||
|
||||
def _run_on_thread(self, query: str, retriever_kwargs: dict[str, Any] | None = None) -> list[Document] | None:
|
||||
"""
|
||||
Process a single query on a separate thread.
|
||||
|
||||
:param query: The text query to process.
|
||||
:param retriever_kwargs: Arguments to pass to the retriever's run method.
|
||||
:returns:
|
||||
List of retrieved documents or None if no results.
|
||||
"""
|
||||
embedding_result = self.query_embedder.run(text=query)
|
||||
query_embedding = embedding_result["embedding"]
|
||||
result = self.retriever.run(query_embedding=query_embedding, **(retriever_kwargs or {}))
|
||||
if result and "documents" in result:
|
||||
return result["documents"]
|
||||
return None
|
||||
|
||||
async def _run_one_async(self, query: str, retriever_kwargs: dict[str, Any]) -> list[Document] | None:
|
||||
"""
|
||||
Process a single query asynchronously.
|
||||
|
||||
:param query: The text query to process.
|
||||
:param retriever_kwargs: Arguments to pass to the retriever's run method.
|
||||
:returns:
|
||||
List of retrieved documents or None if no results.
|
||||
"""
|
||||
embedding_result = await _execute_component_async(self.query_embedder, text=query)
|
||||
|
||||
query_embedding = embedding_result["embedding"]
|
||||
|
||||
result = await _execute_component_async(self.retriever, query_embedding=query_embedding, **retriever_kwargs)
|
||||
|
||||
if result and "documents" in result:
|
||||
return result["documents"]
|
||||
return None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
A dictionary representing the serialized component.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self,
|
||||
retriever=component_to_dict(obj=self.retriever, name="retriever"),
|
||||
query_embedder=component_to_dict(obj=self.query_embedder, name="query_embedder"),
|
||||
max_workers=self.max_workers,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "MultiQueryEmbeddingRetriever":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data: The dictionary to deserialize from.
|
||||
:returns:
|
||||
The deserialized component.
|
||||
"""
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,206 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any
|
||||
|
||||
from haystack import Document, component, default_from_dict, default_to_dict
|
||||
from haystack.components.retrievers.types import TextRetriever
|
||||
from haystack.core.serialization import component_to_dict
|
||||
from haystack.utils.async_utils import _execute_component_async
|
||||
from haystack.utils.misc import _deduplicate_documents
|
||||
|
||||
|
||||
@component
|
||||
class MultiQueryTextRetriever:
|
||||
"""
|
||||
A component that retrieves documents using multiple queries in parallel with a text-based retriever.
|
||||
|
||||
This component takes a list of text queries and uses a text-based retriever to find relevant documents for each
|
||||
query in parallel, using a thread pool to manage concurrent execution. The results are combined and sorted by
|
||||
relevance score.
|
||||
|
||||
You can use this component in combination with QueryExpander component to enhance the retrieval process.
|
||||
|
||||
### Usage example
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.components.writers import DocumentWriter
|
||||
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
||||
from haystack.document_stores.types import DuplicatePolicy
|
||||
from haystack.components.retrievers import InMemoryBM25Retriever
|
||||
from haystack.components.query import QueryExpander
|
||||
from haystack.components.retrievers.multi_query_text_retriever import MultiQueryTextRetriever
|
||||
|
||||
documents = [
|
||||
Document(content="Renewable energy is energy that is collected from renewable resources."),
|
||||
Document(content="Solar energy is a type of green energy that is harnessed from the sun."),
|
||||
Document(content="Wind energy is another type of green energy that is generated by wind turbines."),
|
||||
Document(content="Hydropower is a form of renewable energy using the flow of water to generate electricity."),
|
||||
Document(content="Geothermal energy is heat that comes from the sub-surface of the earth.")
|
||||
]
|
||||
|
||||
document_store = InMemoryDocumentStore()
|
||||
doc_writer = DocumentWriter(document_store=document_store, policy=DuplicatePolicy.SKIP)
|
||||
doc_writer.run(documents=documents)
|
||||
|
||||
in_memory_retriever = InMemoryBM25Retriever(document_store=document_store, top_k=1)
|
||||
multiquery_retriever = MultiQueryTextRetriever(retriever=in_memory_retriever)
|
||||
results = multiquery_retriever.run(queries=["renewable energy?", "Geothermal", "Hydropower"])
|
||||
for doc in results["documents"]:
|
||||
print(f"Content: {doc.content}, Score: {doc.score}")
|
||||
# >>
|
||||
# >> Content: Geothermal energy is heat that comes from the sub-surface of the earth., Score: 1.6474448833731097
|
||||
# >> Content: Hydropower is a form of renewable energy using the flow of water to generate electricity., Score: 1.615
|
||||
# >> Content: Renewable energy is energy that is collected from renewable resources., Score: 1.5255309812344944
|
||||
```
|
||||
""" # noqa E501
|
||||
|
||||
def __init__(self, *, retriever: TextRetriever, max_workers: int = 3) -> None:
|
||||
"""
|
||||
Initialize MultiQueryTextRetriever.
|
||||
|
||||
:param retriever: The text-based retriever to use for document retrieval.
|
||||
:param max_workers: Maximum number of worker threads for parallel processing. Default is 3.
|
||||
"""
|
||||
self.retriever = retriever
|
||||
self.max_workers = max_workers
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the retriever.
|
||||
"""
|
||||
if hasattr(self.retriever, "warm_up"):
|
||||
self.retriever.warm_up()
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""
|
||||
Warm up the retriever on the serving event loop.
|
||||
"""
|
||||
if hasattr(self.retriever, "warm_up_async"):
|
||||
await self.retriever.warm_up_async()
|
||||
elif hasattr(self.retriever, "warm_up"):
|
||||
self.retriever.warm_up()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Release the retriever's resources.
|
||||
"""
|
||||
if hasattr(self.retriever, "close"):
|
||||
self.retriever.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Release the retriever's async resources.
|
||||
"""
|
||||
if hasattr(self.retriever, "close_async"):
|
||||
await self.retriever.close_async()
|
||||
elif hasattr(self.retriever, "close"):
|
||||
self.retriever.close()
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(self, queries: list[str], retriever_kwargs: dict[str, Any] | None = None) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Retrieve documents using multiple queries in parallel.
|
||||
|
||||
:param queries: List of text queries to process.
|
||||
:param retriever_kwargs: Optional dictionary of arguments to pass to the retriever's run method.
|
||||
:returns:
|
||||
A dictionary containing:
|
||||
`documents`: List of retrieved documents sorted by relevance score.
|
||||
"""
|
||||
docs: list[Document] = []
|
||||
retriever_kwargs = retriever_kwargs or {}
|
||||
|
||||
self.warm_up()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||
queries_results = executor.map(lambda query: self._run_on_thread(query, retriever_kwargs), queries)
|
||||
for result in queries_results:
|
||||
if not result:
|
||||
continue
|
||||
docs.extend(result)
|
||||
|
||||
# de-duplicate and sort
|
||||
docs = _deduplicate_documents(docs)
|
||||
docs.sort(key=lambda x: x.score or 0.0, reverse=True)
|
||||
return {"documents": docs}
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
async def run_async(
|
||||
self, queries: list[str], retriever_kwargs: dict[str, Any] | None = None
|
||||
) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Retrieve documents using multiple queries concurrently.
|
||||
|
||||
Uses the retriever's `run_async` method if available, otherwise falls back to running `run`
|
||||
in a thread executor. Queries are processed concurrently using asyncio.gather.
|
||||
|
||||
:param queries: List of text queries to process.
|
||||
:param retriever_kwargs: Optional dictionary of arguments to pass to the retriever's run method.
|
||||
:returns:
|
||||
A dictionary containing:
|
||||
`documents`: List of retrieved documents sorted by relevance score.
|
||||
"""
|
||||
retriever_kwargs = retriever_kwargs or {}
|
||||
|
||||
await self.warm_up_async()
|
||||
|
||||
results = await asyncio.gather(*[self._run_one_async(q, retriever_kwargs) for q in queries])
|
||||
docs: list[Document] = [doc for result in results if result for doc in result]
|
||||
docs = _deduplicate_documents(docs)
|
||||
docs.sort(key=lambda x: x.score or 0.0, reverse=True)
|
||||
return {"documents": docs}
|
||||
|
||||
def _run_on_thread(self, query: str, retriever_kwargs: dict[str, Any] | None = None) -> list[Document] | None:
|
||||
"""
|
||||
Process a single query on a separate thread.
|
||||
|
||||
:param query: The text query to process.
|
||||
:param retriever_kwargs: Optional dictionary of arguments to pass to the retriever's run method.
|
||||
:returns:
|
||||
List of retrieved documents or None if no results.
|
||||
"""
|
||||
result = self.retriever.run(query=query, **(retriever_kwargs or {}))
|
||||
if result and "documents" in result:
|
||||
return result["documents"]
|
||||
return None
|
||||
|
||||
async def _run_one_async(self, query: str, retriever_kwargs: dict[str, Any]) -> list[Document] | None:
|
||||
"""
|
||||
Process a single query asynchronously.
|
||||
|
||||
:param query: The text query to process.
|
||||
:param retriever_kwargs: Arguments to pass to the retriever's run method.
|
||||
:returns:
|
||||
List of retrieved documents or None if no results.
|
||||
"""
|
||||
result = await _execute_component_async(self.retriever, query=query, **retriever_kwargs)
|
||||
|
||||
if result and "documents" in result:
|
||||
return result["documents"]
|
||||
return None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
The serialized component as a dictionary.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self, retriever=component_to_dict(obj=self.retriever, name="retriever"), max_workers=self.max_workers
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "MultiQueryTextRetriever":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data: The dictionary to deserialize from.
|
||||
:returns:
|
||||
The deserialized component.
|
||||
"""
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,363 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from math import inf
|
||||
from typing import Any, Literal
|
||||
|
||||
from haystack import component, default_from_dict, default_to_dict
|
||||
from haystack.components.retrievers.types.protocol import TextRetriever
|
||||
from haystack.core.serialization import component_from_dict, component_to_dict, import_class_by_name
|
||||
from haystack.dataclasses import Document
|
||||
from haystack.utils.async_utils import _execute_component_async
|
||||
from haystack.utils.experimental import _experimental
|
||||
from haystack.utils.misc import _deduplicate_documents, _reciprocal_rank_fusion
|
||||
|
||||
|
||||
@_experimental
|
||||
@component
|
||||
class MultiRetriever:
|
||||
"""
|
||||
A component that accepts text retrievers and runs them in parallel, combining their results.
|
||||
|
||||
> **Note:** This component is experimental and may change or be removed in future releases without prior
|
||||
deprecation notice.
|
||||
|
||||
All retrievers must implement the `TextRetriever` protocol. Use `TextEmbeddingRetriever` to wrap an
|
||||
embedding-based retriever before passing it to this component.
|
||||
|
||||
Each retriever is queried concurrently using a thread pool.
|
||||
The results are deduplicated and returned as a single list of documents.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
||||
from haystack.document_stores.types import DuplicatePolicy
|
||||
from haystack.components.retrievers import InMemoryBM25Retriever, InMemoryEmbeddingRetriever
|
||||
from haystack.components.retrievers import TextEmbeddingRetriever, MultiRetriever
|
||||
from haystack.components.embedders import OpenAITextEmbedder, OpenAIDocumentEmbedder
|
||||
from haystack.components.writers import DocumentWriter
|
||||
|
||||
documents = [
|
||||
Document(content="Renewable energy is energy that is collected from renewable resources."),
|
||||
Document(content="Solar energy is a type of green energy that is harnessed from the sun."),
|
||||
Document(content="Wind energy is another type of green energy that is generated by wind turbines."),
|
||||
]
|
||||
|
||||
# Populate the document store
|
||||
doc_store = InMemoryDocumentStore()
|
||||
doc_embedder = OpenAIDocumentEmbedder()
|
||||
doc_writer = DocumentWriter(document_store=doc_store, policy=DuplicatePolicy.SKIP)
|
||||
doc_writer.run(documents=doc_embedder.run(documents)["documents"])
|
||||
|
||||
# Run the multi-retriever with all retrievers
|
||||
retriever = MultiRetriever(
|
||||
retrievers={
|
||||
"bm25": InMemoryBM25Retriever(document_store=doc_store),
|
||||
"embedding": TextEmbeddingRetriever(
|
||||
retriever=InMemoryEmbeddingRetriever(document_store=doc_store),
|
||||
text_embedder=OpenAITextEmbedder(),
|
||||
),
|
||||
},
|
||||
top_k=3,
|
||||
)
|
||||
|
||||
# Run all retrievers
|
||||
result = retriever.run(query="green energy sources")
|
||||
|
||||
# Run only the BM25 retriever
|
||||
result = retriever.run(query="green energy sources", active_retrievers=["bm25"])
|
||||
|
||||
for doc in result["documents"]:
|
||||
print(doc.content)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
retrievers: dict[str, TextRetriever],
|
||||
filters: dict[str, Any] | None = None,
|
||||
top_k_per_retriever: int | None = None,
|
||||
top_k: int | None = None,
|
||||
max_workers: int = 4,
|
||||
join_mode: Literal["concatenate", "reciprocal_rank_fusion"] = "reciprocal_rank_fusion",
|
||||
) -> None:
|
||||
"""
|
||||
Create the MultiRetriever component.
|
||||
|
||||
:param retrievers:
|
||||
A dictionary mapping names to text retrievers (implementing the `TextRetriever` protocol) to run in
|
||||
parallel.
|
||||
:param filters:
|
||||
A dictionary of filters to apply when retrieving documents.
|
||||
:param top_k_per_retriever:
|
||||
The maximum number of documents to return per retriever. If set, this will override the `top_k`
|
||||
parameter for each retriever. If None, the `top_k` parameter of retrievers will be used.
|
||||
:param top_k:
|
||||
The maximum number of documents to return overall, extracted from the combined results of all
|
||||
retrievers. When set, the results are always merged using reciprocal rank fusion (regardless of
|
||||
`join_mode`) so that the combined list has a consistent global ranking before it is truncated to
|
||||
`top_k`. If None, all results are returned.
|
||||
:param max_workers:
|
||||
The maximum number of threads to use for parallel retrieval.
|
||||
:param join_mode:
|
||||
How to merge results from multiple retrievers. Available modes:
|
||||
- `concatenate`: Combines all results into a single list and deduplicates.
|
||||
- `reciprocal_rank_fusion`: Deduplicates and assigns scores based on reciprocal rank fusion.
|
||||
"""
|
||||
self.retrievers = retrievers
|
||||
self.filters = filters
|
||||
self.top_k_per_retriever = top_k_per_retriever
|
||||
self.top_k = top_k
|
||||
self.max_workers = max_workers
|
||||
self.join_mode = join_mode
|
||||
|
||||
def _merge_results(self, document_lists: list[list[Document]], top_k: int | None = None) -> list[Document]:
|
||||
"""
|
||||
Merge per-retriever result lists according to `join_mode`.
|
||||
|
||||
In `concatenate` mode, all lists are flattened and deduplicated. In `reciprocal_rank_fusion` mode, results
|
||||
are deduplicated and re-scored using RRF, then returned in descending score order. When `top_k` is set, RRF
|
||||
is always used so the combined results have a consistent global ranking, and only the top `top_k` documents
|
||||
are returned.
|
||||
"""
|
||||
# When top_k is set we always use reciprocal rank fusion to merge the results, regardless of join_mode,
|
||||
# so that truncation is applied to a consistently ranked list.
|
||||
if top_k is not None or self.join_mode == "reciprocal_rank_fusion":
|
||||
documents = _reciprocal_rank_fusion(document_lists)
|
||||
merged = sorted(documents, key=lambda d: d.score if d.score is not None else -inf, reverse=True)
|
||||
return merged[:top_k] if top_k is not None else merged
|
||||
return _deduplicate_documents([doc for docs in document_lists for doc in docs])
|
||||
|
||||
def _resolve_retrievers(self, active_retrievers: list[str] | None) -> dict[str, TextRetriever]:
|
||||
"""
|
||||
Returns the subset of retrievers to run based on the active_retrievers list.
|
||||
|
||||
:param active_retrievers:
|
||||
A list of retriever names to run. If None, all retrievers are returned.
|
||||
|
||||
:returns:
|
||||
A dictionary of retriever names to retriever instances.
|
||||
|
||||
:raises ValueError:
|
||||
If any name in `active_retrievers` does not match a retriever name.
|
||||
"""
|
||||
if active_retrievers is None:
|
||||
return self.retrievers
|
||||
unknown = set(active_retrievers) - self.retrievers.keys()
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"Unknown retriever name(s): {sorted(unknown)}. Available retrievers: {sorted(self.retrievers.keys())}"
|
||||
)
|
||||
return {name: self.retrievers[name] for name in active_retrievers}
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the retrievers.
|
||||
"""
|
||||
for retriever in self.retrievers.values():
|
||||
if hasattr(retriever, "warm_up"):
|
||||
retriever.warm_up()
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""
|
||||
Warm up the retrievers on the serving event loop.
|
||||
"""
|
||||
for retriever in self.retrievers.values():
|
||||
if hasattr(retriever, "warm_up_async"):
|
||||
await retriever.warm_up_async()
|
||||
elif hasattr(retriever, "warm_up"):
|
||||
retriever.warm_up()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Release the retrievers' resources.
|
||||
"""
|
||||
for retriever in self.retrievers.values():
|
||||
if hasattr(retriever, "close"):
|
||||
retriever.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Release the retrievers' async resources.
|
||||
"""
|
||||
for retriever in self.retrievers.values():
|
||||
if hasattr(retriever, "close_async"):
|
||||
await retriever.close_async()
|
||||
elif hasattr(retriever, "close"):
|
||||
retriever.close()
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(
|
||||
self,
|
||||
query: str,
|
||||
filters: dict[str, Any] | None = None,
|
||||
top_k_per_retriever: int | None = None,
|
||||
top_k: int | None = None,
|
||||
*,
|
||||
active_retrievers: list[str] | None = None,
|
||||
) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Runs retrievers in parallel on the given query and returns deduplicated results.
|
||||
|
||||
:param query:
|
||||
The query to run the retrievers on.
|
||||
:param filters:
|
||||
Filters to apply. Defaults to the value set at initialization.
|
||||
:param top_k_per_retriever:
|
||||
The maximum number of documents to return per retriever. When set, this will override the `top_k`
|
||||
parameter for each retriever. If None, the `top_k` parameter set for retrievers will be used.
|
||||
Defaults to the value set at initialization.
|
||||
:param top_k:
|
||||
The maximum number of documents to return overall, extracted from the combined results of all
|
||||
retrievers. When set, the results are always merged using reciprocal rank fusion (regardless of
|
||||
`join_mode`) so that the combined list has a consistent global ranking before it is truncated to
|
||||
`top_k`. If None, all results are returned. Defaults to the value set at initialization.
|
||||
:param active_retrievers:
|
||||
Names of retrievers to run. Defaults to all. Must match keys in the `retrievers` dictionary.
|
||||
|
||||
:returns:
|
||||
A dictionary with the keys:
|
||||
- "documents": A deduplicated list of retrieved documents.
|
||||
|
||||
:raises ValueError:
|
||||
If any name in `active_retrievers` does not match a retriever name.
|
||||
"""
|
||||
self.warm_up()
|
||||
|
||||
resolved_top_k_per_retriever = (
|
||||
top_k_per_retriever if top_k_per_retriever is not None else self.top_k_per_retriever
|
||||
)
|
||||
resolved_top_k = top_k if top_k is not None else self.top_k
|
||||
resolved_filters = filters if filters is not None else self.filters
|
||||
|
||||
retrievers_to_run = self._resolve_retrievers(active_retrievers)
|
||||
|
||||
results_by_name: dict[str, list[Document]] = {}
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
|
||||
future_to_name = {}
|
||||
for name, retriever in retrievers_to_run.items():
|
||||
run_kwargs: dict[str, Any] = {"query": query}
|
||||
if resolved_top_k_per_retriever is not None:
|
||||
run_kwargs["top_k"] = resolved_top_k_per_retriever
|
||||
if resolved_filters is not None:
|
||||
run_kwargs["filters"] = resolved_filters
|
||||
future_to_name[executor.submit(retriever.run, **run_kwargs)] = name
|
||||
|
||||
for future in as_completed(future_to_name):
|
||||
name = future_to_name[future]
|
||||
try:
|
||||
results_by_name[name] = future.result().get("documents", [])
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Retriever '{name}' failed: {e}") from e
|
||||
|
||||
document_lists = [results_by_name[name] for name in retrievers_to_run]
|
||||
return {"documents": self._merge_results(document_lists, top_k=resolved_top_k)}
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
async def run_async(
|
||||
self,
|
||||
query: str,
|
||||
filters: dict[str, Any] | None = None,
|
||||
top_k_per_retriever: int | None = None,
|
||||
top_k: int | None = None,
|
||||
*,
|
||||
active_retrievers: list[str] | None = None,
|
||||
) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Runs retrievers concurrently on the given query and returns deduplicated results.
|
||||
|
||||
Uses each retriever's `run_async` method if available, otherwise runs `run` in a thread executor.
|
||||
|
||||
:param query:
|
||||
The query to run the retrievers on.
|
||||
:param filters:
|
||||
Filters to apply. Defaults to the value set at initialization.
|
||||
:param top_k_per_retriever:
|
||||
The maximum number of documents to return per retriever. When set, this will override the `top_k`
|
||||
parameter for each retriever. If None, the `top_k` parameter set for retrievers will be used.
|
||||
Defaults to the value set at initialization.
|
||||
:param top_k:
|
||||
The maximum number of documents to return overall, extracted from the combined results of all
|
||||
retrievers. When set, the results are always merged using reciprocal rank fusion (regardless of
|
||||
`join_mode`) so that the combined list has a consistent global ranking before it is truncated to
|
||||
`top_k`. If None, all results are returned. Defaults to the value set at initialization.
|
||||
:param active_retrievers:
|
||||
Names of retrievers to run. Defaults to all. Must match keys in the `retrievers` dictionary.
|
||||
|
||||
:returns:
|
||||
A dictionary with the keys:
|
||||
- "documents": A deduplicated list of retrieved documents.
|
||||
|
||||
:raises ValueError:
|
||||
If any name in `active_retrievers` does not match a retriever name.
|
||||
"""
|
||||
await self.warm_up_async()
|
||||
|
||||
resolved_top_k_per_retriever = (
|
||||
top_k_per_retriever if top_k_per_retriever is not None else self.top_k_per_retriever
|
||||
)
|
||||
resolved_top_k = top_k if top_k is not None else self.top_k
|
||||
resolved_filters = filters if filters is not None else self.filters
|
||||
|
||||
retrievers_to_run = self._resolve_retrievers(active_retrievers)
|
||||
|
||||
run_kwargs: dict[str, Any] = {"query": query}
|
||||
if resolved_top_k_per_retriever is not None:
|
||||
run_kwargs["top_k"] = resolved_top_k_per_retriever
|
||||
if resolved_filters is not None:
|
||||
run_kwargs["filters"] = resolved_filters
|
||||
|
||||
async def _run_one(name: str, retriever: TextRetriever) -> list[Document]:
|
||||
try:
|
||||
result = await _execute_component_async(retriever, **run_kwargs)
|
||||
return result.get("documents", [])
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Retriever '{name}' failed: {e}") from e
|
||||
|
||||
document_lists = list(await asyncio.gather(*[_run_one(name, r) for name, r in retrievers_to_run.items()]))
|
||||
return {"documents": self._merge_results(document_lists, top_k=resolved_top_k)}
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
Dictionary with serialized data.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self,
|
||||
retrievers={name: component_to_dict(obj=r, name=name) for name, r in self.retrievers.items()},
|
||||
filters=self.filters,
|
||||
top_k_per_retriever=self.top_k_per_retriever,
|
||||
top_k=self.top_k,
|
||||
max_workers=self.max_workers,
|
||||
join_mode=self.join_mode,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "MultiRetriever":
|
||||
"""
|
||||
Creates an instance of the component from a dictionary.
|
||||
|
||||
:param data:
|
||||
Dictionary with the data to create the component.
|
||||
"""
|
||||
retrievers_data = data.get("init_parameters", {}).get("retrievers", {})
|
||||
if retrievers_data:
|
||||
retrievers = {}
|
||||
for name, retriever_data in retrievers_data.items():
|
||||
try:
|
||||
imported_class = import_class_by_name(retriever_data["type"])
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
f"Could not import class {retriever_data['type']} for retriever '{name}'. Error: {str(e)}"
|
||||
) from e
|
||||
retrievers[name] = component_from_dict(cls=imported_class, data=retriever_data, name=name)
|
||||
data["init_parameters"]["retrievers"] = retrievers
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,321 @@
|
||||
# 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}
|
||||
@@ -0,0 +1,181 @@
|
||||
# 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
|
||||
from haystack.components.embedders.types.protocol import TextEmbedder
|
||||
from haystack.components.retrievers.types import EmbeddingRetriever
|
||||
from haystack.core.serialization import component_to_dict
|
||||
from haystack.utils.async_utils import _execute_component_async
|
||||
|
||||
|
||||
@component
|
||||
class TextEmbeddingRetriever:
|
||||
"""
|
||||
A component that retrieves documents using a query with an embedding-based retriever.
|
||||
|
||||
This component takes a text query, converts it to an embedding using a text embedder, and then uses an
|
||||
embedding-based retriever to find relevant documents.
|
||||
The results are sorted by relevance score.
|
||||
|
||||
### Usage example
|
||||
|
||||
```python
|
||||
from haystack import Document
|
||||
from haystack.document_stores.in_memory import InMemoryDocumentStore
|
||||
from haystack.document_stores.types import DuplicatePolicy
|
||||
from haystack.components.embedders import OpenAITextEmbedder, OpenAIDocumentEmbedder
|
||||
from haystack.components.retrievers import InMemoryEmbeddingRetriever, TextEmbeddingRetriever
|
||||
from haystack.components.writers import DocumentWriter
|
||||
|
||||
documents = [
|
||||
Document(content="Renewable energy is energy that is collected from renewable resources."),
|
||||
Document(content="Solar energy is a type of green energy that is harnessed from the sun."),
|
||||
Document(content="Wind energy is another type of green energy that is generated by wind turbines."),
|
||||
Document(content="Geothermal energy is heat that comes from the sub-surface of the earth."),
|
||||
Document(content="Biomass energy is produced from organic materials, such as plant and animal waste."),
|
||||
Document(content="Fossil fuels, such as coal, oil, and natural gas, are non-renewable energy sources."),
|
||||
]
|
||||
|
||||
# Populate the document store
|
||||
doc_store = InMemoryDocumentStore()
|
||||
doc_embedder = OpenAIDocumentEmbedder()
|
||||
doc_writer = DocumentWriter(document_store=doc_store, policy=DuplicatePolicy.SKIP)
|
||||
documents = doc_embedder.run(documents)["documents"]
|
||||
doc_writer.run(documents=documents)
|
||||
|
||||
# Run the retriever
|
||||
in_memory_retriever = InMemoryEmbeddingRetriever(document_store=doc_store, top_k=1)
|
||||
text_embedder = OpenAITextEmbedder()
|
||||
retriever = TextEmbeddingRetriever(retriever=in_memory_retriever, text_embedder=text_embedder)
|
||||
result = retriever.run(query="Geothermal energy")
|
||||
|
||||
for doc in result["documents"]:
|
||||
print(f"Content: {doc.content}, Score: {doc.score}")
|
||||
# >> Content: Geothermal energy is heat that comes from the sub-surface of the earth., Score: 0.8509603046266574
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, *, retriever: EmbeddingRetriever, text_embedder: TextEmbedder) -> None:
|
||||
"""
|
||||
Initialize TextEmbeddingRetriever.
|
||||
|
||||
:param retriever: The embedding-based retriever to use for document retrieval.
|
||||
:param text_embedder: The text embedder to convert a text query to an embedding.
|
||||
"""
|
||||
self.retriever = retriever
|
||||
self.text_embedder = text_embedder
|
||||
|
||||
def warm_up(self) -> None:
|
||||
"""
|
||||
Warm up the text embedder and the retriever.
|
||||
"""
|
||||
for inner in (self.text_embedder, self.retriever):
|
||||
if hasattr(inner, "warm_up"):
|
||||
inner.warm_up()
|
||||
|
||||
async def warm_up_async(self) -> None:
|
||||
"""
|
||||
Warm up the text embedder and the retriever on the serving event loop.
|
||||
"""
|
||||
for inner in (self.text_embedder, self.retriever):
|
||||
if hasattr(inner, "warm_up_async"):
|
||||
await inner.warm_up_async()
|
||||
elif hasattr(inner, "warm_up"):
|
||||
inner.warm_up()
|
||||
|
||||
def close(self) -> None:
|
||||
"""
|
||||
Release the text embedder's and the retriever's resources.
|
||||
"""
|
||||
for inner in (self.text_embedder, self.retriever):
|
||||
if hasattr(inner, "close"):
|
||||
inner.close()
|
||||
|
||||
async def close_async(self) -> None:
|
||||
"""
|
||||
Release the text embedder's and the retriever's async resources.
|
||||
"""
|
||||
for inner in (self.text_embedder, self.retriever):
|
||||
if hasattr(inner, "close_async"):
|
||||
await inner.close_async()
|
||||
elif hasattr(inner, "close"):
|
||||
inner.close()
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
def run(
|
||||
self, query: str, filters: dict[str, Any] | None = None, top_k: int | None = None
|
||||
) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Retrieve documents using a single query.
|
||||
|
||||
:param query: The query to retrieve documents for.
|
||||
:param filters: A dictionary of filters to apply when retrieving documents.
|
||||
:param top_k: The maximum number of documents to return.
|
||||
:returns:
|
||||
A dictionary containing:
|
||||
- `documents`: List of retrieved documents sorted by relevance score.
|
||||
"""
|
||||
self.warm_up()
|
||||
|
||||
embedding_result = self.text_embedder.run(text=query)
|
||||
result = self.retriever.run(query_embedding=embedding_result["embedding"], filters=filters, top_k=top_k)
|
||||
docs: list[Document] = result["documents"]
|
||||
|
||||
# sort
|
||||
docs.sort(key=lambda x: x.score or 0.0, reverse=True)
|
||||
return {"documents": docs}
|
||||
|
||||
@component.output_types(documents=list[Document])
|
||||
async def run_async(
|
||||
self, query: str, filters: dict[str, Any] | None = None, top_k: int | None = None
|
||||
) -> dict[str, list[Document]]:
|
||||
"""
|
||||
Retrieve documents using a single query asynchronously.
|
||||
|
||||
Uses `run_async` on the text embedder and retriever if available, otherwise falls back to
|
||||
running `run` in a thread executor.
|
||||
|
||||
:param query: The query to retrieve documents for.
|
||||
:param filters: A dictionary of filters to apply when retrieving documents.
|
||||
:param top_k: The maximum number of documents to return.
|
||||
:returns:
|
||||
A dictionary containing:
|
||||
- `documents`: List of retrieved documents sorted by relevance score.
|
||||
"""
|
||||
await self.warm_up_async()
|
||||
|
||||
embedding_result = await _execute_component_async(self.text_embedder, text=query)
|
||||
result = await _execute_component_async(
|
||||
self.retriever, query_embedding=embedding_result["embedding"], filters=filters, top_k=top_k
|
||||
)
|
||||
|
||||
docs: list[Document] = result["documents"]
|
||||
docs.sort(key=lambda x: x.score or 0.0, reverse=True)
|
||||
return {"documents": docs}
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Serializes the component to a dictionary.
|
||||
|
||||
:returns:
|
||||
A dictionary representing the serialized component.
|
||||
"""
|
||||
return default_to_dict(
|
||||
self,
|
||||
retriever=component_to_dict(obj=self.retriever, name="retriever"),
|
||||
text_embedder=component_to_dict(obj=self.text_embedder, name="text_embedder"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "TextEmbeddingRetriever":
|
||||
"""
|
||||
Deserializes the component from a dictionary.
|
||||
|
||||
:param data: The dictionary to deserialize from.
|
||||
:returns:
|
||||
The deserialized component.
|
||||
"""
|
||||
return default_from_dict(cls, data)
|
||||
@@ -0,0 +1,7 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from .protocol import EmbeddingRetriever, TextRetriever
|
||||
|
||||
__all__ = ["TextRetriever", "EmbeddingRetriever"]
|
||||
@@ -0,0 +1,56 @@
|
||||
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
from typing import Any, Protocol
|
||||
|
||||
|
||||
class TextRetriever(Protocol):
|
||||
"""
|
||||
This protocol defines the minimal interface that all keyword-based BM25 Retrievers must implement.
|
||||
|
||||
Retrievers are components that process a query and, based on that query, return relevant documents from a document
|
||||
store or other data source. They return a dictionary with a list of Document objects.
|
||||
"""
|
||||
|
||||
def run(self, query: str, filters: dict[str, Any] | None = None, top_k: int | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Retrieve documents that are relevant to the query.
|
||||
|
||||
Implementing classes may accept additional optional parameters in their run method.
|
||||
|
||||
:param query: The input query string.
|
||||
:param filters: A dictionary of filters to apply when retrieving documents.
|
||||
:param top_k: The maximum number of documents to return.
|
||||
|
||||
:returns:
|
||||
A dictionary containing:
|
||||
`documents`: List of retrieved documents sorted by relevance score.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
class EmbeddingRetriever(Protocol):
|
||||
"""
|
||||
This protocol defines the minimal interface that all embedding-based Retrievers must implement.
|
||||
|
||||
Retrievers are components that process a query and, based on that query, return relevant documents from a document
|
||||
store or other data source. They return a dictionary with a list of Document objects.
|
||||
"""
|
||||
|
||||
def run(
|
||||
self, query_embedding: list[float], filters: dict[str, Any] | None = None, top_k: int | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Retrieve documents that are relevant to the query.
|
||||
|
||||
Implementing classes may accept additional optional parameters in their run method.
|
||||
|
||||
:param query_embedding: The input query embedding.
|
||||
:param filters: A dictionary of filters to apply when retrieving documents.
|
||||
:param top_k: The maximum number of documents to return.
|
||||
:returns:
|
||||
A dictionary containing:
|
||||
`documents`: List of retrieved documents sorted by relevance score.
|
||||
"""
|
||||
...
|
||||
Reference in New Issue
Block a user