Skip to content

Commit cef55cf

Browse files
agentic: add answer-mode retrieval core (contract, evaluator, baselines)
1 parent c130ee0 commit cef55cf

9 files changed

Lines changed: 516 additions & 0 deletions

File tree

mteb/agentic/__init__.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Answer-mode retrieval benchmark (core). See README.md."""
2+
3+
from __future__ import annotations
4+
5+
from mteb.agentic.corpus import InMemoryCorpus
6+
from mteb.agentic.data import AnswerTaskData, from_mteb_retrieval
7+
from mteb.agentic.evaluator import AnswerEvaluationResult, AnswerEvaluator
8+
from mteb.agentic.interface import (
9+
AnswerResult,
10+
AnswerSystem,
11+
ChatModel,
12+
ChatResponse,
13+
CorpusHandle,
14+
Message,
15+
Usage,
16+
)
17+
from mteb.agentic.metrics import (
18+
AggregateScores,
19+
ExactMatchJudge,
20+
Judge,
21+
LLMJudge,
22+
aggregate,
23+
)
24+
from mteb.agentic.systems import ClosedBookSystem, OracleContextSystem
25+
26+
__all__ = [
27+
"AggregateScores",
28+
"AnswerEvaluationResult",
29+
"AnswerEvaluator",
30+
"AnswerResult",
31+
"AnswerSystem",
32+
"AnswerTaskData",
33+
"ChatModel",
34+
"ChatResponse",
35+
"ClosedBookSystem",
36+
"CorpusHandle",
37+
"ExactMatchJudge",
38+
"InMemoryCorpus",
39+
"Judge",
40+
"LLMJudge",
41+
"Message",
42+
"OracleContextSystem",
43+
"Usage",
44+
"aggregate",
45+
"from_mteb_retrieval",
46+
]

mteb/agentic/corpus.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Reference CorpusHandle implementations."""
2+
3+
from __future__ import annotations
4+
5+
6+
class InMemoryCorpus:
7+
"""Read-only corpus backed by a dict of documents."""
8+
9+
def __init__(self, documents: dict[str, dict[str, str]]) -> None:
10+
self._docs = documents
11+
12+
def get(self, doc_id: str) -> dict[str, str]:
13+
"""Return one document with its id."""
14+
return {"id": doc_id, **self._docs[doc_id]}

mteb/agentic/data.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""Adapters that turn retrieval datasets into answer-mode inputs."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from typing import TYPE_CHECKING
7+
8+
if TYPE_CHECKING:
9+
from collections.abc import Mapping
10+
11+
12+
@dataclass
13+
class AnswerTaskData:
14+
"""Everything an AnswerEvaluator needs for one split."""
15+
16+
documents: dict[str, dict[str, str]] # doc_id -> {title, text}
17+
questions: dict[str, str] # qid -> question
18+
references: dict[str, str] # qid -> reference answer
19+
gold_by_qid: dict[str, list[str]] # qid -> gold doc ids
20+
gold_by_question: dict[str, list[str]] # question text -> gold doc ids
21+
22+
23+
def from_mteb_retrieval(
24+
corpus: Mapping[str, Mapping[str, str]],
25+
queries: Mapping[str, str],
26+
relevant_docs: Mapping[str, Mapping[str, int]],
27+
answers: Mapping[str, str],
28+
) -> AnswerTaskData:
29+
"""Build answer-mode data from MTEB-style retrieval fields plus answers."""
30+
documents = {
31+
doc_id: {
32+
"title": doc.get("title", ""),
33+
"text": doc.get("text", doc.get("body", "")),
34+
}
35+
for doc_id, doc in corpus.items()
36+
}
37+
questions = dict(queries)
38+
references = dict(answers)
39+
gold_by_qid = {
40+
qid: [doc_id for doc_id, score in rels.items() if score > 0]
41+
for qid, rels in relevant_docs.items()
42+
}
43+
gold_by_question = {
44+
questions[qid]: ids for qid, ids in gold_by_qid.items() if qid in questions
45+
}
46+
return AnswerTaskData(
47+
documents=documents,
48+
questions=questions,
49+
references=references,
50+
gold_by_qid=gold_by_qid,
51+
gold_by_question=gold_by_question,
52+
)

