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

1# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai> 

2# 

3# SPDX-License-Identifier: Apache-2.0 

4 

5from collections import defaultdict 

6from collections.abc import Callable 

7from dataclasses import replace 

8from typing import Any, Literal 

9 

10from dateutil.parser import parse as date_parse 

11 

12from haystack import Document, component, logging 

13from haystack.utils.misc import _deduplicate_documents 

14 

15logger = logging.getLogger(__name__) 

16 

17 

18@component 

19class MetaFieldRanker: 

20 """ 

21 Ranks Documents based on the value of their specific meta field. 

22 

23 The ranking can be performed in descending order or ascending order. 

24 

25 Usage example: 

26 

27 ```python 

28 from haystack import Document 

29 from haystack.components.rankers import MetaFieldRanker 

30 

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 ] 

37 

38 output = ranker.run(documents=docs) 

39 docs = output["documents"] 

40 assert docs[0].content == "Barcelona" 

41 ``` 

42 """ 

43 

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. 

56 

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 """ 

93 

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 

109 

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}") 

122 

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 ) 

130 

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 ) 

137 

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 ) 

145 

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 ) 

153 

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 ) 

161 

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: 

175 

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. 

180 

181 Before ranking, documents are deduplicated by their id, retaining only the document with the highest score 

182 if a score is present. 

183 

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. 

226 

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": []} 

236 

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 ) 

251 

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]} 

256 

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] 

259 

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]} 

271 

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 ) 

277 

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 ) 

296 

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

300 

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]} 

314 

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) 

326 

327 return {"documents": sorted_documents[:top_k]} 

328 

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] 

337 

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] 

349 

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 

357 

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] 

369 

370 return meta_values 

371 

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) 

383 

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 

401 

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 

404 

405 scored_docs = [replace(doc, score=scores_map[doc.id]) for doc in documents] 

406 

407 return sorted(scored_docs, key=lambda doc: doc.score if doc.score else -1, reverse=True) 

408 

409 @staticmethod 

410 def _calculate_rrf(rank: int, k: int = 61) -> float: 

411 """ 

412 Calculates the reciprocal rank fusion. 

413 

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) 

418 

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. 

423 

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