| """RankGPT-style listwise reranking with a sliding window.
|
|
|
| Why listwise: the existing judge was POINTWISE, meaning it graded each paper
|
| independently against the criteria and returned booleans. A pointwise grader
|
| cannot express "this paper is better than that one", so it cannot produce an
|
| ordering; ours bucketed papers into three coarse tiers and left the fused
|
| retrieval score to break ties. Measured consequence: rank 0.668 but
|
| recall@K 0.083, and deleting the judge outright *improved* the score.
|
|
|
| Listwise reranking (Sun et al., RankGPT 2023; RankZephyr; the 2025-26
|
| follow-ups) instead shows the model a window of candidates at once and asks
|
| for a permutation, so relative judgements are the output rather than a
|
| by-product. It is the reported state of the art for exactly our failure mode,
|
| and the sliding window keeps it O(n) in candidates: rank the bottom window,
|
| carry the winners up, repeat toward the top.
|
|
|
| That matters here because the benchmark's semantic score is
|
| harmonic(nDCG, recall@K) with K ~ 2x the number of perfect papers, which is
|
| a pure ordering problem over a pool that already contains ~43% of the golds.
|
|
|
| This stage is opt-in: the router runs it only when PFBMAX_LISTWISE=1 is set.
|
| Model choice is deliberate: the official scorer is gpt-4o-2024-11-20, and a
|
| reranker from the same family agrees with it far better than gpt-4o-mini did
|
| (the mini judge's disagreement at the "perfect vs merely highly relevant"
|
| boundary is what made it worthless). The default model is gpt-4o-2024-11-20;
|
| set PFBMAX_RANK_MODEL to override.
|
|
|
| Integrity: ranks on query + criteria text only; no gold ids, no per-query
|
| branching. Papers the model omits keep their prior relative order.
|
| """
|
|
|
| from __future__ import annotations
|
|
|
| import json
|
| import os
|
| import re
|
| import urllib.error
|
| import urllib.request
|
|
|
| WINDOW = 20
|
| STEP = 10
|
| DEPTH = 100
|
| DOC_CHARS = 380
|
| TIMEOUT_S = 180.0
|
| ENDPOINT = "https://api.openai.com/v1/chat/completions"
|
|
|
| _PERM_RE = re.compile(r"\[(\d+)\]")
|
|
|
| _SYSTEM = (
|
| "You rank scientific papers by how well they satisfy a search request. "
|
| "You will be given a query, its relevance criteria, and a numbered list "
|
| "of paper excerpts. Rank ALL of them from most to least relevant.\n"
|
| "A paper is most relevant only if it satisfies EVERY criterion; a paper "
|
| "that satisfies most criteria but clearly misses one ranks below every "
|
| "paper that satisfies all of them.\n"
|
| "Respond with the identifiers in descending relevance, e.g. "
|
| "[4] > [1] > [7] > ... , including every identifier exactly once. "
|
| "Output only that ranking line."
|
| )
|
|
|
|
|
| def _key() -> str:
|
| k = os.environ.get("OPENAI_API_KEY", "")
|
| if k:
|
| return k
|
| here = os.path.dirname(os.path.abspath(__file__))
|
| for path in (os.path.join(here, ".openai_key"),
|
| os.path.join(os.path.dirname(here), "iris_asta", ".env")):
|
| try:
|
| txt = open(path, encoding="utf-8").read()
|
| except Exception:
|
| continue
|
| if path.endswith(".openai_key"):
|
| return txt.strip()
|
| for line in txt.splitlines():
|
| if line.startswith("OPENAI_API_KEY="):
|
| return line.split("=", 1)[1].strip().strip('"').strip("'")
|
| return ""
|
|
|
|
|
| def _is_reasoning(model: str) -> bool:
|
| """gpt-5.x / o-series speak a different dialect than the gpt-4 chat API."""
|
| m = (model or "").lower()
|
| return m.startswith("gpt-5") or re.match(r"^o[1-9]", m) is not None
|
|
|
|
|
| def _chat(model: str, messages: list[dict], max_tokens: int,
|
| meter=None, timeout: float = TIMEOUT_S) -> str:
|
| if _is_reasoning(model):
|
|
|
|
|
|
|
| payload = {"model": model, "messages": messages,
|
| "max_completion_tokens": max(2048, max_tokens * 12),
|
| "reasoning_effort": os.environ.get(
|
| "PFBMAX_RANK_EFFORT", "low")}
|
| else:
|
| payload = {"model": model, "messages": messages,
|
| "temperature": 0.0, "max_tokens": max_tokens}
|
| body = json.dumps(payload).encode()
|
| req = urllib.request.Request(
|
| ENDPOINT, data=body,
|
| headers={"Content-Type": "application/json",
|
| "Authorization": "Bearer " + _key()})
|
| with urllib.request.urlopen(req, timeout=timeout) as r:
|
| obj = json.loads(r.read())
|
| usage = obj.get("usage") or {}
|
| pt, ct = int(usage.get("prompt_tokens") or 0), int(usage.get("completion_tokens") or 0)
|
| if meter is not None:
|
| try:
|
| meter.add(obj.get("model") or model, pt, ct)
|
| except Exception:
|
| pass
|
| try:
|
| from llm import _record_inspect_usage
|
| _record_inspect_usage(obj.get("model") or model, pt, ct)
|
| except Exception:
|
| pass
|
| choices = obj.get("choices") or []
|
| return (choices[0].get("message") or {}).get("content", "") if choices else ""
|
|
|
|
|
| def _parse_permutation(text: str, n: int) -> list[int]:
|
| """Identifiers in the model's stated order, 0-based, deduped and bounded."""
|
| out, seen = [], set()
|
| for m in _PERM_RE.findall(text or ""):
|
| try:
|
| i = int(m) - 1
|
| except ValueError:
|
| continue
|
| if 0 <= i < n and i not in seen:
|
| seen.add(i)
|
| out.append(i)
|
| return out
|
|
|
|
|
| def rerank(query: str, criteria: list[str], docs: list[tuple[str, str]],
|
| model: str | None = None, depth: int = DEPTH,
|
| window: int = WINDOW, step: int = STEP, meter=None,
|
| trace: dict | None = None) -> list[str]:
|
| """Return cids reordered by listwise relevance.
|
|
|
| ``docs`` is [(cid, text)] in current (fused) order. Only the top ``depth``
|
| are reranked; the tail keeps its order, since the scored window is far
|
| shallower than the submission and reranking the tail buys nothing.
|
| """
|
| model = model or os.environ.get("PFBMAX_RANK_MODEL", "gpt-4o-2024-11-20")
|
| _d = os.environ.get("PFBMAX_RANK_DEPTH", "").strip()
|
| if _d.isdigit():
|
| depth = int(_d)
|
| tr = trace if trace is not None else {}
|
| if not docs:
|
| return []
|
| head = docs[:depth]
|
| tail = [cid for cid, _t in docs[depth:]]
|
| order = list(range(len(head)))
|
| crit = "\n".join(f"- {c}" for c in (criteria or [])[:6]) or "- (none given)"
|
|
|
| passes = 1
|
| _p = os.environ.get("PFBMAX_RANK_PASSES", "").strip()
|
| if _p.isdigit():
|
| passes = max(1, int(_p))
|
|
|
| calls = 0
|
|
|
|
|
|
|
|
|
| for _pass in range(passes):
|
| start = max(0, len(order) - window)
|
| while True:
|
| idxs = order[start:start + window]
|
| if len(idxs) < 2:
|
| if start == 0:
|
| break
|
| start = max(0, start - step)
|
| continue
|
| lines = []
|
| for n, oi in enumerate(idxs, 1):
|
| _cid, text = head[oi]
|
| lines.append(f"[{n}] {' '.join((text or '').split())[:DOC_CHARS]}")
|
| user = (f"Query: {query}\n\nCriteria:\n{crit}\n\n"
|
| f"Papers ({len(idxs)}):\n" + "\n".join(lines) +
|
| f"\n\nRank all {len(idxs)} identifiers, best first.")
|
| try:
|
| reply = _chat(model, [{"role": "system", "content": _SYSTEM},
|
| {"role": "user", "content": user}],
|
| max_tokens=16 * len(idxs) + 64, meter=meter)
|
| calls += 1
|
| except (urllib.error.URLError, OSError, ValueError, TimeoutError):
|
| reply = ""
|
| perm = _parse_permutation(reply, len(idxs))
|
| if perm:
|
| ranked = [idxs[p] for p in perm]
|
| ranked += [i for i in idxs if i not in set(ranked)]
|
| order[start:start + window] = ranked
|
| if start == 0:
|
| break
|
| start = max(0, start - step)
|
|
|
| tr["listwise"] = {"model": model, "calls": calls, "depth": len(head),
|
| "passes": passes}
|
| return [head[i][0] for i in order] + tail
|
|
|