researchpath / scripts /run_eval.py
Chetan110801's picture
Upload folder using huggingface_hub
2f25a40 verified
Raw
History Blame Contribute Delete
9.23 kB
"""Run the full eval harness against the RAG system.
Usage:
uv run python scripts/run_eval.py --groq # FAISS-only, Groq LLM
uv run python scripts/run_eval.py --groq --hybrid # BM25+FAISS fusion
uv run python scripts/run_eval.py --groq --limit 5 # quick smoke test
uv run python scripts/run_eval.py --out data/eval_results_hybrid.json --groq --hybrid
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
from rich.table import Table
from researchpath.embeddings import Embedder
from researchpath.eval import (
EvalResult,
evaluate_example,
load_gold_dataset,
results_to_json,
summarize,
)
from researchpath.index import load_index, search
from researchpath.rag import answer as rag_answer, answer_groq
from researchpath.retrieval import HybridRetriever, Reranker
console = Console()
ROOT = Path(__file__).resolve().parents[1]
INDEX_PATH = ROOT / "data" / "index.faiss"
GOLD_PATH = ROOT / "data" / "gold_dataset.json"
DEFAULT_OUT = ROOT / "data" / "eval_results.json"
def main() -> int:
parser = argparse.ArgumentParser(description="Evaluate baseline RAG against gold dataset.")
parser.add_argument("--k", type=int, default=5, help="Top-k chunks to retrieve (default: 5).")
parser.add_argument("--limit", type=int, default=None, help="Evaluate only first N examples (for quick tests).")
parser.add_argument("--out", type=str, default=str(DEFAULT_OUT), help="Output JSON path.")
parser.add_argument("--groq", action="store_true", help="Use Groq for RAG generation (avoids Gemini rate limits).")
parser.add_argument("--hybrid", action="store_true", help="Use BM25+FAISS hybrid retrieval (RRF fusion).")
parser.add_argument("--rerank", action="store_true", help="Apply cross-encoder reranking on top of retrieval.")
parser.add_argument("--resume", action="store_true", help="Skip examples already in --out file (for retrying after rate limits).")
args = parser.parse_args()
if not INDEX_PATH.exists():
console.print(f"[red]No index at {INDEX_PATH}. Run scripts/build_index.py first.[/red]")
return 1
if not GOLD_PATH.exists():
console.print(f"[red]No gold dataset at {GOLD_PATH}.[/red]")
return 1
examples = load_gold_dataset(GOLD_PATH)
if args.limit:
examples = examples[: args.limit]
completed_ids: set[str] = set()
if args.resume:
out_path_check = Path(args.out) if Path(args.out).is_absolute() else ROOT / args.out
if out_path_check.exists():
try:
with open(out_path_check, encoding="utf-8") as f:
prior = json.load(f)
completed_ids = {r["id"] for r in prior.get("results", [])}
console.print(f"[yellow]Resuming: skipping {len(completed_ids)} already-evaluated examples.[/yellow]")
except Exception:
pass
examples = [e for e in examples if e.id not in completed_ids]
rag_fn = answer_groq if args.groq else rag_answer
rag_provider = "Groq / llama-3.3-70b" if args.groq else "Gemini / gemini-2.5-flash-lite"
if args.rerank:
retrieval_mode = "BM25+FAISS+CrossEncoder rerank" if args.hybrid else "FAISS+CrossEncoder rerank"
else:
retrieval_mode = "BM25+FAISS (RRF)" if args.hybrid else "FAISS dense"
console.print(f"\n[bold cyan]ResearchPath Eval Harness[/bold cyan]")
console.print(f" Gold examples : {len(examples)}")
console.print(f" Retrieval : {retrieval_mode} k={args.k}")
console.print(f" RAG provider : {rag_provider}")
console.print(f" Judge : Groq / llama-3.3-70b")
console.print(f" Index : {INDEX_PATH.relative_to(ROOT)}")
console.print()
console.print("[dim]Loading index and embedder...[/dim]")
index, chunks = load_index(INDEX_PATH)
embedder = Embedder()
hybrid = HybridRetriever(index, chunks, embedder) if args.hybrid or args.rerank else None
if args.rerank:
console.print("[dim]Loading cross-encoder reranker (first run downloads ~80MB)...[/dim]")
reranker = Reranker(hybrid)
else:
reranker = None
console.print("[green]Ready.[/green]\n")
results: list[EvalResult] = []
failures: list[str] = []
out_path = Path(args.out) if Path(args.out).is_absolute() else ROOT / args.out
out_path.parent.mkdir(parents=True, exist_ok=True)
prior_results: list[dict] = []
if args.resume and out_path.exists():
try:
with open(out_path, encoding="utf-8") as f:
prior_results = json.load(f).get("results", [])
except Exception:
prior_results = []
def _save_partial() -> None:
if not results and not prior_results:
return
partial_summary = summarize(results) if results else None
partial_payload = (
results_to_json(results, partial_summary)
if partial_summary
else {"summary": {}, "results": []}
)
# Merge resumed prior results with new ones (prior first, preserves order)
if prior_results:
new_ids = {r["id"] for r in partial_payload["results"]}
merged = [r for r in prior_results if r["id"] not in new_ids] + partial_payload["results"]
partial_payload["results"] = merged
partial_payload["summary"]["n"] = len(merged)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(partial_payload, f, indent=2, ensure_ascii=False)
with Progress(
SpinnerColumn(),
TextColumn("[progress.description]{task.description}"),
TimeElapsedColumn(),
console=console,
) as progress:
task = progress.add_task("Evaluating...", total=len(examples))
for ex in examples:
progress.update(task, description=f"[cyan]{ex.id}[/cyan] — {ex.question[:55]}...")
try:
t0 = time.time()
if reranker:
hits = reranker.search(ex.question, k=args.k)
elif hybrid:
hits = hybrid.search(ex.question, k=args.k)
else:
hits = search(index, chunks, embedder, ex.question, k=args.k)
ra = rag_fn(ex.question, hits)
latency = time.time() - t0
if not args.groq:
time.sleep(3) # stay under Gemini's 20 RPM free-tier ceiling
result = evaluate_example(ex, hits, ra, latency)
results.append(result)
_save_partial() # checkpoint after every example so 429s don't lose work
status = "[green]OK[/green]" if result.answer_correct else "[yellow]MISS[/yellow]"
recall_str = f"recall={result.retrieval_recall:.0%}"
progress.console.print(
f" {status} {ex.id:20s} {recall_str} cite={'Y' if result.citation_present else 'N'} "
f"correct={'Y' if result.answer_correct else 'N'} {latency:.1f}s"
)
except Exception as exc:
failures.append(f"{ex.id}: {exc}")
progress.console.print(f" [red]ERR[/red] {ex.id}: {exc}")
progress.advance(task)
if not results:
console.print("[red]No results collected — check errors above.[/red]")
return 1
summary = summarize(results)
summary.print_table()
payload = results_to_json(results, summary)
with open(out_path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, ensure_ascii=False)
try:
display_path = out_path.relative_to(ROOT)
except ValueError:
display_path = out_path
console.print(f"[green]Saved detailed results to {display_path}[/green]")
if failures:
console.print(f"\n[red]{len(failures)} example(s) failed:[/red]")
for msg in failures:
console.print(f" {msg}")
_print_failures_table(results, console)
return 0
def _print_failures_table(results: list[EvalResult], console: Console) -> None:
misses = [r for r in results if not r.answer_correct]
if not misses:
console.print("[bold green]All examples answered correctly.[/bold green]")
return
console.print(f"\n[bold yellow]Incorrect answers ({len(misses)}/{len(results)}):[/bold yellow]")
table = Table(show_header=True, header_style="bold")
table.add_column("ID", style="cyan", width=22)
table.add_column("Difficulty", width=8)
table.add_column("Recall", width=7)
table.add_column("Cite", width=5)
table.add_column("Expected key claim (truncated)", width=55)
for r in misses:
table.add_row(
r.example.id,
r.example.difficulty,
f"{r.retrieval_recall:.0%}",
"Y" if r.citation_present else "N",
r.example.expected_key_claim[:55],
)
console.print(table)
if __name__ == "__main__":
sys.exit(main())