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()