Files
wehub-resource-sync c56bef871b
Sync docs with Docusaurus / sync (push) Waiting to run
Tests / Check if changed (push) Waiting to run
Tests / format (push) Blocked by required conditions
Tests / check-imports (push) Blocked by required conditions
Tests / Unit / macos-latest (push) Blocked by required conditions
Tests / Unit / ubuntu-latest (push) Blocked by required conditions
Tests / Unit / windows-latest (push) Blocked by required conditions
Tests / mypy (push) Blocked by required conditions
Tests / Integration / ubuntu-latest (push) Blocked by required conditions
Tests / Integration / macos-latest (push) Blocked by required conditions
Tests / Integration / windows-latest (push) Blocked by required conditions
Tests / notify-slack-on-failure (push) Blocked by required conditions
Tests / Mark tests as completed (push) Blocked by required conditions
Docker image release / Build base image (push) Waiting to run
CodeQL / Analyze (python) (push) Has been cancelled
Update Platform Components Table / update (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:28 +08:00

364 lines
16 KiB
Python

# 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)