documind-api / eval.py
GitHub Actions
Sync backend to Hugging Face Space
7ea3046
Raw
History Blame Contribute Delete
5.59 kB
"""Lightweight retrieval evaluation utilities for benchmarkable improvements."""
from __future__ import annotations
import asyncio
import json
from dataclasses import dataclass, field
from pathlib import Path
from statistics import mean
from typing import Any, Awaitable, Callable, Mapping, Sequence
@dataclass(frozen=True, slots=True)
class EvalCase:
query: str
relevant_ids: set[str] = field(default_factory=set)
relevant_text: str = ""
source: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class EvalResult:
query: str
retrieved_ids: list[str]
relevant_ids: set[str]
precision_at_k: float
recall_at_k: float
reciprocal_rank: float
hit: bool
@dataclass(frozen=True, slots=True)
class EvalSummary:
cases: int
precision_at_k: float
recall_at_k: float
mean_reciprocal_rank: float
hit_rate: float
results: list[EvalResult]
def to_dict(self) -> dict[str, Any]:
return {
"cases": self.cases,
"precision_at_k": self.precision_at_k,
"recall_at_k": self.recall_at_k,
"mean_reciprocal_rank": self.mean_reciprocal_rank,
"hit_rate": self.hit_rate,
"results": [
{
"query": result.query,
"retrieved_ids": result.retrieved_ids,
"relevant_ids": sorted(result.relevant_ids),
"precision_at_k": result.precision_at_k,
"recall_at_k": result.recall_at_k,
"reciprocal_rank": result.reciprocal_rank,
"hit": result.hit,
}
for result in self.results
],
}
RetrieverFn = Callable[[str, int], Awaitable[Sequence[Any]]]
async def evaluate_retrieval(cases: Sequence[EvalCase], retrieve: RetrieverFn, *, k: int = 6) -> EvalSummary:
if k < 1:
raise ValueError("k must be positive.")
results: list[EvalResult] = []
for case in cases:
retrieved = list(await retrieve(case.query, k))
retrieved_ids = [_result_id(item) for item in retrieved[:k]]
results.append(score_case(case, retrieved_ids, k=k))
if not results:
return EvalSummary(0, 0.0, 0.0, 0.0, 0.0, [])
return EvalSummary(
cases=len(results),
precision_at_k=mean(result.precision_at_k for result in results),
recall_at_k=mean(result.recall_at_k for result in results),
mean_reciprocal_rank=mean(result.reciprocal_rank for result in results),
hit_rate=mean(1.0 if result.hit else 0.0 for result in results),
results=results,
)
def score_case(case: EvalCase, retrieved_ids: Sequence[str], *, k: int) -> EvalResult:
top_ids = list(retrieved_ids[:k])
relevant = set(case.relevant_ids)
if not relevant:
return EvalResult(case.query, top_ids, relevant, 0.0, 0.0, 0.0, False)
hits = [chunk_id for chunk_id in top_ids if chunk_id in relevant]
precision = len(hits) / k
recall = len(set(hits)) / len(relevant)
reciprocal_rank = 0.0
for index, chunk_id in enumerate(top_ids, start=1):
if chunk_id in relevant:
reciprocal_rank = 1.0 / index
break
return EvalResult(
query=case.query,
retrieved_ids=top_ids,
relevant_ids=relevant,
precision_at_k=precision,
recall_at_k=recall,
reciprocal_rank=reciprocal_rank,
hit=bool(hits),
)
def load_eval_cases(path: str | Path) -> list[EvalCase]:
"""Load JSONL cases with query plus relevant_ids or relevant_id."""
cases: list[EvalCase] = []
with Path(path).open("r", encoding="utf-8") as handle:
for line_number, line in enumerate(handle, start=1):
stripped = line.strip()
if not stripped:
continue
payload = json.loads(stripped)
query = str(payload.get("query", "")).strip()
if not query:
raise ValueError(f"Missing query on line {line_number}.")
relevant_ids = payload.get("relevant_ids", [])
if "relevant_id" in payload:
relevant_ids = [payload["relevant_id"], *list(relevant_ids)]
cases.append(
EvalCase(
query=query,
relevant_ids={str(item) for item in relevant_ids},
relevant_text=str(payload.get("relevant_text", "")),
source=str(payload["source"]) if payload.get("source") is not None else None,
metadata=dict(payload.get("metadata", {}) or {}),
)
)
return cases
def write_eval_summary(summary: EvalSummary, path: str | Path) -> None:
Path(path).write_text(json.dumps(summary.to_dict(), indent=2, sort_keys=True), encoding="utf-8")
def evaluate_retrieval_sync(cases: Sequence[EvalCase], retrieve: RetrieverFn, *, k: int = 6) -> EvalSummary:
return asyncio.run(evaluate_retrieval(cases, retrieve, k=k))
def _result_id(item: Any) -> str:
chunk = getattr(item, "chunk", None)
if chunk is not None:
return str(getattr(chunk, "chunk_id", ""))
if isinstance(item, Mapping):
if "chunk_id" in item:
return str(item["chunk_id"])
if "id" in item:
return str(item["id"])
if "chunk" in item and isinstance(item["chunk"], Mapping):
return str(item["chunk"].get("chunk_id", ""))
return str(getattr(item, "chunk_id", getattr(item, "id", "")))