Coverage for haystack/components/rankers/meta_field.py: 100%
118 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 13:53 +0000
1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2#
3# SPDX-License-Identifier: Apache-2.0
5from collections import defaultdict
6from collections.abc import Callable
7from dataclasses import replace
8from typing import Any, Literal
10from dateutil.parser import parse as date_parse
12from haystack import Document, component, logging
13from haystack.utils.misc import _deduplicate_documents
15logger = logging.getLogger(__name__)
18@component
19class MetaFieldRanker:
20 """
21 Ranks Documents based on the value of their specific meta field.
23 The ranking can be performed in descending order or ascending order.
25 Usage example:
27 ```python
28 from haystack import Document
29 from haystack.components.rankers import MetaFieldRanker
31 ranker = MetaFieldRanker(meta_field="rating")
32 docs = [
33 Document(content="Paris", meta={"rating": 1.3}),
34 Document(content="Berlin", meta={"rating": 0.7}),
35 Document(content="Barcelona", meta={"rating": 2.1}),
36 ]
38 output = ranker.run(documents=docs)
39 docs = output["documents"]
40 assert docs[0].content == "Barcelona"
41 ```
42 """
44 def __init__(
45 self,
46 meta_field: str,
47 weight: float = 1.0,
48 top_k: int | None = None,
49 ranking_mode: Literal["reciprocal_rank_fusion", "linear_score"] = "reciprocal_rank_fusion",
50 sort_order: Literal["ascending", "descending"] = "descending",
51 missing_meta: Literal["drop", "top", "bottom"] = "bottom",
52 meta_value_type: Literal["float", "int", "date"] | None = None,
53 ) -> None:
54 """
55 Creates an instance of MetaFieldRanker.
57 :param meta_field:
58 The name of the meta field to rank by.
59 :param weight:
60 In range [0,1].
61 0 disables ranking by a meta field.
62 0.5 ranking from previous component and based on meta field have the same weight.
63 1 ranking by a meta field only.
64 :param top_k:
65 The maximum number of Documents to return per query.
66 If not provided, the Ranker returns all documents it receives in the new ranking order.
67 :param ranking_mode:
68 The mode used to combine the Retriever's and Ranker's scores.
69 Possible values are 'reciprocal_rank_fusion' (default) and 'linear_score'.
70 Use the 'linear_score' mode only with Retrievers or Rankers that return a score in range [0,1].
71 :param sort_order:
72 Whether to sort the meta field by ascending or descending order.
73 Possible values are `descending` (default) and `ascending`.
74 :param missing_meta:
75 What to do with documents that are missing the sorting metadata field.
76 Possible values are:
77 - 'drop' will drop the documents entirely.
78 - 'top' will place the documents at the top of the metadata-sorted list
79 (regardless of 'ascending' or 'descending').
80 - 'bottom' will place the documents at the bottom of metadata-sorted list
81 (regardless of 'ascending' or 'descending').
82 :param meta_value_type:
83 Parse the meta value into the data type specified before sorting.
84 This will only work if all meta values stored under `meta_field` in the provided documents are strings.
85 For example, if we specified `meta_value_type="date"` then for the meta value `"date": "2015-02-01"`
86 we would parse the string into a datetime object and then sort the documents by date.
87 The available options are:
88 - 'float' will parse the meta values into floats.
89 - 'int' will parse the meta values into integers.
90 - 'date' will parse the meta values into datetime objects.
91 - 'None' (default) will do no parsing.
92 """
94 self.meta_field = meta_field
95 self.weight = weight
96 self.top_k = top_k
97 self.ranking_mode = ranking_mode
98 self.sort_order = sort_order
99 self.missing_meta = missing_meta
100 self._validate_params(
101 weight=self.weight,
102 top_k=self.top_k,
103 ranking_mode=self.ranking_mode,
104 sort_order=self.sort_order,
105 missing_meta=self.missing_meta,
106 meta_value_type=meta_value_type,
107 )
108 self.meta_value_type = meta_value_type
110 def _validate_params(
111 self,
112 *,
113 weight: float,
114 top_k: int | None,
115 ranking_mode: Literal["reciprocal_rank_fusion", "linear_score"],
116 sort_order: Literal["ascending", "descending"],
117 missing_meta: Literal["drop", "top", "bottom"],
118 meta_value_type: Literal["float", "int", "date"] | None,
119 ) -> None:
120 if top_k is not None and top_k <= 0:
121 raise ValueError(f"top_k must be > 0, but got {top_k}")
123 if weight < 0 or weight > 1:
124 raise ValueError(
125 f"Parameter <weight> must be in range [0,1] but is currently set to '{weight}'.\n'0' disables sorting "
126 "by a meta field, '0.5' assigns equal weight to the previous relevance scores and the meta field, and "
127 "'1' ranks by the meta field only.\nChange the <weight> parameter to a value in range 0 to 1 when "
128 "initializing the MetaFieldRanker."
129 )
131 if ranking_mode not in ["reciprocal_rank_fusion", "linear_score"]:
132 raise ValueError(
133 "The value of parameter <ranking_mode> must be 'reciprocal_rank_fusion' or 'linear_score', but is "
134 f"currently set to '{ranking_mode}'.\nChange the <ranking_mode> value to 'reciprocal_rank_fusion' or "
135 "'linear_score' when initializing the MetaFieldRanker."
136 )
138 if sort_order not in ["ascending", "descending"]:
139 raise ValueError(
140 "The value of parameter <sort_order> must be 'ascending' or 'descending', "
141 f"but is currently set to '{sort_order}'.\n"
142 "Change the <sort_order> value to 'ascending' or 'descending' when initializing the "
143 "MetaFieldRanker."
144 )
146 if missing_meta not in ["drop", "top", "bottom"]:
147 raise ValueError(
148 "The value of parameter <missing_meta> must be 'drop', 'top', or 'bottom', "
149 f"but is currently set to '{missing_meta}'.\n"
150 "Change the <missing_meta> value to 'drop', 'top', or 'bottom' when initializing the "
151 "MetaFieldRanker."
152 )
154 if meta_value_type not in ["float", "int", "date", None]:
155 raise ValueError(
156 "The value of parameter <meta_value_type> must be 'float', 'int', 'date' or None but is "
157 f"currently set to '{meta_value_type}'.\n"
158 "Change the <meta_value_type> value to 'float', 'int', 'date' or None when initializing the "
159 "MetaFieldRanker."
160 )
162 @component.output_types(documents=list[Document])
163 def run(
164 self,
165 documents: list[Document],
166 top_k: int | None = None,
167 weight: float | None = None,
168 ranking_mode: Literal["reciprocal_rank_fusion", "linear_score"] | None = None,
169 sort_order: Literal["ascending", "descending"] | None = None,
170 missing_meta: Literal["drop", "top", "bottom"] | None = None,
171 meta_value_type: Literal["float", "int", "date"] | None = None,
172 ) -> dict[str, Any]:
173 """
174 Ranks a list of Documents based on the selected meta field by:
176 1. Sorting the Documents by the meta field in descending or ascending order.
177 2. Merging the rankings from the previous component and based on the meta field according to ranking mode and
178 weight.
179 3. Returning the top-k documents.
181 Before ranking, documents are deduplicated by their id, retaining only the document with the highest score
182 if a score is present.
184 :param documents:
185 Documents to be ranked.
186 :param top_k:
187 The maximum number of Documents to return per query.
188 If not provided, the top_k provided at initialization time is used.
189 :param weight:
190 In range [0,1].
191 0 disables ranking by a meta field.
192 0.5 ranking from previous component and based on meta field have the same weight.
193 1 ranking by a meta field only.
194 If not provided, the weight provided at initialization time is used.
195 :param ranking_mode:
196 (optional) The mode used to combine the Retriever's and Ranker's scores.
197 Possible values are 'reciprocal_rank_fusion' (default) and 'linear_score'.
198 Use the 'score' mode only with Retrievers or Rankers that return a score in range [0,1].
199 If not provided, the ranking_mode provided at initialization time is used.
200 :param sort_order:
201 Whether to sort the meta field by ascending or descending order.
202 Possible values are `descending` (default) and `ascending`.
203 If not provided, the sort_order provided at initialization time is used.
204 :param missing_meta:
205 What to do with documents that are missing the sorting metadata field.
206 Possible values are:
207 - 'drop' will drop the documents entirely.
208 - 'top' will place the documents at the top of the metadata-sorted list
209 (regardless of 'ascending' or 'descending').
210 - 'bottom' will place the documents at the bottom of metadata-sorted list
211 (regardless of 'ascending' or 'descending').
212 If not provided, the missing_meta provided at initialization time is used.
213 :param meta_value_type:
214 Parse the meta value into the data type specified before sorting.
215 This will only work if all meta values stored under `meta_field` in the provided documents are strings.
216 For example, if we specified `meta_value_type="date"` then for the meta value `"date": "2015-02-01"`
217 we would parse the string into a datetime object and then sort the documents by date.
218 The available options are:
219 -'float' will parse the meta values into floats.
220 -'int' will parse the meta values into integers.
221 -'date' will parse the meta values into datetime objects.
222 -'None' (default) will do no parsing.
223 :returns:
224 A dictionary with the following keys:
225 - `documents`: List of Documents sorted by the specified meta field.
227 :raises ValueError:
228 If `top_k` is not > 0.
229 If `weight` is not in range [0,1].
230 If `ranking_mode` is not 'reciprocal_rank_fusion' or 'linear_score'.
231 If `sort_order` is not 'ascending' or 'descending'.
232 If `meta_value_type` is not 'float', 'int', 'date' or `None`.
233 """
234 if not documents:
235 return {"documents": []}
237 top_k = self.top_k if top_k is None else top_k
238 weight = weight if weight is not None else self.weight
239 ranking_mode = ranking_mode or self.ranking_mode
240 sort_order = sort_order or self.sort_order
241 missing_meta = missing_meta or self.missing_meta
242 meta_value_type = meta_value_type or self.meta_value_type
243 self._validate_params(
244 weight=weight,
245 top_k=top_k,
246 ranking_mode=ranking_mode,
247 sort_order=sort_order,
248 missing_meta=missing_meta,
249 meta_value_type=meta_value_type,
250 )
252 deduplicated_documents = _deduplicate_documents(documents)
253 # If the weight is 0 then ranking by meta field is disabled and the original documents should be returned
254 if weight == 0:
255 return {"documents": deduplicated_documents[:top_k]}
257 docs_with_meta_field = [doc for doc in deduplicated_documents if self.meta_field in doc.meta]
258 docs_missing_meta_field = [doc for doc in deduplicated_documents if self.meta_field not in doc.meta]
260 # If all docs are missing self.meta_field return original documents
261 if len(docs_with_meta_field) == 0:
262 logger.warning(
263 "The parameter <meta_field> is currently set to '{meta_field}', but none of the provided "
264 "Documents with IDs {document_ids} have this meta key.\n"
265 "Set <meta_field> to the name of a field that is present within the provided Documents.\n"
266 "Returning the <top_k> of the original Documents since there are no values to rank.",
267 meta_field=self.meta_field,
268 document_ids=",".join([doc.id for doc in deduplicated_documents]),
269 )
270 return {"documents": deduplicated_documents[:top_k]}
272 if len(docs_missing_meta_field) > 0:
273 warning_start = (
274 f"The parameter <meta_field> is currently set to '{self.meta_field}' but the Documents "
275 f"with IDs {','.join([doc.id for doc in docs_missing_meta_field])} don't have this meta key.\n"
276 )
278 if missing_meta == "bottom":
279 logger.warning(
280 "{warning_start}Because the parameter <missing_meta> is set to 'bottom', these Documents will be "
281 "placed at the end of the sorting order.",
282 warning_start=warning_start,
283 )
284 elif missing_meta == "top":
285 logger.warning(
286 "{warning_start}Because the parameter <missing_meta> is set to 'top', these Documents will be "
287 "placed at the top of the sorting order.",
288 warning_start=warning_start,
289 )
290 else:
291 logger.warning(
292 "{warning_start}Because the parameter <missing_meta> is set to 'drop', these Documents will be "
293 "removed from the list of retrieved Documents.",
294 warning_start=warning_start,
295 )
297 # If meta_value_type is provided try to parse the meta values
298 parsed_meta = self._parse_meta(docs_with_meta_field=docs_with_meta_field, meta_value_type=meta_value_type)
299 tuple_parsed_meta_and_docs = list(zip(parsed_meta, docs_with_meta_field, strict=True))
301 # Sort the documents by self.meta_field
302 reverse = sort_order == "descending"
303 try:
304 tuple_sorted_by_meta = sorted(tuple_parsed_meta_and_docs, key=lambda x: x[0], reverse=reverse)
305 except TypeError as error:
306 # Return original documents if mixed types that are not comparable are returned (e.g. int and list)
307 logger.warning(
308 "Tried to sort Documents with IDs {document_ids}, but got TypeError with the message: {error}\n"
309 "Returning the <top_k> of the original Documents since meta field ranking is not possible.",
310 document_ids=",".join([doc.id for doc in docs_with_meta_field]),
311 error=error,
312 )
313 return {"documents": deduplicated_documents[:top_k]}
315 # Merge rankings and handle missing meta fields as specified in the missing_meta parameter
316 sorted_by_meta = [doc for meta, doc in tuple_sorted_by_meta]
317 if missing_meta == "bottom":
318 sorted_documents = sorted_by_meta + docs_missing_meta_field
319 sorted_documents = self._merge_rankings(deduplicated_documents, sorted_documents, weight, ranking_mode)
320 elif missing_meta == "top":
321 sorted_documents = docs_missing_meta_field + sorted_by_meta
322 sorted_documents = self._merge_rankings(deduplicated_documents, sorted_documents, weight, ranking_mode)
323 else:
324 sorted_documents = sorted_by_meta
325 sorted_documents = self._merge_rankings(docs_with_meta_field, sorted_documents, weight, ranking_mode)
327 return {"documents": sorted_documents[:top_k]}
329 def _parse_meta(
330 self, docs_with_meta_field: list[Document], meta_value_type: Literal["float", "int", "date"] | None
331 ) -> list[Any]:
332 """
333 Parse the meta values stored under `self.meta_field` for the Documents provided in `docs_with_meta_field`.
334 """
335 if meta_value_type is None:
336 return [d.meta[self.meta_field] for d in docs_with_meta_field]
338 unique_meta_values = {doc.meta[self.meta_field] for doc in docs_with_meta_field}
339 if not all(isinstance(meta_value, str) for meta_value in unique_meta_values):
340 logger.warning(
341 "The parameter <meta_value_type> is currently set to '{meta_field}', but not all of meta values in the "
342 "provided Documents with IDs {document_ids} are strings.\n"
343 "Skipping parsing of the meta values.\n"
344 "Set all meta values found under the <meta_field> parameter to strings to use <meta_value_type>.",
345 meta_field=meta_value_type,
346 document_ids=",".join([doc.id for doc in docs_with_meta_field]),
347 )
348 return [d.meta[self.meta_field] for d in docs_with_meta_field]
350 parse_fn: Callable
351 if meta_value_type == "float":
352 parse_fn = float
353 elif meta_value_type == "int":
354 parse_fn = int
355 else:
356 parse_fn = date_parse
358 try:
359 meta_values = [parse_fn(d.meta[self.meta_field]) for d in docs_with_meta_field]
360 except ValueError as error:
361 logger.warning(
362 "Tried to parse the meta values of Documents with IDs {document_ids}, but got ValueError with the "
363 "message: {error}\n"
364 "Skipping parsing of the meta values.",
365 document_ids=",".join([doc.id for doc in docs_with_meta_field]),
366 error=error,
367 )
368 meta_values = [d.meta[self.meta_field] for d in docs_with_meta_field]
370 return meta_values
372 def _merge_rankings(
373 self,
374 documents: list[Document],
375 sorted_documents: list[Document],
376 weight: float,
377 ranking_mode: Literal["reciprocal_rank_fusion", "linear_score"],
378 ) -> list[Document]:
379 """
380 Merge the two different rankings for Documents sorted both by their content and by their meta field.
381 """
382 scores_map: dict = defaultdict(int)
384 if ranking_mode == "reciprocal_rank_fusion":
385 for i, (document, sorted_doc) in enumerate(zip(documents, sorted_documents, strict=True)):
386 scores_map[document.id] += self._calculate_rrf(rank=i) * (1 - weight)
387 scores_map[sorted_doc.id] += self._calculate_rrf(rank=i) * weight
388 elif ranking_mode == "linear_score":
389 for i, (document, sorted_doc) in enumerate(zip(documents, sorted_documents, strict=True)):
390 score = float(0)
391 if document.score is None:
392 logger.warning("The score wasn't provided; defaulting to 0.")
393 elif document.score < 0 or document.score > 1:
394 logger.warning(
395 "The score {score} for Document {document_id} is outside the [0,1] range; defaulting to 0",
396 score=document.score,
397 document_id=document.id,
398 )
399 else:
400 score = document.score
402 scores_map[document.id] += score * (1 - weight)
403 scores_map[sorted_doc.id] += self._calc_linear_score(rank=i, amount=len(sorted_documents)) * weight
405 scored_docs = [replace(doc, score=scores_map[doc.id]) for doc in documents]
407 return sorted(scored_docs, key=lambda doc: doc.score if doc.score else -1, reverse=True)
409 @staticmethod
410 def _calculate_rrf(rank: int, k: int = 61) -> float:
411 """
412 Calculates the reciprocal rank fusion.
414 The constant K is set to 61 (60 was suggested by the original paper, plus 1 as python lists are 0-based and
415 the [paper](https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf) used 1-based ranking).
416 """
417 return 1 / (k + rank)
419 @staticmethod
420 def _calc_linear_score(rank: int, amount: int) -> float:
421 """
422 Calculate the meta field score as a linear score between the greatest and the lowest score in the list.
424 This linear scaling is useful for:
425 - Reducing the effect of outliers
426 - Creating scores that are meaningfully distributed in the range [0,1],
427 similar to scores coming from a Retriever or Ranker.
428 """
429 return (amount - rank) / amount