File size: 20,635 Bytes
c38bb1a | 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 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 | #!/usr/bin/env python3
"""
Retrieval evaluation: the chunking comparison table.
THIS IS THE DELIVERABLE FOR REQUIREMENT 2 ("chunking must be vast").
Nine strategies with measured numbers beats any amount of prose about chunking.
WHAT IT MEASURES
----------------
Relevance is judged at PASSAGE level (`is_selected`), but retrieval happens at
CHUNK level. A retrieved chunk counts as relevant if it overlaps any positive
passage -- which is why every Chunk carries `block_ids`. That mapping is what
makes chunking strategies comparable on a corpus whose labels are per passage.
nDCG@5 Hit@5 MRR@5 P@1 standard ranking quality
zero_hit_rate fraction of queries with NO positive in top-k
-- the quality-side analogue of P100, and the
metric that exposes catastrophic failures that
a good mean hides
per_query_std robustness; arXiv:2603.06976 found fine-grained
chunkers have high variance and more zero-hits
n_chunks, index_mb, embed_s the efficiency side of the Pareto frontier
GPU REQUIRED (embeddings). Run on jupyter-pod, not the head node.
python src/evaluate_retrieval.py --max-queries 1000 --langs hi,ta,bn
python src/evaluate_retrieval.py --max-queries 1000 # all 14
"""
from __future__ import annotations
import argparse
import json
import math
import sys
import time
from collections import defaultdict
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.chunkers.base import Document, chunk_document, word_budget # noqa: E402
from src.chunkers.strategies import build_portfolio # noqa: E402
from src.golden_set import canonical_id # noqa: E402
from src.schema_utils import LANG_NAMES, default_root, iter_passages, norm_lang, load_report # noqa: E402
K = 5
# ------------------------------------------------------------------ metrics
def dcg(rels: list[int]) -> float:
return sum(r / math.log2(i + 2) for i, r in enumerate(rels))
def score_ranking(retrieved_ids: list[set[str]], positives: set[str], k: int = K) -> dict:
"""retrieved_ids[i] = canonical_ids covered by the chunk at rank i."""
rels = [1 if (ids & positives) else 0 for ids in retrieved_ids[:k]]
ideal = sorted(rels, reverse=True)
hit = max(rels) if rels else 0
rr = next((1.0 / (i + 1) for i, r in enumerate(rels) if r), 0.0)
idcg = dcg(ideal)
return {
"ndcg": dcg(rels) / idcg if idcg > 0 else 0.0,
"hit": float(hit),
"mrr": rr,
"p1": float(rels[0]) if rels else 0.0,
"zero_hit": 1.0 - float(hit),
}
def score_at_budget(ranked_ids: list[set[str]], ranked_words: list[int],
positives: set[str], budget_words: int) -> dict:
"""Take chunks in rank order until `budget_words` of context is filled.
WHY THIS EXISTS
---------------
Fixed top-k is not a fair comparison across chunking strategies. A strategy
that emits 3.8 chunks per document has all of them retrieved by k=5, so its
Hit@5 is free; one emitting 9.4 chunks faces a real selection. Measured on
this corpus, corr(chunks_per_doc, Hit@5) = -0.885 -- the ranking was largely
reproducing chunk count, not chunk quality.
A reader has a CONTEXT budget, not a chunk budget. Equalising words delivered
is the comparison that matches how the system is actually used.
"""
used, taken = 0, []
for ids, w in zip(ranked_ids, ranked_words):
if used and used + w > budget_words:
break
taken.append(ids)
used += w
rels = [1 if (ids & positives) else 0 for ids in taken]
hit = max(rels) if rels else 0
rr = next((1.0 / (i + 1) for i, r in enumerate(rels) if r), 0.0)
idcg = dcg(sorted(rels, reverse=True))
return {
"b_ndcg": dcg(rels) / idcg if idcg > 0 else 0.0,
"b_hit": float(hit),
"b_mrr": rr,
"b_zero_hit": 1.0 - float(hit),
"b_chunks_used": float(len(taken)),
"b_words_used": float(used),
}
def aggregate(per_query: list[dict]) -> dict:
if not per_query:
return {}
out = {}
keys = [k for k in ("ndcg", "hit", "mrr", "p1", "zero_hit",
"b_ndcg", "b_hit", "b_mrr", "b_zero_hit",
"b_chunks_used", "b_words_used") if k in per_query[0]]
for key in keys:
vals = [q[key] for q in per_query]
mean = sum(vals) / len(vals)
var = sum((v - mean) ** 2 for v in vals) / max(1, len(vals) - 1)
out[key] = round(mean, 4)
if key == "ndcg":
out["ndcg_std"] = round(math.sqrt(var), 4)
out["n_queries"] = len(per_query)
return out
# ------------------------------------------------------------------ data
def load_eval_data(root: Path, lang: str, max_queries: int):
"""Return (queries, docs) for one language, from the validation split."""
import polars as pl
rep = load_report(root)
fmap, pmap = rep["field_mapping"], rep["passage_mapping"]
pcol = fmap["passages"]
qid_c, q_c, lang_c = fmap["query_id"], fmap["query"], fmap.get("lang")
t_key, en_key, sel_key = pmap["text"], pmap.get("text_en"), pmap.get("is_selected")
target = None
for fp in rep["files"]:
if "val" not in Path(fp).name:
continue
try:
probe = pl.read_parquet(fp, columns=[lang_c], n_rows=1)
except Exception:
continue
if norm_lang(probe[lang_c][0]) == lang:
target = fp
break
if target is None:
return [], []
df = pl.read_parquet(target, columns=[qid_c, q_c, pcol], n_rows=max_queries * 3)
queries, docs = [], []
for qid, q, plist in zip(df[qid_c].to_list(), df[q_c].to_list(), df[pcol].to_list()):
if len(queries) >= max_queries:
break
texts, ids, pos = [], [], set()
for idx, text, _en, sel, _u in iter_passages(plist, t_key, en_key, sel_key, None):
if not isinstance(text, str) or not text.strip():
continue
cid = canonical_id(qid, idx)
texts.append(text)
ids.append(cid)
if sel == 1:
pos.add(cid)
if not texts or not pos or not isinstance(q, str) or not q.strip():
continue # unanswerable queries are excluded from RANKING metrics
queries.append({"query_id": str(qid), "text": q, "positives": pos})
docs.append(Document.from_blocks(f"q{qid}", lang, texts, ids))
return queries, docs
# ------------------------------------------------------------------ embedding
class Embedder:
def __init__(self, model_path: str, device: str, batch: int, max_len: int):
import torch
from transformers import AutoModel, AutoTokenizer
self.torch = torch
self.tok = AutoTokenizer.from_pretrained(model_path)
self.model = AutoModel.from_pretrained(
model_path, torch_dtype=torch.float16 if "cuda" in device else torch.float32
).to(device).eval()
self.device, self.batch, self.max_len = device, batch, max_len
@property
def dim(self) -> int:
return int(self.model.config.hidden_size)
def encode(self, texts: list[str]):
torch = self.torch
outs = []
with torch.inference_mode():
for i in range(0, len(texts), self.batch):
enc = self.tok(texts[i:i + self.batch], padding=True, truncation=True,
max_length=self.max_len, return_tensors="pt").to(self.device)
h = self.model(**enc).last_hidden_state
# CLS pooling — bge-m3's dense head
v = h[:, 0]
outs.append(torch.nn.functional.normalize(v, dim=-1).to(torch.float16))
return torch.cat(outs) if outs else torch.zeros((0, self.dim), device=self.device)
# ------------------------------------------------------------------ eval
def evaluate_lang(emb: Embedder, lang: str, queries, docs, strategies, fertility: float,
pool: str = "corpus", budget_words: int = 400):
torch = emb.torch
lo, hi = word_budget(200, fertility)
results = []
t0 = time.perf_counter()
qv = emb.encode([q["text"] for q in queries])
q_secs = time.perf_counter() - t0
for ch in strategies:
t0 = time.perf_counter()
chunks, owner = [], [] # owner[i] = index of the query this chunk belongs to
for qi, doc in enumerate(docs):
for c in chunk_document(doc, ch, lo, hi, post=True):
chunks.append(c)
owner.append(qi)
chunk_s = time.perf_counter() - t0
if not chunks:
continue
t0 = time.perf_counter()
cv = emb.encode([c.text for c in chunks])
embed_s = time.perf_counter() - t0
# POOL CHOICE MATTERS ENORMOUSLY.
# query : rank only the chunks from this query's own ~10 passages.
# Easy (Hit@5 ~ 0.99) and confounded -- a strategy emitting
# fewer than k chunks gets every one of them retrieved for free.
# corpus : rank against EVERY chunk in the language. Realistic, and the
# pool no longer depends on how many chunks a strategy makes.
by_query = defaultdict(list)
for i, qi in enumerate(owner):
by_query[qi].append(i)
n_words = [c.n_words for c in chunks]
depth = max(K, 40) # deep enough to fill the word budget
per_query = []
t0 = time.perf_counter()
with torch.inference_mode():
if pool == "corpus":
for qi, q in enumerate(queries):
sims = (cv @ qv[qi]).float()
top = torch.topk(sims, min(depth, len(chunks))).indices.tolist()
ranked = [set(chunks[t].block_ids) for t in top]
words = [n_words[t] for t in top]
m = score_ranking(ranked, q["positives"])
m.update(score_at_budget(ranked, words, q["positives"], budget_words))
per_query.append(m)
else:
for qi, q in enumerate(queries):
idxs = by_query.get(qi)
if not idxs:
continue
sims = (cv[idxs] @ qv[qi]).float()
top = torch.topk(sims, min(depth, len(idxs))).indices.tolist()
ranked = [set(chunks[idxs[t]].block_ids) for t in top]
words = [n_words[idxs[t]] for t in top]
m = score_ranking(ranked, q["positives"])
m.update(score_at_budget(ranked, words, q["positives"], budget_words))
per_query.append(m)
search_s = time.perf_counter() - t0
agg = aggregate(per_query)
agg.update({
"strategy": ch.name, "family": ch.family, "lang": lang,
"pool": pool, "budget_words": budget_words,
"n_chunks": len(chunks),
"mean_chunk_words": round(sum(c.n_words for c in chunks) / len(chunks), 1),
"chunks_per_doc": round(len(chunks) / max(1, len(docs)), 2),
"block_integrity": round(
1 - sum(c.split_blocks for c in chunks) / max(1, sum(len(c.block_ids) for c in chunks)), 4),
"index_mb": round(len(chunks) * emb.dim * 2 / 2**20, 1),
"chunk_s": round(chunk_s, 2), "embed_s": round(embed_s, 2),
"search_ms_per_query": round(1000 * search_s / max(1, len(per_query)), 3),
})
results.append(agg)
print(f" {ch.name:6s} nDCG@5 {agg['ndcg']:.4f} P@1 {agg['p1']:.3f} "
f"zero {agg['zero_hit']:.3f} | @{budget_words}w: nDCG {agg['b_ndcg']:.4f} "
f"hit {agg['b_hit']:.3f} ({agg['b_chunks_used']:.1f} chunks) "
f"| {agg['chunks_per_doc'] if 'chunks_per_doc' in agg else len(chunks)/max(1,len(docs)):.1f}/doc {embed_s:.1f}s")
return results, q_secs
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--root", type=Path, default=None)
ap.add_argument("--model", default=None, help="path or hub id; default bge-m3 from the local cache")
ap.add_argument("--langs", default=None, help="comma-separated ISO-2; default all")
ap.add_argument("--max-queries", type=int, default=1000)
ap.add_argument("--batch", type=int, default=64)
ap.add_argument("--max-len", type=int, default=192, help="passages are ~55 words; 192 is ample and 2.6x faster than 512")
ap.add_argument("--device", default=None)
ap.add_argument("--pool", choices=["corpus", "query"], default="corpus",
help="corpus = rank against all chunks in the language (realistic); "
"query = only this query's own passages (easy, and confounded "
"by chunks-per-doc vs k)")
ap.add_argument("--budget-words", type=int, default=400,
help="context budget for the fair, chunk-count-independent metric")
ap.add_argument("--allow-cpu", action="store_true",
help="run without a GPU anyway (very slow; for debugging only)")
args = ap.parse_args()
root = args.root.expanduser().resolve() if args.root else default_root()
print(f"==> data root: {root}")
# Fail fast and helpfully: this step needs a GPU and transformers, both of
# which live on jupyter-pod. The head node has neither.
import socket
missing = []
for mod in ("torch", "transformers"):
try:
__import__(mod)
except ImportError:
missing.append(mod)
if missing:
raise SystemExit(
f"\nMissing: {', '.join(missing)} (host: {socket.gethostname()})\n\n"
"This step needs a GPU and the NVIDIA container's python packages.\n"
"kls-headnode has neither; jupyter-pod has both.\n\n"
" -> Open 03_evaluate.ipynb on jupyter-pod and run it there.\n"
" Both hosts share the same NFS, so nothing needs copying.\n\n"
" (If you must run here, create an isolated venv:\n"
f" python3 -m venv --system-site-packages {root.parent}/.venv\n"
f" source {root.parent}/.venv/bin/activate && pip install {' '.join(missing)}\n"
" but it will still be CPU-only and very slow.)\n"
)
import torch
device = pick_device(args.device)
if device == "cpu":
print(f" !! no GPU visible on {socket.gethostname()} — embedding will be ~50x slower.")
print(" Run 03_evaluate.ipynb on jupyter-pod instead.")
if not args.allow_cpu:
raise SystemExit(" (pass --allow-cpu to override)")
else:
p = torch.cuda.get_device_properties(0)
print(f"==> device: {device} ({p.name}, {p.total_memory/2**30:.0f} GiB, {p.multi_processor_count} SMs)")
model = args.model
if model is None:
hits = list((root / "hf_cache" / "hub").glob("models--BAAI--bge-m3/snapshots/*"))
model = str(hits[0]) if hits else "BAAI/bge-m3"
print(f"==> model: {model}")
fert_path = root / "results" / "fertility.json"
fert = {}
if fert_path.exists():
fert = {k: v["fertility_vs_english"]
for k, v in json.loads(fert_path.read_text())["per_language"].items()}
print(f"==> fertility loaded for {len(fert)} languages (per-language word budgets)")
else:
print("==> fertility.json not found — using fertility=1.0 for every language")
langs = args.langs.split(",") if args.langs else list(LANG_NAMES)
langs = [l for l in langs if l != "en"]
emb = Embedder(model, device, args.batch, args.max_len)
strategies = build_portfolio()
print(f"==> {len(strategies)} strategies: {[s.name for s in strategies]}\n")
all_rows = []
for lang in langs:
queries, docs = load_eval_data(root, lang, args.max_queries)
if not queries:
print(f" {lang}: no data, skipping")
continue
print(f" {LANG_NAMES.get(lang, lang)} ({lang}): {len(queries):,} answerable queries, "
f"{sum(len(d.blocks) for d in docs):,} passages, fertility {fert.get(lang, 1.0):.2f}")
rows, _ = evaluate_lang(emb, lang, queries, docs, strategies, fert.get(lang, 1.0),
pool=args.pool, budget_words=args.budget_words)
all_rows.extend(rows)
print()
if not all_rows:
raise SystemExit("no results — check --langs and that the validation split is present")
# ---- the table ----
by_strat = defaultdict(list)
for r in all_rows:
by_strat[r["strategy"]].append(r)
print("=" * 96)
print("CHUNKING COMPARISON (mean across languages)")
print("=" * 96)
hdr = (f"{'strategy':8s}{'family':12s}{'nDCG@5':>8}{'Hit@5':>8}{'MRR@5':>8}{'P@1':>7}"
f"{'zero':>7}{'std':>7}{'chunks':>9}{'BI':>7}{'MB':>7}")
print(hdr); print("-" * 96)
table = []
for name, rows in sorted(by_strat.items(),
key=lambda kv: -sum(r["ndcg"] for r in kv[1]) / len(kv[1])):
m = lambda k: sum(r[k] for r in rows) / len(rows) # noqa: E731
table.append({"strategy": name, "family": rows[0]["family"],
**{k: round(m(k), 4) for k in
("ndcg", "hit", "mrr", "p1", "zero_hit", "ndcg_std",
"block_integrity", "chunks_per_doc",
"b_ndcg", "b_hit", "b_mrr", "b_zero_hit",
"b_chunks_used", "b_words_used")},
"n_chunks": int(m("n_chunks")), "index_mb": round(m("index_mb"), 1),
"n_langs": len(rows)})
print(f"{name:8s}{rows[0]['family']:12s}{m('ndcg'):>8.4f}{m('hit'):>8.3f}{m('mrr'):>8.4f}"
f"{m('p1'):>7.3f}{m('zero_hit'):>7.3f}{m('ndcg_std'):>7.3f}"
f"{int(m('n_chunks')):>9,}{m('block_integrity'):>7.3f}{m('index_mb'):>7.1f}")
best, worst = table[0], table[-1]
print("-" * 96)
if worst["p1"] > 0:
print(f" best/worst P@1 ratio: {best['p1']/worst['p1']:.2f}x "
f"({best['strategy']} {best['p1']:.3f} vs {worst['strategy']} {worst['p1']:.3f})")
print(f" nDCG spread across {len(table)} strategies: "
f"{best['ndcg']-worst['ndcg']:.4f} "
f"({100*(best['ndcg']-worst['ndcg'])/max(1e-9,best['ndcg']):.1f}% of the best)")
print(" ^ a SMALL spread is itself the finding — it is what the ANOVA will quantify.")
# --- confound diagnostic: is the ranking just reproducing chunk count? ---
def _corr(x, y):
mx = sum(x) / len(x); my = sum(y) / len(y)
num = sum((a - mx) * (b - my) for a, b in zip(x, y))
den = (sum((a - mx) ** 2 for a in x) * sum((b - my) ** 2 for b in y)) ** 0.5
return num / den if den else 0.0
cpd = [r["chunks_per_doc"] for r in table]
print(f"\n corr(chunks_per_doc, nDCG@{K}) = {_corr(cpd, [r['ndcg'] for r in table]):+.3f}")
print(f" corr(chunks_per_doc, nDCG@{args.budget_words}w) = "
f"{_corr(cpd, [r['b_ndcg'] for r in table]):+.3f} <- should be much weaker")
free = [r["strategy"] for r in table if r["chunks_per_doc"] <= K]
if free and args.pool == "query":
print(f" !! {free} emit <= k={K} chunks/doc, so top-k retrieves ALL of them.")
print(f" Their Hit@{K} is free. Trust the @{args.budget_words}w columns instead.")
out = root / "results" / "retrieval_eval.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps({
"config": {"max_queries": args.max_queries, "k": K, "model": model,
"max_len": args.max_len, "langs": langs},
"summary": table, "per_language": all_rows,
}, indent=2))
print(f"\n==> wrote {out}")
print(" per_language rows feed the ANOVA (strategy x language x ...)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
|