Coverage for haystack/components/evaluators/document_ndcg.py: 100%

70 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 math import log2 

6from typing import Any 

7 

8from haystack import Document, component, default_to_dict 

9 

10 

11@component 

12class DocumentNDCGEvaluator: 

13 """ 

14 Evaluator that calculates the normalized discounted cumulative gain (NDCG) of retrieved documents. 

15 

16 Each question can have multiple ground truth documents and multiple retrieved documents. 

17 If the ground truth documents have relevance scores, the NDCG calculation uses these scores. 

18 Otherwise, it assumes binary relevance of all ground truth documents. 

19 

20 Usage example: 

21 ```python 

22 from haystack import Document 

23 from haystack.components.evaluators import DocumentNDCGEvaluator 

24 

25 evaluator = DocumentNDCGEvaluator() 

26 result = evaluator.run( 

27 ground_truth_documents=[[Document(content="France", score=1.0), Document(content="Paris", score=0.5)]], 

28 retrieved_documents=[[Document(content="France"), Document(content="Germany"), Document(content="Paris")]], 

29 ) 

30 print(result["individual_scores"]) 

31 # [0.8869] 

32 print(result["score"]) 

33 # 0.8869 

34 ``` 

35 """ 

36 

37 def __init__(self, document_comparison_field: str = "content") -> None: 

38 """ 

39 Create a DocumentNDCGEvaluator component. 

40 

41 :param document_comparison_field: 

42 The Document field to use for comparison. Possible options: 

43 - `"content"`: uses `doc.content` 

44 - `"id"`: uses `doc.id` 

45 - A `meta.` prefix followed by a key name: uses `doc.meta["<key>"]` 

46 (e.g. `"meta.file_id"`, `"meta.page_number"`) 

47 Nested keys are supported (e.g. `"meta.source.url"`). 

48 """ 

49 self.document_comparison_field = document_comparison_field 

50 

51 def _get_comparison_value(self, doc: Document) -> Any: 

52 """ 

53 Extract the comparison value from a document based on the configured field. 

54 """ 

55 if self.document_comparison_field == "content": 

56 return doc.content 

57 if self.document_comparison_field == "id": 

58 return doc.id 

59 if self.document_comparison_field.startswith("meta."): 

60 parts = self.document_comparison_field[5:].split(".") 

61 value = doc.meta 

62 for part in parts: 

63 if not isinstance(value, dict) or part not in value: 

64 return None 

65 value = value[part] 

66 return value 

67 msg = ( 

68 f"Unsupported document_comparison_field: '{self.document_comparison_field}'. " 

69 "Use 'content', 'id', or 'meta.<key>'." 

70 ) 

71 raise ValueError(msg) 

72 

73 def _build_relevance_map(self, gt_docs: list[Document]) -> dict[Any, float]: 

74 """ 

75 Map each ground truth comparison value to its relevance score. 

76 

77 Documents whose comparison value cannot be determined (e.g. missing meta key) are skipped, 

78 since they can never be matched during retrieval either. Documents that share a comparison 

79 value are collapsed to a single entry keeping the highest relevance, so `calculate_dcg` and 

80 `calculate_idcg` credit the same unique relevant set with the same scores. 

81 """ 

82 relevant_value_to_score: dict[Any, float] = {} 

83 for doc in gt_docs: 

84 value = self._get_comparison_value(doc) 

85 if value is None: 

86 continue 

87 relevance = doc.score if doc.score is not None else 1.0 

88 relevant_value_to_score[value] = max(relevant_value_to_score.get(value, relevance), relevance) 

89 return relevant_value_to_score 

90 

91 def to_dict(self) -> dict[str, Any]: 

92 """ 

93 Serializes the component to a dictionary. 

94 

95 :returns: 

96 Dictionary with serialized data. 

97 """ 

98 return default_to_dict(self, document_comparison_field=self.document_comparison_field) 

99 

100 @component.output_types(score=float, individual_scores=list[float]) 

101 def run( 

102 self, ground_truth_documents: list[list[Document]], retrieved_documents: list[list[Document]] 

103 ) -> dict[str, Any]: 

