Semantic indexing: two legs and a fusion

Retrieval arguments usually run on assertion: someone says embeddings understand meaning, someone else says keyword search is fine, and neither side shows a query. The demo at /demo/ is the same eleven questions run three ways against one indexed docset, with the hits and the timings kept. This essay is what to look for in it.

The two legs

A docset in the hub is indexed twice over the same units.

The keyword leg is SQLite FTS5 with BM25 ranking. A question becomes an OR of its terms, the index returns the rows that contain them, and BM25 ranks by term rarity against document length. It is exact: CLAUDE_CODE_SYNC_SKILLS matches the row that literally contains CLAUDE_CODE_SYNC_SKILLS, and no row that does not. It costs no model call — sub-millisecond on a single rare token in the recorded run, and tens of milliseconds on a six-word question, because cost scales with how many terms have to be unioned.

The vector leg embeds the question with the same model the docset was indexed with and ranks units by cosine similarity. It matches on meaning, so a question that shares no words with its answer still finds it. Its cost is one embedding call and is almost independent of the question: in the recording every vector query after the first lands in the same narrow band whether the question is one token or nine words. The first pays the one-off cost of opening the connection to the embedding host — 124 ms against a median of 15 — and is marked as such on the demo page; read it as a connection cost, not a retrieval cost.

Their failure modes are mirror images. BM25 cannot find a page that phrases the answer differently. Embeddings dilute a lone identifier into a low-signal vector, and a docset full of near-identical config rows gives it many almost-equally-good answers. This is why the exact-token probes in /ldo pass P11 exist at all: they are the class that a vector index quietly gets wrong.

Fusing them

The hub’s default is neither leg alone. hub_query_docset(mode="hybrid") runs both and fuses them with reciprocal-rank fusion: each hit scores 1 / (60 + rank) in each list it appears in, and the scores add, keyed by (url, seq). No score from either leg survives the fusion — only the ranks — which is the point. BM25 scores and cosine similarities are not on a comparable scale, and any attempt to weight one against the other is a knob nobody can tune honestly. Rank agreement needs no scale: a unit that both legs put near the top beats a unit that only one leg loves.

The recording shows the mechanism working in the least glamorous way. The two legs agreed on the top hit for only two of the eleven questions; for two others the fused winner was the top hit of neither leg — a unit each leg ranked second or third, promoted past two disagreeing favourites because both legs voted for it.

What the recording shows

Read /demo/ with three questions in mind.

Two honest caveats. The timings are a single run on a single laptop with one local embedding host, so the ratios between the legs are the finding and the absolute milliseconds are not (and the first query pays the connection cost noted above). And this is a recording, dated on the page — it is not a live endpoint, and it will not be one until the query API arrives.

Run it yourself

Everything on the demo page comes from one command against an indexed docset, so the same comparison can be run over yours. The keyword index is built on first use; the facts layer is preferred automatically when a docset has one.

# the cheap leg — FTS5/BM25, no model call
docset_indexer.py keyword <docset> "CLAUDE_CODE_SYNC_SKILLS"

# the meaning leg — one embedding call, then cosine
docset_indexer.py query <docset> "which hook events fire once per turn" --layer auto

# both, fused — what hub_query_docset serves by default
hub_query_docset(docset=..., question=..., mode="hybrid")

The recording itself is regenerated with site/tools/gen_demo.py, which is the one generator in this site that reads a live hub and therefore the one that never runs in CI: it is run by hand on a box that has the indexes, and its output is committed. See the reference for the surrounding commands.