File size: 9,339 Bytes
b35072c b85685c b35072c b85685c b35072c b85685c b35072c b85685c b35072c b85685c b35072c b85685c b35072c b85685c b35072c b85685c b35072c b85685c b35072c b85685c b35072c b85685c b35072c b85685c b35072c b85685c b35072c b85685c b35072c b85685c b35072c b85685c b35072c b85685c b35072c b85685c b35072c b85685c b35072c df1df12 b85685c b35072c b85685c b35072c b85685c b35072c | 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 | """Offline evaluation: do multi-query search and reranking actually help?
Two steps, so OpenAlex/LLM costs are paid once and runs are reproducible:
python evaluate.py build # fetch candidates + judge relevance -> eval/dataset.json
python evaluate.py run # rank the saved candidates every way, report metrics
Relevance labels come from an LLM judge (0 = not, 1 = partly, 2 = highly
relevant) over a pool of every system's top results (TREC-style pooling).
They are a starting point: correct them by hand in eval/dataset.json.
"""
import json
import math
from pathlib import Path
from analyzer import MAX_ABSTRACT_CHARS, LLMError, chat_json, decompose
from reranker import DEFAULT_MODEL, load_model, rerank
from retriever import fetch_papers, multi_search
DATASET = Path("eval/dataset.json")
QUERIES = Path("eval/queries.txt")
JUDGE_BATCH = 5
# ββ Metrics βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def dcg_at_k(relevances: list[float], k: int) -> float:
"""Discounted Cumulative Gain at rank k."""
return sum(rel / math.log2(i + 1) for i, rel in enumerate(relevances[:k], start=1))
def ndcg_at_k(ranked_relevances: list[float], all_relevances: list[float], k: int) -> float:
"""NDCG at rank k, normalized by the best possible top-k over ALL judged papers.
Normalizing by every judged paper (not just the ones this ranking returned)
means a system that fails to retrieve relevant papers is penalized for it.
"""
idcg = dcg_at_k(sorted(all_relevances, reverse=True), k)
return dcg_at_k(ranked_relevances, k) / idcg if idcg else 0.0
def mrr(ranked_relevances: list[float], threshold: float = 2) -> float:
"""Reciprocal rank of the first paper with relevance >= threshold (default: highly relevant)."""
for rank, rel in enumerate(ranked_relevances, start=1):
if rel >= threshold:
return 1.0 / rank
return 0.0
# ββ Build: candidates + LLM relevance judgments βββββββββββββββββββββββββββββββ
def judge(question: str, papers: list[dict]) -> dict[str, int]:
"""Grade each paper 0/1/2 for the question. Returns {paperId: grade}."""
labels = {}
for i in range(0, len(papers), JUDGE_BATCH):
batch = papers[i:i + JUDGE_BATCH]
papers_text = "\n\n".join(
f"[{j}] Title: {p['title']}\nAbstract: {p['abstract'][:MAX_ABSTRACT_CHARS]}"
for j, p in enumerate(batch)
)
prompt = f"""You are an expert assessor judging search results for a researcher.
Research question: {question}
Grade each paper's relevance using its title and abstract:
2 = highly relevant: directly addresses the question; the researcher would definitely want it
1 = partly relevant: related, addresses some parts of the question
0 = not relevant
{papers_text}
Respond with JSON only: {{"grades": [{{"index": 0, "grade": 2}}]}}"""
data = chat_json(prompt, max_tokens=60 * len(batch))
for g in data.get("grades", []):
j, grade = g.get("index"), g.get("grade")
if isinstance(j, int) and 0 <= j < len(batch) and grade in (0, 1, 2):
labels[batch[j]["paperId"]] = grade
return labels
def _system_rankings(question: str, entry: dict, papers: dict, models: dict) -> dict[str, list[str]]:
"""Every system's ranking of the saved candidates, as lists of paperIds."""
def docs(ids: list[str]) -> list[dict]:
return [{"paperId": pid, **papers[pid]} for pid in ids]
rankings = {
"OpenAlex (1 search)": entry["single"],
"Multi-search (merged)": entry["multi"],
}
for name, model in models.items():
short = name.split("/")[-1]
rankings[f"1 search + {short}"] = [p["paperId"] for p in rerank(question, docs(entry["single"]), model)]
rankings[f"Multi + {short}"] = [p["paperId"] for p in rerank(question, docs(entry["multi"]), model)]
return rankings
def build(model_names: list[str], pool_depth: int, refresh: bool) -> None:
data = json.loads(DATASET.read_text("utf-8")) if DATASET.exists() else {"queries": [], "papers": {}}
done = {q["question"]: q for q in data["queries"]}
questions = [q.strip() for q in QUERIES.read_text("utf-8").splitlines() if q.strip() and not q.startswith("#")]
models = {name: load_model(name) for name in model_names}
for n, question in enumerate(questions, 1):
entry = done.get(question)
if entry is None or refresh:
print(f"[{n}/{len(questions)}] fetching: {question}")
plan = decompose(question)
single = fetch_papers(question)
multi = multi_search(plan["sub_queries"])
for p in single + multi:
data["papers"][p["paperId"]] = {"title": p["title"], "abstract": p["abstract"]}
entry = {
"question": question,
"sub_queries": plan["sub_queries"],
"single": [p["paperId"] for p in single],
"multi": [p["paperId"] for p in multi],
"labels": (entry or {}).get("labels", {}),
}
if question not in done:
data["queries"].append(entry)
else:
data["queries"][data["queries"].index(done[question])] = entry
done[question] = entry
# Pool: union of every system's top results; judge only papers not yet labeled.
rankings = _system_rankings(question, entry, data["papers"], models)
pool = {pid for ranking in rankings.values() for pid in ranking[:pool_depth]}
todo = [{"paperId": pid, **data["papers"][pid]} for pid in sorted(pool) if pid not in entry["labels"]]
if todo:
print(f" judging {len(todo)} papers ...")
try:
entry["labels"].update(judge(question, todo))
except LLMError as e:
print(f" judge failed ({e}); rerun build to resume")
DATASET.parent.mkdir(exist_ok=True)
DATASET.write_text(json.dumps(data, indent=1, ensure_ascii=False), "utf-8") # save after every query
print(f"\nSaved {len(data['queries'])} queries, {len(data['papers'])} papers -> {DATASET}")
# ββ Run: metrics per system βββββββββββββββββββββββββββββββββββββββββββββββββββ
def run(model_names: list[str], k: int, verbose: bool) -> None:
data = json.loads(DATASET.read_text("utf-8"))
models = {name: load_model(name) for name in model_names}
totals: dict[str, dict[str, float]] = {}
for entry in data["queries"]:
labels = entry["labels"]
if not labels:
continue
all_rels = list(labels.values())
rankings = _system_rankings(entry["question"], entry, data["papers"], models)
if verbose:
print(f"\n{entry['question']}")
for system, ranking in rankings.items():
rels = [labels.get(pid, 0) for pid in ranking]
scores = {
f"NDCG@{k}": ndcg_at_k(rels, all_rels, k),
"MRR": mrr(rels),
f"Judged@{k}": sum(pid in labels for pid in ranking[:k]) / k,
}
for metric, value in scores.items():
totals.setdefault(system, {}).setdefault(metric, 0.0)
totals[system][metric] += value
if verbose:
print(f" {system:<34}" + "".join(f"{v:>10.3f}" for v in scores.values()))
n = sum(1 for q in data["queries"] if q["labels"])
metrics = list(next(iter(totals.values())))
print(f"\nMean over {n} queries (labels: LLM judge, 0/1/2; MRR = first highly relevant paper)\n")
print(f" {'System':<34}" + "".join(f"{m:>10}" for m in metrics))
print(" " + "-" * (34 + 10 * len(metrics)))
for system, scores in totals.items():
print(f" {system:<34}" + "".join(f"{scores[m] / n:>10.3f}" for m in metrics))
print(f"\n Judged@{k} < 1.000 means some top results were never labeled (unlabeled count as 0);")
print(" rerun build with the same --models to judge them.")
if __name__ == "__main__":
import argparse
import sys
from dotenv import load_dotenv
load_dotenv()
sys.stdout.reconfigure(encoding="utf-8") # Windows consoles default to cp1252
parser = argparse.ArgumentParser(description="Evaluate retrieval + reranking against judged queries")
parser.add_argument("command", choices=["build", "run"])
parser.add_argument("--models", nargs="+", default=[DEFAULT_MODEL], help="Reranker models to compare")
parser.add_argument("--k", type=int, default=10, help="Metric cutoff")
parser.add_argument("--pool-depth", type=int, default=20, help="build: judge each system's top N")
parser.add_argument("--refresh", action="store_true", help="build: re-fetch candidates for every query")
parser.add_argument("--verbose", action="store_true", help="run: show per-query scores")
args = parser.parse_args()
if args.command == "build":
build(args.models, args.pool_depth, args.refresh)
else:
run(args.models, args.k, args.verbose)
|