Spaces:
Sleeping
Sleeping
File size: 10,985 Bytes
8f41246 | 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 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 | """
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()
|