Spaces:
Sleeping
Sleeping
| """ | |
| 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 βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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" | |
| 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() | |