pay-equity-for-eu / scripts /eval /run_benchmark.py
Franskaman110's picture
Benchmarking
71ad734
Raw
History Blame Contribute Delete
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()