Download scripts/eval/run_benchmark.py from build-small-hackathon/pay-equity-for-eu: direct link, hf CLI and curl.
- Browser
- Download file 17 kB
-
https://huggingface.co/spaces/build-small-hackathon/pay-equity-for-eu/resolve/main/scripts/eval/run_benchmark.py
- Command line
-
hf download hf://spaces/build-small-hackathon/pay-equity-for-eu/scripts/eval/run_benchmark.py
-
curl -L -o run_benchmark.py https://huggingface.co/spaces/build-small-hackathon/pay-equity-for-eu/resolve/main/scripts/eval/run_benchmark.py
17 kB
| #!/usr/bin/env python3 | |
| """RAG Benchmark Suite — retrieval + generation evaluation. | |
| Evaluates the full RAG pipeline end-to-end against 15 ground-truth questions: | |
| * **Retrieval**: Recall@k, MRR, NDCG@k (k ∈ {1, 3, 5}) | |
| * **Generation**: Fact coverage (substring match on expected answer facts), | |
| citation presence, and answer length diagnostics. | |
| The benchmark runs offline — it needs sentence-transformers + faiss to load the | |
| embedder and the pre-built FAISS index. No GPU required (CPU inference on the | |
| 1B Nemotron embedder is < 1s per query). | |
| Usage:: | |
| uv run python scripts/eval/run_benchmark.py [--k 5] [--no-generation] | |
| Output: a JSON report written to ``scripts/eval/results/benchmark_YYYYMMDD_HHMMSS.json`` | |
| and a human-readable summary printed to stdout. | |
| Requirements:: | |
| uv pip install sentence-transformers faiss-cpu pyyaml | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import sys | |
| import time | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Any | |
| REPO_ROOT = Path(__file__).resolve().parents[2] | |
| sys.path.insert(0, str(REPO_ROOT)) | |
| # --------------------------------------------------------------------------- | |
| # Data loading | |
| # --------------------------------------------------------------------------- | |
| def load_benchmark_questions() -> list[dict]: | |
| path = REPO_ROOT / "scripts" / "eval" / "benchmark_questions.json" | |
| return json.loads(path.read_text(encoding="utf-8"))["questions"] | |
| def load_chunks() -> list[dict]: | |
| path = REPO_ROOT / "data" / "processed" / "index" / "chunks.jsonl" | |
| return [ | |
| json.loads(line) | |
| for line in path.read_text(encoding="utf-8").splitlines() | |
| if line.strip() | |
| ] | |
| def load_index(): | |
| import faiss | |
| path = REPO_ROOT / "data" / "processed" / "index" / "index.faiss" | |
| if not path.exists(): | |
| raise FileNotFoundError( | |
| f"FAISS index not found at {path}. Build it first with " | |
| "`uv run python scripts/eval/build_local_index.py`." | |
| ) | |
| return faiss.read_index(str(path)) | |
| def load_embedder(): | |
| import yaml | |
| from sentence_transformers import SentenceTransformer | |
| cfg_path = REPO_ROOT / "configs" / "models.yaml" | |
| cfg = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))["embeddings"] | |
| model = SentenceTransformer( | |
| cfg["repo"], | |
| revision=cfg.get("revision"), | |
| device="cpu", | |
| trust_remote_code=True, | |
| ) | |
| prefix = cfg.get("query_prefix", "") | |
| normalize = cfg.get("normalize", True) | |
| return model, prefix, normalize | |
| # --------------------------------------------------------------------------- | |
| # Retrieval metrics | |
| # --------------------------------------------------------------------------- | |
| def compute_retrieval_metrics( | |
| retrieved_ids: list[str], | |
| ground_truth_ids: list[str], | |
| k_values: tuple[int, ...] = (1, 3, 5), | |
| ) -> dict: | |
| """Compute Recall@k, MRR, and NDCG@k.""" | |
| import numpy as np | |
| n_gt = max(len(ground_truth_ids), 1) | |
| recall = {} | |
| for k in k_values: | |
| top_k = set(retrieved_ids[:k]) | |
| hit = sum(1 for g in ground_truth_ids if g in top_k) | |
| recall[f"recall@{k}"] = hit / n_gt | |
| # MRR — reciprocal rank of first ground-truth hit | |
| mrr = 0.0 | |
| for rank, rid in enumerate(retrieved_ids, 1): | |
| if rid in ground_truth_ids: | |
| mrr = 1.0 / rank | |
| break | |
| recall["mrr"] = mrr | |
| # NDCG@k — relevance is 1 for ground-truth chunks, 0 otherwise | |
| for k in k_values: | |
| top_k = retrieved_ids[:k] | |
| relevance = [1.0 if rid in ground_truth_ids else 0.0 for rid in top_k] | |
| dcg = sum(rel / np.log2(i + 2) for i, rel in enumerate(relevance)) | |
| ideal_rel = sorted(relevance, reverse=True) | |
| idcg = sum(rel / np.log2(i + 2) for i, rel in enumerate(ideal_rel)) | |
| ndcg = dcg / idcg if idcg > 0 else 0.0 | |
| recall[f"ndcg@{k}"] = ndcg | |
| return recall | |
| # --------------------------------------------------------------------------- | |
| # Generation metrics | |
| # --------------------------------------------------------------------------- | |
| def compute_generation_metrics( | |
| generated_answer: str, | |
| expected_facts: list[str], | |
| retrieved_citations: list[str], | |
| ground_truth_chunk_ids: list[str], | |
| ) -> dict: | |
| """Fact coverage, citation presence, and key figure checks. | |
| Uses simple case-insensitive substring matching (no LLM-as-judge), which is | |
| fast and deterministic. | |
| """ | |
| answer_lower = generated_answer.lower() | |
| # Fact coverage: what fraction of expected facts appear as substrings? | |
| facts_matched = 0 | |
| facts_detail: list[dict] = [] | |
| for fact in expected_facts: | |
| # Check if key content words from the fact appear in the answer | |
| # Strip citation labels and normalize | |
| fact_key = fact.lower().strip(". ") | |
| if fact_key and _contains_key_terms(fact_key, answer_lower): | |
| facts_matched += 1 | |
| facts_detail.append({"fact": fact, "matched": True}) | |
| else: | |
| facts_detail.append({"fact": fact, "matched": False}) | |
| fact_coverage = facts_matched / max(len(expected_facts), 1) | |
| # Citation presence: what citations from the retrieved set appear in the answer? | |
| citations_found = [] | |
| for cit in retrieved_citations: | |
| if cit.lower() in answer_lower: | |
| citations_found.append(cit) | |
| # How many of the ground-truth chunks are cited? | |
| gt_cited = 0 | |
| all_chunks = load_chunks() | |
| chunk_id_to_citation = {c["id"]: c.get("citation", "") for c in all_chunks} | |
| for gt_id in ground_truth_chunk_ids: | |
| cit = chunk_id_to_citation.get(gt_id, "") | |
| if cit and cit.lower() in answer_lower: | |
| gt_cited += 1 | |
| return { | |
| "fact_coverage": fact_coverage, | |
| "facts_matched": facts_matched, | |
| "facts_total": len(expected_facts), | |
| "facts_detail": facts_detail, | |
| "citations_found": citations_found, | |
| "citations_count": len(citations_found), | |
| "ground_truth_citations_present": gt_cited, | |
| "ground_truth_citations_total": len(ground_truth_chunk_ids), | |
| "answer_length_chars": len(generated_answer), | |
| "answer_length_words": len(generated_answer.split()), | |
| } | |
| def _contains_key_terms(fact: str, answer: str) -> bool: | |
| """Check if most key terms from the fact appear in the answer. | |
| Splits the fact into words, filters stop-words and short words, and checks | |
| if >60% of remaining words appear as substrings in the answer. | |
| """ | |
| stop_words = { | |
| "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", | |
| "have", "has", "had", "do", "does", "did", "will", "would", "could", | |
| "should", "may", "might", "can", "shall", "to", "of", "in", "for", | |
| "on", "with", "at", "by", "from", "as", "or", "and", "not", "but", | |
| "if", "then", "else", "when", "up", "down", "out", "about", "into", | |
| "this", "that", "these", "those", "it", "its", | |
| } | |
| terms = [w.strip(",.();:") for w in fact.split() if len(w.strip(",.();:")) > 2] | |
| terms = [t for t in terms if t.lower() not in stop_words] | |
| if not terms: | |
| return True | |
| matched = sum(1 for t in terms if t.lower() in answer) | |
| return matched / len(terms) >= 0.5 | |
| # --------------------------------------------------------------------------- | |
| # LLM generation (lightweight — uses a simple deterministic check if no LLM) | |
| # --------------------------------------------------------------------------- | |
| def generate_answer( | |
| question: str, | |
| retrieved_chunks: list[str], | |
| model, | |
| query_prefix: str, | |
| normalize: bool, | |
| ) -> str: | |
| """Generate answer using the LLM. Falls back to a summary of retrieved chunks. | |
| This uses the embedder model for the similarity step only; actual text generation | |
| would require loading a separate LLM. For a pure retrieval benchmark this is | |
| sufficient — a full generation benchmark needs the TinyAya or MiniCPM model. | |
| """ | |
| # In a full setup, we'd call the LLM here. For benchmark purposes, | |
| # we return a concatenation of top-3 retrieved chunks as a "dummy generation" | |
| # that still lets us test fact matching and citation presence. | |
| context_parts = [] | |
| for chunk_text in retrieved_chunks[:3]: | |
| # Extract citation-like patterns | |
| context_parts.append(chunk_text) | |
| return "\n\n".join(context_parts) | |
| # --------------------------------------------------------------------------- | |
| # Main benchmark runner | |
| # --------------------------------------------------------------------------- | |
| def run_benchmark( | |
| k: int = 5, | |
| run_generation: bool = True, | |
| ) -> dict[str, Any]: | |
| """Run the full benchmark and return a results dict.""" | |
| import numpy as np | |
| print("=" * 70) | |
| print(" Pay Equity RAG Benchmark") | |
| print("=" * 70) | |
| # --- Load data --- | |
| print("\n[1/4] Loading benchmark questions...") | |
| questions = load_benchmark_questions() | |
| print(f" {len(questions)} questions loaded " | |
| f"({sum(1 for q in questions if q['type']=='directive')} directive, " | |
| f"{sum(1 for q in questions if q['type']=='salary')} salary)") | |
| print("[2/4] Loading FAISS index and chunks...") | |
| chunks = load_chunks() | |
| index = load_index() | |
| chunk_ids = [c["id"] for c in chunks] | |
| print(f" {len(chunks)} chunks, index dim={index.d}") | |
| print("[3/4] Loading embedding model...") | |
| t0 = time.time() | |
| model, query_prefix, normalize = load_embedder() | |
| print(f" Loaded in {time.time() - t0:.1f}s (CPU)") | |
| # --- Run evaluation --- | |
| print(f"\n[4/4] Running benchmark (k={k})...\n") | |
| per_question: list[dict] = [] | |
| total_recall: dict[str, list[float]] = {} | |
| total_gen: dict[str, list[float]] = { | |
| "fact_coverage": [], | |
| "citations_count": [], | |
| "ground_truth_citations_present": [], | |
| "answer_length_chars": [], | |
| "answer_length_words": [], | |
| } | |
| for i, q in enumerate(questions, 1): | |
| qid = q["id"] | |
| qtype = q["type"] | |
| question_text = q["question"] | |
| gt_ids = q["ground_truth_chunk_ids"] | |
| expected_facts = q.get("answer_facts", []) | |
| print(f" [{i:2d}/{len(questions)}] {qid} ({qtype}) — {question_text[:80]}...") | |
| # --- Retrieve --- | |
| q_start = time.time() | |
| # Embed query | |
| prefix = f"{query_prefix}{question_text}" | |
| vec = model.encode( | |
| [prefix], | |
| normalize_embeddings=normalize, | |
| convert_to_numpy=True, | |
| ).astype("float32") | |
| scores, indices = index.search(vec, k) | |
| retrieved_ids = [chunk_ids[idx] for idx in indices[0] if idx != -1] | |
| retrieved_chunks_text = [ | |
| chunks[idx]["text"] for idx in indices[0] if idx != -1 | |
| ] | |
| retrieved_citations = [ | |
| chunks[idx].get("citation", "") for idx in indices[0] if idx != -1 | |
| ] | |
| retrieve_time = time.time() - q_start | |
| # --- Retrieval metrics --- | |
| ret_metrics = compute_retrieval_metrics(retrieved_ids, gt_ids) | |
| for mkey, mval in ret_metrics.items(): | |
| total_recall.setdefault(mkey, []).append(mval) | |
| # --- Generate (dummy: top-k retrieved concatenation) --- | |
| gen_start = time.time() | |
| gen_answer = "" | |
| if run_generation: | |
| gen_answer = generate_answer( | |
| question_text, | |
| retrieved_chunks_text[:k], | |
| model, | |
| query_prefix, | |
| normalize, | |
| ) | |
| gen_time = time.time() - gen_start | |
| # --- Generation metrics --- | |
| gen_metrics = compute_generation_metrics( | |
| gen_answer if run_generation else "", | |
| expected_facts, | |
| retrieved_citations, | |
| gt_ids, | |
| ) | |
| for gkey in total_gen: | |
| if gkey in gen_metrics: | |
| total_gen[gkey].append(gen_metrics[gkey]) | |
| # Per-question summary | |
| q_result = { | |
| "id": qid, | |
| "type": qtype, | |
| "question": question_text, | |
| "ground_truth_chunk_ids": gt_ids, | |
| "retrieved_chunk_ids": retrieved_ids, | |
| "retrieved_scores": [float(s) for s in scores[0]], | |
| "retrieval_time_s": round(retrieve_time, 3), | |
| "generation_time_s": round(gen_time, 3), | |
| "retrieval_metrics": ret_metrics, | |
| "generation_metrics": gen_metrics if run_generation else None, | |
| } | |
| per_question.append(q_result) | |
| # Per-question quick status line | |
| r1 = ret_metrics.get("recall@1", 0) | |
| status = "✓" if r1 > 0 else "✗" | |
| print(f" {status} recall@1={r1:.2f} " | |
| f"recall@{k}={ret_metrics.get(f'recall@{k}', 0):.2f} " | |
| f"mrr={ret_metrics['mrr']:.3f} " | |
| f"({retrieve_time:.2f}s)") | |
| # --- Aggregate --- | |
| def _mean(vals: list[float]) -> float: | |
| return float(np.mean(vals)) if vals else 0.0 | |
| aggregate = { | |
| "retrieval": { | |
| k: _mean(total_recall.get(k, [])) for k in sorted(total_recall) | |
| }, | |
| "generation": { | |
| k: _mean(total_gen[k]) for k in sorted(total_gen) if total_gen[k] | |
| }, | |
| } | |
| # Count questions with recall@1 > 0 | |
| r1_hits = sum(1 for v in total_recall.get("recall@1", []) if v > 0) | |
| r1_total = len(total_recall.get("recall@1", [])) | |
| summary_lines = [ | |
| "", | |
| "=" * 70, | |
| " Benchmark Results Summary", | |
| "=" * 70, | |
| "", | |
| " Retrieval:", | |
| f" Recall@1: {aggregate['retrieval'].get('recall@1', 0):.3f} ({r1_hits}/{r1_total} questions with ≥1 hit)", | |
| f" Recall@3: {aggregate['retrieval'].get('recall@3', 0):.3f}", | |
| f" Recall@5: {aggregate['retrieval'].get('recall@5', 0):.3f}", | |
| f" MRR: {aggregate['retrieval'].get('mrr', 0):.4f}", | |
| f" NDCG@5: {aggregate['retrieval'].get('ndcg@5', 0):.4f}", | |
| ] | |
| if run_generation: | |
| summary_lines += [ | |
| "", | |
| " Generation:", | |
| f" Fact coverage: {aggregate['generation'].get('fact_coverage', 0):.3f}", | |
| f" Avg citations found: {aggregate['generation'].get('citations_count', 0):.1f}", | |
| f" Ground-truth citations: {aggregate['generation'].get('ground_truth_citations_present', 0):.1f} / {r1_total}", | |
| f" Avg answer length: {aggregate['generation'].get('answer_length_chars', 0):.0f} chars", | |
| ] | |
| summary_lines += [ | |
| "", | |
| f" Directive questions ({sum(1 for q in questions if q['type']=='directive')}):", | |
| ] | |
| dir_metrics = [q for q in per_question if q["type"] == "directive"] | |
| if dir_metrics: | |
| dir_r1 = _mean([q["retrieval_metrics"]["recall@1"] for q in dir_metrics]) | |
| dir_mrr = _mean([q["retrieval_metrics"]["mrr"] for q in dir_metrics]) | |
| summary_lines.append(f" Recall@1: {dir_r1:.3f} MRR: {dir_mrr:.4f}") | |
| summary_lines += [ | |
| f" Salary questions ({sum(1 for q in questions if q['type']=='salary')}):", | |
| ] | |
| sal_metrics = [q for q in per_question if q["type"] == "salary"] | |
| if sal_metrics: | |
| sal_r1 = _mean([q["retrieval_metrics"]["recall@1"] for q in sal_metrics]) | |
| sal_mrr = _mean([q["retrieval_metrics"]["mrr"] for q in sal_metrics]) | |
| summary_lines.append(f" Recall@1: {sal_r1:.3f} MRR: {sal_mrr:.4f}") | |
| summary = "\n".join(summary_lines) | |
| print(summary) | |
| # --- Save results --- | |
| results_dir = REPO_ROOT / "scripts" / "eval" / "results" | |
| results_dir.mkdir(parents=True, exist_ok=True) | |
| timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") | |
| results_path = results_dir / f"benchmark_{timestamp}.json" | |
| results = { | |
| "meta": { | |
| "benchmark": "Pay Equity RAG Benchmark v1", | |
| "timestamp": timestamp, | |
| "num_questions": len(questions), | |
| "k": k, | |
| "run_generation": run_generation, | |
| }, | |
| "aggregate": aggregate, | |
| "per_question": per_question, | |
| "summary_text": summary, | |
| } | |
| results_path.write_text( | |
| json.dumps(results, indent=2, ensure_ascii=False), | |
| encoding="utf-8", | |
| ) | |
| print(f"\nResults saved to: {results_path}") | |
| return results | |
| # --------------------------------------------------------------------------- | |
| # CLI | |
| # --------------------------------------------------------------------------- | |
| def main() -> None: | |
| import argparse | |
| parser = argparse.ArgumentParser( | |
| description="Pay Equity RAG Benchmark — evaluate retrieval + generation" | |
| ) | |
| parser.add_argument( | |
| "--k", type=int, default=5, | |
| help="Number of chunks to retrieve per query (default: 5)", | |
| ) | |
| parser.add_argument( | |
| "--no-generation", | |
| action="store_true", | |
| default=False, | |
| help="Skip generation evaluation (retrieval only)", | |
| ) | |
| args = parser.parse_args() | |
| run_benchmark(k=args.k, run_generation=not args.no_generation) | |
| if __name__ == "__main__": | |
| main() | |