mteb/agentic/evaluator.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""Runs one AnswerSystem over a question set and scores it on three axes."""
2+
3+
from __future__ import annotations
4+
5+
import time
6+
from dataclasses import dataclass
7+
from typing import TYPE_CHECKING, Any
8+
9+
from mteb.agentic.metrics import aggregate
10+
11+
if TYPE_CHECKING:
12+
from collections.abc import Mapping
13+
14+
from mteb.agentic.interface import AnswerResult, AnswerSystem, CorpusHandle
15+
from mteb.agentic.metrics import AggregateScores, Judge
16+
17+
18+
@dataclass
19+
class AnswerEvaluationResult:
20+
"""Aggregate scores plus a per-question record."""
21+
22+
scores: AggregateScores
23+
per_question: list[dict[str, Any]]
24+
25+
26+
class AnswerEvaluator:
27+
"""Evaluate an answer-mode system over a fixed corpus and question set."""
28+
29+
def __init__(
30+
self,
31+
questions: Mapping[str, str],
32+
references: Mapping[str, str],
33+
corpus: CorpusHandle,
34+
judge: Judge,
35+
) -> None:
36+
# questions and references are keyed by query id.
37+
self.questions = questions
38+
self.references = references
39+
self.corpus = corpus
40+
self.judge = judge
41+
42+
def __call__(self, system: AnswerSystem) -> AnswerEvaluationResult:
43+
"""Run the system over every question and return aggregate scores."""
44+
results: list[AnswerResult] = []
45+
correctness: list[float] = []
46+
per_question: list[dict[str, Any]] = []
47+
for qid, question in self.questions.items():
48+
start = time.perf_counter()
49+
result = system.answer(question, self.corpus)
50+
elapsed = time.perf_counter() - start
51+
if result.usage.latency_s is None:
52+
result.usage.latency_s = elapsed
53+
score = self.judge.score(question, result.answer, self.references[qid])
54+
results.append(result)
55+
correctness.append(score)
56+
per_question.append(
57+
{
58+
"query_id": qid,
59+
"answer": result.answer,
60+
"correct": score,
61+
"cited_doc_ids": result.cited_doc_ids,
62+
"latency_s": result.usage.latency_s,
63+
"cost_usd": result.usage.cost_usd,
64+
"num_llm_calls": result.usage.num_llm_calls,
65+
}
66+
)
67+
return AnswerEvaluationResult(
68+
scores=aggregate(results, correctness),
69+
per_question=per_question,
70+
)

mteb/agentic/interface.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
"""Core contract for answer-mode retrieval systems."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass, field
6+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
7+
8+
if TYPE_CHECKING:
9+
from collections.abc import Sequence
10+
11+
# Provider-agnostic chat message.
12+
Message = dict[str, str]
13+
14+
15+
@dataclass
16+
class ChatResponse:
17+
"""Result of one chat completion call."""
18+
19+
text: str
20+
prompt_tokens: int = 0
21+
completion_tokens: int = 0
22+
cost_usd: float | None = None
23+
24+
25+
@runtime_checkable
26+
class ChatModel(Protocol):
27+
"""Provider-agnostic chat interface."""
28+
29+
name: str
30+
31+
def generate(self, messages: Sequence[Message], **kwargs: Any) -> ChatResponse:
32+
"""Generate a single completion for a chat transcript."""
33+
...
34+
35+
36+
@runtime_checkable
37+
class CorpusHandle(Protocol):
38+
"""Read access to a fixed corpus."""
39+
40+
def get(self, doc_id: str) -> dict[str, str]:
41+
"""Fetch one document as a mapping with at least id and text."""
42+
...
43+
44+
45+
@dataclass
46+
class Usage:
47+
"""Cost and latency accounting for one answer."""
48+
49+
prompt_tokens: int = 0
50+
completion_tokens: int = 0
51+
num_llm_calls: int = 0
52+
cost_usd: float | None = None
53+
latency_s: float | None = None
54+
55+
56+
@dataclass
57+
class AnswerResult:
58+
"""What a system returns for one question."""
59+
60+
answer: str
61+
cited_doc_ids: list[str] = field(default_factory=list)
62+
usage: Usage = field(default_factory=Usage)
63+
64+
65+
@runtime_checkable
66+
class AnswerSystem(Protocol):
67+
"""End-to-end system that produces an answer, not a ranking."""
68+
69+
name: str
70+
71+
def answer(self, question: str, corpus: CorpusHandle) -> AnswerResult:
72+
"""Answer a single question using the corpus."""
73+
...

