| |
| """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)) |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 = 0.0 |
| for rank, rid in enumerate(retrieved_ids, 1): |
| if rid in ground_truth_ids: |
| mrr = 1.0 / rank |
| break |
| recall["mrr"] = mrr |
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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() |
|
|
| |
| facts_matched = 0 |
| facts_detail: list[dict] = [] |
| for fact in expected_facts: |
| |
| |
| 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) |
|
|
| |
| citations_found = [] |
| for cit in retrieved_citations: |
| if cit.lower() in answer_lower: |
| citations_found.append(cit) |
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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. |
| """ |
| |
| |
| |
| context_parts = [] |
| for chunk_text in retrieved_chunks[:3]: |
| |
| context_parts.append(chunk_text) |
| return "\n\n".join(context_parts) |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
| |
| 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)") |
|
|
| |
| 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]}...") |
|
|
| |
| q_start = time.time() |
| |
| 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 |
|
|
| |
| ret_metrics = compute_retrieval_metrics(retrieved_ids, gt_ids) |
| for mkey, mval in ret_metrics.items(): |
| total_recall.setdefault(mkey, []).append(mval) |
|
|
| |
| 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 |
|
|
| |
| 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]) |
|
|
| |
| 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) |
|
|
| |
| 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)") |
|
|
| |
| 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] |
| }, |
| } |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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() |
|
|