| """ |
| 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, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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() |
|
|
| |
| 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() |
|
|