mteb/agentic/metrics.py

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Correctness judges and three-axis aggregation for answer-mode eval."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
from dataclasses import dataclass
7+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
8+
9+
if TYPE_CHECKING:
10+
from collections.abc import Sequence
11+
12+
from mteb.agentic.interface import AnswerResult, ChatModel
13+
14+
15+
@runtime_checkable
16+
class Judge(Protocol):
17+
"""Scores answer correctness in the range 0 to 1."""
18+
19+
def score(self, question: str, predicted: str, reference: str) -> float:
20+
"""Grade a predicted answer against a reference answer."""
21+
...
22+
23+
24+
def _normalize(text: str) -> str:
25+
# Lowercase, drop articles and punctuation, collapse whitespace.
26+
text = text.lower()
27+
text = re.sub(r"\b(a|an|the)\b", " ", text)
28+
text = re.sub(r"[^a-z0-9 ]", " ", text)
29+
return " ".join(text.split())
30+
31+
32+
class ExactMatchJudge:
33+
"""Normalized exact match."""
34+
35+
def score(self, question: str, predicted: str, reference: str) -> float: # noqa: PLR6301
36+
"""Return 1.0 on a normalized exact match, else 0.0."""
37+
return 1.0 if _normalize(predicted) == _normalize(reference) else 0.0
38+
39+
40+
_JUDGE_PROMPT = (
41+
"You grade a predicted answer against a reference answer. "
42+
"Reply with only YES if the prediction is correct, otherwise NO.\n\n"
43+
"Question: {question}\nReference: {reference}\nPrediction: {predicted}"
44+
)
45+
46+
47+
class LLMJudge:
48+
"""Grades open ended answers with a ChatModel."""
49+
50+
def __init__(self, model: ChatModel) -> None:
51+
self.model = model
52+
53+
def score(self, question: str, predicted: str, reference: str) -> float:
54+
"""Return 1.0 if the judge model answers YES, else 0.0."""
55+
prompt = _JUDGE_PROMPT.format(
56+
question=question, reference=reference, predicted=predicted
57+
)
58+
out = self.model.generate([{"role": "user", "content": prompt}])
59+
return 1.0 if out.text.strip().lower().startswith("yes") else 0.0
60+
61+
62+
@dataclass
63+
class AggregateScores:
64+
"""Three-axis summary over a question set: quality, cost, latency."""
65+
66+
accuracy: float
67+
mean_cost_usd: float | None
68+
total_cost_usd: float | None
69+
mean_latency_s: float | None
70+
mean_llm_calls: float
71+
n: int
72+
73+
74+
def aggregate(
75+
results: Sequence[AnswerResult], correctness: Sequence[float]
76+
) -> AggregateScores:
77+
"""Reduce per-question results into the three reported axes."""
78+
n = len(results)
79+
if n == 0:
80+
return AggregateScores(0.0, None, None, None, 0.0, 0)
81+
costs = [r.usage.cost_usd for r in results if r.usage.cost_usd is not None]
82+
latencies = [r.usage.latency_s for r in results if r.usage.latency_s is not None]
83+
total_cost = sum(costs) if costs else None
84+
mean_cost = sum(costs) / len(costs) if costs else None
85+
return AggregateScores(
86+
accuracy=sum(correctness) / n,
87+
mean_cost_usd=mean_cost,
88+
total_cost_usd=total_cost,
89+
mean_latency_s=(sum(latencies) / len(latencies)) if latencies else None,
90+
mean_llm_calls=sum(r.usage.num_llm_calls for r in results) / n,
91+
n=n,
92+
)

mteb/agentic/systems/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Answer-mode reference systems: floor and ceiling baselines."""
2+
3+
from __future__ import annotations
4+
5+
from mteb.agentic.systems.baselines import ClosedBookSystem, OracleContextSystem
6+
7+
__all__ = [
8+
"ClosedBookSystem",
9+
"OracleContextSystem",
10+
]

0 commit comments

Comments
 (0)