IndiaFinBench / rag /scripts /run_evaluation.py
Rajveer Singh Pall
Deploy IndiaFinBench research site
8f41246
Raw
History Blame Contribute Delete
11 kB
"""
rag/scripts/run_evaluation.py
------------------------------
CLI entry point for Phase 3 evaluation.
Stages:
1. Load frozen index (must exist β€” run build_index first).
2. Load or generate the 50-item eval dataset.
3. Run retrieval-only ablation (B0–B5) β€” no LLM calls, fast.
4. Optionally run generation evaluation on B2 (hybrid) and B0 (dense)
using the configured LLM backend + Gemini faithfulness judge.
5. Print structured terminal report.
6. Save JSON report to data/eval/results_<timestamp>.json.
Usage:
python -m rag.scripts.run_evaluation
python -m rag.scripts.run_evaluation --no-generation
python -m rag.scripts.run_evaluation --eval-set data/eval/eval_set.json
python -m rag.scripts.run_evaluation --configs B0,B2,B5 # subset of ablation
"""
import argparse
import dataclasses
import json
import logging
import os
import sys
import time
from datetime import datetime
from pathlib import Path
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-8s %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description="Run Phase 3 evaluation for the IndiaFinBench RAG pipeline."
)
p.add_argument("--index-dir", type=Path, default=Path("rag/index"))
p.add_argument("--data-dir", type=Path, default=Path("data/parsed"))
p.add_argument("--eval-set", type=Path, default=Path("data/eval/eval_set.json"),
help="Path to eval_set.json. Generated if missing and --generate-eval set.")
p.add_argument("--output-dir", type=Path, default=Path("data/eval"))
p.add_argument("--no-generation", action="store_true",
help="Skip generation evaluation (retrieval metrics only).")
p.add_argument("--generate-eval", action="store_true",
help="Generate synthetic eval set via Gemini if eval_set.json missing.")
p.add_argument("--n-synthetic", type=int, default=35,
help="Number of synthetic QA pairs to generate.")
p.add_argument("--configs", type=str, default=None,
help="Comma-separated subset of ablation config IDs to run, "
"e.g. B0_dense_only,B2_hybrid,B5_higher_k")
p.add_argument("--gemini-key", type=str, default=None,
help="Gemini API key (overrides GEMINI_API_KEY env var).")
p.add_argument("--groq-key", type=str, default=None,
help="Groq API key (overrides GROQ_API_KEY env var).")
p.add_argument("--max-gen-items",type=int, default=None,
help="Limit generation eval to first N items (useful for quick tests).")
return p.parse_args()
def main() -> None:
# Force UTF-8 stdout so box-drawing chars in the terminal report work on Windows
sys.stdout.reconfigure(encoding="utf-8")
args = parse_args()
# Apply key overrides before any imports that might read them
if args.gemini_key:
os.environ["GEMINI_API_KEY"] = args.gemini_key
if args.groq_key:
os.environ["GROQ_API_KEY"] = args.groq_key
# ── Imports ───────────────────────────────────────────────────────────────
from rag.config import RAGConfig
from rag.data_loader import DataLoader
from rag.embeddings import BGEEmbedder
from rag.evaluation import (
ABLATION_CONFIGS,
FAITHFULNESS_JUDGE_MODEL,
FAITHFULNESS_JUDGE_PROMPT_VERSION,
evaluate_generation,
load_or_generate_eval_set,
print_terminal_report,
run_ablation,
save_report,
)
from rag.bm25_index import BM25Index
from rag.index import FAISSIndex
from rag.pipeline import RAGPipeline
from rag.preprocessing import TextPreprocessor
from rag.chunking import RecursiveCharacterSplitter
# ── Load index ────────────────────────────────────────────────────────────
if not (args.index_dir / "faiss.index").exists():
logger.error(
"Index not found at %s. Run: python -m rag.scripts.build_index first.",
args.index_dir,
)
sys.exit(1)
cfg = RAGConfig(data_dir=args.data_dir, index_dir=args.index_dir)
logger.info("Loading embedder: %s", cfg.embedding_model)
t_emb = time.perf_counter()
embedder = BGEEmbedder(
model_name = cfg.embedding_model,
device = cfg.embedding_device,
batch_size = cfg.embedding_batch_size,
)
logger.info(" Embedder loaded in %.1fs (dim=%d)", time.perf_counter() - t_emb, embedder.dim)
logger.info("Loading FAISS + BM25 index from %s", args.index_dir)
faiss_idx = FAISSIndex.load(args.index_dir, embedder.dim)
bm25_idx = BM25Index.load(args.index_dir)
logger.info(" Index: %d vectors, %d BM25 chunks", faiss_idx.size, bm25_idx.size)
# ── Load or generate eval set ─────────────────────────────────────────────
docs: list | None = None
chunks: list | None = None
if not args.eval_set.exists():
if not args.generate_eval:
logger.error(
"Eval set not found at %s. "
"Run with --generate-eval to create it, or provide --eval-set path.",
args.eval_set,
)
sys.exit(1)
logger.info("Generating synthetic eval set (%d items)…", args.n_synthetic)
loader = DataLoader(cfg.data_dir)
preprocessor = TextPreprocessor()
splitter = RecursiveCharacterSplitter(
target_chunk_chars=cfg.target_chunk_chars,
overlap_chars=cfg.overlap_chars,
min_chunk_chars=cfg.min_chunk_chars,
)
docs = loader.load()
for d in docs:
d.raw_text = preprocessor.process(d.raw_text)
chunks = [c for d in docs for c in splitter.split_document(d)]
eval_items = load_or_generate_eval_set(
path = args.eval_set,
docs = docs,
chunks = chunks,
n_synthetic = args.n_synthetic,
api_key = args.gemini_key,
)
n_with_gt = sum(1 for i in eval_items if i.relevant_chunk_ids)
logger.info(
"Eval set: %d items total (%d with ground-truth chunk IDs, %d adversarial).",
len(eval_items), n_with_gt, len(eval_items) - n_with_gt,
)
# ── Filter ablation configs ───────────────────────────────────────────────
selected_configs = ABLATION_CONFIGS
if args.configs:
ids = {c.strip() for c in args.configs.split(",")}
selected_configs = [c for c in ABLATION_CONFIGS if c["id"] in ids]
if not selected_configs:
logger.error("No matching configs found for: %s", args.configs)
sys.exit(1)
# ── Stage 1: Retrieval ablation ───────────────────────────────────────────
logger.info("Stage 1: Retrieval-only ablation (%d configs)…", len(selected_configs))
ablation_results = run_ablation(
base_faiss = faiss_idx,
base_bm25 = bm25_idx,
embedder = embedder,
base_cfg = cfg,
eval_items = eval_items,
configs = selected_configs,
)
# ── Stage 2: Generation evaluation (optional) ─────────────────────────────
gen_results: dict = {}
if not args.no_generation:
gemini_key = args.gemini_key or os.environ.get("GEMINI_API_KEY")
if not gemini_key:
logger.warning(
"GEMINI_API_KEY not set β€” skipping generation evaluation. "
"Set it or pass --gemini-key to enable faithfulness scoring."
)
else:
import google.generativeai as genai # type: ignore[import]
genai.configure(api_key=gemini_key)
judge_model = genai.GenerativeModel(
FAITHFULNESS_JUDGE_MODEL,
generation_config={"temperature": 0.0, "max_output_tokens": 1024},
)
# Run generation eval on B2 (proposed) and B0 (dense baseline)
for target_id in ("B2_hybrid", "B0_dense_only"):
target_cfg = next(
(c for c in selected_configs if c["id"] == target_id), None
)
if target_cfg is None:
continue
logger.info("Stage 2: Generation eval for %s…", target_id)
# Wire up a full pipeline with the appropriate mode
pipeline = RAGPipeline(config=cfg)
pipeline.load_index()
gm, gf = evaluate_generation(
pipeline = pipeline,
eval_items = eval_items,
embedder = embedder,
gemini_model= judge_model,
mode = target_cfg["mode"],
max_items = args.max_gen_items,
)
gen_results[target_id] = (gm, gf)
# ── Print terminal report ─────────────────────────────────────────────────
print_terminal_report(ablation_results, gen_results, eval_items)
# ── Save JSON report ──────────────────────────────────────────────────────
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
report_path = args.output_dir / f"results_{timestamp}.json"
config_snapshot = {
"embedding_model": cfg.embedding_model,
"target_chunk_chars": cfg.target_chunk_chars,
"overlap_chars": cfg.overlap_chars,
"top_k": cfg.top_k,
"candidates": cfg.candidates,
"rrf_k": cfg.rrf_k,
"max_per_source": cfg.max_per_source,
"llm_backend": cfg.llm_backend,
"temperature": cfg.temperature,
"index_dir": str(args.index_dir),
"eval_items": len(eval_items),
"items_with_gt": n_with_gt,
"judge_model": FAITHFULNESS_JUDGE_MODEL,
"judge_prompt_ver": FAITHFULNESS_JUDGE_PROMPT_VERSION,
"timestamp": timestamp,
}
save_report(ablation_results, gen_results, config_snapshot, report_path)
print(f"\n Report saved β†’ {report_path}")
print()
if __name__ == "__main__":
main()