Islam Mamedov
Fix CI recall gate and add workflow badge
d6df2f2
Raw
History Blame Contribute Delete
7.34 kB
"""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()