Skip to content

Commit a7fb2f1

Browse files
otiscuileianakin87
andauthored
fix: DocumentRecallEvaluator single_hit reports false 1.0 recall on missing comparison values (#11951)
Co-authored-by: anakin87 <stefanofiorucci@gmail.com>
1 parent cc67bbb commit a7fb2f1

3 files changed

Lines changed: 92 additions & 15 deletions

File tree

haystack/components/evaluators/document_recall.py

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -112,33 +112,47 @@ def _get_comparison_value(self, doc: Document) -> Any:
112112
)
113113
raise ValueError(msg)
114114

115-
def _recall_single_hit(self, ground_truth_documents: list[Document], retrieved_documents: list[Document]) -> float:
116-
unique_truths = {self._get_comparison_value(g) for g in ground_truth_documents}
117-
unique_retrievals = {self._get_comparison_value(p) for p in retrieved_documents}
118-
retrieved_ground_truths = unique_truths.intersection(unique_retrievals)
119-
120-
return float(len(retrieved_ground_truths) > 0)
121-
122-
def _recall_multi_hit(self, ground_truth_documents: list[Document], retrieved_documents: list[Document]) -> float:
123-
unique_truths = {self._get_comparison_value(g) for g in ground_truth_documents}
124-
unique_retrievals = {self._get_comparison_value(p) for p in retrieved_documents}
125-
retrieved_ground_truths = unique_truths.intersection(unique_retrievals)
115+
def _unique_comparison_values(self, documents: list[Document]) -> set:
116+
"""
117+
Collect the unique comparison values of the documents, ignoring missing or empty ones.
118+
"""
119+
return {value for doc in documents if (value := self._get_comparison_value(doc)) not in ("", None)}
126120

127-
if not unique_truths or unique_truths <= {"", None}:
121+
def _comparison_sets(
122+
self, ground_truth_documents: list[Document], retrieved_documents: list[Document]
123+
) -> tuple[set, set]:
124+
"""
125+
Collect the unique comparison values of both sides, warning if either has no value to match on.
126+
"""
127+
unique_truths = self._unique_comparison_values(ground_truth_documents)
128+
if not unique_truths:
128129
logger.warning(
129130
"There are no ground truth documents or none of them contain a valid comparison value. "
130131
"Score will be set to 0."
131132
)
132-
return 0.0
133133

134-
if not unique_retrievals or unique_retrievals <= {"", None}:
134+
unique_retrievals = self._unique_comparison_values(retrieved_documents)
135+
if not unique_retrievals:
135136
logger.warning(
136137
"There are no retrieved documents or none of them contain a valid comparison value. "
137138
"Score will be set to 0."
138139
)
140+
141+
return unique_truths, unique_retrievals
142+
143+
def _recall_single_hit(self, ground_truth_documents: list[Document], retrieved_documents: list[Document]) -> float:
144+
unique_truths, unique_retrievals = self._comparison_sets(ground_truth_documents, retrieved_documents)
145+
if not unique_truths or not unique_retrievals:
146+
return 0.0
147+
148+
return float(len(unique_truths.intersection(unique_retrievals)) > 0)
149+
150+
def _recall_multi_hit(self, ground_truth_documents: list[Document], retrieved_documents: list[Document]) -> float:
151+
unique_truths, unique_retrievals = self._comparison_sets(ground_truth_documents, retrieved_documents)
152+
if not unique_truths or not unique_retrievals:
139153
return 0.0
140154

141-
return len(retrieved_ground_truths) / len(unique_truths)
155+
return len(unique_truths.intersection(unique_retrievals)) / len(unique_truths)
142156

