rag-system / scripts /benchmark_suite.py
joshsears's picture
Polish: BGE-large embeddings, contextual retrieval, 142 tests passing, lint clean
21ca2ea
Raw
History Blame Contribute Delete
17.8 kB
"""
Benchmark Suite β€” Named dataset evaluation with technique comparison.
Runs the RAG evaluation harness in two modes:
1. COMPARISON MODE: Naive RAG vs Full Stack β€” quantifies what each technique buys
2. CI MODE: Single run against thresholds for the quality gate
Produces:
- Terminal table (Rich) with per-technique metrics
- JSON output file for CI consumption and README badges
- Comparison table showing delta vs naive baseline
Usage:
# Full comparison (for README/blog):
python3 scripts/benchmark_suite.py --compare
# CI quality gate:
python3 scripts/benchmark_suite.py --output eval_results.json \\
--min-faithfulness 3.5 --min-recall 0.5
# Run against custom QA file:
python3 scripts/benchmark_suite.py --qa-file my_questions.json --compare
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
import time
from dataclasses import asdict, dataclass
from pathlib import Path
# Add project root to path
sys.path.insert(0, str(Path(__file__).parent.parent))
logging.basicConfig(level=logging.WARNING) # suppress verbose logs during benchmark
from rich import box # noqa: E402
from rich.console import Console # noqa: E402
from rich.table import Table # noqa: E402
console = Console()
# ── Data structures ───────────────────────────────────────────────────────────
@dataclass
class BenchmarkConfig:
"""Configuration for one benchmark run (one RAG technique stack)."""
name: str
description: str
use_hybrid: bool = True
use_reranker: bool = True
use_hyde: bool = False
use_multi_query: bool = False
top_k: int = 6
mode: str = "hybrid"
@dataclass
class BenchmarkResult:
"""Results for one technique configuration."""
config_name: str
total_samples: int
mean_faithfulness: float
mean_recall_at_k: float
mean_answer_relevancy: float
mean_context_precision: float
mean_latency_ms: float
passed_quality_gate: bool = True
error: str = ""
# ── Benchmark configurations ──────────────────────────────────────────────────
BENCHMARK_CONFIGS = [
BenchmarkConfig(
name="naive",
description="Naive RAG (dense-only, no reranking)",
use_hybrid=False,
use_reranker=False,
use_hyde=False,
use_multi_query=False,
mode="dense",
),
BenchmarkConfig(
name="hybrid",
description="Hybrid (dense+BM25+RRF)",
use_hybrid=True,
use_reranker=False,
use_hyde=False,
mode="hybrid",
),
BenchmarkConfig(
name="hybrid+rerank",
description="Hybrid + cross-encoder reranking",
use_hybrid=True,
use_reranker=True,
use_hyde=False,
mode="hybrid",
),
BenchmarkConfig(
name="hybrid+rerank+hyde",
description="Full stack (hybrid + reranking + HyDE)",
use_hybrid=True,
use_reranker=True,
use_hyde=True,
mode="hybrid",
),
]
# ── Single-config evaluation ──────────────────────────────────────────────────
def run_config(
config: BenchmarkConfig,
qa_pairs: list[dict],
) -> BenchmarkResult:
"""
Run the evaluation harness for one benchmark configuration.
Temporarily patches settings to match the config, runs all QA pairs,
then restores settings.
"""
from config import settings
from models import EvalSample
# Patch settings for this config
original_hybrid = settings.use_hybrid_search
original_reranker = settings.use_reranker
original_hyde = settings.use_hyde
original_top_k = settings.top_k
settings.use_hybrid_search = config.use_hybrid
settings.use_reranker = config.use_reranker
settings.use_hyde = config.use_hyde
settings.top_k = config.top_k
faithfulness_scores: list[float] = []
recall_scores: list[float] = []
relevancy_scores: list[float] = []
precision_scores: list[float] = []
latencies: list[float] = []
error_msg = ""
try:
from core.evaluation import evaluate_sample
for qa in qa_pairs:
sample = EvalSample(
question=qa["question"],
expected_answer=qa.get("expected_answer", ""),
relevant_sources=qa.get("relevant_sources", []),
collection=qa.get("collection", "eval_test"),
)
try:
result = evaluate_sample(sample)
faithfulness_scores.append(result.faithfulness_score)
recall_scores.append(result.recall_at_k)
relevancy_scores.append(result.answer_relevancy)
latencies.append(result.latency_ms)
# Context precision: approximate as recall here (full metric needs CRAG output)
precision_scores.append(result.recall_at_k)
except Exception as e:
logging.warning("Sample evaluation failed for '%s': %s", qa["question"][:40], e)
except Exception as e:
error_msg = str(e)
logging.error("Config '%s' failed: %s", config.name, e)
finally:
# Restore original settings
settings.use_hybrid_search = original_hybrid
settings.use_reranker = original_reranker
settings.use_hyde = original_hyde
settings.top_k = original_top_k
def safe_mean(lst: list[float]) -> float:
return sum(lst) / len(lst) if lst else 0.0
return BenchmarkResult(
config_name=config.name,
total_samples=len(qa_pairs),
mean_faithfulness=round(safe_mean(faithfulness_scores), 2),
mean_recall_at_k=round(safe_mean(recall_scores), 4),
mean_answer_relevancy=round(safe_mean(relevancy_scores), 4),
mean_context_precision=round(safe_mean(precision_scores), 4),
mean_latency_ms=round(safe_mean(latencies), 1),
error=error_msg,
)
# ── Full comparison run ───────────────────────────────────────────────────────
def run_comparison(qa_pairs: list[dict]) -> list[BenchmarkResult]:
"""Run all benchmark configurations and return results."""
results: list[BenchmarkResult] = []
for config in BENCHMARK_CONFIGS:
console.print(
f"\n[bold cyan]Running config:[/bold cyan] {config.name} β€” {config.description}"
)
start = time.perf_counter()
result = run_config(config, qa_pairs)
elapsed = time.perf_counter() - start
console.print(
f" Done in {elapsed:.1f}s | faith={result.mean_faithfulness:.2f} recall={result.mean_recall_at_k:.3f}"
)
results.append(result)
return results
# ── Display ───────────────────────────────────────────────────────────────────
def print_comparison_table(results: list[BenchmarkResult]) -> None:
"""Print a comparison table showing technique improvements."""
table = Table(
title="RAG Technique Comparison",
box=box.ROUNDED,
show_header=True,
header_style="bold magenta",
)
table.add_column("Configuration", style="cyan", min_width=20)
table.add_column("Faithfulness", justify="right", min_width=14)
table.add_column("Recall@K", justify="right", min_width=10)
table.add_column("Relevancy", justify="right", min_width=10)
table.add_column("Latency (p50)", justify="right", min_width=12)
table.add_column("vs Naive", justify="right", min_width=10)
# Baseline = naive
baseline = results[0] if results else None
for r in results:
if r.error:
table.add_row(r.config_name, "[red]ERROR[/red]", "β€”", "β€”", "β€”", "β€”")
continue
# Delta vs naive baseline
if baseline and r.config_name != "naive":
faith_delta = r.mean_faithfulness - baseline.mean_faithfulness
recall_delta = r.mean_recall_at_k - baseline.mean_recall_at_k
delta_str = f"faith {faith_delta:+.2f} / recall {recall_delta:+.3f}"
delta_color = "green" if (faith_delta > 0 or recall_delta > 0) else "yellow"
delta_cell = f"[{delta_color}]{delta_str}[/{delta_color}]"
else:
delta_cell = "[dim]baseline[/dim]"
faith_color = (
"green"
if r.mean_faithfulness >= 4.0
else ("yellow" if r.mean_faithfulness >= 3.0 else "red")
)
recall_color = (
"green"
if r.mean_recall_at_k >= 0.7
else ("yellow" if r.mean_recall_at_k >= 0.5 else "red")
)
table.add_row(
r.config_name,
f"[{faith_color}]{r.mean_faithfulness:.2f}/5.0[/{faith_color}]",
f"[{recall_color}]{r.mean_recall_at_k:.3f}[/{recall_color}]",
f"{r.mean_answer_relevancy:.3f}",
f"{r.mean_latency_ms:.0f}ms",
delta_cell,
)
console.print("\n")
console.print(table)
console.print(
f"\n[dim]{results[0].total_samples if results else 0} samples per configuration[/dim]\n"
)
def print_summary_table(result: BenchmarkResult, thresholds: dict) -> None:
"""Print a single-config quality gate summary."""
table = Table(
title="Quality Gate Results",
box=box.ROUNDED,
header_style="bold blue",
)
table.add_column("Metric", style="cyan", min_width=20)
table.add_column("Score", justify="right", min_width=10)
table.add_column("Threshold", justify="right", min_width=10)
table.add_column("Status", justify="center", min_width=8)
def gate_row(name, score, threshold):
passed = score >= threshold
status = "[green]PASS[/green]" if passed else "[red]FAIL[/red]"
score_color = "green" if passed else "red"
table.add_row(name, f"[{score_color}]{score}[/{score_color}]", str(threshold), status)
gate_row("Faithfulness", result.mean_faithfulness, thresholds["faithfulness"])
gate_row("Recall@K", result.mean_recall_at_k, thresholds["recall"])
gate_row("Answer Relevancy", result.mean_answer_relevancy, thresholds["relevancy"])
table.add_row("Avg Latency", f"{result.mean_latency_ms:.0f}ms", "β€”", "β€”")
table.add_row("Samples", str(result.total_samples), "β€”", "β€”")
console.print("\n")
console.print(table)
# ── QA pair loading ───────────────────────────────────────────────────────────
def load_qa_pairs(qa_file: str | None) -> list[dict]:
"""Load QA pairs from file or use built-in defaults."""
if qa_file and Path(qa_file).exists():
with open(qa_file) as f:
pairs = json.load(f)
console.print(f"[dim]Loaded {len(pairs)} QA pairs from {qa_file}[/dim]")
return pairs
# Try auto-generated eval pairs
auto_path = Path("eval_qa_pairs.json")
if auto_path.exists():
with open(auto_path) as f:
pairs = json.load(f)
console.print(f"[dim]Loaded {len(pairs)} QA pairs from eval_qa_pairs.json[/dim]")
return pairs
# Inline fallback β€” works with any ingested collection
console.print("[yellow]No QA file found β€” using generic test questions[/yellow]")
return [
{
"question": "What is machine learning?",
"expected_answer": "Machine learning is a subset of AI that enables systems to learn from data.",
"relevant_sources": [],
"collection": "eval_test",
},
{
"question": "What is retrieval-augmented generation?",
"expected_answer": "RAG combines retrieval with LLM generation using external context.",
"relevant_sources": [],
"collection": "eval_test",
},
{
"question": "How does HyDE work in RAG?",
"expected_answer": "HyDE generates a hypothetical answer and uses its embedding for retrieval.",
"relevant_sources": [],
"collection": "eval_test",
},
]
# ── Main ──────────────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(description="RAG Benchmark Suite")
parser.add_argument("--compare", action="store_true", help="Run full technique comparison")
parser.add_argument("--qa-file", type=str, default=None, help="Path to QA pairs JSON file")
parser.add_argument("--output", type=str, default=None, help="Output JSON file for results")
parser.add_argument(
"--min-faithfulness", type=float, default=2.5, help="Minimum faithfulness (1-5)"
)
parser.add_argument("--min-recall", type=float, default=0.5, help="Minimum Recall@K (0-1)")
parser.add_argument(
"--min-relevancy", type=float, default=0.5, help="Minimum answer relevancy (0-1)"
)
args = parser.parse_args()
console.print("[bold cyan]\nRAG Benchmark Suite[/bold cyan]")
console.print(
f"Thresholds: faithfulness>={args.min_faithfulness} recall>={args.min_recall} relevancy>={args.min_relevancy}\n"
)
qa_pairs = load_qa_pairs(args.qa_file)
thresholds = {
"faithfulness": args.min_faithfulness,
"recall": args.min_recall,
"relevancy": args.min_relevancy,
}
if args.compare:
# ── Full comparison mode ──────────────────────────────────────────────
results = run_comparison(qa_pairs)
print_comparison_table(results)
# Save all configs to output file
if args.output:
output_data = {
"mode": "comparison",
"configs": [asdict(r) for r in results],
"baseline": asdict(results[0]) if results else {},
"best": asdict(max(results, key=lambda r: r.mean_faithfulness)) if results else {},
}
# Also write the best config's metrics at top level for CI gate
best = max(results, key=lambda r: r.mean_faithfulness) if results else None
if best:
output_data.update(
{
"mean_faithfulness": best.mean_faithfulness,
"mean_recall_at_k": best.mean_recall_at_k,
"mean_answer_relevancy": best.mean_answer_relevancy,
"mean_latency_ms": best.mean_latency_ms,
"total_samples": best.total_samples,
}
)
with open(args.output, "w") as f:
json.dump(output_data, f, indent=2)
console.print(f"[dim]Results saved to {args.output}[/dim]")
# Gate on best result
if results:
best = max(results, key=lambda r: r.mean_faithfulness)
print_summary_table(best, thresholds)
if (
best.mean_faithfulness >= args.min_faithfulness
and best.mean_recall_at_k >= args.min_recall
and best.mean_answer_relevancy >= args.min_relevancy
):
console.print("[bold green]QUALITY GATE PASSED[/bold green]\n")
sys.exit(0)
else:
console.print("[bold red]QUALITY GATE FAILED[/bold red]\n")
sys.exit(1)
else:
# ── CI single-config mode (full stack) ───────────────────────────────
full_stack = BENCHMARK_CONFIGS[-1] # hybrid+rerank+hyde
console.print(f"Running: {full_stack.name} β€” {full_stack.description}")
result = run_config(full_stack, qa_pairs)
print_summary_table(result, thresholds)
if args.output:
output_data = {
"mode": "single",
"config": full_stack.name,
"mean_faithfulness": result.mean_faithfulness,
"mean_recall_at_k": result.mean_recall_at_k,
"mean_answer_relevancy": result.mean_answer_relevancy,
"mean_context_precision": result.mean_context_precision,
"mean_latency_ms": result.mean_latency_ms,
"total_samples": result.total_samples,
"error": result.error,
}
with open(args.output, "w") as f:
json.dump(output_data, f, indent=2)
console.print(f"[dim]Results saved to {args.output}[/dim]")
# Quality gate
if result.error:
console.print(f"[red]Evaluation failed: {result.error}[/red]")
sys.exit(1)
passed = (
result.mean_faithfulness >= args.min_faithfulness
and result.mean_recall_at_k >= args.min_recall
and result.mean_answer_relevancy >= args.min_relevancy
)
if passed:
console.print("[bold green]QUALITY GATE PASSED[/bold green]\n")
sys.exit(0)
else:
console.print("[bold red]QUALITY GATE FAILED[/bold red]\n")
sys.exit(1)
if __name__ == "__main__":
main()