File size: 5,453 Bytes
406a5e6 | 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 | """Second retrieval round seeded by JUDGE-CONFIRMED papers.
Everything measured here says the remaining loss is in two places: papers we
never retrieve at all, and selection accuracy. Blind widening does not fix
the first -- a wider fanout raises pool recall (0.533 -> 0.644) yet measured
NEUTRAL-to-negative end to end, because the extra candidates dilute the
window the selector can afford to look at.
The difference here is WHERE we widen. After the pool judge runs, we know which
papers it scored as perfectly relevant. Their citation neighbourhood is not a
blind widening: a paper cited by, or citing, a confirmed-relevant paper is
drawn from a distribution far denser in golds than another BM25 page. So this
round expands only around confirmed hits, then judges the newcomers with the
same cheap selector before letting any of them into the submission.
That is what Ai2's own "diligent" mode buys with its long search budget, in a
shape that stays cheap here: corpus calls are inexpensive compared to LLM
calls, so the whole expansion costs wall-clock time plus one extra judge pass
over the NEW papers only.
Integrity: seeds come from the judge's scores on retrieved text, never from
gold ids; emitted evidence is the paper's own verbatim abstract.
Env: PFBMAX_DILIGENT=1 enables, PFBMAX_DILIGENT_SEEDS (default 8),
PFBMAX_DILIGENT_PER_SEED (default 60), PFBMAX_DILIGENT_JUDGE (default 200).
"""
from __future__ import annotations
import os
DOC_CHARS = 1500
def _envi(name, default):
raw = (os.environ.get(name) or "").strip()
try:
return int(raw) if raw else default
except ValueError:
return default
def _cid(paper):
for attr in ("corpusId", "corpus_id"):
v = getattr(paper, attr, None)
if v:
return str(v)
return None
def expand(query, criteria, pool, client, inserted_before, judge,
trace=None):
"""Return [(cid, evidence, score)] triples of NEW papers the judge rates
highly.
The score is included so the caller can merge by QUALITY. An
earlier version appended newcomers to the TAIL: recall@FULL rose
(0.1998 -> 0.2078, so the expansion really does find new golds) but the
score FELL 0.0427, because tail papers cannot reach the top-K window while
still joining the judged set and dragging nDCG down. Position is the whole
game when the scored window is K ~ 2P.
``judge`` is criterion_judge; it is called only on the newcomers, so the
marginal cost is one judge pass over ``PFBMAX_DILIGENT_JUDGE`` papers.
"""
tr = trace if trace is not None else {}
order = list(pool.ranked())
if not order:
return []
n_seeds = _envi("PFBMAX_DILIGENT_SEEDS", 8)
per_seed = _envi("PFBMAX_DILIGENT_PER_SEED", 60)
n_judge = _envi("PFBMAX_DILIGENT_JUDGE", 200)
seen = set(order)
seeds = order[:n_seeds] # pool is judge-sorted at this point
fresh = {}
for seed in seeds:
for direction in ("references", "citations"):
try:
papers = client.get_citations(
seed, direction=direction, limit=per_seed,
fields="corpusId,title,abstract,year") or []
except Exception:
papers = []
for p in papers:
c = _cid(p)
if not c or c in seen or c in fresh:
continue
abstract = (getattr(p, "abstract", "") or "").strip()
title = (getattr(p, "title", "") or "").strip()
if not abstract:
continue # no verbatim text -> cannot furnish evidence
fresh[c] = (title, abstract)
tr["diligent_fresh"] = len(fresh)
if not fresh:
return []
items = list(fresh.items())[:n_judge]
docs = [(c, (t + ". " + a)[:DOC_CHARS]) for c, (t, a) in items]
# score each newcomer with the SAME replicated-grader scale the pool was
# ranked on, so the caller can merge by quality instead of by arrival.
# Parallel: 200 sequential judge calls is ~5 minutes a query, which makes
# the round untestable regardless of whether it helps.
from concurrent.futures import ThreadPoolExecutor
model = os.environ.get("PFBMAX_CJ_MODEL", "gpt-4o-mini")
workers = _envi("PFBMAX_CJ_WORKERS", 8)
def _one(item):
cid, text = item
try:
return cid, judge.score_paper(query, criteria, text, model)
except Exception:
return cid, None
scored = {}
with ThreadPoolExecutor(max_workers=max(1, workers)) as ex:
for cid, v in ex.map(_one, docs):
if v is not None:
scored[cid] = v
lookup = dict(items)
try:
gate = float(os.environ.get("PFBMAX_DILIGENT_GATE") or 0.66)
except ValueError:
gate = 0.66
keep = _envi("PFBMAX_DILIGENT_KEEP", 60)
out = []
for cid in sorted(scored, key=lambda c: -scored[c])[:keep]:
if scored[cid] < gate:
break # below the grader's own "highly relevant" threshold: not worth a slot
t, a = lookup.get(cid, ("", ""))
ev = (a or t)[:1200]
if ev:
out.append((cid, ev, scored[cid]))
tr["diligent_kept"] = len(out)
tr["diligent_judged"] = len(scored)
return out
|