Tutorial 3 — Retrieval: four signals, fused¶
What you will build. A working query path over the files Tutorial 1 emitted — dense vectors, learned-sparse term weights, BM25 and knowledge-graph traversal — fused into one ranking and packed into a token budget. Then you will take it apart and see what each signal actually contributes, and where each one is blind.
Prerequisites. Tutorial 1 and Tutorial 2. You will re-run the pipeline once, with one line added.
Time. About 30 minutes.
0. The one architectural fact to hold on to¶
The retrieval layer is strictly stateless. It never holds an index and never runs first-stage search (ADR-0048). It is a set of pure transforms over a candidate list you fetched from your engine — Qdrant, Elastic, Weaviate, Azure AI Search, whatever you already run.
That splits retrieval into two clocks:
- Index time — the dense embedder, the sparse embedder, the BM25 tokenizer and the graph features are Providers inside the pipeline. They turn chunk text into vectors, term weights and statistics, materialised as files during the run.
- Query time — fuse, expand, rerank and pack are a downstream library. They hold nothing and fetch nothing.
In this tutorial your corpus is four documents, so you are the engine: the candidate list is
simply every row of records.parquet. Every component below behaves identically when the candidates
come from a real store instead — that is the whole point of the seam.
1. Add the sparse signal¶
Tutorial 1's stack already emits a dense embedding column and BM25 statistics. Add the third
index-time signal by putting one more line in the export_corpus config:
- name: export_corpus
capability: export
provider: export.jsonl_parquet
depends_on: [enrich]
config:
basename: records
context_columns: true
embedder: {provider: embedding.hashing, config: {dimension: 256}}
bm25: {provider: tokenizer.regex, config: {}}
sparse_embedder: {provider: sparse.hashing, config: {vocab_size: 1024}} # new
uv run latence stack check latence.stack.yaml
uv run latence run latence.stack.yaml --run-id run-0003
uv run python -c "
import pyarrow.parquet as pq
print(pq.read_schema('latence-out/_latence/runs/run-0003/export/records.parquet').names)
"
✓ look for — two new columns, sparse_indices and sparse_values, and nothing else changed:
['record_id', 'schema_version', 'provenance', 'classification', 'content', 'media_type',
'embedding', 'sparse_indices', 'sparse_values', 'context_header', 'kg_node_ids',
'neighbor_node_ids', 'kg_node_community', 'kg_node_centrality']
Parallel list<int64> / list<double> columns are the shape a store's sparse index ingests
directly. Like every signal, it is generated over the redacted corpus text, so the sparse index
can never re-leak the PII the corpus removed.
You also need the embedded graph engine the multi-hop expander uses (MIT, serverless, nothing to stand up), if you did not install it during the quickstart:
2. Build the four legs¶
Save this as search.py:
"""Four retrieval signals over the emitted files, fused, graph-expanded and packed."""
import math
import sys
import pyarrow.parquet as pq
from latence_core.capability import ProviderRegistry
from latence_retrieval import (
Bm25Rescorer,
Candidate,
DuckDBGraphSource,
MultiHopExpander,
Packer,
RrfFuser,
)
EXPORT, QUERY = sys.argv[1], sys.argv[2]
rows = pq.read_table(f"{EXPORT}/records.parquet").to_pylist()
registry = ProviderRegistry()
def candidate(row, score):
return Candidate(
id=row["record_id"],
score=score,
text=row["content"],
metadata={"kg_node_ids": list(row["kg_node_ids"] or [])},
)
def show(label, cands, n=4):
print(f"\n-- {label}")
for c in cands[:n]:
print(f" {c.score:8.4f} {c.text.splitlines()[0][:44]}")
def rank(scored):
return sorted(scored, key=lambda c: (-c.score, c.id))
# 1 - dense: the SAME Embedder Provider the Export wired, resolved by name
dense_model = registry.load("embedding.hashing")({"dimension": 256})
qvec = next(iter(dense_model.embed([QUERY])))
def cosine(a, b):
den = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))
return sum(x * y for x, y in zip(a, b)) / den if den else 0.0
dense = rank(candidate(r, cosine(qvec, r["embedding"])) for r in rows)
show("dense - cosine over the emitted `embedding` column", dense)
# 2 - sparse: the SAME SparseEmbedder Provider, dot product in term space
sparse_model = registry.load("sparse.hashing")({"vocab_size": 1024})
qs = next(iter(sparse_model.encode_sparse([QUERY])))
qterms = dict(zip(qs.indices, qs.values))
sparse = rank(
candidate(
r,
sum(qterms.get(i, 0.0) * v for i, v in zip(r["sparse_indices"], r["sparse_values"])),
)
for r in rows
)
show("sparse - dot product over `sparse_indices`/`sparse_values`", sparse)
# 3 - BM25: exact rescoring from the emitted corpus statistics
lexical = Bm25Rescorer.from_export(EXPORT).process(QUERY, [candidate(r, 0.0) for r in rows])
show("BM25 - from bm25-stats.json, no re-tokenisation", lexical)
# 4 - fuse: three incomparable scales, so fuse by rank alone
fused = RrfFuser().fuse([dense, sparse, lexical])
show("fused - reciprocal rank over the three lists", fused)
# 5 - graph: edge-path proximity to the top hit
expanded = MultiHopExpander(
{"seed_top_k": 1, "max_hops": 2, "hop_weight": 0.01},
graph_source=DuckDBGraphSource(f"{EXPORT}/graph-edges.parquet"),
).process(QUERY, fused)
show("after 2-hop graph expansion", expanded)
# 6 - pack: relevance minus redundancy, under a token budget
show("packed @ 300 tokens", Packer({"budget": 300}).process(QUERY, expanded))
Two things in there are not incidental.
ProviderRegistry().load("embedding.hashing") resolves the same Provider by the same name the
Export used. That is how a query vector is guaranteed to land in the same space as the corpus
vectors — you do not re-implement the embedder on the query side, you resolve it. The identical
discipline applies to the sparse encoder, and to BM25: Bm25Rescorer.from_export builds the
rescorer straight from the emitted bm25-stats.json, mapping its avgdl and per-term idf onto the
rescorer's statistics. No re-tokenisation of the corpus happens at query time. Note that it is a
classmethod, not a bare constructor — a bare Bm25Rescorer() falls back to using the candidate pool
as its own corpus, which is a different and much weaker statistic.
3. Run it¶
✓ look for — six blocks. The first three are the raw signals:
-- dense - cosine over the emitted `embedding` column
0.4291 Master Services Agreement
0.3313 Travel and Expense Policy
0.3162 Incident Report 2024-07
0.1596 Acme Corporation Employee Handbook
-- sparse - dot product over `sparse_indices`/`sparse_values`
3.0000 Master Services Agreement
2.0000 Incident Report 2024-07
2.0000 Travel and Expense Policy
1.0000 Acme Corporation Employee Handbook
-- BM25 - from bm25-stats.json, no re-tokenisation
1.8207 Master Services Agreement
0.5277 Travel and Expense Policy
0.4981 Incident Report 2024-07
0.0000 Acme Corporation Employee Handbook
All three put the right document first, and all three are on completely incomparable scales —
a cosine in [-1, 1], an unbounded dot product, an unbounded BM25 sum. That is the problem fusion
exists to solve.
4. What each signal is actually for¶
Before you fuse them, be clear about what you just measured — and about one thing you did not.
BM25 is exact lexical matching with corpus-aware weighting. A rare term (IBAN, a status code,
a header field name) carries high idf and dominates; a chunk sharing none of the query's terms
scores exactly 0.000. It is unbeatable on precise lookups and blind to paraphrase.
Learned sparse produces term weights in a vocabulary space, so it can expand a query into related terms while staying in the interpretable, invertible world of terms. In production this is SPLADE; the query side reuses the same composition class the pipeline ran at index time, so query and corpus land in the identical sparse space.
The graph is the only signal that follows edges rather than similarity — the subject of the next section.
Dense is supposed to be the semantic signal. Here it is not:
embedding.hashing cannot demonstrate semantics — and neither can sparse.hashing
Both in-core signal generators are deterministic references. They hash terms into buckets. They exist so the seam, the emission and the entire offline test suite run with zero downloads and byte-reproducible output, and they do that job perfectly.
What they cannot do is retrieve on meaning. Query this corpus with a paraphrase that shares no words with the answer and the "dense" leg scores it near zero, because there is nothing semantic inside it. Do not conclude anything about dense-vs-lexical retrieval from these numbers — you would be comparing a lexical signal to a lexical signal.
To see the real behaviour, swap in a real model. It is a one-line config change on the
embedder block — {provider: embedding.sentence_transformers, config: {model: ibm-granite/granite-embedding-97m-multilingual-r2, dimension: 384, device: cpu}}
— plus uv pip install -e packages/latence-embedder-st. The query side changes the same
one line, because it resolves the Provider by name. That the swap is one line in two places is
the point of the seam; that the reference is honest about being a reference is the point of
this box.
5. Fusion¶
-- fused - reciprocal rank over the three lists
0.0492 Master Services Agreement
0.0481 Travel and Expense Policy
0.0479 Incident Report 2024-07
0.0469 Acme Corporation Employee Handbook
RrfFuser sums weight / (k + rank) across the input lists. It looks only at ranks, never at
scores, which is exactly right when the scores have no common unit: a cosine of 0.43 and a BM25
of 1.82 cannot be added, but "first in both lists" is meaningful in any unit.
Notice the consequence: the fused scores are all around 0.05, in a band about 0.002 wide.
Reciprocal-rank scores are small and tightly clustered by construction. Remember that number — the
next step depends on it entirely.
Two variations worth knowing:
RrfFuser().fuse([dense, sparse, lexical], weights=[2.0, 1.0, 1.0])weights a leg you trust more.WeightedFuserpreserves magnitudes (min-max normalised) when you actually do want score differences rather than rank differences.
Fusion is skipped entirely when only one leg fired, so a single store's own scores are never discarded and re-derived.
6. The graph leg, and how to get it wrong¶
-- after 2-hop graph expansion
0.0581 Travel and Expense Policy
0.0579 Incident Report 2024-07
0.0569 Acme Corporation Employee Handbook
0.0492 Master Services Agreement
That is wrong, and instructively so. The correct answer went from first to last.
Here is what happened. MultiHopExpander anchors on the top seed_top_k candidates, reads their
kg_node_ids, traverses graph-edges.parquet out to max_hops, and gives every other candidate
a hop-decayed bonus for the closest hop at which one of its own nodes lands in that neighbourhood.
A one-hop neighbour earns the full hop_weight; a k-hop neighbour earns
hop_weight × hop_decay^(k-1).
Two things went wrong at once:
- The bonus was scaled to the wrong range.
hop_weight: 0.01is a sensible nudge next to raw BM25 scores of ~5. Next to fused reciprocal-rank scores spread over0.002, it is five times the entire score range — so the bonus stopped being a tiebreaker and became the ranking. - On a four-document corpus every document is one hop from every other. The graph has five nodes and everything touches Acme or Globex. A uniform bonus to everything-but-the-seed is, precisely, an inversion.
Fix the first by scaling the weight to the signal it is added to:
expanded = MultiHopExpander(
{"seed_top_k": 1, "max_hops": 2, "hop_weight": 0.0005},
graph_source=DuckDBGraphSource(f"{EXPORT}/graph-edges.parquet"),
).process(QUERY, fused)
✓ look for — the seed holds first place and the graph acts as a tiebreaker among the rest:
-- after 2-hop graph expansion
0.0492 Master Services Agreement
0.0486 Travel and Expense Policy
0.0484 Incident Report 2024-07
0.0474 Acme Corporation Employee Handbook
The second problem is not tunable — it is a property of a four-document corpus, and there is no honest way to make a toy graph produce a multi-hop win. What a real corpus looks like is the next section.
Two more traps worth knowing before you meet them
Node ids are content hashes, not labels. graph-nodes.parquet node ids look like
96987fab1d4dd7e2. Seeding a traversal with the literal string "Acme" resolves to nothing and
returns an empty neighbourhood — which looks exactly like a dead graph. Resolve the label to its
node_id(s) via canonical_name first.
A large seed_top_k can absorb the whole graph. A candidate whose entity nodes are already
in the seed set earns no bonus. On this corpus, seed_top_k: 2 covers four of the five nodes
and expansion becomes a complete no-op — zero bonuses, no error, no warning. Start at 1 and
raise it as the corpus grows.
7. What the graph is actually for¶
The mechanism is best stated as the thing single-hop retrieval structurally cannot do.
Take a question framed in one document and answered in another: "the cipher suites for the TLS protocol referenced by RFC 7230." The query is about RFC 7230 (HTTP); the answer lives in RFC 5246 (TLS). No lexical signal puts RFC 5246's chunk near this query — its text is not about HTTP. No semantic signal does either, for the same reason. Fusing them cannot help: fusion makes each signal cover the other's blind spots, and here they share the blind spot. The evidence is one reference edge away, and neither signal follows edges.
The expander does. It takes the RFC 7230 chunks the first stage did find, reads their entity node
ids, traverses the 7230 → 5246 reference edge in the emitted graph, and rescores RFC 5246's chunk
up into the returned window.
That has been measured, on 80 IETF RFCs run through the enterprise-SOTA pipeline against a live Qdrant instance:
| retrieval configuration | recall@10 · all 90 questions | recall@10 · the 7 multi-hop cases |
|---|---|---|
| dense only (Granite → Qdrant) | 0.644 | 0.000 |
| BM25 only | 0.656 | 0.000 |
| fused — dense + BM25 + KG 2-hop + rerank | 0.678 | 1.000 |
Read that honestly, which is how the deep dive reports it. Across the broad 90-question set the aggregate lift is modest — three points — because most questions are answerable by a single signal and fusion merely ties them. The advantage lands exactly where it should: on the seven questions that require chaining evidence across documents, where single signals score zero and the fused stack scores perfect. Seven of ninety is about 8%, and that is the true rate, not a padded one.
The defensible claim is not "fused is several times better everywhere". It is that the knowledge-graph stack unlocks a class of queries single retrievers structurally cannot answer.
One detail from that run is worth carrying into your own corpus: the induced graph initially
recovered only 14% of the corpus's real cross-reference edges, because the header-only extractor
could not see inline body citations. Adding the deterministic inline_refs extractor lifted
coverage to ~112% of the gold graph. Those recovered edges are precisely what the expander
traverses. No edges, no multi-hop win — the retrieval result is only ever as good as the graph.
8. Packing¶
With the corrected hop_weight, the packed block is the whole reordered list — four short chunks
fit comfortably in 300 tokens:
-- packed @ 300 tokens
0.0492 Master Services Agreement
0.0486 Travel and Expense Policy
0.0484 Incident Report 2024-07
0.0474 Acme Corporation Employee Handbook
Packer solves a quadratic knapsack: it maximises relevance minus redundancy under a token
budget, greedily adding the candidate with the best
(value − λ · similarity-to-already-picked) / cost that still fits. Naive top-k spends the budget
on three near-identical passages; the packer prefers diverse, non-redundant evidence.
It selects, it does not re-score — the returned candidates are the originals, unchanged, ordered by descending first-stage score. So the order is: rerank first, then pack.
Useful knobs:
budget— required, in tokens.redundancy_weight— the λ on pairwise similarity (default0.5;0gives a pure top-value knapsack).value_signals— ametadata-key → weightmap of additive richness signals. This is how you feed a chunk's peak entity centrality into the packing decision, using thekg_node_centralitycolumn the Export already wrote.
The token counter is itself a seam: a zero-dependency heuristic by default, real tiktoken when you
install latence-retrieval[pack].
✓ look for — drop budget to 100 and re-run: the packed block loses its lowest-value member
and returns three chunks instead of four. The budget is a hard constraint — every candidate costs at
least one token, so a selection never exceeds it.
9. Reranking, and the same story with a real engine¶
Two pieces this tutorial has left out on purpose.
Reranking is the precision pass between fusing and packing: a cross-encoder scores each
candidate against the query jointly, rather than as independent embeddings, and reorders. The
Reranker component pins no checkpoint — you name one, and its license is your explicit choice.
Install latence-retrieval[rerank] for the real sentence-transformers cross-encoder adapter.
A real engine. Everything above works unchanged when the candidate list comes from a store instead of a Parquet file. Load the export:
uv run latence-sink-qdrant load latence-out/_latence/runs/run-0003/export \
--collection first-pipeline --url http://localhost:6333
load is idempotent — re-run it and it upserts only what changed and sweeps what the Export no
longer contains, which is what makes a retract or purge take effect in the store. Its
--vector dense|sparse|both flag decides which signal it writes; under both the sparse column
pair becomes a queryable named sparse vector rather than inert payload.
On the query side, QdrantBackend declares its honest capabilities from the closed set
{dense, sparse, bm25, filter, multivector} and exposes exactly one method, search(query) —
the only fetch in the entire library, and it runs on your engine. The key it reads for chunk
text is the key the sink writes, so a collection this framework loaded needs no mapping
configuration. Point the seam at your own store and nothing else in this tutorial changes.
A capability the backend cannot serve is a loud error, never a silent drop, and the result reports which modes actually ran.
What you have¶
A query path built from the emitted files alone, and a clear-eyed view of what each signal does: BM25 for exact terms, sparse for term expansion, dense for meaning (once you wire a real model), the graph for evidence that similarity cannot reach — and the two ways to get the graph leg wrong.
Next: Bringing your own Provider — implement a Capability, register it as a plugin, and have the conformance suite check you.