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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:22:28 +08:00
commit c56bef871b
9296 changed files with 1854228 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# 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 = {
"llm_ranker": ["LLMRanker"],
"lost_in_the_middle": ["LostInTheMiddleRanker"],
"meta_field": ["MetaFieldRanker"],
"meta_field_grouping_ranker": ["MetaFieldGroupingRanker"],
}
if TYPE_CHECKING:
from .llm_ranker import LLMRanker as LLMRanker
from .lost_in_the_middle import LostInTheMiddleRanker as LostInTheMiddleRanker
from .meta_field import MetaFieldRanker as MetaFieldRanker
from .meta_field_grouping_ranker import MetaFieldGroupingRanker as MetaFieldGroupingRanker
else:
sys.modules[__name__] = LazyImporter(name=__name__, module_file=__file__, import_structure=_import_structure)
+403
View File
@@ -0,0 +1,403 @@
# 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.components.builders import PromptBuilder
from haystack.components.generators.chat.openai import OpenAIChatGenerator
from haystack.components.generators.chat.types import ChatGenerator
from haystack.core.serialization import component_to_dict
from haystack.dataclasses import ChatMessage
from haystack.utils import deserialize_chatgenerator_inplace
from haystack.utils.async_utils import _execute_component_async
from haystack.utils.misc import _deduplicate_documents, _parse_dict_from_json
logger = logging.getLogger(__name__)
def _default_openai_chat_generator() -> ChatGenerator:
return OpenAIChatGenerator(
model="gpt-4.1-mini",
generation_kwargs={
"temperature": 0.0,
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "document_ranking",
"schema": {
"type": "object",
"properties": {
"documents": {
"type": "array",
"items": {
"type": "object",
"properties": {"index": {"type": "integer"}},
"required": ["index"],
"additionalProperties": False,
},
}
},
"required": ["documents"],
"additionalProperties": False,
},
},
},
},
)
DEFAULT_PROMPT_TEMPLATE = """
You are ranking retrieved documents for relevance to a query.
Return valid JSON only, with this structure:
{
"documents": [
{"index": 1}
]
}
Rules:
- Rank documents from most relevant to least relevant for answering the query.
- Only include documents that are relevant to the query.
- Do not return or rank documents that are not relevant.
- If none are relevant, return {"documents": []}.
- Use only document indices from the provided documents.
- Do not repeat document indices.
- Do not include explanations or any text outside the JSON object.
Query:
{{ query }}
Documents:
{% for document in documents %}
Document {{ loop.index }}:
content: {{ document.content or "" }}
{% endfor %}
""".strip()
@component
class LLMRanker:
"""
Ranks documents for a query using a Large Language Model.
The LLM is expected to return a JSON object containing ranked document indices.
Usage example:
```python
from haystack import Document
from haystack.components.generators.chat import OpenAIChatGenerator
from haystack.components.rankers import LLMRanker
chat_generator = OpenAIChatGenerator(
model="gpt-4.1-mini",
generation_kwargs={
"temperature": 0.0,
"response_format": {
"type": "json_schema",
"json_schema": {
"name": "document_ranking",
"schema": {
"type": "object",
"properties": {
"documents": {
"type": "array",
"items": {
"type": "object",
"properties": {"index": {"type": "integer"}},
"required": ["index"],
"additionalProperties": False,
},
}
},
"required": ["documents"],
"additionalProperties": False,
},
},
},
},
)
ranker = LLMRanker(chat_generator=chat_generator)
documents = [
Document(id="paris", content="Paris is the capital of France."),
Document(id="berlin", content="Berlin is the capital of Germany."),
]
result = ranker.run(query="capital of Germany", documents=documents)
print(result["documents"][0].id)
```
"""
def __init__(
self,
*,
chat_generator: ChatGenerator | None = None,
prompt: str = DEFAULT_PROMPT_TEMPLATE,
top_k: int = 10,
raise_on_failure: bool = False,
) -> None:
"""
Initialize the LLMRanker component.
:param chat_generator:
The chat generator to use for reranking. If `None`, a default `OpenAIChatGenerator` configured for JSON
output is used.
:param prompt:
Custom prompt template for reranking. The prompt must include exactly the variables `query` and
`documents` and instruct the LLM to return ranked 1-based document indices as JSON.
:param top_k:
The maximum number of documents to return.
:param raise_on_failure:
If `True`, raise when generation or response parsing fails. If `False`, log the failure and return the
input documents in fallback order.
"""
if top_k <= 0:
raise ValueError(f"top_k must be > 0, but got {top_k}")
self.top_k = top_k
self.raise_on_failure = raise_on_failure
self.prompt = prompt
self._prompt_builder = PromptBuilder(template=self.prompt, required_variables=["documents", "query"])
if set(self._prompt_builder.variables) != {"documents", "query"}:
raise ValueError("prompt must include exactly the variables 'documents' and 'query'.")
if chat_generator is None:
self._chat_generator = _default_openai_chat_generator()
else:
self._chat_generator = chat_generator
def warm_up(self) -> None:
"""Warm up the underlying chat generator."""
if hasattr(self._chat_generator, "warm_up"):
self._chat_generator.warm_up()
async def warm_up_async(self) -> None:
"""Warm up the underlying chat generator on the serving event loop."""
if hasattr(self._chat_generator, "warm_up_async"):
await self._chat_generator.warm_up_async()
elif hasattr(self._chat_generator, "warm_up"):
self._chat_generator.warm_up()
def close(self) -> None:
"""Release the underlying chat generator's resources."""
if hasattr(self._chat_generator, "close"):
self._chat_generator.close()
async def close_async(self) -> None:
"""Release the underlying chat generator's async resources."""
if hasattr(self._chat_generator, "close_async"):
await self._chat_generator.close_async()
elif hasattr(self._chat_generator, "close"):
self._chat_generator.close()
def to_dict(self) -> dict[str, Any]:
"""
Serialize this component to a dictionary.
:returns:
Dictionary with serialized data.
"""
return default_to_dict(
self,
chat_generator=component_to_dict(obj=self._chat_generator, name="chat_generator"),
prompt=self.prompt,
top_k=self.top_k,
raise_on_failure=self.raise_on_failure,
)
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "LLMRanker":
"""
Deserialize this component from a dictionary.
:param data:
The dictionary representation of the component.
:returns:
The deserialized component instance.
"""
init_params = data.get("init_parameters", {})
if init_params.get("chat_generator"):
deserialize_chatgenerator_inplace(init_params, key="chat_generator")
return default_from_dict(cls, data)
@component.output_types(documents=list[Document])
def run(self, query: str, documents: list[Document], top_k: int | None = None) -> dict[str, list[Document]]:
"""
Rank documents for a query using an LLM.
Before ranking, duplicate documents are removed.
:param query:
The query used for reranking.
:param documents:
Candidate documents to rerank.
:param top_k:
The maximum number of documents to return. Overrides the instance's `top_k` if provided.
:returns:
A dictionary with the ranked documents under the `documents` key.
"""
if top_k is not None and top_k <= 0:
raise ValueError(f"top_k must be > 0, but got {top_k}")
if not documents:
return {"documents": []}
top_k = self.top_k if top_k is None else top_k
deduplicated_documents = _deduplicate_documents(documents)
fallback_documents = deduplicated_documents
if not query.strip():
logger.warning("Empty query provided to LLMRanker. Returning documents without reranking.")
return {"documents": fallback_documents}
self.warm_up()
prompt = self._prompt_builder.run(query=query.strip(), documents=deduplicated_documents)
try:
result = self._chat_generator.run(messages=[ChatMessage.from_user(prompt["prompt"])])
except Exception as exc:
if self.raise_on_failure:
raise
logger.warning(
"LLMRanker failed during chat generation. Returning fallback order. Error: {error}", error=exc
)
return {"documents": fallback_documents}
try:
reply_text = self._get_reply_text(result)
ranked_documents = self._rank_documents_from_reply(reply_text=reply_text, documents=deduplicated_documents)
except (TypeError, ValueError) as exc:
if self.raise_on_failure:
raise
logger.warning(
"LLMRanker failed while processing the chat response. Returning fallback order. Error: {error}",
error=exc,
)
return {"documents": fallback_documents}
return {"documents": ranked_documents[:top_k]}
@component.output_types(documents=list[Document])
async def run_async(
self, query: str, documents: list[Document], top_k: int | None = None
) -> dict[str, list[Document]]:
"""
Asynchronously rank documents for a query using an LLM.
Before ranking, duplicate documents are removed.
This is the asynchronous version of the `run` method. It has the same parameters and return values
but can be used with `await` in an async code. If the chat generator only implements a synchronous
`run` method, it is executed in a thread to avoid blocking the event loop.
:param query:
The query used for reranking.
:param documents:
Candidate documents to rerank.
:param top_k:
The maximum number of documents to return. Overrides the instance's `top_k` if provided.
:returns:
A dictionary with the ranked documents under the `documents` key.
"""
if top_k is not None and top_k <= 0:
raise ValueError(f"top_k must be > 0, but got {top_k}")
if not documents:
return {"documents": []}
top_k = self.top_k if top_k is None else top_k
deduplicated_documents = _deduplicate_documents(documents)
fallback_documents = deduplicated_documents
if not query.strip():
logger.warning("Empty query provided to LLMRanker. Returning documents without reranking.")
return {"documents": fallback_documents}
await self.warm_up_async()
prompt = self._prompt_builder.run(query=query.strip(), documents=deduplicated_documents)
try:
result = await _execute_component_async(
self._chat_generator, messages=[ChatMessage.from_user(prompt["prompt"])]
)
except Exception as exc:
if self.raise_on_failure:
raise
logger.warning(
"LLMRanker failed during chat generation. Returning fallback order. Error: {error}", error=exc
)
return {"documents": fallback_documents}
try:
reply_text = self._get_reply_text(result)
ranked_documents = self._rank_documents_from_reply(reply_text=reply_text, documents=deduplicated_documents)
except (TypeError, ValueError) as exc:
if self.raise_on_failure:
raise
logger.warning(
"LLMRanker failed while processing the chat response. Returning fallback order. Error: {error}",
error=exc,
)
return {"documents": fallback_documents}
return {"documents": ranked_documents[:top_k]}
@staticmethod
def _get_reply_text(result: dict[str, Any]) -> str:
replies = result.get("replies") or []
if not replies:
raise ValueError("ChatGenerator returned no replies.")
reply_text = replies[0].text
if reply_text is None:
raise ValueError("ChatGenerator returned a reply without text.")
return reply_text
@staticmethod
def _rank_documents_from_reply(reply_text: str, documents: list[Document]) -> list[Document]:
parsed_response = _parse_dict_from_json(reply_text, expected_keys=["documents"], raise_on_failure=True)
ranked_entries = parsed_response["documents"]
if not isinstance(ranked_entries, list):
raise TypeError("Expected 'documents' in ranking response to be a list.")
if not ranked_entries:
return []
ranked_documents: list[Document] = []
for entry in ranked_entries:
if not isinstance(entry, dict):
raise TypeError("Expected each ranked document entry to be a JSON object.")
document_index = entry.get("index")
if document_index is None:
continue
try:
# LLMs can return numeric indices as strings even when asked for integers.
document_index = int(document_index)
except (TypeError, ValueError):
continue
# Jinja's `loop.index` is 1-based:
# https://jinja.palletsprojects.com/en/stable/templates/#for
if document_index < 1 or document_index > len(documents):
continue
document = documents[document_index - 1]
ranked_documents.append(document)
if not ranked_documents:
raise ValueError("Ranking response did not contain any valid document indices.")
return ranked_documents
@@ -0,0 +1,137 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from haystack import Document, component
from haystack.utils.misc import _deduplicate_documents
@component
class LostInTheMiddleRanker:
"""
A LostInTheMiddle Ranker.
Ranks documents based on the 'lost in the middle' order so that the most relevant documents are either at the
beginning or end, while the least relevant are in the middle.
LostInTheMiddleRanker assumes that some prior component in the pipeline has already ranked documents by relevance
and requires no query as input but only documents. It is typically used as the last component before building a
prompt for an LLM to prepare the input context for the LLM.
Lost in the Middle ranking lays out document contents into LLM context so that the most relevant contents are at
the beginning or end of the input context, while the least relevant is in the middle of the context. See the
paper ["Lost in the Middle: How Language Models Use Long Contexts"](https://arxiv.org/abs/2307.03172) for more
details.
Usage example:
```python
from haystack.components.rankers import LostInTheMiddleRanker
from haystack import Document
ranker = LostInTheMiddleRanker()
docs = [Document(content="Paris"), Document(content="Berlin"), Document(content="Madrid")]
result = ranker.run(documents=docs)
for doc in result["documents"]:
print(doc.content)
```
"""
def __init__(self, word_count_threshold: int | None = None, top_k: int | None = None) -> None:
"""
Initialize the LostInTheMiddleRanker.
If 'word_count_threshold' is specified, this ranker includes all documents up until the point where adding
another document would exceed the 'word_count_threshold'. The last document that causes the threshold to
be breached will be included in the resulting list of documents, but all subsequent documents will be
discarded.
:param word_count_threshold: The maximum total number of words across all documents selected by the ranker.
:param top_k: The maximum number of documents to return.
"""
if isinstance(word_count_threshold, int) and word_count_threshold <= 0:
raise ValueError(
f"Invalid value for word_count_threshold: {word_count_threshold}. word_count_threshold must be > 0."
)
if isinstance(top_k, int) and top_k <= 0:
raise ValueError(f"top_k must be > 0, but got {top_k}")
self.word_count_threshold = word_count_threshold
self.top_k = top_k
@component.output_types(documents=list[Document])
def run(
self, documents: list[Document], top_k: int | None = None, word_count_threshold: int | None = None
) -> dict[str, list[Document]]:
"""
Reranks documents based on the "lost in the middle" order.
Before ranking, documents are deduplicated by their id, retaining only the document with the highest score
if a score is present.
:param documents: List of Documents to reorder.
:param top_k: The maximum number of documents to return.
:param word_count_threshold: The maximum total number of words across all documents selected by the ranker.
:returns:
A dictionary with the following keys:
- `documents`: Reranked list of Documents
:raises ValueError:
If any of the documents is not textual.
"""
if isinstance(word_count_threshold, int) and word_count_threshold <= 0:
raise ValueError(
f"Invalid value for word_count_threshold: {word_count_threshold}. word_count_threshold must be > 0."
)
if isinstance(top_k, int) and top_k <= 0:
raise ValueError(f"top_k must be > 0, but got {top_k}")
if not documents:
return {"documents": []}
top_k = top_k or self.top_k
word_count_threshold = word_count_threshold or self.word_count_threshold
deduplicated_documents = _deduplicate_documents(documents)
documents_to_reorder = deduplicated_documents[:top_k] if top_k else deduplicated_documents
# If there's only one document, return it as is
if len(documents_to_reorder) == 1:
return {"documents": documents_to_reorder}
# Raise an error if any document is not textual
if any(not doc.content_type == "text" for doc in documents_to_reorder):
raise ValueError("Some provided documents are not textual; LostInTheMiddleRanker can process only text.")
# Initialize word count and indices for the "lost in the middle" order
word_count = 0
document_index = list(range(len(documents_to_reorder)))
lost_in_the_middle_indices = [0]
# If word count threshold is set and the first document has content, calculate word count for the first document
if word_count_threshold and documents_to_reorder[0].content:
word_count = len(documents_to_reorder[0].content.split())
# If the first document already meets the word count threshold, return it
if word_count >= word_count_threshold:
return {"documents": [documents_to_reorder[0]]}
# Start from the second document and create "lost in the middle" order
for doc_idx in document_index[1:]:
# Calculate the index at which the current document should be inserted
insertion_index = len(lost_in_the_middle_indices) // 2 + len(lost_in_the_middle_indices) % 2
# Insert the document index at the calculated position
lost_in_the_middle_indices.insert(insertion_index, doc_idx)
# If word count threshold is set and the document has content, calculate the total word count
if word_count_threshold and documents_to_reorder[doc_idx].content:
word_count += len(documents_to_reorder[doc_idx].content.split()) # type: ignore[union-attr]
# If the total word count meets the threshold, stop processing further documents
if word_count >= word_count_threshold:
break
# Documents in the "lost in the middle" order
ranked_docs = [documents_to_reorder[idx] for idx in lost_in_the_middle_indices]
return {"documents": ranked_docs}
+429
View File
@@ -0,0 +1,429 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from collections import defaultdict
from collections.abc import Callable
from dataclasses import replace
from typing import Any, Literal
from dateutil.parser import parse as date_parse
from haystack import Document, component, logging
from haystack.utils.misc import _deduplicate_documents
logger = logging.getLogger(__name__)
@component
class MetaFieldRanker:
"""
Ranks Documents based on the value of their specific meta field.
The ranking can be performed in descending order or ascending order.
Usage example:
```python
from haystack import Document
from haystack.components.rankers import MetaFieldRanker
ranker = MetaFieldRanker(meta_field="rating")
docs = [
Document(content="Paris", meta={"rating": 1.3}),
Document(content="Berlin", meta={"rating": 0.7}),
Document(content="Barcelona", meta={"rating": 2.1}),
]
output = ranker.run(documents=docs)
docs = output["documents"]
assert docs[0].content == "Barcelona"
```
"""
def __init__(
self,
meta_field: str,
weight: float = 1.0,
top_k: int | None = None,
ranking_mode: Literal["reciprocal_rank_fusion", "linear_score"] = "reciprocal_rank_fusion",
sort_order: Literal["ascending", "descending"] = "descending",
missing_meta: Literal["drop", "top", "bottom"] = "bottom",
meta_value_type: Literal["float", "int", "date"] | None = None,
) -> None:
"""
Creates an instance of MetaFieldRanker.
:param meta_field:
The name of the meta field to rank by.
:param weight:
In range [0,1].
0 disables ranking by a meta field.
0.5 ranking from previous component and based on meta field have the same weight.
1 ranking by a meta field only.
:param top_k:
The maximum number of Documents to return per query.
If not provided, the Ranker returns all documents it receives in the new ranking order.
:param ranking_mode:
The mode used to combine the Retriever's and Ranker's scores.
Possible values are 'reciprocal_rank_fusion' (default) and 'linear_score'.
Use the 'linear_score' mode only with Retrievers or Rankers that return a score in range [0,1].
:param sort_order:
Whether to sort the meta field by ascending or descending order.
Possible values are `descending` (default) and `ascending`.
:param missing_meta:
What to do with documents that are missing the sorting metadata field.
Possible values are:
- 'drop' will drop the documents entirely.
- 'top' will place the documents at the top of the metadata-sorted list
(regardless of 'ascending' or 'descending').
- 'bottom' will place the documents at the bottom of metadata-sorted list
(regardless of 'ascending' or 'descending').
:param meta_value_type:
Parse the meta value into the data type specified before sorting.
This will only work if all meta values stored under `meta_field` in the provided documents are strings.
For example, if we specified `meta_value_type="date"` then for the meta value `"date": "2015-02-01"`
we would parse the string into a datetime object and then sort the documents by date.
The available options are:
- 'float' will parse the meta values into floats.
- 'int' will parse the meta values into integers.
- 'date' will parse the meta values into datetime objects.
- 'None' (default) will do no parsing.
"""
self.meta_field = meta_field
self.weight = weight
self.top_k = top_k
self.ranking_mode = ranking_mode
self.sort_order = sort_order
self.missing_meta = missing_meta
self._validate_params(
weight=self.weight,
top_k=self.top_k,
ranking_mode=self.ranking_mode,
sort_order=self.sort_order,
missing_meta=self.missing_meta,
meta_value_type=meta_value_type,
)
self.meta_value_type = meta_value_type
def _validate_params(
self,
*,
weight: float,
top_k: int | None,
ranking_mode: Literal["reciprocal_rank_fusion", "linear_score"],
sort_order: Literal["ascending", "descending"],
missing_meta: Literal["drop", "top", "bottom"],
meta_value_type: Literal["float", "int", "date"] | None,
) -> None:
if top_k is not None and top_k <= 0:
raise ValueError(f"top_k must be > 0, but got {top_k}")
if weight < 0 or weight > 1:
raise ValueError(
f"Parameter <weight> must be in range [0,1] but is currently set to '{weight}'.\n'0' disables sorting "
"by a meta field, '0.5' assigns equal weight to the previous relevance scores and the meta field, and "
"'1' ranks by the meta field only.\nChange the <weight> parameter to a value in range 0 to 1 when "
"initializing the MetaFieldRanker."
)
if ranking_mode not in ["reciprocal_rank_fusion", "linear_score"]:
raise ValueError(
"The value of parameter <ranking_mode> must be 'reciprocal_rank_fusion' or 'linear_score', but is "
f"currently set to '{ranking_mode}'.\nChange the <ranking_mode> value to 'reciprocal_rank_fusion' or "
"'linear_score' when initializing the MetaFieldRanker."
)
if sort_order not in ["ascending", "descending"]:
raise ValueError(
"The value of parameter <sort_order> must be 'ascending' or 'descending', "
f"but is currently set to '{sort_order}'.\n"
"Change the <sort_order> value to 'ascending' or 'descending' when initializing the "
"MetaFieldRanker."
)
if missing_meta not in ["drop", "top", "bottom"]:
raise ValueError(
"The value of parameter <missing_meta> must be 'drop', 'top', or 'bottom', "
f"but is currently set to '{missing_meta}'.\n"
"Change the <missing_meta> value to 'drop', 'top', or 'bottom' when initializing the "
"MetaFieldRanker."
)
if meta_value_type not in ["float", "int", "date", None]:
raise ValueError(
"The value of parameter <meta_value_type> must be 'float', 'int', 'date' or None but is "
f"currently set to '{meta_value_type}'.\n"
"Change the <meta_value_type> value to 'float', 'int', 'date' or None when initializing the "
"MetaFieldRanker."
)
@component.output_types(documents=list[Document])
def run(
self,
documents: list[Document],
top_k: int | None = None,
weight: float | None = None,
ranking_mode: Literal["reciprocal_rank_fusion", "linear_score"] | None = None,
sort_order: Literal["ascending", "descending"] | None = None,
missing_meta: Literal["drop", "top", "bottom"] | None = None,
meta_value_type: Literal["float", "int", "date"] | None = None,
) -> dict[str, Any]:
"""
Ranks a list of Documents based on the selected meta field by:
1. Sorting the Documents by the meta field in descending or ascending order.
2. Merging the rankings from the previous component and based on the meta field according to ranking mode and
weight.
3. Returning the top-k documents.
Before ranking, documents are deduplicated by their id, retaining only the document with the highest score
if a score is present.
:param documents:
Documents to be ranked.
:param top_k:
The maximum number of Documents to return per query.
If not provided, the top_k provided at initialization time is used.
:param weight:
In range [0,1].
0 disables ranking by a meta field.
0.5 ranking from previous component and based on meta field have the same weight.
1 ranking by a meta field only.
If not provided, the weight provided at initialization time is used.
:param ranking_mode:
(optional) The mode used to combine the Retriever's and Ranker's scores.
Possible values are 'reciprocal_rank_fusion' (default) and 'linear_score'.
Use the 'score' mode only with Retrievers or Rankers that return a score in range [0,1].
If not provided, the ranking_mode provided at initialization time is used.
:param sort_order:
Whether to sort the meta field by ascending or descending order.
Possible values are `descending` (default) and `ascending`.
If not provided, the sort_order provided at initialization time is used.
:param missing_meta:
What to do with documents that are missing the sorting metadata field.
Possible values are:
- 'drop' will drop the documents entirely.
- 'top' will place the documents at the top of the metadata-sorted list
(regardless of 'ascending' or 'descending').
- 'bottom' will place the documents at the bottom of metadata-sorted list
(regardless of 'ascending' or 'descending').
If not provided, the missing_meta provided at initialization time is used.
:param meta_value_type:
Parse the meta value into the data type specified before sorting.
This will only work if all meta values stored under `meta_field` in the provided documents are strings.
For example, if we specified `meta_value_type="date"` then for the meta value `"date": "2015-02-01"`
we would parse the string into a datetime object and then sort the documents by date.
The available options are:
-'float' will parse the meta values into floats.
-'int' will parse the meta values into integers.
-'date' will parse the meta values into datetime objects.
-'None' (default) will do no parsing.
:returns:
A dictionary with the following keys:
- `documents`: List of Documents sorted by the specified meta field.
:raises ValueError:
If `top_k` is not > 0.
If `weight` is not in range [0,1].
If `ranking_mode` is not 'reciprocal_rank_fusion' or 'linear_score'.
If `sort_order` is not 'ascending' or 'descending'.
If `meta_value_type` is not 'float', 'int', 'date' or `None`.
"""
if not documents:
return {"documents": []}
top_k = top_k or self.top_k
weight = weight if weight is not None else self.weight
ranking_mode = ranking_mode or self.ranking_mode
sort_order = sort_order or self.sort_order
missing_meta = missing_meta or self.missing_meta
meta_value_type = meta_value_type or self.meta_value_type
self._validate_params(
weight=weight,
top_k=top_k,
ranking_mode=ranking_mode,
sort_order=sort_order,
missing_meta=missing_meta,
meta_value_type=meta_value_type,
)
deduplicated_documents = _deduplicate_documents(documents)
# If the weight is 0 then ranking by meta field is disabled and the original documents should be returned
if weight == 0:
return {"documents": deduplicated_documents[:top_k]}
docs_with_meta_field = [doc for doc in deduplicated_documents if self.meta_field in doc.meta]
docs_missing_meta_field = [doc for doc in deduplicated_documents if self.meta_field not in doc.meta]
# If all docs are missing self.meta_field return original documents
if len(docs_with_meta_field) == 0:
logger.warning(
"The parameter <meta_field> is currently set to '{meta_field}', but none of the provided "
"Documents with IDs {document_ids} have this meta key.\n"
"Set <meta_field> to the name of a field that is present within the provided Documents.\n"
"Returning the <top_k> of the original Documents since there are no values to rank.",
meta_field=self.meta_field,
document_ids=",".join([doc.id for doc in deduplicated_documents]),
)
return {"documents": deduplicated_documents[:top_k]}
if len(docs_missing_meta_field) > 0:
warning_start = (
f"The parameter <meta_field> is currently set to '{self.meta_field}' but the Documents "
f"with IDs {','.join([doc.id for doc in docs_missing_meta_field])} don't have this meta key.\n"
)
if missing_meta == "bottom":
logger.warning(
"{warning_start}Because the parameter <missing_meta> is set to 'bottom', these Documents will be "
"placed at the end of the sorting order.",
warning_start=warning_start,
)
elif missing_meta == "top":
logger.warning(
"{warning_start}Because the parameter <missing_meta> is set to 'top', these Documents will be "
"placed at the top of the sorting order.",
warning_start=warning_start,
)
else:
logger.warning(
"{warning_start}Because the parameter <missing_meta> is set to 'drop', these Documents will be "
"removed from the list of retrieved Documents.",
warning_start=warning_start,
)
# If meta_value_type is provided try to parse the meta values
parsed_meta = self._parse_meta(docs_with_meta_field=docs_with_meta_field, meta_value_type=meta_value_type)
tuple_parsed_meta_and_docs = list(zip(parsed_meta, docs_with_meta_field, strict=True))
# Sort the documents by self.meta_field
reverse = sort_order == "descending"
try:
tuple_sorted_by_meta = sorted(tuple_parsed_meta_and_docs, key=lambda x: x[0], reverse=reverse)
except TypeError as error:
# Return original documents if mixed types that are not comparable are returned (e.g. int and list)
logger.warning(
"Tried to sort Documents with IDs {document_ids}, but got TypeError with the message: {error}\n"
"Returning the <top_k> of the original Documents since meta field ranking is not possible.",
document_ids=",".join([doc.id for doc in docs_with_meta_field]),
error=error,
)
return {"documents": deduplicated_documents[:top_k]}
# Merge rankings and handle missing meta fields as specified in the missing_meta parameter
sorted_by_meta = [doc for meta, doc in tuple_sorted_by_meta]
if missing_meta == "bottom":
sorted_documents = sorted_by_meta + docs_missing_meta_field
sorted_documents = self._merge_rankings(deduplicated_documents, sorted_documents, weight, ranking_mode)
elif missing_meta == "top":
sorted_documents = docs_missing_meta_field + sorted_by_meta
sorted_documents = self._merge_rankings(deduplicated_documents, sorted_documents, weight, ranking_mode)
else:
sorted_documents = sorted_by_meta
sorted_documents = self._merge_rankings(docs_with_meta_field, sorted_documents, weight, ranking_mode)
return {"documents": sorted_documents[:top_k]}
def _parse_meta(
self, docs_with_meta_field: list[Document], meta_value_type: Literal["float", "int", "date"] | None
) -> list[Any]:
"""
Parse the meta values stored under `self.meta_field` for the Documents provided in `docs_with_meta_field`.
"""
if meta_value_type is None:
return [d.meta[self.meta_field] for d in docs_with_meta_field]
unique_meta_values = {doc.meta[self.meta_field] for doc in docs_with_meta_field}
if not all(isinstance(meta_value, str) for meta_value in unique_meta_values):
logger.warning(
"The parameter <meta_value_type> is currently set to '{meta_field}', but not all of meta values in the "
"provided Documents with IDs {document_ids} are strings.\n"
"Skipping parsing of the meta values.\n"
"Set all meta values found under the <meta_field> parameter to strings to use <meta_value_type>.",
meta_field=meta_value_type,
document_ids=",".join([doc.id for doc in docs_with_meta_field]),
)
return [d.meta[self.meta_field] for d in docs_with_meta_field]
parse_fn: Callable
if meta_value_type == "float":
parse_fn = float
elif meta_value_type == "int":
parse_fn = int
else:
parse_fn = date_parse
try:
meta_values = [parse_fn(d.meta[self.meta_field]) for d in docs_with_meta_field]
except ValueError as error:
logger.warning(
"Tried to parse the meta values of Documents with IDs {document_ids}, but got ValueError with the "
"message: {error}\n"
"Skipping parsing of the meta values.",
document_ids=",".join([doc.id for doc in docs_with_meta_field]),
error=error,
)
meta_values = [d.meta[self.meta_field] for d in docs_with_meta_field]
return meta_values
def _merge_rankings(
self,
documents: list[Document],
sorted_documents: list[Document],
weight: float,
ranking_mode: Literal["reciprocal_rank_fusion", "linear_score"],
) -> list[Document]:
"""
Merge the two different rankings for Documents sorted both by their content and by their meta field.
"""
scores_map: dict = defaultdict(int)
if ranking_mode == "reciprocal_rank_fusion":
for i, (document, sorted_doc) in enumerate(zip(documents, sorted_documents, strict=True)):
scores_map[document.id] += self._calculate_rrf(rank=i) * (1 - weight)
scores_map[sorted_doc.id] += self._calculate_rrf(rank=i) * weight
elif ranking_mode == "linear_score":
for i, (document, sorted_doc) in enumerate(zip(documents, sorted_documents, strict=True)):
score = float(0)
if document.score is None:
logger.warning("The score wasn't provided; defaulting to 0.")
elif document.score < 0 or document.score > 1:
logger.warning(
"The score {score} for Document {document_id} is outside the [0,1] range; defaulting to 0",
score=document.score,
document_id=document.id,
)
else:
score = document.score
scores_map[document.id] += score * (1 - weight)
scores_map[sorted_doc.id] += self._calc_linear_score(rank=i, amount=len(sorted_documents)) * weight
scored_docs = [replace(doc, score=scores_map[doc.id]) for doc in documents]
return sorted(scored_docs, key=lambda doc: doc.score if doc.score else -1, reverse=True)
@staticmethod
def _calculate_rrf(rank: int, k: int = 61) -> float:
"""
Calculates the reciprocal rank fusion.
The constant K is set to 61 (60 was suggested by the original paper, plus 1 as python lists are 0-based and
the [paper](https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf) used 1-based ranking).
"""
return 1 / (k + rank)
@staticmethod
def _calc_linear_score(rank: int, amount: int) -> float:
"""
Calculate the meta field score as a linear score between the greatest and the lowest score in the list.
This linear scaling is useful for:
- Reducing the effect of outliers
- Creating scores that are meaningfully distributed in the range [0,1],
similar to scores coming from a Retriever or Ranker.
"""
return (amount - rank) / amount
@@ -0,0 +1,127 @@
# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
#
# SPDX-License-Identifier: Apache-2.0
from collections import defaultdict
from haystack import Document, component
from haystack.utils.misc import _deduplicate_documents
@component
class MetaFieldGroupingRanker:
"""
Reorders the documents by grouping them based on metadata keys.
The MetaFieldGroupingRanker can group documents by a primary metadata key `group_by`, and subgroup them with an optional
secondary key, `subgroup_by`.
Within each group or subgroup, it can also sort documents by a metadata key `sort_docs_by`.
The output is a flat list of documents ordered by `group_by` and `subgroup_by` values.
Any documents without a group are placed at the end of the list.
The proper organization of documents helps improve the efficiency and performance of subsequent processing by an LLM.
### Usage example
```python
from haystack.components.rankers import MetaFieldGroupingRanker
from haystack.dataclasses import Document
docs = [
Document(content="Javascript is a popular programming language", meta={"group": "42", "split_id": 7, "subgroup": "subB"}),
Document(content="Python is a popular programming language",meta={"group": "42", "split_id": 4, "subgroup": "subB"}),
Document(content="A chromosome is a package of DNA", meta={"group": "314", "split_id": 2, "subgroup": "subC"}),
Document(content="An octopus has three hearts", meta={"group": "11", "split_id": 2, "subgroup": "subD"}),
Document(content="Java is a popular programming language", meta={"group": "42", "split_id": 3, "subgroup": "subB"})
]
ranker = MetaFieldGroupingRanker(group_by="group",subgroup_by="subgroup", sort_docs_by="split_id")
result = ranker.run(documents=docs)
print(result["documents"])
# >> [
# >> Document(id=d665bbc83e52c08c3d8275bccf4f22bf2bfee21c6e77d78794627637355b8ebc,
# >> content: 'Java is a popular programming language', meta: {'group': '42', 'split_id': 3, 'subgroup': 'subB'}),
# >> Document(id=a20b326f07382b3cbf2ce156092f7c93e8788df5d48f2986957dce2adb5fe3c2,
# >> content: 'Python is a popular programming language', meta: {'group': '42', 'split_id': 4, 'subgroup': 'subB'}),
# >> Document(id=ce12919795d22f6ca214d0f161cf870993889dcb146f3bb1b3e1ffdc95be960f,
# >> content: 'Javascript is a popular programming language', meta: {'group': '42', 'split_id': 7, 'subgroup': 'subB'}),
# >> Document(id=d9fc857046c904e5cf790b3969b971b1bbdb1b3037d50a20728fdbf82991aa94,
# >> content: 'A chromosome is a package of DNA', meta: {'group': '314', 'split_id': 2, 'subgroup': 'subC'}),
# >> Document(id=6d3b7bdc13d09aa01216471eb5fb0bfdc53c5f2f3e98ad125ff6b85d3106c9a3,
# >> content: 'An octopus has three hearts', meta: {'group': '11', 'split_id': 2, 'subgroup': 'subD'})
```
""" # noqa: E501
def __init__(self, group_by: str, subgroup_by: str | None = None, sort_docs_by: str | None = None) -> None:
"""
Creates an instance of MetaFieldGroupingRanker.
:param group_by: The metadata key to aggregate the documents by.
:param subgroup_by: The metadata key to aggregate the documents within a group that was created by the
`group_by` key.
:param sort_docs_by: Determines which metadata key is used to sort the documents. If not provided, the
documents within the groups or subgroups are not sorted and are kept in the same order as
they were inserted in the subgroups.
"""
self.group_by = group_by
self.sort_docs_by = sort_docs_by
self.subgroup_by = subgroup_by
@component.output_types(documents=list[Document])
def run(self, documents: list[Document]) -> dict[str, list[Document]]:
"""
Groups the provided list of documents based on the `group_by` parameter and optionally the `subgroup_by`.
Before grouping, documents are deduplicated by their id, retaining only the document with the highest score
if a score is present.
The output is a list of documents reordered based on how they were grouped.
:param documents: The list of documents to group.
:returns:
A dictionary with the following keys:
- documents: The list of documents ordered by the `group_by` and `subgroup_by` metadata values.
"""
if not documents:
return {"documents": []}
document_groups: dict[str, dict[str, list[Document]]] = defaultdict(lambda: defaultdict(list))
no_group_docs = []
deduplicated_documents = _deduplicate_documents(documents)
for doc in deduplicated_documents:
group_value = str(doc.meta.get(self.group_by, ""))
# If no group value, add to no_group_docs and continue
if not group_value:
no_group_docs.append(doc)
continue
# Get subgroup value or use a default if not specified
subgroup_value = "no_subgroup"
if self.subgroup_by and self.subgroup_by in doc.meta:
subgroup_value = str(doc.meta[self.subgroup_by])
document_groups[group_value][subgroup_value].append(doc)
# use a non-optional key for type checking; "" disables sorting.
sort_field = self.sort_docs_by or ""
ordered_docs = []
for subgroups in document_groups.values():
for docs in subgroups.values():
if sort_field:
# Sort by the field value, placing documents with a missing value last.
# The (is_missing, value) tuple ensures that only actual field values are
# compared, making the sort work for numbers, strings, and other types.
docs.sort(key=lambda d: (d.meta.get(sort_field) is None, d.meta.get(sort_field)))
ordered_docs.extend(docs)
ordered_docs.extend(no_group_docs)
return {"documents": ordered_docs}