143157
@component.output_types(score=float, individual_scores=list[float])
144158
def run(
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
fixes:
3+
- |
4+
Fixed ``DocumentRecallEvaluator`` returning wrong scores when the configured
5+
``document_comparison_field`` is missing or empty on some documents. Those documents were
6+
considered a match for each other, so unrelated documents could count as correctly retrieved.
7+
They are now ignored when computing the score.

test/components/evaluators/test_document_recall.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,46 @@ def test_from_dict(self):
152152
new_evaluator = default_from_dict(DocumentRecallEvaluator, data)
153153
assert new_evaluator.mode == RecallMode.SINGLE_HIT
154154

155+
def test_empty_ground_truth_documents(self, evaluator):
156+
ground_truth_documents = [[]]
157+
retrieved_documents = [[Document(content="test")]]
158+
score = evaluator.run(ground_truth_documents, retrieved_documents)
159+
assert score == {"individual_scores": [0.0], "score": 0.0}
160+
161+
def test_empty_retrieved_documents(self, evaluator):
162+
ground_truth_documents = [[Document(content="test")]]
163+
retrieved_documents = [[]]
164+
score = evaluator.run(ground_truth_documents, retrieved_documents)
165+
assert score == {"individual_scores": [0.0], "score": 0.0}
166+
167+
def test_empty_string_ground_truth_documents(self, evaluator):
168+
ground_truth_documents = [[Document(content="")]]
169+
retrieved_documents = [[Document(content="test")]]
170+
score = evaluator.run(ground_truth_documents, retrieved_documents)
171+
assert score == {"individual_scores": [0.0], "score": 0.0}
172+
173+
def test_empty_string_retrieved_documents(self, evaluator):
174+
ground_truth_documents = [[Document(content="test")]]
175+
retrieved_documents = [[Document(content="")]]
176+
score = evaluator.run(ground_truth_documents, retrieved_documents)
177+
assert score == {"individual_scores": [0.0], "score": 0.0}
178+
179+
def test_no_match_when_comparison_field_missing_on_all_documents(self):
180+
evaluator = DocumentRecallEvaluator(mode=RecallMode.SINGLE_HIT, document_comparison_field="meta.file_id")
181+
result = evaluator.run(
182+
ground_truth_documents=[[Document(content="real ground truth")]],
183+
retrieved_documents=[[Document(content="totally unrelated doc")]],
184+
)
185+
assert result == {"individual_scores": [0.0], "score": 0.0}
186+
187+
def test_no_match_when_comparison_field_missing_on_some_documents(self):
188+
evaluator = DocumentRecallEvaluator(mode=RecallMode.SINGLE_HIT, document_comparison_field="meta.file_id")
189+
result = evaluator.run(
190+
ground_truth_documents=[[Document(meta={"file_id": "f1"}), Document(content="no file_id")]],
191+
retrieved_documents=[[Document(content="also no file_id"), Document(meta={"file_id": "f2"})]],
192+
)
193+
assert result == {"individual_scores": [0.0], "score": 0.0}
194+
155195

156196
class TestDocumentRecallEvaluatorMultiHit:
157197
@pytest.fixture
@@ -265,3 +305,19 @@ def test_empty_string_retrieved_documents(self, evaluator):
265305
retrieved_documents = [[Document(content="")]]
266306
score = evaluator.run(ground_truth_documents, retrieved_documents)
267307
assert score == {"individual_scores": [0.0], "score": 0.0}
308+
309+
def test_no_match_when_comparison_field_missing_on_some_documents(self):
310+
evaluator = DocumentRecallEvaluator(mode=RecallMode.MULTI_HIT, document_comparison_field="meta.file_id")
311+
result = evaluator.run(
312+
ground_truth_documents=[[Document(meta={"file_id": "f1"}), Document(content="no file_id")]],
313+
retrieved_documents=[[Document(content="also no file_id"), Document(meta={"file_id": "f2"})]],
314+
)
315+
assert result == {"individual_scores": [0.0], "score": 0.0}
316+
317+
def test_ground_truth_missing_comparison_field_excluded_from_denominator(self):
318+
evaluator = DocumentRecallEvaluator(mode=RecallMode.MULTI_HIT, document_comparison_field="meta.file_id")
319+
result = evaluator.run(
320+
ground_truth_documents=[[Document(meta={"file_id": "f1"}), Document(content="no file_id")]],
321+
retrieved_documents=[[Document(meta={"file_id": "f1"})]],
322+
)
323+
assert result == {"individual_scores": [1.0], "score": 1.0}

0 commit comments

Comments
 (0)