5 · Retrieval¶
Chapter 4 finished with two directories of files. This chapter turns them back into answers: what runs at query time, what it costs, and exactly what the measurements do — and do not — say.
The export is inert. corpus.parquet holds the chunk text, its 768-dim embedding and the
four denormalised KG columns; bm25-stats.json + bm25-postings.parquet hold the lexical
signal; graph-edges.parquet and graph-hyperedges.kv hold the graph. Nothing about that
is a retrieval engine, and the framework does not ship one. latence-retrieval holds no
index, opens no connection of its own and runs no first-stage search: it composes over
candidates your engine returned (ADR-0048).
That constraint is what makes the whole chapter portable — the composition below runs
identically on Qdrant, on a brute-force numpy scan, or on whatever your organisation
already pays for.
There are zero LLM calls anywhere on this path. Every signal is a precomputed column, a graph traversal, or a deterministic solver.
5.1 · The shape¶
The production composition is one class —
latence_retrieval.rescue.GraphRescueRetrieval — and four phases.
text · vector · seed_node_ids] DE[(dense backend
Qdrant · brute force · yours)] BM[(bm25 backend)] GR[(GraphRetriever
graph-edges.parquet)] HK[(HyperedgeKV
graph-hyperedges.kv)] end subgraph Pipeline["GraphRescueRetrieval — policy and composition only"] direction TB BASE["1 · BASE
RRF over dense + BM25 — those two only
then max-normalise to 1.0"] GATE{"gate
graph_mode
off · auto · force"} RES["2 · RESCORE
Expander: bounded 1-hop bonus
off the chunk's own KG columns"] RCU["3 · RESCUE
traversal + hypergraph, fused with
EACH OTHER, then APPENDED and deduped"] SEL["4 · SELECT
Packer knapsack · CoE · none"] end QB --> BASE DE --> BASE BM --> BASE BASE --> GATE GATE -->|"not fired"| SEL GATE -->|"fired"| RES RES --> RCU GR --> RCU HK --> RCU RCU -->|"base order untouched"| SEL SEL --> OUT[["RescueResult
candidates · GateDecision
rescued count · tokens spent"]]
Read the arrow labelled base order untouched as the thesis of the chapter. The earlier design ran graph traversal as one more retrieval leg and RRF-fused it with dense and BM25; that was measured to lower recall on every dataset of the matrix. The components were fine. The application was wrong, and the fix is an asymmetry: dense and BM25 are peers and fuse; the graph lanes are not peers and append.
5.2 · Phase 1 — BASE¶
Two first-stage fetches at pool_k: 100 each, fused by Reciprocal Rank Fusion, then
max-normalised.
# packages/latence-retrieval/src/latence_retrieval/rescue.py:666
rankings = [list(ranking) for ranking in (dense, bm25) if ranking]
if not rankings:
return []
return _unit_scaled(self._fuser.fuse(rankings))
RrfFuser scores each candidate Σ wᵢ/(k + rankᵢ) with k = 60 — rank position only, no
score magnitude — which is why a cosine list and an Okapi list combine without their
incomparable scales distorting each other. Ties break on ascending id, and the emitted
candidate keeps the first-seen text/metadata in input-list order. That last detail is
load-bearing and reappears in §5.9.
The max-normalisation (_unit_scaled, rescue.py:1091) divides every score by the set
maximum. It is strictly monotone, so the base ordering is unchanged — it is a change of
units, not of ranking. It exists because the next phase adds absolute bonuses: an RRF
score sits near 0.033 at rank 0, so an un-normalised hop bonus of 0.5 would let a single
graph link outvote the entire base list. On the unit scale the base's own rank-to-rank gap
near the head is about 0.016, and the shipped hop bonus is 0.05 — enough to move a
corroborated candidate a few ranks, never enough to vault the head.
Both legs are optional in principle (at least one is required, or construction fails), and
a bundle with no vector simply drops the dense leg.
5.3 · Phase 2 — RESCORE¶
Expander is a plain CandidateProcessor: (query, candidates) → candidates. It runs
no graph database. Everything it needs is already on the chunk, put there by chapter 4's
enrich stage:
| Column | What it holds | What the Expander does with it |
|---|---|---|
kg_node_ids |
the chunk's own entity node ids | co-mention link; also the doc-derived traversal seeds |
neighbor_node_ids |
node ids the chunk's triples reach | 1-hop edge link, checked in both directions |
kg_node_community |
community id per entity | flat shared-community bonus |
kg_node_centrality |
centrality float per entity | weight × peak centrality (the per-entity list is reduced to its max) |
It takes the top seed_top_k candidates by first-stage score as the graph anchor, then
gives every candidate hop_weight × (number of distinct seeds it is 1-hop-linked to), plus
the optional community and centrality bonuses, and re-sorts. A link is a KG edge
(a.nodes & b.reach or b.nodes & a.reach) or a shared entity node (a.nodes & b.nodes)
— expand.py:240.
The rescue pipeline overrides the Expander's own library defaults with its measured operating point, which is worth stating because the two differ:
| Knob | Expander default |
What GraphRescueRetrieval passes |
|---|---|---|
seed_top_k |
3 | 5 |
hop_weight |
0.5 | 0.05 |
community_weight |
0.0 (off) | 0.01 |
centrality_weight |
0.0 (off) | 0.05 |
A candidate carrying none of these columns earns nothing and the transform is an
order-preserving no-op. A column that is present but malformed fails loudly at the
boundary — the read contract has one home, latence_retrieval.kg_columns, and every
consumer (Expander, Packer, seed harvest) runs that same code.
5.4 · Phase 3 — RESCUE, and why it appends¶
Two graph lanes run, both anchored on the same seed set:
- traversal —
GraphRetriever, aRetrievalBackenddeclaringGRAPH. It asks aGraphSourcefor the seeds' bounded neighbourhood (defaultmax_hops: 2), resolves reached nodes back to chunks through aGraphDocumentSource, and scores each chunkseed_score × hop_decay ** hat its closest hop (defaults1.0and0.5). - hypergraph —
hypergraph_signalsover the emittedgraph-hyperedges.kv: entry from the query's alias n-grams (weight 1.0) and the seed node ids (weightalpha), expansion, ans_hypprune, two-stage random-walk-with-restart, then a vertex/coverage gate. Retained Hyperedges, ordered by descending ρ with an id tiebreak, resolve to chunks through the caller'sHyperedgeDocumentSource.
The seeds come from the query and the top first-stage documents — query seeds first, so the cap trims the weakest anchors rather than an arbitrary set:
# rescue.py:775 — _graph_seeds
seeds: list[str] = list(dict.fromkeys(query_seeds))
seen = set(seeds)
for candidate in base[: self._seed_docs_k]: # 5
for node in _node_ids(candidate): # the candidate's own kg_node_ids
if node not in seen:
seen.add(node); seeds.append(node)
return tuple(seeds[: self._seed_node_cap]) # 32
The two lanes are peers of each other — both are graph reach over the same KG — so RRF
between them is the same legitimate move as dense+BM25 (rescue.py:798). What never happens
is fusing that result into the base. This is the mechanism, verbatim:
# rescue.py:1071 — _append_and_dedupe
seen = {candidate.id for candidate in base}
ordered: list[tuple[Candidate, bool]] = [(candidate, False) for candidate in base]
for candidate in rescue:
if candidate.id in seen:
continue
seen.add(candidate.id)
ordered.append((candidate, True))
if len(ordered) - len(base) >= cap: # rescue_cap: 50
break
return [
candidate.model_copy(update={
"score": 1.0 / (k + rank), # rank_score_k: 60
"metadata": {**candidate.metadata, "rescued": rescued},
})
for rank, (candidate, rescued) in enumerate(ordered)
]
Four invariants live in those fifteen lines:
- The base list's relative order is copied unchanged.
- A rescue candidate already in the base is dropped, never re-ranked to its rescue position. Rescue can add, it can never promote.
- What remains is appended below the whole base, capped at 50 — a tail, not a second ranking.
- Every candidate is rescored by its position as
1/(60 + rank). The score now encodes the order instead of competing with it, and it decays in the same slow shape the base already had — so a candidate deep in the pool keeps enough value for the packer to consider instead of the greedy stalling at a fraction of the budget. Themetadata["rescued"]stamp is what lets a selector, an operator or an evaluation tell what the base earned from what the graph carried in.
Why fusing would be wrong, stated as a mechanism rather than a preference. RRF is
rank-only. A traversal list is dense at the top by construction — every chunk mentioning a
seed entity scores exactly seed_score, ordered by id — so its rank-1 entry is an
arbitrary member of a large tie class, and at equal weight it contributes the same
1/61 as the strongest dense hit. Fusing therefore lets a chunk that merely mentions a
query entity displace a chunk the dense and lexical stages both chose. Appending removes
the competition entirely: the graph can only occupy positions no base candidate wanted.
5.5 · The gate¶
Paying for a traversal on a query that does not need one is pure latency, and on a
known-item query it is actively harmful. graph_mode decides:
# rescue.py:739 — _decide, graph_mode="auto"
reasons: list[str] = []
if lowered & self._relation_cues: reasons.append("relation-cue")
if len(query_seeds) >= self._entity_heavy_seeds: reasons.append("entity-heavy")
if agreement < self._low_agreement: reasons.append("low-agreement")
if confidence < self._low_confidence: reasons.append("low-confidence")
navigational = (
len(words) <= self._navigational_max_words
and "relation-cue" not in reasons
and agreement >= self._high_agreement
)
| Signal | Fires when | Default |
|---|---|---|
relation-cue |
the query carries a relation word (why, between, caused, depends, compare, …) |
the shipped 22-word lexicon |
entity-heavy |
the query itself names ≥ n linked entities | 2 — one entity is a look-up, two is a question about how they stand to each other |
low-agreement |
Jaccard overlap of the dense and BM25 top-k ids | top-10 < 0.30 |
low-confidence |
dense top-1 score | < 0.60 — the one corpus-dependent knob |
veto navigational |
≤ 6 words, no relation cue, and the two routes agree ≥ 0.50 | overrides everything that fired |
agreement is Jaccard, and it is 0.0 when either list is empty: a route that returned
nothing agrees with nobody, which is a low-confidence signal, not a perfect one
(top_k_agreement, rescue.py:1007).
off and force do not erase the policy's opinion — they record what auto would have
decided (vetoed_by: "mode-off", reason mode-force). A mode that threw that away could
not distinguish a gate that is too tight from a lane that is too weak. Every decision comes
back on the result as a frozen GateDecision, so the hit rate is a number you report.
Measured, graph_mode: auto, full query sets:
| dataset | queries | fired | hit rate | entity-heavy |
low-agreement |
relation-cue |
vetoed |
|---|---|---|---|---|---|---|---|
| vidoseek | 1,142 | 1,132 | 0.9912 | 1,104 | 532 | 67 | 0 |
| ohr_bench | 4,541 | 4,541 | 1.0000 | 4,535 | 2,794 | 652 | 0 |
| uda | 8,583 | 8,583 | 1.0000 | 8,583 | 8,202 | 1,233 | 0 |
| multihop_rag | 2,255 | 2,255 | 1.0000 | 2,255 | 1,340 | 1,049 | 0 |
| wiki2multihop | 12,576 | 12,573 | 0.9998 | 12,575 | 10,504 | 881 | 3 |
| musique | 1,209 | 1,208 | 0.9992 | 1,185 | 1,089 | 64 | 0 |
| hotpotqa | 3,703 | 3,703 | 1.0000 | 3,701 | 2,906 | 359 | 0 |
The honest reading: on benchmark corpora the gate is nearly a no-op. These are QA
datasets — every query names entities and asks a relation — so entity-heavy fires almost
everywhere and three queries in 33,000 were vetoed as navigational. The gate is built for
the traffic mix a real deployment has, where "acme msa 2024 pdf" and "q3 revenue" are a
large share of queries; this matrix simply contains none of them. Do not read a 100% hit
rate as evidence the gate works — read it as evidence the corpus does not exercise it, and
measure your own.
5.6 · Phase 4 — SELECT¶
The union is handed to one of three selectors. Two of them spend a token budget, which is the only place in the pipeline where a knob maps directly onto an LLM bill.
packed — the knapsack QKP (Packer). Maximises
by a deterministic greedy over marginal-value-per-token, ties on ascending id. value is
the max-scaled first-stage score plus pack_centrality_weight: 0.25 × the chunk's peak
kg_node_centrality — the one graph feature that reaches selection as a value rather than
as an ordering. Redundancy precedence is: an explicit similarity_key entry, else equal
cluster_key, else lexical Jaccard over the texts.
The greedy stops when every remaining marginal is ≤ 0. Measured, that stranded ~45% of a
10,000-token budget — and on a retrieval bench unspent budget is unrecovered recall. Hence
the shipped fill: "backfill":
# packages/latence-retrieval/src/latence_retrieval/pack.py:434
if self._fill == "backfill":
by_value = sorted(
(index for index in range(len(items)) if index not in chosen_set),
key=lambda index: (-values[index], items[index].id),
)
for index in by_value:
if costs[index] > remaining:
continue
chosen.append(index); chosen_set.add(index); remaining -= costs[index]
Backfill never touches the greedy phase's picks or their order — it only appends into
budget the QKP declined to spend. It is safe precisely because the pool is already
query-conditioned by the first stage: these are not arbitrary chunks, they are the ones two
retrievers already voted for. fill: "stop" restores pure QKP semantics if you want the
precision-per-token point.
coe — chain-of-evidence (coe.select). CoE selects Hyperedges, not arbitrary chunks,
so it cannot rank the union directly. The composition therefore lets CoE decide which of the
rescue candidates are evidence worth budget, and fills the rest with the base in its own
rank order:
# rescue.py:928 — _coe_select
for candidate in pool:
if candidate.metadata.get("rescued", False) and candidate.id not in allowed:
continue
cost = max(_TOKENS.count(candidate.text), 1)
if spent + cost > budget:
continue
selected.append(candidate); spent += cost
Choosing coe without a configured hypergraph lane is a loud error at construction and at
select(): a pipeline that never propagated any Hyperedges has nothing for it to buy.
none returns the ranked union unchanged — for callers running their own downstream
selection, and for like-for-like recall@k against plain rankings.
Token cost is measured by a TokenCounter seam. The default HeuristicTokenCounter is
pure-Python (word pieces plus standalone punctuation) and needs no download;
TiktokenCounter gives an exact count against a named encoding, and no encoding is
pinned — you must name one (ADR-0012).
5.7 · Running it¶
The three entry points¶
run(bundle) is the one-shot production call. Underneath, the pipeline is two halves, and
run(bundle) is select(bundle.text, retrieve(bundle)), byte for byte:
retrieval = pipeline.retrieve(bundle) # expensive: 2 fetches, traversal, propagation
for budget in (4_000, 10_000, 16_000):
result = pipeline.select(bundle.text, retrieval, budget=budget) # cheap, pure
coe = pipeline.select(bundle.text, retrieval, selector="coe") # same union
The split is what makes a budget or selector A/B honest and affordable: every operating
point is settled against the identical candidate union, so the comparison measures the
selector rather than N slightly different retrievals. RescueRetrieval — the handoff — is
frozen and carries base, union, gate, rescued and the hypergraph signals (which is
exactly why the CoE selector can still work after the split).
Config, grouped by phase¶
Every default below is the measured operating point. Unknown keys are rejected at
construction against _CONFIG_KEYS (rescue.py:147), so a typo is a loud error rather than
a silently-ignored knob.
| Phase | Keys (default) |
|---|---|
| gate | graph_mode (auto), relation_cues (shipped lexicon), entity_heavy_seeds (2), agreement_k (10), low_agreement (0.30), high_agreement (0.50), low_confidence (0.60), navigational_max_words (6) |
| base | pool_k (100) |
| rescore | seed_top_k (5), hop_weight (0.05), community_weight (0.01), centrality_weight (0.05) |
| rescue | seed_docs_k (5), seed_node_cap (32), rescue_cap (50), rank_score_k (60), hyperedge_kv_path (None) |
| select | selector (packed), budget (10,000 packed / 16,000 coe), select_pool_k (100), pack_centrality_weight (0.25), similarity_key (redundancy), fill (backfill) |
The flat reference table, the wizard mapping and the campaign.yaml block live in the
graph-rescue retrieval guide.
The serving path, and what makes it equivalent to the benchmark path¶
The benchmark's dense leg is a brute-force cosine over a resident matrix. Production serves
it through Qdrant. Neither the composition nor the phases change — both are the same
GraphRescueRetrieval, and the only variable is which object satisfies the
RetrievalBackend protocol:
served = QdrantBackend({
"collection": "wiki2multihop-dot", "capabilities": ["dense", "filter"],
"dimension": 768, "url": "http://localhost:6333",
"search_params": {"hnsw_ef": 512},
})
pipeline = GraphRescueRetrieval(config, dense=served, bm25=..., graph=..., ...)
Two details make the substitution total rather than approximate. QdrantBackend reads the
chunk text from payload key content and the corpus identity from payload key record_id
— the framework's own export column names, which latence-sink-qdrant writes verbatim — so
Candidate.id is still the corpus id. Everything downstream (the fuser's dedup,
append-and-dedupe, hyperedge→chunk resolution, gold scoring) keys on that. A payload missing
the configured key is a loud BackendSearchError, never a silently empty Candidate.text.
Measured head-to-head, same queries, same gold, selector: "none" so nothing masks a
first-stage delta (benchmark/serving/results/*.summary.json):
| dataset | metric | brute-force reference | served via Qdrant | delta |
|---|---|---|---|---|
| wiki2multihop (12,576 q) | R@10 | 0.708129 | 0.707851 | −0.000278 |
| wiki2multihop | docR@10 | 0.718333 | 0.718054 | −0.000278 |
| multihop_rag (2,255 q) | R@10 | 0.375756 | 0.375756 | 0.0 |
| multihop_rag | docR@10 | 0.854176 | 0.854176 | 0.0 |
Dense-lane ANN recall@10 against the exact top-10 is 0.999491 / 1.0; gate decisions agree on
100% of queries on both. Served latency for the whole pipeline, single node over HTTP, one
query at a time: wiki2multihop median 82.6 ms / p95 213.4 ms; multihop_rag median 42.2 ms /
p95 83.1 ms. hnsw_ef is the knob — at Qdrant's default it drops to R@10 −0.0125 on a
200-query wiki2multihop sample, at 512 it is exact to the fourth decimal. Full runbook:
serving with Qdrant.
5.8 · How it was measured¶
The leg roster¶
Ten legs over seven datasets, 70 cells, every query, no --limit. Three families, and the
family determines whether a leg is on the Δ chain (benchmark/s5/legs.py:208):
| Leg | Family | What it computes | What it isolates |
|---|---|---|---|
dense |
ladder | brute-force cosine over corpus.parquet:embedding (content + the chapter-4 context header) |
the dense floor |
bm25 |
ladder | dense ⊕ BM25, equal-weight RRF |
what the lexical signal adds — the real hybrid baseline |
kg |
ladder | + GraphRetriever traversal as a fused peer |
the graph as a co-equal RRF list |
hyperedges |
ladder | + hypergraph_signals chunks as a fused peer |
the hypergraph lane, same wrong application |
coe |
ladder | + coe.select output as a fused peer, budget 4,000 |
chain-of-evidence as a fused list |
packer |
ladder | fuses what coe fuses, then Packer at budget 4,000 |
selection on top of the fused-graph ranking |
dense_content_only |
ablation | dense re-run against corpus-vectors-content-only.parquet |
the chapter-4 context header, alone. Only the corpus side changes — queries carry no header, so both legs rank the identical query vectors |
graph_rescue |
rescue | the shipped component, selector: "none" |
the ranked union — comparable to the ranking rungs at recall@k |
graph_rescue_packed |
rescue | same retrieval, selector: "packed", headline 10,000 |
selection vs packer: same selector, correct application |
graph_rescue_coe |
rescue | same retrieval, selector: "coe", headline 16,000 |
the recall-side selector |
The four ablations that matter:
dense_content_onlyvsdense— the context header's contribution, which would otherwise be an invisible advantage folded into every dense number, and an uneven one: the three S3 corpora carry populated headers while the reused musique/hotpotqa exports carrycontext_header: nullon every row. (Those two are recordedstatus: "missing-inputs"for this leg — the sidecar was never built — never as a zero.)kg/hyperedgesvsbm25— the graph signals applied as fused peers. This is the regression the whole architecture exists to remove, and it is kept in the table rather than deleted.packervsgraph_rescue_packed— the samePackerover two different candidate unions. Isolates selection from the ranking it selects over.graph_rescuevsbm25— see §5.9, where this one gets its exact reading.
A rescue leg refuses to run through the ladder's fuse path at all
(legs.py:779): returning [dense, bm25] for it would be the base stage wearing the rescue
leg's name, and the cell would read as "graph rescue bought nothing".
How the metrics are actually computed¶
Gold is a set of chunk ids, not one relevant document, because that is what the datasets provide after resolution — a 2Wiki question has two supporting paragraphs, a ViDoSeek question a page's worth of chunks, a MultiHop-RAG question up to four articles.
# benchmark/s5/metrics.py:54
def recall_at_k(ranked, gold, k):
if not gold:
return 0.0
return len(gold & set(ranked[:k])) / len(gold)
recall@kis set recall, so a query whose page split into three chunks is not counted as solved by retrieving one of them. The published ViDoSeek/OHR-Bench baselines score at page level — the same quantity when a page is one chunk, and a strictly harder one when it is not.nDCG@10uses binary gains with the ideal ranking capped atmin(|gold|, 10), so a query with 30 gold chunks cannot score below one with 2 purely for having more gold.doc_recall@{5,10}is the same set recall after both sides are deduplicated to source documents, first occurrence wins — a document takes the position of its best-ranked chunk. A chunk with no known document is dropped rather than mapped to a placeholder, because a placeholder would merge every unknown chunk into one fake document and inflate the number in the direction nobody checks. It exists because the de-facto comparison line (HippoRAG-2 reports passage recall@5) is a document quantity, and ten retrieved chunks cover far fewer than ten distinct documents.chain_coverage@10is the fraction of a gold chain's slots with ≥ 1 chunk in the top-10;full_chain_hit@10is 1 only when every slot is covered — the metric that distinguishes a multi-hop retriever from a lucky single-hop one. Both areNonewhere the dataset publishes no chain, never 0.0.- The document view is derived in the harness, from the export, not by the legs, so every
leg's number is deduplicated by the identical map (
run_matrix.py:505). - Latency for a rescue curve point is
retrieve_ms + this budget's select_ms— the retrieval is paid once per query, so no curve point looks free.
The determinism contract, and the finding behind it¶
# benchmark/s5/run_matrix.py:159
THREADS_REFUSAL = (
f"pin {' = '.join(THREAD_VARS)} = {PINNED_THREADS} (the dense leg's BLAS gemv and the query "
"embedder reassociate their float sums per thread count, which changes rankings: measured, "
"33/1209 musique queries get a different dense pool at 4 threads vs 1)."
)
The harness refuses to run unless PYTHONHASHSEED=0 and OMP_NUM_THREADS,
OPENBLAS_NUM_THREADS, MKL_NUM_THREADS are all 1, at the API entry so the campaign
stage is bound by it too.
That second half joined the contract after a measurement, and it is worth the space because it generalises to anyone benchmarking retrieval. Comparing the pre-optimisation matrix against the re-run, 39 of 46 comparable cells were identical on every quality number and 7 moved in the fourth decimal. The obvious hypothesis — that the vectorised BM25 reassociated float summation and tipped a value on a rounding boundary — was tested and killed:
- The arithmetic cannot reach it. musique/bm25's unrounded
recall@10is 0.3404457170718723, which sits 4.283e-06 below the 0.34045 rounding boundary while the metric's own summation error is 1.1e-15. Six orders of magnitude short. Andtokens_packed_meanisround(S/N, 1)over an integer sum, so float accumulation cannot move it at all — the vidoseek cell that moved requires ≥ 170 tokens of genuinely different packed content. - The ranking code was byte-identical between the two sweeps. Zero functional diff over
DenseBackend,Bm25Backend,_Bm25TermMatrix,fuse_signals,POOL_K,_tokenize,Runner,fuser.py,bm25.py,metrics.py,goldmap.py,embed.py. - The variable was the environment. Flipping only the thread count on current head reproduces it exactly:
| threads | unrounded recall@10 |
reported | unrounded ndcg@10 |
reported |
|---|---|---|---|---|
| 1 | 0.3404457170718723 | 0.3404 | 0.4161434108235836 | 0.4161 |
| 4 | 0.3405835720484370 | 0.3406 | 0.4161918995551152 | 0.4162 |
Same code, same corpus, same 1,209 queries: 33 get a different dense pool, 4 a different
top-10 membership, and exactly one (musique-2hop__67660_158861, |gold| = 6) a
different gold-hit count. 1/(1209 × 6) = 1.37855e-04 — matching the observed difference
to the digit.
So: a real ranking change, not a regression, and not caused by the optimisation work. The
correct reading is 39 of 46 exact and 7 inside the harness's own reproducibility floor of
roughly ±1 query in 1,209 — a floor that was previously undeclared. A benchmark that
lets the thread scheduler edit its results is not a benchmark, and the fix belongs in the
harness rather than in a note asking operators to remember. Twenty further cells are
reported as not comparable because the pre-optimisation run measured them under
--limit 1000 and the re-run measured them whole (hotpotqa 1,000 → 3,703, uda → 8,583,
wiki2multihop → 12,576); a recall over a different query set is a different quantity, and
calling it either a pass or a failure would be a lie about what was measured.
5.9 · What was measured¶
Recall against the baselines¶
recall@10, full query sets, no limit. dense is the baseline the work had to beat;
bm25 is the hybrid rung, included because it is the number a sceptical reader will ask
for.
| dataset | dense |
bm25 (hybrid) |
packer (the old floor) |
graph_rescue |
vs dense |
vs bm25 |
|---|---|---|---|---|---|---|
| vidoseek | 0.9466 | 0.9705 | 0.7538 | 0.9726 | +0.0260 | +0.0021 |
| ohr_bench | 0.5890 | 0.6609 | 0.2793 | 0.6589 | +0.0699 | −0.0020 |
| uda | 0.0594 | 0.1261 | 0.0266 | 0.1276 | +0.0682 (2.1×) | +0.0015 |
| multihop_rag | 0.3328 | 0.3772 | 0.2028 | 0.3759 | +0.0431 | −0.0013 |
| wiki2multihop | 0.6753 | 0.7018 | 0.5446 | 0.7096 | +0.0343 | +0.0078 |
| musique | 0.3353 | 0.3404 | 0.2500 | 0.3404 | +0.0051 | 0.0000 |
| hotpotqa | 0.5576 | 0.5786 | 0.4103 | 0.5786 | +0.0210 | 0.0000 |
Graph rescue beats the dense baseline on all seven datasets, and the regression the
original table recorded is gone: no leg that appends graph evidence lands below dense.
Document recall says the same — doc_recall@10 for graph_rescue is vidoseek 0.9956,
ohr_bench 0.9738, uda 0.4466, multihop_rag 0.8551, wiki2multihop 0.7198, musique 0.6068,
hotpotqa 0.8119, dense-or-better everywhere.
Against the hybrid rung the picture is smaller and two-sided, and pretending otherwise
would be marketing. Graph rescue wins on three datasets, ties on two, and loses in the third
decimal on two. It also trades head precision for depth on some corpora: on ohr_bench
nDCG@10 is 0.5849 against bm25's 0.6137 and recall@1 0.2487 against 0.2796, while on
wiki2multihop it wins full_chain_hit@10 0.4222 against 0.4076. The Expander lifts
graph-corroborated evidence into the top-10 and in doing so reshuffles the top-1.
What the recall numbers actually attribute — read this before quoting them¶
This follows from the code and is verifiable in one run, and it changes how the table above should be read.
With pool_k: 100, a dense leg that returns a full pool makes the fused base 100–200
candidates long. _append_and_dedupe places the entire base first, so the first rescued
candidate sits at rank ≥ 100. Two consequences:
- For
graph_rescue(selector: "none") the ranked list is the union in that order, so no rescued candidate can appear in any top-10. Everyrecall@k (k ≤ 10)andnDCG@10in the rescue rows is a measurement of the base: dense + BM25 fused, max-normalised, and rescored by the Expander off the KG columns. select()truncates tounion[:select_pool_k](rescue.py:878) withselect_pool_k: 100, so on these corpora the selector's pool is also entirely base.
Both are directly observable — with the shipped defaults and 100-candidate engines,
retrieve() returns a 200-long base, a 250-long union, and the first rescued stamp at
index 200; select() selects nothing marked rescued.
That yields a clean attribution nobody has to take on trust. When the gate does not fire,
the base is plain equal-weight RRF over [dense, bm25] with k = 60 — bit-identical to
the bm25 ladder rung, since max-normalisation is monotone. So:
graph_rescue−bm25is exactly the Expander rescore, on the queries where the gate fired: +0.0078 (wiki2multihop), +0.0021 (vidoseek), +0.0015 (uda), 0.0000 (musique, hotpotqa), −0.0013 (multihop_rag), −0.0020 (ohr_bench).
And it explains the rest of the matrix without any further assumption. graph_rescue_coe
reproduces graph_rescue exactly on six of seven datasets, and graph_rescue_packed on
four, because the selectors only ever drop from a pool whose head is the base — the union's
1/(60+rank) scores are strictly decreasing, so the packer's (-score, id) sort preserves
relative order and can never promote. Where they differ (vidoseek, ohr_bench, uda) the budget
binds, which §5.10 measures directly.
So the traversal and hypergraph lanes are architecturally live and measurably inert at k = 10 on this matrix. What they cost is latency.
And what they would buy, now measured: almost nothing¶
The configurations that would un-inert them — select_pool_k > |base|, a k beyond the
base's length, a narrower pool_k, or interleaving instead of appending — were run in ticket
06 over three datasets, against a base arm that reproduces this matrix's graph_rescue
cells to four decimals. All four are refuted, and none of them was the binding constraint.
The bound that settles it is an oracle: promote every rescued gold chunk to the front of the list, which no shippable policy can beat.
| dataset | rescue-only gold per query | queries with any | ceiling Δ recall@10 | perfect rerank of the base |
|---|---|---|---|---|
| multihop_rag (2,255) | 0.142 | 9.8% | +0.0056 | +0.3724 |
| wiki2multihop (2,500) | 0.021 | 2.0% | +0.0077 | +0.0905 |
| vidoseek (1,142) | 0.000 | 0% | +0.0000 | +0.0244 |
On vidoseek the graph lanes reach not one gold chunk the base missed. And the same oracle pointed at the base alone — a perfect rescore of candidates dense+BM25 already retrieved — is worth 66× and 12× the graph's entire ceiling on the two multi-hop corpora. The base retrieves 87% of multihop_rag's gold into its top 100 and ranks 38% into its top 10: a ranking problem wearing a reach problem's clothes.
Two results worth keeping out of that:
- §5.4's append-not-fuse choice is confirmed, not merely defended. Run as a continuum, RRF
weight on the graph lane is monotonically harmful — −0.0187 at
w=0.25, −0.1121 atw=1.00on multihop_rag — and no weight is small enough to help without being small enough to do nothing. Fusing destroys the ranking; appending contains it. That containment is real. - The Expander should not inherit the win.
graph_rescue−bm25is exactly the Expander (above), and it is recall-neutral and nDCG-negative on four of the five datasets where it acts. It trades head precision for a third-decimal recall gain.
The right claim from these numbers is therefore:
Applied as rescue, the graph never lowers recall — which fusing it demonstrably does, by up to −0.11. But the traversal and hypergraph lanes reach almost nothing the base missed, and no configuration of
select_pool_k,k,pool_kor fusion weight converts that into a measurable win. At 221–538 ms/query against BM25's 1.1–8.3 ms, they do not earn their latency. The next unit of retrieval quality is a rescore over the base's own top-100.
Full design, arms and stop rule:
evidence/graph-inertia.md.
Where the architecture does pay — the budget, not the cut¶
The verdict above is about the graph payload: what traversal adds to a ranking. It says nothing about the discipline — appending rather than fusing — and that is a separate, measurable thing.
Compare the two selection legs at an equal 4,000-token budget. packer packs a graph-fused
ranking; graph_rescue_packed packs the append-not-fuse union. Same knapsack, same spend, only the
candidate list differs:
| dataset | chain_coverage@10 | Δ | full_chain_hit@10 | Δ |
|---|---|---|---|---|
| musique | 0.4575 → 0.5905 | +0.1330 | 0.1092 → 0.2266 | +0.1174 |
| hotpotqa | 0.6181 → 0.7960 | +0.1779 | 0.3508 → 0.6130 | +0.2622 |
| multihop_rag | 0.6206 → 0.7506 | +0.1300 | 0.3282 → 0.4736 | +0.1454 |
| wiki2multihop | 0.5547 → 0.7199 | +0.1652 | 0.2417 → 0.4216 | +0.1799 |
Thirteen to twenty-six points of full-chain completion, at identical token spend, on the four corpora that publish a real chain.
This does not contradict the inertia finding — it is its mirror image. Because the rescued
payload reaches almost no gold the base missed, the gain here cannot be the graph adding
evidence; it is graph-fusion damage being removed. The oracle study measured the same effect
from the other direction, as a monotonic decline in the fusion weight down to −0.1121 at w=1.00.
So the honest summary of the graph in this system: its value is negative-avoidance, and it is largest exactly where a budget binds and a chain must complete. Keeping graph scores out of the ranking is worth a great deal; putting them in costs a great deal; and traversing further buys nothing measurable at these pool sizes.
One anomaly, unexplained and not investigated: multihop_rag chain coverage peaks at 4,000
tokens (0.7506) and falls at 8,000 and beyond (0.7136, then 0.7112). More budget makes it worse.
Latency¶
64 cells carry a comparable pre/post latency pair (median of per-query wall clock).
| median speedup | 9.83× |
| maximum | 463.8× (ohr_bench__bm25, 1,303 → 2.81 ms) |
| the one at scale | uda__graph_rescue 6,648 → 646 ms (10.3×) |
uda__hyperedges 5,362 → 79.6 ms (67×), uda__kg 859 → 37.2 ms (23×) |
|
wiki2multihop__bm25 2,058 → 8.3 ms (247×), ohr_bench__kg 1,301 → 3.3 ms (395×) |
|
| cells that got slower | 5, all reported: vidoseek__graph_rescue_packed +15.5 ms, graph_rescue_coe +10.8 ms, graph_rescue +1.7 ms (≈3% on a ~470 ms baseline), and two sub-2 ms dense cells |
uda__graph_rescue_coe is the clearest single result: it has no pre-optimisation
counterpart, because on the old code it never finished at all across 10+ hours. It now
completes, at 664.6 ms median over 8,583 queries.
Where the time went, per the code that changed: BM25 became a term-major CSR built once per export, so a query is one sparse mat-vec instead of a Python loop over the ~92% of the corpus that holds at least one query term. The hypergraph reader gained a per-file process-cached index (int-indexed postings CSR + incidence) instead of a binary search plus JSON decode per postings row — at 677k hyperedges that was ~4.7M key comparisons and ~230k JSON decodes per query. Both fast paths are pinned byte-identical to the slow ones by test and by a full real-query parity run.
Current shape at rest, graph_rescue median / p95 in ms, in-process brute-force dense:
musique 114.6 / 149.8 · hotpotqa 144.5 / 179.3 · wiki2multihop 240.9 / 451.7 · ohr_bench
441.0 / 584.4 · vidoseek 467.0 / 550.5 · multihop_rag 509.6 / 642.8 · uda 646.0 / 1,026.0.
Served through Qdrant the numbers are lower on the large corpus (wiki2multihop 82.6 ms
median), because the brute-force reference only looks cheap while a 57,812 × 768 matrix is
resident in the process.
5.10 · The budget curve¶
A selection stage optimises best context within a budget, so a single recall@10 is the
wrong shape of number for it. The curve is the native protocol, and it is settled against
one retrieval per query through select(), so it varies the budget and nothing else.
vidoseek, graph_rescue_packed, 1,142 queries — the dataset where the packed leg looks
worst (0.8424 against the unpacked 0.9726) and therefore the one worth reading:
| budget | recall@10 |
doc_recall@10 |
nDCG@10 |
tokens actually packed |
|---|---|---|---|---|
| 2,000 | 0.2747 | 0.9387 | 0.2790 | 1,920.5 |
| 4,000 | 0.5599 | 0.9921 | 0.5276 | 3,930.6 |
| 8,000 | 0.7545 | 0.9947 | 0.6751 | 7,936.1 |
| 10,000 | 0.8424 | 0.9947 | 0.7338 | 9,931.8 |
| 16,000 | 0.9631 | 0.9956 | 0.8063 | 15,931.4 |
| 24,000 | 0.9696 | 0.9956 | 0.8110 | 23,922.4 |
| 32,000 | 0.9726 | 0.9956 | 0.8138 | 31,912.8 |
Three things are visible, and none of them is an opinion.
- The packer spends what it is given — 9,932 of 10,000, 31,913 of 32,000. That is backfill working; the pre-backfill greedy left ~4,500 tokens of a 10k budget unspent.
- At 32k the packed leg equals the unpacked leg exactly — 0.9726 recall@10 and 0.8138 nDCG@10, to the digit. The ceiling is the budget, not the packer. vidoseek is a page-image corpus with long pages and its answers do not fit in 10,000 tokens.
doc_recall@10never drops below 0.9387, even at a 2,000-token budget. The right document is found at every budget; what a small budget truncates is the chunk-level evidence inside it. If your downstream consumer cites documents, a 2k budget costs you almost nothing; if it quotes spans, it costs you two thirds of your evidence.
The CoE curve behaves differently, and the difference is why the two selectors carry
different headline budgets. multihop_rag, graph_rescue_coe, 2,255 queries:
| budget | recall@10 |
doc_recall@10 |
tokens packed |
|---|---|---|---|
| 2,000 | 0.2016 | 0.5303 | 1,950.9 |
| 4,000 | 0.3401 | 0.6840 | 3,950.3 |
| 8,000 | 0.3759 | 0.8157 | 7,946.8 |
| 10,000 | 0.3759 | 0.8403 | 9,947.9 |
| 16,000 | 0.3759 | 0.8534 | 15,944.4 |
| 24,000 | 0.3759 | 0.8551 | 23,935.8 |
| 32,000 | 0.3759 | 0.8551 | 31,916.7 |
recall@10 saturates at 8k because — per §5.9 — the top ten is base and the budget stops
binding there. Document recall keeps climbing to 24k, which is the quantity CoE is
buying: more of the evidence set, deeper in the list. Hence the headlines: SELECT_BUDGET =
10_000 for the knapsack (which saturates its greedy phase around 5.5k and backfills the
rest) and COE_SELECT_BUDGET = 16_000 for CoE, where the spend still buys recall. Each
headline is a member of the curve, so the two can never disagree.
Two corpora show the other end of the behaviour: on musique and hotpotqa the CoE spend plateaus at ~4,281 and ~4,119 tokens whatever the budget, because the whole union costs less than the budget. An unspent budget there is not a failure — it is the selector correctly declining to invent context.
5.11 · What this means for a deployment¶
It augments an engine, it does not replace one. There is no index to operate, no server
to run, no state to back up. GraphRescueRetrieval accepts your RetrievalBackend and holds
nothing between queries — identical inputs always yield an identical result. The seam is a
structural Protocol, so an adapter is capabilities plus search(query); QdrantBackend
is the shipped reference and FakeBackend the faithful offline fake, so the seam has never
had a single implementation. Capabilities are declared honestly: the reference Qdrant
adapter serves {dense, sparse, filter} and refuses bm25/multivector at construction
rather than silently degrading.
The token budget is a direct line item. budget is the number of context tokens per
query you hand an LLM, and tokens_packed on every result is what was actually spent. On
vidoseek the same pipeline delivers doc_recall@10 ≥ 0.9387 at 1,920 tokens or
recall@10 = 0.9726 at 31,913 — a 16× cost range over one config value, measured on your own
corpus with retrieve() once and select() seven times. Nothing in the retrieval path
itself calls a model, so that budget is the marginal cost.
Latency at enterprise scale. The 220k-chunk uda corpus runs the full composition —
dense + BM25 + gate + expander + 2-hop traversal + hypergraph propagation + union — at 646 ms
median, 1,026 ms p95, single-threaded, on a 5.1-core cgroup. Served through Qdrant on the
57,812-chunk corpus it is 82.6 ms median / 213.4 ms p95 over HTTP, one query at a time, with
no batching. Set graph_mode: off and you pay for dense + BM25 + fuse and nothing else; the
gate is how you pay for the graph only on the queries that plausibly need it.
Provenance survives to the answer. Candidate.id is the corpus record_id, preserved
across the fuser's dedup, the append-and-dedupe, the packer's selection and the Qdrant
payload round-trip. That row carries provenance.document_id, char_start/char_end and
page_start/page_end in the parent document's assembled-markdown coordinates, plus
page_slice and offset_map so a sub-chunk offset resolves to its true original page — not
a lower bound. Every retrieved item is therefore citable back to a source span in a named
file, which is usually the difference between a demo and something an auditor accepts.
Determinism is not aspirational. No randomness anywhere on the path; every tie in every
component breaks on ascending id; the gate returns its reasons; the selector returns its
spend. The one caveat is the one §5.8 measured: floating-point summation order is part of the
run's identity, so pin your BLAS threads before you compare two numbers.
Closing the loop¶
Five chapters, one through-line: a folder of files becomes a corpus and a graph that are the same artifact, and that identity is what query time spends.
What the graph bought. Not a new retriever — a set of columns and sidecars that make
graph-shaped operations cheap at query time. The 1-hop projection onto every chunk turns
graph-aware rescoring into a pure metadata operation on candidates already in hand, so it
runs on any vector store with no graph database in the path, and it is worth up to +0.008
recall@10 over a strong hybrid baseline. The traversal leg and the hyperedge sidecar are
the mechanism by which a document the first stage never returned can enter a result at all —
the thing a post-fusion rescorer structurally cannot express. And the headline number is
really the negative one: applied as rescue rather than as fusion, the graph stopped lowering
recall, on all seven datasets.
What it cost. Two corpus-wide stages that see everything at once, a second export, and a query-time budget: roughly 600 ms per query at 220k chunks single-threaded, 80 ms served through an ANN engine at 58k. Plus the discipline: a determinism contract strict enough to refuse to run, and a benchmark honest enough to publish the seven cells that moved in the fourth decimal and the twenty it declined to compare at all.
What to do next.
- Replace something. Every stage in chapters 1–4 and every engine in this one sits behind
a structural
Protocol. The exact input shape, carrier type and alternative Providers of every stage are generated from the source intodocs/reference/stage-contracts.jsonand rendered as the stage contract reference — that file, not any prose on this site, is the authority on I/O. Then run the Provider conformance suite: it drives every registered Provider through its Capability's case and checks contract adherence (C1), graceful typed failure (C2), recorded and permissive-or-opt-in licensing (C3), declared determinism (C4), device honesty (C5) and no PII leakage (C6). A Provider for a Capability with no case fails the suite loudly rather than skipping silently. Swapping a Provider cannot break the flow; what it can change is quality, and that stays yours to measure. - Run it on your own corpus. Quickstart for a green pipeline,
running the campaign to reproduce this matrix, and
PYTHONHASHSEED=0 OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1 MKL_NUM_THREADS=1 python -m benchmark.s5.run_matrix --datasets all --legs allto re-measure it. Cells are cached;--forcere-measures. - Deploy it. Graph-rescue retrieval for the config surface and the wizard mapping, serving with Qdrant for the verified path from "pipeline finished" to "queries served".
- Argue with it. The reasoning, with its alternatives and its falsifiers, is in
the deep dive and the decision log. The measurements
are in the benchmark pages and in
benchmark/sota-campaign/, cell by cell, including the ones that did not go our way.