Spaces:
Running
retrieval: remove the cross-encoder reranker (ablation showed no earned keep) (#100)
Browse filesThe 2026-07-10 built-in ablation on real content measured the reranker at
recall@8 0.810 (ON) vs 0.790 (OFF) and MRR 0.615 vs 0.620 — +0.02 recall,
-0.005 MRR. That does not justify a ~90MB cross-encoder loaded on the query
path (its cold download was the 17.9s latency outlier) plus ~0.3-0.5s per
query. On real content the dense+keyword RRF fusion already ranks well; the
reranker was a crutch from the empty-stub era. So it is removed, not just
disabled.
- index/retrieve.py: drop the rerank path and the rerank_fn param; the
interleaved per-kind RRF top-k is the final order (the exact-symbol pin
still applies). index/rerank.py deleted.
- app/main.py: drop the reranker warmup (no 90MB model to pre-load).
- eval.yml: drop the input and TORCHDOCS_RERANK env; run_retrieval
reverts from the ON/OFF ablation to a single pass (nothing left to ablate).
- conftest.py: drop the test-suite TORCHDOCS_RERANK=0 default; test_rerank.py
deleted. Stale comments in route.py / diagnose_retrieval.py updated.
218 tests pass; ruff clean.
Claude-Session: https://claude.ai/code/session_01LX1gm8fuzK2ZeEWi3Zar1b
Co-authored-by: Claude <noreply@anthropic.com>
- .github/workflows/eval.yml +1 -9
- agent/route.py +2 -2
- app/main.py +1 -9
- eval/diagnose_retrieval.py +1 -1
- eval/run_retrieval.py +17 -49
- index/rerank.py +0 -104
- index/retrieve.py +15 -25
- tests/conftest.py +0 -8
- tests/index/test_rerank.py +0 -106
|
@@ -32,15 +32,9 @@ on:
|
|
| 32 |
live index was built with, or the query vector width won't fit."
|
| 33 |
type: string
|
| 34 |
default: "BAAI/bge-small-en-v1.5"
|
| 35 |
-
rerank:
|
| 36 |
-
description: "cross-encoder rerank stage (the production default is ON;
|
| 37 |
-
pick 0 for a before/after ablation against the raw fused order)"
|
| 38 |
-
type: choice
|
| 39 |
-
options: ["1", "0"]
|
| 40 |
-
default: "1"
|
| 41 |
k:
|
| 42 |
description: "top-k sections retrieved per question (the app's default
|
| 43 |
-
is 8; try 4 to measure precision-over-volume
|
| 44 |
type: string
|
| 45 |
default: "8"
|
| 46 |
provider:
|
|
@@ -92,7 +86,6 @@ jobs:
|
|
| 92 |
NEON_URL: ${{ secrets.NEON }}
|
| 93 |
TORCHDOCS_EVAL_SET: ${{ inputs.set }}
|
| 94 |
TORCHDOCS_EMBED_MODEL: ${{ inputs.model }}
|
| 95 |
-
TORCHDOCS_RERANK: ${{ inputs.rerank }}
|
| 96 |
TORCHDOCS_RETRIEVAL_K: ${{ inputs.k }}
|
| 97 |
run: python -m eval.run_retrieval
|
| 98 |
- name: Rank diagnostic (why do the known misses miss)
|
|
@@ -175,7 +168,6 @@ jobs:
|
|
| 175 |
TORCHDOCS_OPENAI_COMPAT_MODEL: ${{ inputs.llm }}
|
| 176 |
TORCHDOCS_EVAL_SET: ${{ inputs.set }}
|
| 177 |
TORCHDOCS_JUDGE_LIMIT: ${{ inputs.judge_limit }}
|
| 178 |
-
TORCHDOCS_RERANK: ${{ inputs.rerank }}
|
| 179 |
TORCHDOCS_RETRIEVAL_K: ${{ inputs.k }}
|
| 180 |
run: python -m eval.run_judge
|
| 181 |
- name: Commit results back to the branch
|
|
|
|
| 32 |
live index was built with, or the query vector width won't fit."
|
| 33 |
type: string
|
| 34 |
default: "BAAI/bge-small-en-v1.5"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
k:
|
| 36 |
description: "top-k sections retrieved per question (the app's default
|
| 37 |
+
is 8; try 4 to measure precision-over-volume)"
|
| 38 |
type: string
|
| 39 |
default: "8"
|
| 40 |
provider:
|
|
|
|
| 86 |
NEON_URL: ${{ secrets.NEON }}
|
| 87 |
TORCHDOCS_EVAL_SET: ${{ inputs.set }}
|
| 88 |
TORCHDOCS_EMBED_MODEL: ${{ inputs.model }}
|
|
|
|
| 89 |
TORCHDOCS_RETRIEVAL_K: ${{ inputs.k }}
|
| 90 |
run: python -m eval.run_retrieval
|
| 91 |
- name: Rank diagnostic (why do the known misses miss)
|
|
|
|
| 168 |
TORCHDOCS_OPENAI_COMPAT_MODEL: ${{ inputs.llm }}
|
| 169 |
TORCHDOCS_EVAL_SET: ${{ inputs.set }}
|
| 170 |
TORCHDOCS_JUDGE_LIMIT: ${{ inputs.judge_limit }}
|
|
|
|
| 171 |
TORCHDOCS_RETRIEVAL_K: ${{ inputs.k }}
|
| 172 |
run: python -m eval.run_judge
|
| 173 |
- name: Commit results back to the branch
|
|
@@ -6,8 +6,8 @@ wall-clock — unacceptable for the common case. But the loop's value is real
|
|
| 6 |
only for MULTI-SOURCE questions (catalog / compare / recipe / internals),
|
| 7 |
where it assembles pages one search can't: measured agentic coverage 0.567 vs
|
| 8 |
0.133 single-shot. A usage question ("how do I use SGD with momentum?") is
|
| 9 |
-
answered by ONE retrieval pass + ONE generation —
|
| 10 |
-
retrieval (recall@8 0.
|
| 11 |
|
| 12 |
So: a zero-LLM-call heuristic sends multi-source shapes to the loop and
|
| 13 |
everything else to the grounded single-shot path. The heuristic is allowed to
|
|
|
|
| 6 |
only for MULTI-SOURCE questions (catalog / compare / recipe / internals),
|
| 7 |
where it assembles pages one search can't: measured agentic coverage 0.567 vs
|
| 8 |
0.133 single-shot. A usage question ("how do I use SGD with momentum?") is
|
| 9 |
+
answered by ONE retrieval pass + ONE generation — backed by the hybrid
|
| 10 |
+
per-kind RRF retrieval (recall@8 ~0.81 on real content).
|
| 11 |
|
| 12 |
So: a zero-LLM-call heuristic sends multi-source shapes to the loop and
|
| 13 |
everything else to the grounded single-shot path. The heuristic is allowed to
|
|
@@ -108,8 +108,7 @@ def _warm_up() -> None:
|
|
| 108 |
"""Load the embedding model once so the first question isn't slow.
|
| 109 |
|
| 110 |
This also covers the guard: its topicality check embeds the (translated)
|
| 111 |
-
question with the same model.
|
| 112 |
-
the same reason — its ~90MB cross-encoder must not download mid-question.
|
| 113 |
"""
|
| 114 |
try:
|
| 115 |
from index.embed import embed_query
|
|
@@ -117,13 +116,6 @@ def _warm_up() -> None:
|
|
| 117 |
embed_query("warmup")
|
| 118 |
except Exception as exc: # noqa: BLE001 — warmup is best-effort
|
| 119 |
print(f"[app] warmup skipped: {exc}")
|
| 120 |
-
try:
|
| 121 |
-
from index import rerank
|
| 122 |
-
|
| 123 |
-
if rerank.enabled():
|
| 124 |
-
rerank.rerank("warmup", [{"url": ""}, {"url": ""}], k=1)
|
| 125 |
-
except Exception as exc: # noqa: BLE001 — warmup is best-effort
|
| 126 |
-
print(f"[app] rerank warmup skipped: {exc}")
|
| 127 |
|
| 128 |
|
| 129 |
def render(answer: Answer) -> str:
|
|
|
|
| 108 |
"""Load the embedding model once so the first question isn't slow.
|
| 109 |
|
| 110 |
This also covers the guard: its topicality check embeds the (translated)
|
| 111 |
+
question with the same model.
|
|
|
|
| 112 |
"""
|
| 113 |
try:
|
| 114 |
from index.embed import embed_query
|
|
|
|
| 116 |
embed_query("warmup")
|
| 117 |
except Exception as exc: # noqa: BLE001 — warmup is best-effort
|
| 118 |
print(f"[app] warmup skipped: {exc}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
|
| 121 |
def render(answer: Answer) -> str:
|
|
@@ -85,7 +85,7 @@ def main() -> int:
|
|
| 85 |
# The decisive number: the expected page's OWN nearest chunk — its
|
| 86 |
# absolute cosine distance, and its true dense rank among ALL api
|
| 87 |
# chunks (count of api chunks strictly closer). rank ~25 = a crowding
|
| 88 |
-
# problem (a deeper pool
|
| 89 |
# hundreds = an embedding problem (only doc-side enrichment helps).
|
| 90 |
pat = f"%{expected}%"
|
| 91 |
best = conn.execute(
|
|
|
|
| 85 |
# The decisive number: the expected page's OWN nearest chunk — its
|
| 86 |
# absolute cosine distance, and its true dense rank among ALL api
|
| 87 |
# chunks (count of api chunks strictly closer). rank ~25 = a crowding
|
| 88 |
+
# problem (a deeper pool helps); a far distance / rank in the
|
| 89 |
# hundreds = an embedding problem (only doc-side enrichment helps).
|
| 90 |
pat = f"%{expected}%"
|
| 91 |
best = conn.execute(
|
|
@@ -31,14 +31,9 @@ from dotenv import load_dotenv
|
|
| 31 |
|
| 32 |
EVAL_DIR = Path(__file__).parent
|
| 33 |
EVAL_SET = os.environ.get("TORCHDOCS_EVAL_SET", "v1")
|
|
|
|
| 34 |
_K = os.environ.get("TORCHDOCS_RETRIEVAL_K", "8")
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
def _results_path(rerank_on: bool) -> Path:
|
| 38 |
-
"""Standard file for the reranked run; a _norerank sibling for the ablation
|
| 39 |
-
(a k≠8 sweep suffixes k too) — so neither masquerades as the other."""
|
| 40 |
-
variant = ("" if _K == "8" else f"_k{_K}") + ("" if rerank_on else "_norerank")
|
| 41 |
-
return EVAL_DIR / "results" / f"retrieval_{EVAL_SET}{variant}.jsonl"
|
| 42 |
|
| 43 |
|
| 44 |
def load_questions() -> dict[str, dict]:
|
|
@@ -86,19 +81,16 @@ def question_metrics(expected: list[list[str]], pointers: list[dict]) -> dict:
|
|
| 86 |
}
|
| 87 |
|
| 88 |
|
| 89 |
-
def
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
The rerank on/off toggle is TORCHDOCS_RERANK, which retrieve() reads (via
|
| 93 |
-
index.rerank.enabled()) fresh on every call — so flipping the env here runs
|
| 94 |
-
the exact production path with the reranker in or out. Returns the aggregate
|
| 95 |
-
plus per-question records (also written to the pass's own results file)."""
|
| 96 |
-
os.environ["TORCHDOCS_RERANK"] = "1" if rerank_on else "0"
|
| 97 |
from index.retrieve import retrieve
|
| 98 |
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
| 100 |
records, recalls, mrrs = [], [], []
|
| 101 |
-
print(f"
|
| 102 |
print(f"{'id':<6}{'recall@' + str(k):<12}{'MRR':<8}misses")
|
| 103 |
for qid, q in questions.items():
|
| 104 |
expected = q.get("expected", [])
|
|
@@ -117,41 +109,17 @@ def _measure(questions: dict[str, dict], k: int, *, rerank_on: bool) -> dict:
|
|
| 117 |
{"id": qid, "question": q["question"], "k": k, **m,
|
| 118 |
"retrieved": [p["url"] + "#" + p.get("anchor", "") for p in pointers]}
|
| 119 |
)
|
| 120 |
-
path = _results_path(rerank_on)
|
| 121 |
-
path.parent.mkdir(exist_ok=True)
|
| 122 |
-
with path.open("w") as out:
|
| 123 |
-
for record in records:
|
| 124 |
-
out.write(json.dumps(record, ensure_ascii=False) + "\n")
|
| 125 |
-
n = len(recalls)
|
| 126 |
-
return {
|
| 127 |
-
"recall": sum(recalls) / n if n else 0.0,
|
| 128 |
-
"mrr": sum(mrrs) / n if n else 0.0,
|
| 129 |
-
"n": n,
|
| 130 |
-
"path": path,
|
| 131 |
-
}
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
def main() -> int:
|
| 135 |
-
load_dotenv()
|
| 136 |
-
k = int(os.environ.get("TORCHDOCS_RETRIEVAL_K", "8"))
|
| 137 |
-
questions = load_questions()
|
| 138 |
|
| 139 |
-
|
| 140 |
-
# ablation the team keeps asking for, so make it a permanent side-by-side
|
| 141 |
-
# instead of a manual rerank=0 dispatch nobody can trigger from mobile.
|
| 142 |
-
on = _measure(questions, k, rerank_on=True)
|
| 143 |
-
off = _measure(questions, k, rerank_on=False)
|
| 144 |
-
if not on["n"]:
|
| 145 |
print("no questions with expectations — nothing measured")
|
| 146 |
return 1
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
print(f"
|
| 154 |
-
print(f"\nresults → {on['path']} | {off['path']}")
|
| 155 |
return 0
|
| 156 |
|
| 157 |
|
|
|
|
| 31 |
|
| 32 |
EVAL_DIR = Path(__file__).parent
|
| 33 |
EVAL_SET = os.environ.get("TORCHDOCS_EVAL_SET", "v1")
|
| 34 |
+
# a k≠8 sweep suffixes the file so it can't masquerade as the standard k=8 run
|
| 35 |
_K = os.environ.get("TORCHDOCS_RETRIEVAL_K", "8")
|
| 36 |
+
RESULTS = EVAL_DIR / "results" / f"retrieval_{EVAL_SET}{'' if _K == '8' else f'_k{_K}'}.jsonl"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
def load_questions() -> dict[str, dict]:
|
|
|
|
| 81 |
}
|
| 82 |
|
| 83 |
|
| 84 |
+
def main() -> int:
|
| 85 |
+
load_dotenv()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
from index.retrieve import retrieve
|
| 87 |
|
| 88 |
+
k = int(os.environ.get("TORCHDOCS_RETRIEVAL_K", "8"))
|
| 89 |
+
questions = load_questions()
|
| 90 |
+
|
| 91 |
+
RESULTS.parent.mkdir(exist_ok=True)
|
| 92 |
records, recalls, mrrs = [], [], []
|
| 93 |
+
print(f"eval set: {EVAL_SET} ({len(questions)} questions), k={k}")
|
| 94 |
print(f"{'id':<6}{'recall@' + str(k):<12}{'MRR':<8}misses")
|
| 95 |
for qid, q in questions.items():
|
| 96 |
expected = q.get("expected", [])
|
|
|
|
| 109 |
{"id": qid, "question": q["question"], "k": k, **m,
|
| 110 |
"retrieved": [p["url"] + "#" + p.get("anchor", "") for p in pointers]}
|
| 111 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
+
if not recalls:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
print("no questions with expectations — nothing measured")
|
| 115 |
return 1
|
| 116 |
+
print(f"\naggregate over {len(recalls)} questions: "
|
| 117 |
+
f"mean recall@{k}={sum(recalls) / len(recalls):.3f} "
|
| 118 |
+
f"mean MRR={sum(mrrs) / len(mrrs):.3f}")
|
| 119 |
+
with RESULTS.open("w") as out:
|
| 120 |
+
for record in records:
|
| 121 |
+
out.write(json.dumps(record, ensure_ascii=False) + "\n")
|
| 122 |
+
print(f"results → {RESULTS}")
|
|
|
|
| 123 |
return 0
|
| 124 |
|
| 125 |
|
|
@@ -1,104 +0,0 @@
|
|
| 1 |
-
"""Cross-encoder reranking: the precision stage after candidate generation.
|
| 2 |
-
|
| 3 |
-
Why a reranker, and why now: the gloss re-embed (measured 2026-07-09) moved
|
| 4 |
-
several expected pages INTO their kind-pool (LayerNorm: true dense rank
|
| 5 |
-
899 → 2; SGD: 7 → 3) but bi-encoder distance + RRF still decide the final
|
| 6 |
-
order. A cross-encoder reads query and candidate TOGETHER — the interaction a
|
| 7 |
-
bi-encoder cannot see — and fixes exactly that ordering. It cannot rescue a
|
| 8 |
-
page that never became a candidate (Linear at dense rank 3,412 needs deeper
|
| 9 |
-
index-side enrichment, not reranking); see docs/retrieval-gaps-and-improvements.md.
|
| 10 |
-
|
| 11 |
-
What the cross-encoder scores: the index stores no chunk content (pointers
|
| 12 |
-
only), and hydrating dozens of candidates per query is too slow for the live
|
| 13 |
-
app — so each candidate is scored on its metadata line: symbol + page title +
|
| 14 |
-
heading path + the page's committed gloss (index/glosses.jsonl). For api pages
|
| 15 |
-
the gloss is exactly the plain-language "what is this page for" sentence, which
|
| 16 |
-
is the signal a relevance judgment needs.
|
| 17 |
-
|
| 18 |
-
Runs on CPU (~90MB model, a few hundred ms for a 24-candidate slate), behind
|
| 19 |
-
the TORCHDOCS_RERANK kill switch so eval can measure before/after and a broken
|
| 20 |
-
model can be turned off without a deploy. Fail-open: any model error returns
|
| 21 |
-
the fused order unchanged — reranking must never take retrieval down.
|
| 22 |
-
"""
|
| 23 |
-
|
| 24 |
-
from __future__ import annotations
|
| 25 |
-
|
| 26 |
-
import os
|
| 27 |
-
import threading
|
| 28 |
-
from typing import Any
|
| 29 |
-
|
| 30 |
-
RERANK_MODEL = os.environ.get("TORCHDOCS_RERANK_MODEL", "cross-encoder/ms-marco-MiniLM-L-6-v2")
|
| 31 |
-
|
| 32 |
-
# candidates fed to the cross-encoder: k=8 finals from a 24-candidate slate
|
| 33 |
-
# keeps 3× headroom for the reorder while staying sub-second on CPU
|
| 34 |
-
RERANK_SLATE = int(os.environ.get("TORCHDOCS_RERANK_SLATE", "24"))
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
def enabled() -> bool:
|
| 38 |
-
"""Kill switch, read per call so tests/deploys can flip it late."""
|
| 39 |
-
return os.environ.get("TORCHDOCS_RERANK", "1") not in ("0", "false", "no")
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
_MODEL_LOCK = threading.Lock()
|
| 43 |
-
_MODEL = None
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
def _model():
|
| 47 |
-
# same double-checked load as index/embed._model: exactly one instance,
|
| 48 |
-
# concurrent first calls must not download/build the model twice
|
| 49 |
-
global _MODEL
|
| 50 |
-
if _MODEL is None:
|
| 51 |
-
with _MODEL_LOCK:
|
| 52 |
-
if _MODEL is None:
|
| 53 |
-
from sentence_transformers import CrossEncoder
|
| 54 |
-
|
| 55 |
-
print(f"[rerank] loading {RERANK_MODEL} (first run downloads ~90MB)")
|
| 56 |
-
_MODEL = CrossEncoder(RERANK_MODEL, device="cpu")
|
| 57 |
-
return _MODEL
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def _score_pairs(pairs: list[tuple[str, str]]) -> list[float]:
|
| 61 |
-
return [float(s) for s in _model().predict(pairs, show_progress_bar=False)]
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def rerank_text(pointer: dict[str, Any], glosses: dict[str, str]) -> str:
|
| 65 |
-
"""The candidate line the cross-encoder judges the query against."""
|
| 66 |
-
from index.embed import symbol_from_url
|
| 67 |
-
|
| 68 |
-
parts = [
|
| 69 |
-
p
|
| 70 |
-
for p in (
|
| 71 |
-
symbol_from_url(pointer.get("url", "")),
|
| 72 |
-
pointer.get("page_title", ""),
|
| 73 |
-
pointer.get("heading_path", ""),
|
| 74 |
-
glosses.get(pointer.get("url", ""), ""),
|
| 75 |
-
)
|
| 76 |
-
if p
|
| 77 |
-
]
|
| 78 |
-
return ". ".join(parts)
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
def rerank(
|
| 82 |
-
query: str,
|
| 83 |
-
pointers: list[dict[str, Any]],
|
| 84 |
-
k: int,
|
| 85 |
-
scorer=None,
|
| 86 |
-
) -> list[dict[str, Any]]:
|
| 87 |
-
"""Reorder candidate pointers by cross-encoder relevance; return the top-k.
|
| 88 |
-
|
| 89 |
-
`scorer` (tests) replaces the model: (list of (query, text) pairs) → scores.
|
| 90 |
-
Fail-open: any scoring error keeps the incoming (RRF-fused) order.
|
| 91 |
-
"""
|
| 92 |
-
if len(pointers) <= 1:
|
| 93 |
-
return pointers[:k]
|
| 94 |
-
from index.embed import load_glosses
|
| 95 |
-
|
| 96 |
-
glosses = load_glosses()
|
| 97 |
-
pairs = [(query, rerank_text(p, glosses)) for p in pointers]
|
| 98 |
-
try:
|
| 99 |
-
scores = (scorer or _score_pairs)(pairs)
|
| 100 |
-
except Exception as exc: # noqa: BLE001 — reranking must never break retrieval
|
| 101 |
-
print(f"[rerank] scoring failed ({type(exc).__name__}: {exc}); keeping fused order")
|
| 102 |
-
return pointers[:k]
|
| 103 |
-
order = sorted(range(len(pointers)), key=lambda i: scores[i], reverse=True)
|
| 104 |
-
return [pointers[i] for i in order[:k]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -8,12 +8,13 @@ interleaved strongest-first into the top-k, and the answering model judges
|
|
| 8 |
what is actually relevant.
|
| 9 |
This replaced a global-RRF design whose crowding fixes (a reference channel,
|
| 10 |
reserved seats, distance gates) traded one benchmark regression for another.
|
| 11 |
-
A cross-encoder rerank
|
| 12 |
-
|
|
|
|
| 13 |
|
| 14 |
-
Returns POINTERS (url, anchor, heading path, ...)
|
| 15 |
-
|
| 16 |
-
|
| 17 |
"""
|
| 18 |
|
| 19 |
from __future__ import annotations
|
|
@@ -157,7 +158,6 @@ def retrieve(
|
|
| 157 |
pool: int = 20,
|
| 158 |
debug: bool = False,
|
| 159 |
kind: str | None = None,
|
| 160 |
-
rerank_fn=None,
|
| 161 |
) -> list[dict[str, Any]]:
|
| 162 |
"""Top-k pointers for a query, drawn from per-kind pools.
|
| 163 |
|
|
@@ -167,16 +167,16 @@ def retrieve(
|
|
| 167 |
(e.g. `scaled_dot_product_attention`) that dense similarity misses.
|
| 168 |
|
| 169 |
Candidates farther than RELEVANCE_GAP from the best hit are dropped, then
|
| 170 |
-
the pools are interleaved (strongest pool first)
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
| 175 |
|
| 176 |
`kind` restricts the search to one content space — the agent's planner
|
| 177 |
sets it when it decides the question needs the reference catalog rather
|
| 178 |
-
than tutorial prose.
|
| 179 |
-
forces the rerank path on.
|
| 180 |
"""
|
| 181 |
from contextlib import nullcontext
|
| 182 |
|
|
@@ -236,21 +236,11 @@ def retrieve(
|
|
| 236 |
for ck in ranked[:3]:
|
| 237 |
print(f"[debug] {pointers[ck]['heading_path']} | {pointers[ck]['url']}")
|
| 238 |
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
# rerank reads a slate wider than k; round-robin order is prefix-stable, so
|
| 242 |
-
# with reranking off (or failing open) the first k match today's behavior
|
| 243 |
-
use_rerank = rerank_fn is not None or rerank_mod.enabled()
|
| 244 |
-
slate = max(k, rerank_mod.RERANK_SLATE) if use_rerank else k
|
| 245 |
-
ranked = _interleave_pools(pools, dists, slate)
|
| 246 |
-
|
| 247 |
-
if use_rerank and len(ranked) > 1:
|
| 248 |
-
rerank_fn = rerank_fn or rerank_mod.rerank
|
| 249 |
-
ranked = [p["chunk_key"] for p in rerank_fn(query, [pointers[ck] for ck in ranked], k=k)]
|
| 250 |
|
| 251 |
# exact API lookup: if the user typed a precise symbol and its reference
|
| 252 |
# page was found, pin it first — the docs-search behavior users expect
|
| 253 |
-
# (an explicit identifier outranks
|
| 254 |
if symbol:
|
| 255 |
exact = next(
|
| 256 |
(ck for kd, pl in pools for ck in pl if is_exact_api(pointers[ck], symbol)),
|
|
|
|
| 8 |
what is actually relevant.
|
| 9 |
This replaced a global-RRF design whose crowding fixes (a reference channel,
|
| 10 |
reserved seats, distance gates) traded one benchmark regression for another.
|
| 11 |
+
(A cross-encoder rerank stage once reordered the fused slate; the 2026-07-10
|
| 12 |
+
ablation on real content measured it at +0.02 recall / −0.005 MRR and it was
|
| 13 |
+
removed — the fused RRF order is the final order.)
|
| 14 |
|
| 15 |
+
Returns POINTERS (url, anchor, heading path, content, ...). The caller reads
|
| 16 |
+
each pointer's stored content directly (index/hydrate.py). This is the engine
|
| 17 |
+
behind the agent's `search_docs` tool.
|
| 18 |
"""
|
| 19 |
|
| 20 |
from __future__ import annotations
|
|
|
|
| 158 |
pool: int = 20,
|
| 159 |
debug: bool = False,
|
| 160 |
kind: str | None = None,
|
|
|
|
| 161 |
) -> list[dict[str, Any]]:
|
| 162 |
"""Top-k pointers for a query, drawn from per-kind pools.
|
| 163 |
|
|
|
|
| 167 |
(e.g. `scaled_dot_product_attention`) that dense similarity misses.
|
| 168 |
|
| 169 |
Candidates farther than RELEVANCE_GAP from the best hit are dropped, then
|
| 170 |
+
the pools are interleaved (strongest pool first) and the first k are the
|
| 171 |
+
result. An exact-symbol match is pinned first, docs-search style. (A
|
| 172 |
+
cross-encoder rerank stage sat here until the 2026-07-10 ablation on real
|
| 173 |
+
content measured it at +0.02 recall / −0.005 MRR — no earned keep for its
|
| 174 |
+
~90MB model and per-query cost — so it was removed; the fused RRF order is
|
| 175 |
+
the final order.)
|
| 176 |
|
| 177 |
`kind` restricts the search to one content space — the agent's planner
|
| 178 |
sets it when it decides the question needs the reference catalog rather
|
| 179 |
+
than tutorial prose.
|
|
|
|
| 180 |
"""
|
| 181 |
from contextlib import nullcontext
|
| 182 |
|
|
|
|
| 236 |
for ck in ranked[:3]:
|
| 237 |
print(f"[debug] {pointers[ck]['heading_path']} | {pointers[ck]['url']}")
|
| 238 |
|
| 239 |
+
ranked = _interleave_pools(pools, dists, k)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
|
| 241 |
# exact API lookup: if the user typed a precise symbol and its reference
|
| 242 |
# page was found, pin it first — the docs-search behavior users expect
|
| 243 |
+
# (an explicit identifier outranks the fused ranking)
|
| 244 |
if symbol:
|
| 245 |
exact = next(
|
| 246 |
(ck for kd, pl in pools for ck in pl if is_exact_api(pointers[ck], symbol)),
|
|
@@ -5,21 +5,13 @@ and agent/translate.py caches default-path translations. Both are deliberate
|
|
| 5 |
in production — state must be shared across requests — but they would leak
|
| 6 |
between tests: a model named "model-a" cooled down by one test would be
|
| 7 |
silently skipped in the next.
|
| 8 |
-
|
| 9 |
-
The cross-encoder reranker (on by default in production) is switched off for
|
| 10 |
-
the unit suite — no test may download a 90MB model. Tests that exercise the
|
| 11 |
-
rerank path inject a fake scorer via retrieve(rerank_fn=...) instead.
|
| 12 |
"""
|
| 13 |
|
| 14 |
-
import os
|
| 15 |
-
|
| 16 |
import pytest
|
| 17 |
|
| 18 |
from agent.llm import reset_cooldowns
|
| 19 |
from agent.translate import _translate_default
|
| 20 |
|
| 21 |
-
os.environ.setdefault("TORCHDOCS_RERANK", "0")
|
| 22 |
-
|
| 23 |
|
| 24 |
@pytest.fixture(autouse=True)
|
| 25 |
def _reset_shared_llm_state():
|
|
|
|
| 5 |
in production — state must be shared across requests — but they would leak
|
| 6 |
between tests: a model named "model-a" cooled down by one test would be
|
| 7 |
silently skipped in the next.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"""
|
| 9 |
|
|
|
|
|
|
|
| 10 |
import pytest
|
| 11 |
|
| 12 |
from agent.llm import reset_cooldowns
|
| 13 |
from agent.translate import _translate_default
|
| 14 |
|
|
|
|
|
|
|
| 15 |
|
| 16 |
@pytest.fixture(autouse=True)
|
| 17 |
def _reset_shared_llm_state():
|
|
@@ -1,106 +0,0 @@
|
|
| 1 |
-
"""The rerank stage — scoring text, ordering, fail-open, and retrieve() wiring."""
|
| 2 |
-
|
| 3 |
-
from index.rerank import enabled, rerank, rerank_text
|
| 4 |
-
from index.retrieve import retrieve
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
def _ptr(key, url="https://docs.pytorch.org/docs/stable/generated/torch.nn.Linear.html",
|
| 8 |
-
title="Linear", heading="Linear", kind="api"):
|
| 9 |
-
return {
|
| 10 |
-
"chunk_key": key, "url": url, "anchor": "", "page_title": title,
|
| 11 |
-
"heading_path": heading, "library": "core", "kind": kind,
|
| 12 |
-
"source_link": "", "part": 0,
|
| 13 |
-
}
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
def test_enabled_reads_env_per_call(monkeypatch):
|
| 17 |
-
monkeypatch.setenv("TORCHDOCS_RERANK", "0")
|
| 18 |
-
assert not enabled()
|
| 19 |
-
monkeypatch.setenv("TORCHDOCS_RERANK", "1")
|
| 20 |
-
assert enabled()
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
def test_rerank_text_includes_symbol_title_heading_and_gloss(monkeypatch):
|
| 24 |
-
url = "https://docs.pytorch.org/docs/stable/generated/torch.nn.Linear.html"
|
| 25 |
-
glosses = {url: "The standard fully-connected layer applying a weight matrix and bias."}
|
| 26 |
-
text = rerank_text(_ptr("a", url=url), glosses)
|
| 27 |
-
assert "torch.nn.Linear" in text # symbol from the url
|
| 28 |
-
assert "Linear" in text
|
| 29 |
-
assert "fully-connected layer" in text # the gloss is the semantic payload
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
def test_rerank_orders_by_score_and_cuts_to_k(monkeypatch):
|
| 33 |
-
monkeypatch.setattr("index.embed.load_glosses", lambda: {})
|
| 34 |
-
ptrs = [_ptr("low"), _ptr("high"), _ptr("mid")]
|
| 35 |
-
scores = {"low": 0.1, "high": 0.9, "mid": 0.5}
|
| 36 |
-
|
| 37 |
-
def scorer(pairs):
|
| 38 |
-
# pairs align with ptrs order; look scores up by position
|
| 39 |
-
return [scores[p["chunk_key"]] for p in ptrs]
|
| 40 |
-
|
| 41 |
-
out = rerank("q", ptrs, k=2, scorer=scorer)
|
| 42 |
-
assert [p["chunk_key"] for p in out] == ["high", "mid"]
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
def test_rerank_fails_open_to_the_incoming_order(monkeypatch):
|
| 46 |
-
monkeypatch.setattr("index.embed.load_glosses", lambda: {})
|
| 47 |
-
|
| 48 |
-
def broken(pairs):
|
| 49 |
-
raise RuntimeError("model exploded")
|
| 50 |
-
|
| 51 |
-
ptrs = [_ptr("first"), _ptr("second"), _ptr("third")]
|
| 52 |
-
out = rerank("q", ptrs, k=2, scorer=broken)
|
| 53 |
-
assert [p["chunk_key"] for p in out] == ["first", "second"]
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
def test_rerank_single_candidate_skips_scoring():
|
| 57 |
-
# no glosses/model needed — must not even build pairs
|
| 58 |
-
out = rerank("q", [_ptr("only")], k=8, scorer=None)
|
| 59 |
-
assert [p["chunk_key"] for p in out] == ["only"]
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
# --- retrieve() wiring -------------------------------------------------------
|
| 63 |
-
|
| 64 |
-
from tests.index.test_retrieve import FakeConn, _row # noqa: E402 — shared fakes
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
def test_retrieve_feeds_a_wide_slate_to_the_reranker_and_keeps_its_order():
|
| 68 |
-
refs = [_row(f"ref{i}", kind="api", dist=0.2 + i / 100) for i in range(10)]
|
| 69 |
-
conn = FakeConn([refs, [], [], [], [], []])
|
| 70 |
-
seen = {}
|
| 71 |
-
|
| 72 |
-
def fake_rerank(query, pointers, k):
|
| 73 |
-
seen["slate"] = len(pointers)
|
| 74 |
-
return list(reversed(pointers))[:k] # deliberately invert the fused order
|
| 75 |
-
|
| 76 |
-
results = retrieve(
|
| 77 |
-
"descriptive question", k=3, conn=conn,
|
| 78 |
-
embed_fn=lambda q: [0.0] * 384, rerank_fn=fake_rerank,
|
| 79 |
-
)
|
| 80 |
-
assert seen["slate"] == 10 # the whole fused slate, not just top-k
|
| 81 |
-
assert [r["chunk_key"] for r in results] == ["ref9", "ref8", "ref7"]
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
def test_retrieve_skips_reranker_when_disabled(monkeypatch):
|
| 85 |
-
monkeypatch.setenv("TORCHDOCS_RERANK", "0")
|
| 86 |
-
refs = [_row(f"ref{i}", kind="api", dist=0.2) for i in range(4)]
|
| 87 |
-
conn = FakeConn([refs, [], [], [], [], []])
|
| 88 |
-
results = retrieve("q", k=2, conn=conn, embed_fn=lambda q: [0.0] * 384)
|
| 89 |
-
# fused order preserved — no reranker touched it (none injected, env off)
|
| 90 |
-
assert [r["chunk_key"] for r in results] == ["ref0", "ref1"]
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def test_exact_symbol_pin_outranks_the_reranker():
|
| 94 |
-
exact = _row("exact", kind="api", url="https://docs.pytorch.org/docs/stable/generated/torch.optim.SGD.html")
|
| 95 |
-
refs = [_row(f"ref{i}", kind="api", dist=0.2) for i in range(3)]
|
| 96 |
-
# query pools: dense, keyword, then the symbol channel returns the exact page
|
| 97 |
-
conn = FakeConn([refs, [], [], [], [], [], [exact]])
|
| 98 |
-
|
| 99 |
-
def fake_rerank(query, pointers, k):
|
| 100 |
-
return [p for p in pointers if p["chunk_key"] != "exact"][:k] # reranker demotes it
|
| 101 |
-
|
| 102 |
-
results = retrieve(
|
| 103 |
-
"how do I use torch.optim.SGD?", k=3, conn=conn,
|
| 104 |
-
embed_fn=lambda q: [0.0] * 384, rerank_fn=fake_rerank,
|
| 105 |
-
)
|
| 106 |
-
assert results[0]["chunk_key"] == "exact" # the pin wins anyway
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|