Skip to content

Commit df7e1eb

Browse files
authored
feat: retrival detail add matched_count (#422)
* fix: update semantic_scholar cli example * feat: retrival detail add matched_count * fix comment
1 parent 4c7cdd7 commit df7e1eb

4 files changed

Lines changed: 238 additions & 14 deletions

File tree

dingo/exec/retrieval.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ def execute(self) -> SummaryModel:
105105
continue
106106

107107
try:
108+
self._attach_relevant_docs(model, tasks)
108109
results = mteb.evaluate(
109110
model,
110111
tasks=tasks,
@@ -161,6 +162,81 @@ def execute(self) -> SummaryModel:
161162
self.summary = summary
162163
return summary
163164

165+
@staticmethod
166+
def _attach_relevant_docs(model: SearchClientModel, tasks: list[Any]) -> None:
167+
"""Load task qrels into the search adapter for detailed trace annotation."""
168+
for task in tasks:
169+
task.load_data()
170+
if hasattr(task, "convert_v1_dataset_format_to_v2"):
171+
task.convert_v1_dataset_format_to_v2(num_proc=None)
172+
173+
task_name = task.metadata.name
174+
attached = False
175+
for hf_subset, splits in getattr(task, "dataset", {}).items():
176+
if not isinstance(splits, dict):
177+
continue
178+
for hf_split, data_split in splits.items():
179+
if not isinstance(data_split, dict):
180+
continue
181+
relevant_docs = data_split.get("relevant_docs")
182+
if relevant_docs is None:
183+
continue
184+
model.set_relevant_docs(
185+
task_name,
186+
hf_split,
187+
hf_subset,
188+
relevant_docs,
189+
)
190+
attached = True
191+
192+
if attached:
193+
continue
194+
195+
hf_subset = getattr(task, "hf_subset", "default")
196+
relevant_docs_dict = getattr(task, "relevant_docs", {})
197+
for (
198+
hf_subset,
199+
hf_split,
200+
relevant_docs,
201+
) in RetrievalExecutor._iter_legacy_qrels(relevant_docs_dict, hf_subset):
202+
model.set_relevant_docs(
203+
task_name,
204+
hf_split,
205+
hf_subset,
206+
relevant_docs,
207+
)
208+
209+
@staticmethod
210+
def _iter_legacy_qrels(
211+
relevant_docs_dict: Any,
212+
default_subset: str,
213+
):
214+
"""Yield qrels from older MTEB task.relevant_docs layouts."""
215+
if not isinstance(relevant_docs_dict, dict):
216+
return
217+
218+
for key, value in relevant_docs_dict.items():
219+
if RetrievalExecutor._looks_like_qrels(value):
220+
yield default_subset, key, value
221+
elif isinstance(value, dict):
222+
for split, qrels in value.items():
223+
if RetrievalExecutor._looks_like_qrels(qrels):
224+
yield key, split, qrels
225+
226+
@staticmethod
227+
def _looks_like_qrels(value: Any) -> bool:
228+
if not isinstance(value, dict):
229+
return False
230+
if not value:
231+
return True
232+
sample = next(iter(value.values()))
233+
if isinstance(sample, dict):
234+
if not sample:
235+
return True
236+
nested_sample = next(iter(sample.values()))
237+
return not isinstance(nested_sample, (dict, list, tuple, set))
238+
return isinstance(sample, (list, tuple, set))
239+
164240
def _extract_metrics(self, model_result) -> dict[str, float]:
165241
"""Extract metrics of interest from MTEB ModelResult."""
166242
metrics: dict[str, float] = {}

dingo/retrieval/backends/semantic_scholar.py

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,7 @@
1111
--backend semantic_scholar \
1212
--tasks SciFact \
1313
--api-url https://api.semanticscholar.org \
14-
--limit 100 \
15-
--max-queries 5 \
16-
--rate-limit 1.1 \
17-
-o outputs/retrieval_eval
18-
19-
S2_API_KEY=your_key dingo eval-retrieval \
20-
--backend semantic_scholar \
21-
--tasks SciFact \
22-
--api-url https://api.semanticscholar.org \
14+
--api-token YOUR_S2_API_KEY \
2315
--limit 100 \
2416
--max-queries 5 \
2517
-o outputs/retrieval_eval

dingo/retrieval/mteb_adapter.py

Lines changed: 95 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ def __init__(
7070
self._corpus_size = 0
7171
self._collisions = 0
7272
self._search_traces: list[dict[str, Any]] = []
73+
self._relevant_docs_by_context: dict[
74+
tuple[str, str, str], dict[str, set[str]]
75+
] = {}
7376

7477
safe_name = client.name.replace(" ", "-")
7578
self._mteb_model_meta = ModelMeta(
@@ -99,6 +102,31 @@ def __init__(
99102
def mteb_model_meta(self) -> ModelMeta:
100103
return self._mteb_model_meta
101104

105+
def set_relevant_docs(
106+
self,
107+
task_name: str,
108+
hf_split: str,
109+
hf_subset: str,
110+
relevant_docs: dict[str, Any],
111+
) -> None:
112+
"""Attach qrels for richer debug traces.
113+
114+
MTEB's SearchProtocol does not pass qrels into ``search()``, but Dingo's
115+
detailed traces are easier to inspect when mapped hits are annotated as
116+
relevant or not.
117+
"""
118+
normalized: dict[str, set[str]] = {}
119+
for qid, docs in (relevant_docs or {}).items():
120+
if isinstance(docs, dict):
121+
normalized[str(qid)] = {
122+
str(doc_id) for doc_id, score in docs.items() if score
123+
}
124+
else:
125+
normalized[str(qid)] = {str(doc_id) for doc_id in docs}
126+
self._relevant_docs_by_context[
127+
(task_name, hf_split, hf_subset)
128+
] = normalized
129+
102130
def index(
103131
self,
104132
corpus: "CorpusDatasetType",
@@ -158,6 +186,9 @@ def search(
158186
errors = 0
159187
total_matched = 0
160188
query_details: list[dict[str, Any]] = []
189+
relevant_docs_by_qid = self._relevant_docs_by_context.get(
190+
(task_metadata.name, hf_split, hf_subset)
191+
)
161192

162193
def _process_query(idx_qid_text):
163194
idx, qid, q_text = idx_qid_text
@@ -168,13 +199,18 @@ def _process_query(idx_qid_text):
168199
query=q_text, results=[], response_time_ms=0.0,
169200
status_code=0, error=str(e),
170201
)
171-
return idx, qid, q_text, error_resp, None, None, None
202+
return idx, qid, q_text, error_resp, None, None, None, None
172203

173204
if response.error:
174-
return idx, qid, q_text, response, None, None, None
205+
return idx, qid, q_text, response, None, None, None, None
175206

176207
doc_scores: dict[str, float] = {}
177208
top_api_results: list[dict[str, Any]] = []
209+
relevant_doc_ids = (
210+
relevant_docs_by_qid.get(str(qid))
211+
if relevant_docs_by_qid is not None
212+
else None
213+
)
178214
mapping_stats: dict[str, int] = {
179215
"doc_id_exact": 0,
180216
"title_fallback": 0,
@@ -195,13 +231,33 @@ def _process_query(idx_qid_text):
195231
"score": paper.score,
196232
"resolved_corpus_id": resolved_id,
197233
"mapping_source": src,
234+
"is_relevant": (
235+
bool(resolved_id and resolved_id in relevant_doc_ids)
236+
if relevant_doc_ids is not None
237+
else None
238+
),
198239
}
199240
)
200241
if not resolved_id or resolved_id in doc_scores:
201242
continue
202243
doc_scores[resolved_id] = 1.0 / (rank + 1)
203244

204-
return idx, qid, q_text, response, doc_scores, top_api_results, mapping_stats
245+
relevant_matched_count = (
246+
sum(1 for doc_id in doc_scores if doc_id in relevant_doc_ids)
247+
if relevant_doc_ids is not None
248+
else None
249+
)
250+
251+
return (
252+
idx,
253+
qid,
254+
q_text,
255+
response,
256+
doc_scores,
257+
top_api_results,
258+
mapping_stats,
259+
relevant_matched_count,
260+
)
205261

206262
items = [(i, qid, qt) for i, (qid, qt) in enumerate(zip(query_ids, query_texts))]
207263

@@ -215,7 +271,16 @@ def _process_query(idx_qid_text):
215271
unit="query",
216272
)
217273
for future in concurrent.futures.as_completed(futures):
218-
idx, qid, q_text, response, doc_scores, top_api_results, mapping_stats = future.result()
274+
(
275+
idx,
276+
qid,
277+
q_text,
278+
response,
279+
doc_scores,
280+
top_api_results,
281+
mapping_stats,
282+
relevant_matched_count,
283+
) = future.result()
219284

220285
if doc_scores is None:
221286
errors += 1
@@ -231,19 +296,44 @@ def _process_query(idx_qid_text):
231296
"response_time_ms": response.response_time_ms,
232297
"api_results_count": 0,
233298
"matched_count": 0,
299+
"mapped_count": 0,
300+
"relevant_matched_count": 0,
301+
"relevant_total": 0,
234302
}
235303
)
236304
else:
237305
results[qid] = doc_scores
238306
total_matched += len(doc_scores)
307+
matched_count = (
308+
relevant_matched_count
309+
if relevant_matched_count is not None
310+
else len(doc_scores)
311+
)
312+
relevant_doc_ids = (
313+
relevant_docs_by_qid.get(str(qid))
314+
if relevant_docs_by_qid is not None
315+
else None
316+
)
239317
query_details.append(
240318
{
241319
"qid": qid,
242320
"query_text": q_text,
243321
"error": "",
244322
"response_time_ms": response.response_time_ms,
245323
"api_results_count": len(response.results),
246-
"matched_count": len(doc_scores),
324+
"matched_count": matched_count,
325+
"mapped_count": len(doc_scores),
326+
"relevant_matched_count": relevant_matched_count,
327+
"relevant_total": (
328+
len(relevant_doc_ids)
329+
if relevant_doc_ids is not None
330+
else None
331+
),
332+
"gold_doc_ids": (
333+
sorted(relevant_doc_ids)
334+
if relevant_doc_ids is not None
335+
else None
336+
),
247337
"top_api_results": top_api_results,
248338
"retrieved_doc_ids": list(doc_scores.keys()),
249339
"mapping_stats": mapping_stats,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# """Unit tests for retrieval MTEB adapter traces."""
2+
3+
# from types import SimpleNamespace
4+
5+
# from dingo.retrieval.mteb_adapter import SearchClientModel
6+
# from dingo.retrieval.search_client import PaperResult, SearchClient, SearchResponse
7+
8+
9+
# class FakeSearchClient(SearchClient):
10+
# name = "fake-search"
11+
12+
# def search(self, query: str, limit: int = 100) -> SearchResponse:
13+
# return SearchResponse(
14+
# query=query,
15+
# results=[
16+
# PaperResult(
17+
# paper_id="external-1",
18+
# title="Mapped Non Relevant Paper",
19+
# score=0.9,
20+
# )
21+
# ],
22+
# response_time_ms=12.3,
23+
# status_code=200,
24+
# )
25+
26+
27+
# def test_trace_distinguishes_mapped_from_relevant_matches():
28+
# model = SearchClientModel(FakeSearchClient(), search_limit=10)
29+
# task_metadata = SimpleNamespace(name="FakeTask")
30+
# corpus = [
31+
# {"id": "doc-1", "title": "Mapped Non Relevant Paper", "text": ""},
32+
# {"id": "doc-2", "title": "Gold Paper", "text": ""},
33+
# ]
34+
# queries = {"id": ["q1"], "text": ["test query"]}
35+
36+
# model.set_relevant_docs(
37+
# "FakeTask",
38+
# "test",
39+
# "default",
40+
# {"q1": {"doc-2": 1}},
41+
# )
42+
# model.index(
43+
# corpus,
44+
# task_metadata=task_metadata,
45+
# hf_split="test",
46+
# hf_subset="default",
47+
# encode_kwargs={},
48+
# )
49+
50+
# results = model.search(
51+
# queries,
52+
# task_metadata=task_metadata,
53+
# hf_split="test",
54+
# hf_subset="default",
55+
# top_k=10,
56+
# encode_kwargs={},
57+
# )
58+
59+
# assert results == {"q1": {"doc-1": 1.0}}
60+
# trace_query = model.get_search_traces()[0]["queries"][0]
61+
# assert trace_query["mapped_count"] == 1
62+
# assert trace_query["matched_count"] == 0
63+
# assert trace_query["relevant_matched_count"] == 0
64+
# assert trace_query["relevant_total"] == 1
65+
# assert trace_query["gold_doc_ids"] == ["doc-2"]
66+
# assert trace_query["top_api_results"][0]["is_relevant"] is False

0 commit comments

Comments
 (0)