File size: 7,342 Bytes
bbb66cb
 
3df6802
 
 
 
 
 
bbb66cb
 
 
 
 
 
3df6802
 
bbb66cb
 
 
 
 
 
 
 
3df6802
cc98906
 
bbb66cb
 
 
 
 
 
3df6802
bbb66cb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3df6802
 
 
bbb66cb
 
 
 
 
3df6802
80a78e0
 
bbb66cb
 
 
a29c69c
 
 
 
 
 
bbb66cb
 
 
 
3df6802
bbb66cb
 
 
 
 
 
3df6802
d6df2f2
bbb66cb
3df6802
bbb66cb
d6df2f2
bbb66cb
 
d6df2f2
 
 
 
 
 
 
 
 
 
bbb66cb
 
d6df2f2
bbb66cb
 
 
d6df2f2
 
 
a29c69c
 
d6df2f2
 
 
 
 
 
 
 
 
a29c69c
d6df2f2
 
a29c69c
d6df2f2
 
a29c69c
d6df2f2
 
 
 
 
 
a29c69c
d6df2f2
 
 
 
 
 
bbb66cb
 
 
3df6802
 
bbb66cb
 
 
3df6802
bbb66cb
 
3df6802
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bbb66cb
 
 
3df6802
bbb66cb
 
 
 
 
 
 
 
 
3df6802
bbb66cb
 
 
 
 
 
 
 
 
 
 
 
 
 
3df6802
bbb66cb
 
 
 
 
 
 
 
 
 
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
"""Evaluate the RAG pipeline against a hand-labeled question set.

Now supports retrieval modes for ablation runs:
    python src/eval.py --mode dense
    python src/eval.py --mode hybrid
    python src/eval.py --mode hybrid_rerank
    python src/eval.py --mode hybrid_rerank --answers

Metrics:
    recall@k   - did any gold source appear in the top-k retrieved chunks?
    MRR        - 1/rank of the first gold hit (higher = ranked better)
    refusal    - (with --answers) did unanswerable questions get a refusal?
    faithful/correct - (with --answers) LLM-as-judge on generated answers

LLM answers/judgments are cached in data/eval_cache.json (keyed by model
and prompt), so re-runs only pay for what changed.
"""

import argparse
import hashlib
import json
import time
from pathlib import Path

from retrieval import retrieve
from dotenv import load_dotenv
load_dotenv(override=True)

DATA_DIR = Path("data")
EVAL_SET = DATA_DIR / "eval_set.jsonl"
CACHE_FILE = DATA_DIR / "eval_cache.json"
K = 5
REFUSAL_TEXT = "I couldn't find this in the indexed codebase"
SLEEP_BETWEEN_LLM_CALLS = 5

JUDGE_PROMPT = """\
You are grading a RAG system's answer. Given the question, the context the
system retrieved, and its answer, output ONLY a JSON object:
{{"faithful": true/false, "correct": true/false}}

faithful = every claim in the answer is supported by the context
correct  = the answer actually answers the question accurately

Question: {question}

Context:
{context}

Answer:
{answer}"""


def load_cache() -> dict:
    if CACHE_FILE.exists():
        return json.loads(CACHE_FILE.read_text())
    return {}


def save_cache(cache: dict) -> None:
    CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2))


def cache_key(*parts: str) -> str:
    return hashlib.sha256("||".join(parts).encode()).hexdigest()[:16]


