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,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)
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user