Spaces:
Sleeping
Sleeping
File size: 9,231 Bytes
2f25a40 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 | """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())
|