def is_gold_hit(hit: dict, gold: list[str]) -> bool:
    meta = hit["meta"]
    haystack = f"{hit['id']} {meta.get('path', '')} {meta.get('symbol', '')}".lower()
    return any(g.lower() in haystack for g in gold)


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--mode", default="dense",
                            choices=["dense", "dense_rw", "dense_rerank",
                                    "hybrid", "hybrid_rerank"])
    parser.add_argument("--answers", action="store_true",
                        help="also generate answers and run the LLM judge")
    parser.add_argument("--k", type=int, default=K)
    parser.add_argument(
        "--min-recall",
        type=float,
        default=None,
        help="Exit with code 1 when recall@k is below this threshold.",
    )
    args = parser.parse_args()

    items = [json.loads(line)
             for line in EVAL_SET.read_text().splitlines() if line.strip()]
    print(f"[eval] mode={args.mode}, {len(items)} questions "
          f"({sum(i['answerable'] for i in items)} answerable)")

    cache = load_cache()

    # -------- retrieval metrics --------
    recalls, mrrs = [], []
    retrieved_per_q = []

    for item in items:
        hits = retrieve(item["question"], k=args.k, mode=args.mode)
        retrieved_per_q.append(hits)

        if not item["answerable"]:
            continue

        rank = next(
            (
                position
                for position, hit in enumerate(hits, 1)
                if is_gold_hit(hit, item["gold"])
            ),
            None,
        )

        recalls.append(1.0 if rank else 0.0)
        mrrs.append(1.0 / rank if rank else 0.0)

        if not rank:
            print(f"  [miss] {item['question']}")

    if not recalls:
        raise RuntimeError(
            "The evaluation set contains no answerable questions."
        )

    recall_at_k = sum(recalls) / len(recalls)
    mrr = sum(mrrs) / len(mrrs)

    print(f"\n=== Retrieval (mode={args.mode}, k={args.k}) ===")
    print(
        f"recall@{args.k}: {recall_at_k:.2f}  "
        f"({int(sum(recalls))}/{len(recalls)})"
    )
    print(f"MRR:       {mrr:.2f}")

    if args.min_recall is not None:
        if recall_at_k < args.min_recall:
            print(
                f"\n[gate] FAIL: recall@{args.k}={recall_at_k:.4f} "
                f"is below the required {args.min_recall:.4f}"
            )
            raise SystemExit(1)

        print(
            f"\n[gate] PASS: recall@{args.k}={recall_at_k:.4f} "
            f"meets the required {args.min_recall:.4f}"
        )

    if not args.answers:
        print(
            "\n(retrieval-only run; add --answers "
            "for generation metrics)"
        )
        return

    # -------- generation + judge metrics --------
    import os

    from ask import SYSTEM_PROMPT, build_prompt
    from groq import Groq
    client = Groq(api_key=os.environ["GROQ_API_KEY"])
    llm_model = os.environ.get("GROQ_MODEL", "openai/gpt-oss-120b")
    print(f"[eval] llm={llm_model}")

    def llm(prompt: str, system: str | None = None) -> str:
        key = cache_key(llm_model, system or "", prompt)
        if key in cache:
            return cache[key]
        messages = ([{"role": "system", "content": system}] if system else [])
        messages.append({"role": "user", "content": prompt})
        out = None
        for attempt in range(4):
            try:
                out = client.chat.completions.create(
                    model=llm_model, messages=messages,
                    temperature=0.1).choices[0].message.content
                break
            except Exception as e:
                print(f"  [retry {attempt + 1}/4] {str(e)[:160]}")
                time.sleep(30)
        if out is None:
            raise RuntimeError("LLM call failed 4 times; try again later")
        cache[key] = out
        save_cache(cache)
        time.sleep(SLEEP_BETWEEN_LLM_CALLS)
        return out

    refusal_ok, faithful, correct = [], [], []
    for item, hits in zip(items, retrieved_per_q):
        ans = llm(build_prompt(item["question"], hits),
                  system=SYSTEM_PROMPT)

        if not item["answerable"]:
            ok = REFUSAL_TEXT.lower() in ans.lower()
            refusal_ok.append(1.0 if ok else 0.0)
            if not ok:
                print(f"  [no refusal] {item['question']}")
            continue

        context = "\n\n".join(h["text"][:1200] for h in hits)
        verdict_raw = llm(JUDGE_PROMPT.format(
            question=item["question"], context=context, answer=ans))
        try:
            start = verdict_raw.index("{")
            end = verdict_raw.rindex("}") + 1
            verdict = json.loads(verdict_raw[start:end])
        except (ValueError, json.JSONDecodeError):
            print(f"  [judge parse fail] {item['question']}")
            continue
        faithful.append(1.0 if verdict.get("faithful") else 0.0)
        correct.append(1.0 if verdict.get("correct") else 0.0)
        if not verdict.get("correct"):
            print(f"  [incorrect] {item['question']}")

    print(f"\n=== Generation (mode={args.mode}) ===")
    if faithful:
        print(f"faithful:  {sum(faithful)/len(faithful):.2f}")
        print(f"correct:   {sum(correct)/len(correct):.2f}")
    if refusal_ok:
        print(f"refusal:   {sum(refusal_ok)/len(refusal_ok):.2f}  "
              f"({int(sum(refusal_ok))}/{len(refusal_ok)})")


if __name__ == "__main__":
    main()