Write-up
boost's flagship retrieval is invisible to almost everyone who installs it. boost search "my
app is slow" on the five default taps returns dnanexus-integration,
tamarind, cirq, molfeat — bioinformatics and quantum
computing, nothing about performance. That is BM25 doing exactly what BM25 does when a human types
like a human, and it is what every keyless user experiences.
The vector database was never the problem. Vectors already live on the user's own machine in
sqlite-vec (~/.boost/cache/rag_vectors.sqlite), refreshed per tap against the commit it
was built from. Nothing is hosted and nothing needs to be. The only API-bound step is
turning text into vectors: embed.provider() returns "voyage" with
VOYAGE_API_KEY set, "openai" with OPENAI_API_KEY, and
otherwise None — at which point everything degrades to BM25. So the real dilemma
("every user rebuilds the index, or someone hosts and pays for it") is false in both halves.
Six steps, and the first two carry most of the value.
1 — A local ONNX provider, as the third link in a chain built for it. Extend
embed.provider() to Voyage → OpenAI → local → BM25, using ONNX
Runtime on CPU with a small model (bge-small-en-v1.5, 384-dim, ~30 MB once). The
sqlite-vec store, the per-tap commit cache and KNN search are all untouched — only the
text→vector call changes. This removes the key, the signup and the card for every
user, and Voyage stays a free quality upgrade for anyone who adds one.
2 — Prebuild per-registry shards in CI. Embedding the full catalogue (~28k items,
~150M tokens) on a laptop is an hour-plus, which no one will wait for. A workflow embeds each
registry and publishes vectors as GitHub Release artifacts keyed on the registry's git commit
— the cache key the code already uses — so boost tap fetches the catalogue
and its shard. This is how Homebrew, npm and crates.io index a moving world.
3 — Local delta top-up. When a tap runs ahead of its published shard, or is a team or
private registry CI has never seen, embed just those files on the spot. This is the structural
answer to an unbounded ecosystem: shards cover the popular registries, the local model makes the
long tail self-serve, and cost scales with what each user taps rather than with the size of the
whole ecosystem. Without it, every uncatalogued registry is a dead end for a keyless user.
4 — Fuse the two engines with reciprocal rank fusion. Confirmed against the shipped
code: rag.retrieve_any() picks one engine — dense when it is ready and
returns a non-empty result, BM25 otherwise. There is no hybrid path today. RRF changes that to "run
both, fuse by rank": score = 1/(60+rank_bm25) + 1/(60+rank_dense). Fusing on
ranks, not scores, is the point — a BM25 score and a cosine similarity are on
incomparable scales, and rank fusion sidesteps calibration entirely. The two engines fail in
opposite directions and this corpus needs both: registries are full of exact identifiers
(EAS, pptx, skill names) where BM25 is strongest and embeddings weakest,
while sloppy queries are where BM25 returns quantum computing. retrieve_any is the
single seam every retrieval path already passes through, CLI and MCP alike, so it is one function.
The tradeoffs are real and should be measured rather than assumed: rank fusion discards confidence,
so plain RRF can trail pure dense on purely semantic queries; "why did this rank third?" gains a
two-part answer; and both engines must index the same chunks (they already share chunking). Ship
the k=60 default, benchmark it, and tune only if the eval says so.
5 — A hosted demo on free tiers, so the experience is reachable without installing
anything. 6 — Publish the eval: BM25 vs local dense vs Voyage dense vs hybrid on the
existing golden set, with recall@k and MRR. That last one settles step 4 with data instead of
argument — and if pure dense wins on this corpus, that is the result worth publishing.
Scope note. This is an epic, not a single change. Step 1 is the one that removes the wall and
is worth landing alone; steps 2–3 are what make it fast enough to be real; step 4 is ~20 lines
in one function and independently shippable; 5 and 6 are separable. Expect this card to decompose
into per-step items as each is picked up — the value of keeping it whole here is that the
steps only make sense against each other.
Step 1 is shipped, with two corrections to the plan above that surfaced only by measuring.
fastembed cannot be used here. Its PyPI classifier declares License ::
Other/Proprietary License even though the project is Apache-2.0, and
scripts/check_licenses.py denies that with no override —
UNDECLARED_OK exists for a package declaring nothing (the ragas
precedent), not for one declaring the wrong thing. Its tree also drags in
py-rust-stemmers, which declares no licence at all, plus Pillow,
requests and loguru, none of which boost has a use for. Measured with the
repo's own gate: the fastembed closure is 32 packages with 2 findings, ONNX Runtime plus a
tokenizer is 20 packages with 0. The lean pair shipped, at the cost of ~150 lines of
download/pool/normalise in core/localembed.py.
The model is 133 MB, not ~30 MB. That figure describes the quantized rebuild third
parties publish; BAAI's own ONNX export is 133,093,490 bytes. boost fetches the authoritative one
and sha256-verifies it against a pinned repository revision — a project with signed taps and
hash-pinned locks has no business taking model weights from a re-uploader to save a one-time
download. Quantization is a genuine follow-up, but it changes the vectors, so it needs its own eval
rather than a swap.
Two details worth recording for whoever takes step 4. BGE is CLS-pooled, not mean-pooled:
mean pooling would still emit 384 plausible-looking floats and quietly worse retrieval, which is
the kind of error an eval catches and a unit test does not. And the chain puts local last,
so a user with a Voyage key keeps voyage-4 instead of being silently downgraded.
Verified end to end against the real weights: 384 dimensions as declared, L2 norm 1.000000, and
sim("making my application faster", "application performance tuning") = 0.7691
against sim(…, "quantum computing circuit simulation") = 0.5338 —
the exact failure this card opened with, now ordered correctly.
Constraints this must respect: the shipped runtime is stdlib-only and
[project].dependencies is empty, so a local model belongs behind an extra like
[rag], with the keyless path degrading to BM25 exactly as it does now. The required
eval gate already floors BM25 recall@k at 0.85 over the golden set, so step 6 has a
harness to extend rather than invent. Related:
[[dense-search-fallback-and-stale-tap-pruning]] and
[[cache-the-catalog-entry-set-across-rag-queries]].
A sibling item proposes a different backend, and landed from another loop while this was in
review: [[keyless-dense-tier-local-static-embeddings]] argues for a static model
(model2vec/potion class) — a lookup table rather than a transformer, pure stdlib, no
numpy, no onnxruntime, ~1 ms per query, reranking BM25's top-200. The
two are not the same design and the comparison is worth settling with the eval (step 6) rather than
by argument.
One of its objections applies directly to what shipped here and was worth measuring rather than
waving away: it holds that import numpy alone costs 180–390 ms cold, which
would disqualify a transformer from a one-shot CLI path. Measured on this machine, best of three
cold processes: import numpy 51 ms, import onnxruntime
62 ms, and a complete cold embed() — process start, ONNX session
build over the 133 MB graph, tokenize, infer — 233 ms. So the objection does
not reproduce here, though import cost is genuinely machine- and version-dependent and their number
may be real on theirs. 233 ms is also very likely faster than the Voyage round trip it
sits beside, and it is paid only by users who installed the extra.
If step 6 is the tie-break, it needs a method and not just a number. The sibling item already
produced one cautionary result worth inheriting rather than repeating: its own headline, +11.0
recall / +15.9 hit@1, did not survive verification. Three failures, none specific to a static
model, all reachable from here. Its baseline used a kind oracle the live search path does not have,
so the comparison was never against what a user experiences. The blend weight
(w_dense=0.7) and the rerank pool depth were both tuned by argmax on the same
82 queries they were then reported on — fitting and reporting on one set. And at n=82 on a
binary metric, the smallest net win reaching p<0.05 is 6 queries: its
hit@1 (+13 net) clears that bar, while recall (+9) sits at the resolution floor and
should not have led.
So four constraints on step 6, whichever backend wins. Report McNemar on paired per-query
outcomes rather than two independent-looking averages, since the engines are scored on the same
queries. Hold the blend weight and pool depth out of the query set they are scored on. Lead
with hit@1 — on a golden set this size, recall moves inside its own noise. And
measure each engine alone before any fusion, so a hybrid win cannot be quietly credited to
the embedder that did not earn it. One structural caveat also transfers: a skill's name is only
~10.5% of a mean-pooled surface vector while 106 description clusters are shared across 270 distinct
names, so a lift measured on today's corpus may shrink rather than hold as the index grows
toward 50k items — an argument for re-measuring at scale before believing any of this.
The static approach still wins on cost and would win outright if the quality gap is small —
which is exactly what the eval should decide. Its own card is right that neither should ship a
retrieval claim before that eval exists.
Step 6, run against those four constraints — and it does not say what this card assumed.
Corpus rebuilt from tests/eval/taps.txt (6 taps, 743 entries, 3740 passages embedded
locally), k=10, 91 golden queries. Each engine measured alone, no fusion:
catalog.search hit@1 0.714, recall 0.918, MRR 0.783; BM25 full-content hit@1
0.780, recall 1.000, MRR 0.860; dense (local bge-small) hit@1 0.780, recall 0.956, MRR
0.853.
Leading with hit@1 as instructed: 71 queries each — an exact tie. Recall
differs by 4 queries (91 against 87) and MRR by 0.6, both under the ~6-query floor this card sets
for p<0.05 at this n. So the honest reading is no significant difference
between BM25 and local dense on the golden set — not a win for either. An earlier draft of
this note claimed BM25 won; that was the recall number leading, which is exactly the error the four
constraints above were written to prevent.
The more useful result is that the golden set cannot grade this feature at all. It scores
real catalog items by name, which is BM25's strength by construction —
CLAUDE.md already records that BM25 recall over this corpus is 1.000. It contains none
of the human-phrased queries the keyless work exists for, and on those the two engines separate
sharply: "my app is slow" returns phoenix-docker-setup,
guidelines, solidjs---error-boundaries from BM25 against
analyse-problem, performance-optimization,
fastapi-best-practices from dense; "I need to make my website accessible"
returns do-and-judge, write-concisely, do-in-steps against
accessibility-guidelines first. That is a demonstration, not a measurement
— there is no scored query set of that shape yet, which is the point.
Three consequences. Step 6's first deliverable is a query set, not a number: golden queries
in the human-phrased style, or the eval keeps answering a question nobody asked. Step 4 (RRF)
gains support — a tie on keyword queries plus a qualitative dense win on human ones is the
"two engines fail in opposite directions" case, and fusing is the response to it. And the current
preference order deserves a look: rag.retrieve_any takes dense whenever it is ready
and non-empty, so shipping step 1 without step 4 silently moved keyword queries onto the engine that
is, at best, tied for them.
Method note. The first indexing pass reported 3 taps failed to embed (2716 of 3740 passages).
A rerun stored all 3740, and two 30-batch replays never reproduced a failure, so it was transient
resource pressure rather than a defect — the retry path (no commit recorded for a failed tap)
did its job. The figures above come from a complete index.
Step 4 shipped, measured against the same 91 queries. retrieve_any no longer
picks an engine; it over-fetches RRF_K=60 from each and fuses by reciprocal rank,
1/(60+rank) summed, keyed on (name, tap) — the key both engines
already dedupe on. Adding a hybrid column to the eval:
catalog.search hit@1 0.714; BM25 hit@1 0.780, recall 1.000, MRR 0.860, nDCG 0.895;
dense hit@1 0.780, recall 0.956, MRR 0.853, nDCG 0.876; hybrid hit@1 0.813, recall 0.978, MRR
0.883, nDCG 0.905.
Hybrid leads on hit@1, MRR and nDCG, and gives up 2 queries of recall to BM25.
Neither difference is significant by the bar set above: +3 net queries on hit@1 and −2
on recall, both inside the ~6-query floor for p<0.05 at this n. So the case for
fusing is not "it scores higher" — it is that fusing is the only option that is at or near
best on both query shapes, where preferring either engine is measurably wrong for half of them.
This reverses a deliberate earlier decision, and that is worth flagging rather than burying:
test_a_non_empty_dense_result_is_still_final existed precisely to stop
retrieve_any "always running BM25 too". Its premise was that a dense hit is the better
answer, which the tie above retires. The other half of that fix — an empty dense
result is a thin index, not a verdict, and must fall through to BM25 — still stands and is
still tested.
One honest counter-observation. On two hand-picked human-phrased queries, fusion at k=3
lost the best dense hit: "my app is slow" dropped
performance-optimization and "I need to make my website accessible"
dropped accessibility-guidelines, in both cases because a junk BM25 rank-1 outweighed a
good dense rank-2. That is exactly the tradeoff this card predicted — "rank fusion discards
confidence" — and it is an anecdote at n=2 against a measured win at n=91, so it did not block
shipping. It is the strongest argument for the query set step 6 still needs, and the first thing to
re-measure once that exists.
Step 6's real deliverable, and it settles the question. The missing piece was never a number
— it was a query set of the shape this feature exists for.
tests/eval/golden-natural.jsonl is 50 queries written from each target's own
description and nothing else, phrased as a user problem, with the target's distinctive
name tokens deliberately excluded (a query containing "docker" finds docker-expert by
string match and measures nothing). The whole set was written before any engine was run
against it and scored once, so it could not be selected to flatter a result already seen. A
mechanical check caught five queries that had leaked a name token and they were rewritten.
Over the same corpus at k=10, 50 queries: catalog.search recall 0.330 hit@1 0.080;
BM25 recall 0.690 hit@1 0.240; dense recall 0.760 hit@1 0.420;
hybrid RRF recall 0.820 hit@1 0.420 MRR 0.559 nDCG 0.614.
BM25 collapses on this shape — hit@1 falls from 0.780 on the keyword set to
0.240 here, recall from 1.000 to 0.690. And dense beats it by +9 net queries on hit@1
(21 of 50 against 12), which clears the ~6-query significance floor this card set. That is the
first significant retrieval difference anyone has measured in this repo, and it is in the
opposite direction from the keyword set.
So the two sets together say something neither says alone. On keyword queries BM25 and dense tie and
hybrid edges ahead; on natural queries BM25 is far behind and hybrid is at-or-above dense on every
metric. Hybrid is the only engine that is at or near best on both shapes — which is
precisely the argument step 4 was shipped on, now with a significant margin behind it rather than
+3 queries inside the noise.
One caveat the slice exposes: skill queries score hit@1 0.459 against
workflow 0.308. The workflows in this corpus are overwhelmingly
<technology>-expert agents, so a problem-phrased query has to bridge from a
symptom to a product name with no shared vocabulary at all — the hardest case, and the one
where a larger embedding model would most likely show its value. Worth re-running against Voyage
before concluding the local model is enough.
The set is deliberately not wired into make eval or the required gate: it needs
the [rag] extra and a built store, and its purpose is comparing engines rather than
flooring one. Run it with --golden tests/eval/golden-natural.jsonl.
Step 4 is shipped too. rag.rrf_fuse landed in #360 with
RRF_K = 60, fusing on ranks exactly as described above, and
retrieve_any reports hybrid RRF when both engines are built. Recording it
here because this card still read as though only step 1 were done, and the next loop scanning for
work would have rebuilt it. The step's own instruction — “ship the k=60 default, benchmark
it, and tune only if the eval says so” — was followed: the benchmark is
tests/eval/golden-natural.jsonl, and k=60 is untouched.
Steps 2, 3, 5 and 6 remain open. Note that step 6 (publish the eval) is now partly answered by
the gate work in #365, which floors four metrics instead of recall alone and keys
baselines to their query set, so BM25-vs-dense-vs-hybrid comparisons are at least falsifiable. What
step 6 still wants is the published write-up rather than the instrument.
Step 6 is shipped — the eval is published in docs/eval.html, and it settles
step 4 with data. Every engine, same corpus, both query sets, k=10. Voyage is
absent because it needs a key and these runs were keyless, which is the configuration this whole
epic exists to serve.
On the keyword set (91 queries, graded by name): BM25 0.978 / 0.791 / 0.854 / 0.882,
dense 0.956 / 0.780 / 0.853 / 0.876, hybrid 0.978 / 0.780 / 0.864 /
0.891. On the natural-language set (50 queries, name tokens stripped): BM25
0.750 / 0.340 / 0.474 / 0.524, dense 0.760 / 0.420 / 0.541 / 0.580, hybrid
0.820 / 0.440 / 0.578 / 0.623.
Fusing beats choosing, which is what step 4 claimed. Hybrid wins or ties on both sets, and on
human-phrased queries it beats both of its own components on every metric. The components
fail in opposite directions exactly as predicted — BM25 takes hit@1 on name-shaped queries
(0.791 vs 0.780), dense takes it on human-phrased ones (0.420 vs 0.340) — so preferring either
would hand half the queries to the engine that is worse at them. The k=60 default was shipped
unchanged and the eval did not ask for it to be tuned.
One estimate in this card was badly wrong. Step 2 says embedding the full catalogue on a
laptop is “an hour-plus, which no one will wait for”. Measured: building the keyless
store over 743 entries (3,740 chunks, ONNX bge-small-en-v1.5 on CPU) took
4,431 s — 74 minutes, about 1.2 s per chunk. Extrapolated to ~28k items that is on the
order of days. This does not weaken the keyless tier: queries embed in milliseconds and the
store is built once. It does mean step 2 (prebuilt per-registry shards) is a requirement rather
than an optimisation, and step 3 (local delta top-up) has to stay scoped to genuinely small
deltas.
Still open: steps 2, 3 and 5.
Claim released. Steps 1, 4 and 6 have shipped; steps 2, 3 and 5 are unowned and open. Step 2
is the one to take next and it is better justified than when it was written: embedding measured at
~1.2 s/chunk locally, so prebuilt per-registry shards are a requirement rather than an optimisation.
Step 2, the shard mechanism, is shipped. boost reindex --export-shard TAP writes
one registry's vectors as JSON; --import-shard FILE merges them. Measured end to end on
the real store: exporting anthropics/skills gives 262 chunks in 0.63 MB, and
importing it into a fresh BOOST_HOME takes 0.12 s against roughly five minutes to
embed the same rows locally. That ratio is the whole point of the step.
Import refuses rather than degrades. A shard carries the provider, model, dimension and the
registry commit it was built from, and all four are checked. Mixing vectors from a different
embedding space would not raise — it would quietly return nonsense rankings, which is worse
than failing — and accepting a shard from a stale commit would let build() mark
that tap “reused” and never re-embed it, pinning the user to old vectors indefinitely.
Verified: a shard with a doctored commit is rejected with a message naming both hashes.
What this does NOT remove: the query-side model download. A shard eliminates the
document embedding cost, but a keyless user still needs the ~133 MB local model to embed
their own query. Confirmed by importing into a fresh BOOST_HOME, where retrieval
returned zero hits until the model was present — dense.status() reported
ready the whole time, because the store genuinely was ready. The card's framing of
“the only API-bound step is turning text into vectors” is right, but that step runs on
both sides, and only the document half can be shipped ahead of time.
Still open in step 2: the CI workflow that publishes shards as release artifacts, and the
boost tap integration that fetches one automatically. Both are now plumbing on top of a
verified mechanism rather than open questions. Steps 3 and 5 are untouched.
Step 3 works, and it did not need new code — it needed proving. Local delta top-up falls
out of the shard mechanism plus the commit-keyed reuse build() already had:
import_shard records the tap's commit in the same meta.commits map
build() consults, so an imported shard is indistinguishable from locally-built vectors
as far as reuse is concerned.
Measured end to end with a stubbed embedder, on a store holding an imported shard for
anthropics/skills plus a freshly tapped Aaronontheweb/dotnet-cursor-rules:
158 chunks embedded — the new tap only — and the shard's 262 untouched. The
resulting store is one coherent embedding space: 420 vectors, both taps' commits recorded, single
provider/model/dim. That is exactly the shape this step asks for — shards cover the popular
registries, the local model makes the long tail self-serve, and cost scales with what a user taps
rather than with the ecosystem.
Two tests now pin it, because the coupling is the kind that breaks silently: an import that forgot to
record the commit would still produce a working store, and the only symptom would be
re-embedding the shard's chunks on every later build — minutes of wasted CPU that nothing
reports.
Steps 1, 2, 3, 4 and 6 are now done; step 5 (a hosted demo) is the remainder, along with the
CI workflow that publishes shards as release artifacts, which is noted under step 2.
Step 2's publishing half is shipped — .github/workflows/shards.yml. Weekly
plus on-demand, it taps each registry, embeds it, exports a shard and uploads it as an artifact.
It is deliberately not chained off release, and that is the load-bearing decision.
publish.yml already sits at ci → release → sbom, GitHub's
documented three-level workflow_run limit; a fourth link would silently never fire.
sbom.yml's header records exactly what that looks like in this repo — 253
releases, 0 runs, 0 assets, a control that appeared present and produced nothing. Shards are keyed on
a registry's commit anyway, not on a boost release, so coupling them to our version cadence
would be wrong even if the chain allowed it. Same reasoning for uploading artifacts rather than
release assets: attaching them to a release would re-publish unchanged vectors on every version bump.
Scale drove the shape. At the measured ~1.2 s/chunk, the 20-tap corpus is ~3.4 h
against the 6 h job limit and the full 466-registry catalogue is far past it. So the workflow
fans out one job per registry (fail-fast: false, so one bad registry cannot lose
the others' work) rather than embedding everything in a single job.
Two things were caught by testing the pieces locally rather than trusting the YAML. The
matrix-planning step used printf '%s\n' $repos, which relies on word-splitting that does
not survive quoting: it produced a one-entry matrix holding all twenty repos as a single
string — one job attempting the entire corpus, straight past the job limit, and it would
have looked like a plausible timeout rather than a bug. Splitting in Python fixes it. The shard
validation step was also run against a real 262-chunk shard and against a provenance-stripped copy,
to confirm it accepts one and rejects the other; a shard missing provider,
model, dim or commit cannot be validated on import and would be
refused by every consumer, silently, forever.
Unproven until it runs: the workflow has never executed. The pieces are tested, the YAML
parses and the pinned action SHAs match the repo's existing ones, but the first real run is the first
end-to-end exercise.
Step 5 is shipped as a keyword demo, and the card should say plainly what that does and does not
deliver. docs/demo.html runs BM25 in the browser over a real 743-item, six-registry
catalogue — a 1.27 MB index, built by scripts/build_demo_index.py from the
same index the CLI uses. Nothing installs, nothing leaves the visitor's machine, and GitHub Pages
already served docs/ so no new hosting was needed.
The transfer figure was wrong, and the way it was wrong is the useful part. This card and the
page both claimed 320 KB gzipped, taken from a local gzip -6 run
(319.0 KB; -9 gives 310.2 KB). Fetching the deployed asset with
Accept-Encoding: gzip returns 340,866 bytes — 333 KB, because a CDN
trades compression ratio for speed. So the published number understated what a visitor actually
downloads by 4%, and only measuring the served artefact caught it. Both places now quote
the wire figure.
Parity is the claim that had to be earned. The page asserts it runs “the same BM25
ranking boost search runs”, which is only worth saying if it is true, so the JS
scorer and tokenizer are ports of rag._bm25 and rag.tokenize rather than
approximations — same k1=1.2, b=0.75, same idf, same stopwords.
Verified by running both implementations over six queries and diffing the results:
top-5 identical on 6/6. Ten tests pin the contract that makes that possible.
What it deliberately does not demo is the semantic half, and the page says so with numbers
rather than hedging. Embedding a query needs the ~133 MB local model; shipping that to
a visitor is not a free tier, and a hosted inference endpoint means someone pays per query. The page
therefore states the measured gap it cannot show — on natural-language queries BM25 scores
hit@1 0.340 against hybrid's 0.440 — and links the eval page for the
full comparison. A demo that quietly implied it was showing semantic search would misrepresent the
product to exactly the audience the epic is trying to reach.
A cheaper path exists and is recorded rather than taken. A model2vec-class model is ~8 MB
and would make a genuine in-browser semantic demo plausible — but that rests on the unverified
~29 ms/doc claim in keyless-dense-tier-local-static-embeddings, and it would make
the docs site load its first external dependency, a property every page currently holds. Worth
revisiting once that card is measured.
All six steps of this epic are now done.