Rabbook / evaluation /evaluate_retrieval_metrics.py
Matcry's picture
Deploy snapshot
c76423f
Raw
History Blame Contribute Delete
5.06 kB
"""
Deterministic IR metrics for the Rabbook retrieval pipeline.
No LLM judging. Measures whether the retriever fetches the labelled chunks
from the golden dataset. Run this first β€” it is fast and free.
Metrics reported at k=DEFAULT_RETRIEVAL_K:
Hit@k β€” was at least one relevant chunk in the top-k results?
Recall@k β€” what fraction of relevant chunks appeared in the top-k?
Precision@k β€” what fraction of the top-k results were relevant?
MRR β€” mean reciprocal rank of the first relevant chunk hit
"""
import warnings
from dotenv import load_dotenv
load_dotenv()
warnings.filterwarnings("ignore", category=DeprecationWarning)
from core.config import DEFAULT_RETRIEVAL_K
from .eval_common import (
build_embeddings,
build_llm,
build_reranker,
load_dataset,
load_retrieval_bundle,
retrieve_chunk_ids,
)
# ---------------------------------------------------------------------------
# Pure metric functions
# ---------------------------------------------------------------------------
def hit_at_k(predicted_ids: list[str], relevant_ids: set[str], k: int) -> float:
"""1.0 if any of the top-k predicted ids appear in the relevant set."""
return 1.0 if any(chunk_id in relevant_ids for chunk_id in predicted_ids[:k]) else 0.0
def recall_at_k(predicted_ids: list[str], relevant_ids: set[str], k: int) -> float:
"""Fraction of relevant chunks that appear in the top-k results."""
if not relevant_ids:
return 0.0
hits = len(set(predicted_ids[:k]) & relevant_ids)
return hits / len(relevant_ids)
def precision_at_k(predicted_ids: list[str], relevant_ids: set[str], k: int) -> float:
"""Fraction of the top-k results that are relevant."""
if k == 0:
return 0.0
hits = len(set(predicted_ids[:k]) & relevant_ids)
return hits / k
def reciprocal_rank(predicted_ids: list[str], relevant_ids: set[str]) -> float:
"""1/rank of the first relevant chunk in the ranked list (1-indexed). 0 if none found."""
for rank, chunk_id in enumerate(predicted_ids, start=1):
if chunk_id in relevant_ids:
return 1.0 / rank
return 0.0
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
print("Initializing models...")
embeddings = build_embeddings()
reranker = build_reranker()
llm = build_llm()
print("Loading retrieval bundle...")
vectorstore, bm25_index = load_retrieval_bundle(embeddings)
dataset = load_dataset()
# Only evaluate cases that have labelled relevant chunks ("answer" cases).
answer_cases = [c for c in dataset if c.get("relevant_chunk_ids")]
fallback_cases = [c for c in dataset if not c.get("relevant_chunk_ids")]
k = DEFAULT_RETRIEVAL_K
print(f"\nEvaluating {len(answer_cases)} answer cases at k={k}...")
print(f"(Skipping {len(fallback_cases)} fallback case(s) β€” no relevant_chunk_ids labelled)\n")
col_q = 62
print(
f"{'Question':<{col_q}} {'Hit':>4} {'Rec':>5} {'Pre':>5} {'RR':>5}"
f" Predicted / Relevant"
)
print("-" * 120)
hit_scores: list[float] = []
recall_scores: list[float] = []
precision_scores: list[float] = []
rr_scores: list[float] = []
for case in answer_cases:
question = case["question"]
relevant_ids = set(case["relevant_chunk_ids"])
predicted_ids = retrieve_chunk_ids(
question, vectorstore, bm25_index, reranker, llm, k=k
)
hit = hit_at_k(predicted_ids, relevant_ids, k)
rec = recall_at_k(predicted_ids, relevant_ids, k)
pre = precision_at_k(predicted_ids, relevant_ids, k)
rr = reciprocal_rank(predicted_ids, relevant_ids)
hit_scores.append(hit)
recall_scores.append(rec)
precision_scores.append(pre)
rr_scores.append(rr)
q_label = question[:col_q]
predicted_label = str(predicted_ids)
relevant_label = str(sorted(relevant_ids))
print(
f"{q_label:<{col_q}} {hit:>4.2f} {rec:>5.3f} {pre:>5.3f} {rr:>5.3f}"
f" {predicted_label} / {relevant_label}"
)
if not hit_scores:
print("No answer cases to evaluate.")
return
n = len(hit_scores)
print()
print("=" * 60)
print(f"Macro-averages over {n} evaluated cases (k={k})")
print("=" * 60)
print(f" Hit@{k}: {sum(hit_scores) / n:.3f} (fraction of cases with β‰₯1 relevant chunk in top-k)")
print(f" Recall@{k}: {sum(recall_scores) / n:.3f} (fraction of labelled chunks retrieved)")
print(f" Precision@{k}: {sum(precision_scores) / n:.3f} (fraction of retrieved chunks that are relevant)")
print(f" MRR: {sum(rr_scores) / n:.3f} (mean reciprocal rank of first relevant chunk)")
print(f"\n Note: {len(fallback_cases)} fallback case(s) skipped β€” they have no labelled relevant_chunk_ids.")
if __name__ == "__main__":
main()