Spaces:
Sleeping
Sleeping
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()
|