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