104 """ 

105 Run the DocumentNDCGEvaluator on the given inputs. 

106 

107 `ground_truth_documents` and `retrieved_documents` must have the same length. 

108 The list items within `ground_truth_documents` and `retrieved_documents` can differ in length. 

109 

110 :param ground_truth_documents: 

111 Lists of expected documents, one list per question. Binary relevance is used if documents have no scores. 

112 :param retrieved_documents: 

113 Lists of retrieved documents, one list per question. 

114 :returns: 

115 A dictionary with the following outputs: 

116 - `score` - The average of calculated scores. 

117 - `individual_scores` - A list of numbers from 0.0 to 1.0 that represents the NDCG for each question. 

118 """ 

119 self.validate_inputs(ground_truth_documents, retrieved_documents) 

120 

121 individual_scores = [] 

122 

123 for gt_docs, ret_docs in zip(ground_truth_documents, retrieved_documents, strict=True): 

124 dcg = self.calculate_dcg(gt_docs, ret_docs) 

125 idcg = self.calculate_idcg(gt_docs) 

126 ndcg = dcg / idcg if idcg > 0 else 0 

127 individual_scores.append(ndcg) 

128 

129 score = sum(individual_scores) / len(ground_truth_documents) 

130 

131 return {"score": score, "individual_scores": individual_scores} 

132 

133 @staticmethod 

134 def validate_inputs(gt_docs: list[list[Document]], ret_docs: list[list[Document]]) -> None: 

135 """ 

136 Validate the input parameters. 

137 

138 :param gt_docs: 

139 The ground_truth_documents to validate. 

140 :param ret_docs: 

141 The retrieved_documents to validate. 

142 

143 :raises ValueError: 

144 If the ground_truth_documents or the retrieved_documents are an empty list. 

145 If the length of ground_truth_documents and retrieved_documents differs. 

146 If any list of documents in ground_truth_documents contains a mix of documents with and without a score. 

147 """ 

148 if len(gt_docs) == 0 or len(ret_docs) == 0: 

149 msg = "ground_truth_documents and retrieved_documents must be provided." 

150 raise ValueError(msg) 

151 

152 if len(gt_docs) != len(ret_docs): 

153 msg = "The length of ground_truth_documents and retrieved_documents must be the same." 

154 raise ValueError(msg) 

155 

156 for docs in gt_docs: 

157 if any(doc.score is not None for doc in docs) and any(doc.score is None for doc in docs): 

158 msg = "Either none or all documents in each list of ground_truth_documents must have a score." 

159 raise ValueError(msg) 

160 

161 def calculate_dcg(self, gt_docs: list[Document], ret_docs: list[Document]) -> float: 

162 """ 

163 Calculate the discounted cumulative gain (DCG) of the retrieved documents. 

164 

165 :param gt_docs: 

166 The ground truth documents. 

167 :param ret_docs: 

168 The retrieved documents. 

169 :returns: 

170 The discounted cumulative gain (DCG) of the retrieved 

171 documents based on the ground truth documents. 

172 """ 

173 dcg = 0.0 

174 relevant_value_to_score = self._build_relevance_map(gt_docs) 

175 

176 # Credit each relevant value at most once by popping it when first matched. A duplicate 

177 # retrieval of the same document then finds nothing, so it cannot inflate DCG past IDCG. 

178 for i, doc in enumerate(ret_docs): 

179 value = self._get_comparison_value(doc) 

180 if value is not None and value in relevant_value_to_score: 

181 dcg += relevant_value_to_score.pop(value) / log2(i + 2) # i + 2 because i is 0-indexed 

182 return dcg 

183 

184 def calculate_idcg(self, gt_docs: list[Document]) -> float: 

185 """ 

186 Calculate the ideal discounted cumulative gain (IDCG) of the ground truth documents. 

187 

188 Ground truth documents whose comparison value cannot be determined (e.g. missing meta key) 

189 are excluded, since they can never be matched in `calculate_dcg` either. Documents that share 

190 a comparison value are collapsed to a single relevant item, mirroring `calculate_dcg`, which 

191 credits each relevant value at most once. Including duplicates or unmatchable documents here 

192 would inflate the IDCG and make it impossible for NDCG to reach 1.0 for a perfect retrieval. 

193 

194 :param gt_docs: 

195 The ground truth documents. 

196 :returns: 

197 The ideal discounted cumulative gain (IDCG) of the ground truth documents. 

198 """ 

199 relevant_value_to_score = self._build_relevance_map(gt_docs) 

200 

201 idcg = 0.0 

202 for i, relevance in enumerate(sorted(relevant_value_to_score.values(), reverse=True)): 

203 idcg += relevance / log2(i + 2) # i + 2 because i is 0-indexed 

204 return idcg