Spaces:
Sleeping
Sleeping
kevinkim728
Refactor eval.py to remove unused context fetching functions, streamlining the pipeline configuration.
2ef8eb3 | import json | |
| import math | |
| import sys | |
| import time | |
| from datetime import datetime | |
| from pathlib import Path | |
| from pydantic import BaseModel | |
| from answer import ( | |
| fetch_context_hybrid, client, model | |
| ) | |
| BI_ENCODER = "nomic-ai/nomic-embed-text-v1.5" | |
| CROSS_ENCODER_MODEL = "BAAI/bge-reranker-large" | |
| RESULTS_DIR = Path("results") | |
| PIPELINES = { | |
| "hybrid": (fetch_context_hybrid, 0), | |
| } | |
| class TestQuestion(BaseModel): | |
| question: str | |
| keywords: list[str] | |
| reference_answer: str | |
| category: str | |
| class JudgeScore(BaseModel): | |
| feedback: str | |
| accuracy: float | |
| completeness: float | |
| relevance: float | |
| def load_tests(path="tests.jsonl"): | |
| tests = [] | |
| with open(path) as f: | |
| for line in f: | |
| line = line.strip() | |
| if line: | |
| tests.append(TestQuestion(**json.loads(line))) | |
| return tests | |
| def calculate_mrr(keyword, chunks): | |
| """Reciprocal rank for a single keyword across retrieved chunks.""" | |
| for i, chunk in enumerate(chunks): | |
| if keyword.lower() in chunk.page_content.lower(): | |
| return 1 / (i + 1) | |
| return 0.0 | |
| def calculate_dcg(relevances, k): | |
| """Discounted Cumulative Gain.""" | |
| dcg = 0.0 | |
| for i in range(min(k, len(relevances))): | |
| dcg += relevances[i] / math.log2(i + 2) | |
| return dcg | |
| def calculate_ndcg(keyword, chunks, k=10): | |
| """nDCG for a single keyword (binary relevance).""" | |
| relevances = [1 if keyword.lower() in chunk.page_content.lower() else 0 for chunk in chunks[:k]] | |
| dcg = calculate_dcg(relevances, k) | |
| ideal_relevances = sorted(relevances, reverse=True) | |
| idcg = calculate_dcg(ideal_relevances, k) | |
| return dcg / idcg if idcg > 0 else 0.0 | |
| def keyword_coverage(chunks, keywords): | |
| """Fraction of keywords that appear anywhere in the retrieved chunks.""" | |
| combined = " ".join(chunk.page_content.lower() for chunk in chunks) | |
| hits = sum(1 for kw in keywords if kw.lower() in combined) | |
| return hits / len(keywords) if keywords else 0.0 | |
| def evaluate_answer(question, reference_answer, pipeline_answer): | |
| response = client.chat.completions.create( | |
| model=model, | |
| messages=[ | |
| {"role": "system", "content": """You are an expert evaluator assessing RAG answer quality. | |
| Score the pipeline answer vs the reference on three dimensions (1-5): | |
| - accuracy: how factually correct is it (1=wrong, 5=perfectly accurate — any wrong answer must score 1) | |
| - completeness: how thoroughly does it cover all aspects (5 only if ALL reference info is included) | |
| - relevance: how directly does it answer without extra fluff (5 only if completely on-topic with no extra info) | |
| Reply with ONLY this format — feedback on one line, then scores: | |
| FEEDBACK: <one sentence evaluation> | |
| SCORES: accuracy,completeness,relevance | |
| Example: | |
| FEEDBACK: Answer correctly explains MRR but omits the averaging step mentioned in the reference. | |
| SCORES: 4,3,5"""}, | |
| {"role": "user", "content": f"Question: {question}\n\nReference: {reference_answer}\n\nPipeline answer: {pipeline_answer}"} | |
| ] | |
| ) | |
| raw = response.choices[0].message.content.strip() | |
| feedback = "" | |
| accuracy, completeness, relevance = 0.0, 0.0, 0.0 | |
| for line in raw.splitlines(): | |
| if line.startswith("FEEDBACK:"): | |
| feedback = line.replace("FEEDBACK:", "").strip() | |
| elif line.startswith("SCORES:"): | |
| scores_str = line.replace("SCORES:", "").strip() | |
| parts = [x.strip() for x in scores_str.split(',') if x.strip()] | |
| if len(parts) == 3: | |
| try: | |
| accuracy, completeness, relevance = float(parts[0]), float(parts[1]), float(parts[2]) | |
| except ValueError: | |
| pass | |
| return JudgeScore(feedback=feedback, accuracy=accuracy, completeness=completeness, relevance=relevance) | |
| def print_model_info(): | |
| print("\n--- Models ---") | |
| print(f" Bi-encoder: {BI_ENCODER}") | |
| print(f" Cross-encoder: {CROSS_ENCODER_MODEL}") | |
| print(f" Inference LLM: {model}") | |
| print() | |
| def debug_pipeline(name): | |
| fetch_fn, _ = PIPELINES[name] | |
| tests = load_tests() | |
| print_model_info() | |
| print(f"=== DEBUG: {name} ===\n") | |
| for i, test in enumerate(tests): | |
| print(f"[{i+1}/{len(tests)}] {test.question}") | |
| chunks = fetch_fn(test.question) | |
| mrr_scores = [] | |
| for kw in test.keywords: | |
| rank = None | |
| for j, chunk in enumerate(chunks): | |
| if kw.lower() in chunk.page_content.lower(): | |
| rank = j + 1 | |
| break | |
| if rank: | |
| print(f" \"{kw}\" → rank {rank} (MRR: {1/rank:.3f})") | |
| mrr_scores.append(1 / rank) | |
| else: | |
| print(f" \"{kw}\" → NOT FOUND") | |
| mrr_scores.append(0.0) | |
| print(f" Question MRR: {sum(mrr_scores)/len(mrr_scores):.3f}\n") | |
| def run_pipeline(name, overwrite=False): | |
| fetch_fn, sleep_secs = PIPELINES[name] | |
| tests = load_tests() | |
| RESULTS_DIR.mkdir(exist_ok=True) | |
| bi_short = BI_ENCODER.split("/")[-1] | |
| ce_short = CROSS_ENCODER_MODEL.split("/")[-1].replace("ms-marco-", "") | |
| llm_short = model.split("/")[-1] | |
| if name in ("cross_encoder", "hybrid"): | |
| filename = RESULTS_DIR / f"{name}_{bi_short}_{ce_short}_{llm_short}.json" | |
| else: | |
| filename = RESULTS_DIR / f"{name}_{bi_short}_{llm_short}.json" | |
| if filename.exists() and not overwrite: | |
| answer = input(f"\n{filename.name} already exists. Replace it? (y/n): ").strip().lower() | |
| if answer != "y": | |
| timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") | |
| filename = RESULTS_DIR / f"{filename.stem}_{timestamp}.json" | |
| print(f"Saving to {filename.name} instead.") | |
| print_model_info() | |
| print(f"Running: {name} ({len(tests)} questions)") | |
| if sleep_secs: | |
| print(f"Sleeping {sleep_secs}s between questions — est. {sleep_secs * (len(tests) - 1) // 60 + 1} min\n") | |
| all_mrr, all_ndcg, coverage_scores = [], [], [] | |
| for i, test in enumerate(tests): | |
| print(f" [{i+1}/{len(tests)}] {test.question[:60]}...") | |
| chunks = fetch_fn(test.question) | |
| mrr_per_kw = [calculate_mrr(kw, chunks) for kw in test.keywords] | |
| ndcg_per_kw = [calculate_ndcg(kw, chunks) for kw in test.keywords] | |
| all_mrr.append(sum(mrr_per_kw) / len(mrr_per_kw)) | |
| all_ndcg.append(sum(ndcg_per_kw) / len(ndcg_per_kw)) | |
| coverage_scores.append(keyword_coverage(chunks, test.keywords)) | |
| if sleep_secs and i < len(tests) - 1: | |
| time.sleep(sleep_secs) | |
| result = { | |
| "pipeline": name, | |
| "models": { | |
| "bi_encoder": BI_ENCODER, | |
| "cross_encoder": CROSS_ENCODER_MODEL, | |
| "inference_llm": model, | |
| }, | |
| "avg_mrr": sum(all_mrr) / len(all_mrr), | |
| "avg_ndcg": sum(all_ndcg) / len(all_ndcg), | |
| "avg_coverage": sum(coverage_scores) / len(coverage_scores), | |
| } | |
| with open(filename, "w") as f: | |
| json.dump(result, f, indent=2) | |
| print(f"\nSaved to {filename.name}") | |
| print(f"MRR: {result['avg_mrr']:.3f} | nDCG: {result['avg_ndcg']:.3f} | Coverage: {result['avg_coverage']:.3f}") | |
| def compare(): | |
| print(f"\n{'Pipeline':<20} {'MRR':>6} {'nDCG':>7} {'Coverage':>10} {'File'}") | |
| print("-" * 75) | |
| for name in PIPELINES: | |
| matches = sorted(RESULTS_DIR.glob(f"{name}_*.json")) | |
| if matches: | |
| for path in matches: | |
| r = json.loads(path.read_text()) | |
| print(f"{name:<20} {r['avg_mrr']:>6.3f} {r['avg_ndcg']:>7.3f} {r['avg_coverage']:>10.3f} {path.name}") | |
| else: | |
| print(f"{name:<20} {'(not run yet)':>28}") | |
| if __name__ == "__main__": | |
| args = sys.argv[1:] | |
| overwrite = "-y" in args | |
| args = [a for a in args if a != "-y"] | |
| cmd = " ".join(args) | |
| if cmd == "compare": | |
| compare() | |
| elif cmd.startswith("debug ") and cmd[6:] in PIPELINES: | |
| debug_pipeline(cmd[6:]) | |
| elif cmd in PIPELINES: | |
| run_pipeline(cmd, overwrite=overwrite) | |
| else: | |
| print("Usage: uv run eval.py <pipeline|compare|debug <pipeline>> [-y]") | |
| print(f" Pipelines: {', '.join(PIPELINES.keys())}") | |
| sys.exit(1) | |