Repository navigation
Staged search matches the whole-index scan on ~83% of queries at k=10 on PubMed (MedCPT) embeddings #562
Description
Summary. api.md asks users to "check agreement on your own data if it is unlike the corpora above". I ran that check on 101K PubMed abstracts embedded with NCBI's MedCPT model (d=768). At k=10, the staged search returns exactly the whole-index scan's ids for only 82.6% of queries (4-bit), against 100% on the README's OpenAI data. k=10 is the typical retrieval setting for RAG, so on embeddings like these users get a slightly worse top-10 than the api.md tables suggest, with no warning: recall10@10 is 0.931 staged vs 0.944 for the full scan.
Setup
- Data. NCBI publishes MedCPT article embeddings for all of PubMed (ftp). I took two 50.5K-row slices, from chunk 30 (2018) and chunk 36 (2022), giving 101,000 × 768.
- Control. The first 101K rows of the README's own dataset (Qdrant DBpedia,
text-embedding-3-larged=1536). Both the model and the corpus differ, so this is a reference point, not a controlled comparison. - Harness. Follows
benchmarks/suite/recall_*.py:- L2-normalise; 1,000 held-out queries against a 100K database; 3 split seeds.
- TQ+ index (
calibrate()on 1,024 rows). - Hits are tie-aware: a returned id counts if its exact score is within 1e-6 of the best.
- Agreement method. The same index is searched in two processes, one of them with
TURBOVEC_{2,4}BIT_PLANES=0. - Machine. turbovec 1.1.1 from PyPI, on 2-core x86 with AVX-512 VBMI and VNNI, so the planes path is active.
- Harness check. On the OpenAI data the harness reproduces the suite's JSON results to within about 1 point.
The result
| data | bits | identical id set at k=10 | recall10@10, staged / full |
|---|---|---|---|
| OpenAI d=1536 | 4 | 100% | 0.969 / 0.969 |
| OpenAI d=1536 | 2 | 100% | 0.898 / 0.898 |
| MedCPT d=768 | 4 | 82.6% | 0.931 / 0.944 |
| MedCPT d=768 | 2 | 83.1% | 0.775 / 0.779 |
- Consistent across seeds. Per seed, at 4-bit: 81.7%, 83.8% and 82.2%.
- Not a duplicate-vector artefact. Comparing the two lists' exact scores instead of their ids gives the same 83%.
- The true nearest neighbour sometimes goes missing. It is absent from the staged top-10 for 0.37% of queries at 4-bit. The full scan never misses it.
Why k=10 and not k=64
The staged search first ranks every vector by its sign bits alone and keeps a shortlist of max(256, 20 × k) candidates. Only that shortlist gets the finer scoring, and the later stages also narrow in proportion to k (max(96, 6 × k), then max(32, 1.5 × k)).
| k=64 | k=10 | |
|---|---|---|
| 4-bit first-stage shortlist | 1,024 | 256 (the floor) |
| Pulls the true neighbour into the top 10 | yes, for every query | not always |
The suite's recall figures use k=64, so they test the roomy case. Searching at k=64 and keeping the top 10 puts the true nearest neighbour in the top 10 for every query at 4-bit, while search(k=10) misses it for 0.37% of queries.
Example. A corrigendum (PMID 36114062) has, as its exact nearest neighbour, the article it corrects (PMID 36115732, cos 0.874). The next two neighbours are other fluid-flow papers, at 0.861 and 0.860. On the same index:
search(q, k=64)returns the corrected article at rank 1.search(q, k=8)andsearch(q, k=10)leave it out entirely: it is dropped somewhere in the narrower candidate stages.
Why this data is harder for the shortlist
| mean pairwise cos | ‖mean of unit vectors‖ | median top-1 − top-2 gap | |
|---|---|---|---|
| OpenAI d=1536 | 0.086 | 0.29 | 0.0266 |
| MedCPT d=768 | 0.647 | 0.80 | 0.0069 |
MedCPT vectors are bunched tightly together. There are two plausible reasons this hurts a sign-bit shortlist, and I can't fully separate them:
- Small margins. The gap between the true Exact Beta codebook, LUT search, cached codes #1 and Feature request: standalone Rust crate (no Python dependency) #2 is about 4× smaller. api.md's random-vector row already shows that small margins alone can defeat a sign-bit shortlist.
- A strong shared direction. Most documents share a large common component (‖mean‖ = 0.80). After rotation, that component sets each coordinate's sign the same way for most documents, so their sign-only scores bunch together. A fixed-size shortlist taken from bunched scores is more likely to cut off a true neighbour.
One test points towards (2). I subtracted the corpus mean from the documents and left the queries untouched. Since q·(x−μ) = q·x − q·μ, and q·μ is the same for every document, the exact ranking and the margins don't change. Only the shared direction is removed. With that change, identical top-10 id sets at 4-bit rose from 82.6% to 93.0% (seeds: 93.5%, 92.7% and 92.9%). Centring does lower final recall, though (see below), so it isn't a fix in itself.
How common is this geometry? I only have partial evidence:
- BAAI/bge-small-en-v1.5, a general-purpose model, gives mean pairwise cos 0.59 on PubMed abstracts and 0.43 on DBpedia texts, against 0.09 for
text-embedding-3-large. So the bunching isn't confined to biomedical models. - api.md reports 99.92–100% agreement for all-mpnet-base-v2, so at least one popular open model is fine.
- I haven't run the agreement check on bge with more than 32,768 vectors.
Workarounds and possible directions
Tested, on MedCPT:
- Search at k=64 and keep the top 10. recall10@10 matches the full scan (0.943 vs 0.944 at 4-bit; 0.779 for both at 2-bit). The cost is speed: api.md's own timings show k=64 is slower than k=10.
TURBOVEC_4BIT_PLANES=0/TURBOVEC_2BIT_PLANES=0. This gives exactly the full-scan result, which is what the setting is for.
Untested ideas:
- Don't let the first-stage shortlist shrink to the 256 floor on data like this. For example, it could be sized by more than k, or widened when the sign-bit scores are tightly bunched.
- Add a note in api.md that domain-tuned and many open-source embedding models can be "unlike the corpora above", alongside the workarounds.
Questions
- Is ~83% agreement at k=10 what you'd expect for data like this? Or is it a sign that the shortlist size should depend on more than k? The current sizes are
max(256, 20 × k)at 4-bit andmax(128, 12.8 × k)in the 2-bit two-stage search. - Would a MedCPT row in the api.md agreement tables be useful?
- The obvious next run is the agreement check on bge-small or bge-base with 100K vectors. Happy to do that, or something else that would help answer (1).
More detail: full agreement table and other observations
Full agreement table (3 × 1,000 queries):
| data | bits | identical id set, k=1 / 10 / 100 | ids shared, k=10 / 100 | recall100@100, staged / full |
|---|---|---|---|---|
| OpenAI d=1536 | 4 | 100% / 100% / 100% | 100% / 100% | 0.974 / 0.974 |
| OpenAI d=1536 | 2 | 100% / 100% / 100% | 100% / 100% | 0.908 / 0.908 |
| MedCPT d=768 | 4 | 99.2% / 82.6% / 57.2% | 97.6% / 99.1% | 0.954 / 0.957 |
| MedCPT d=768 | 2 | 99.6% / 83.1% / 79.1% | 97.7% / 99.7% | 0.798 / 0.798 |
The k=100 drop is mostly swaps at the edge of the list: the lists share 99.1% of ids, and recall is nearly unchanged. At 2-bit, k=10, the true nearest neighbour is missing for 0.50% of queries staged and 0.20% with the full scan.
Harness check, OpenAI d=1536 recall@1 at k=64:
| TQ | TQ+ | FAISS | |
|---|---|---|---|
| 2-bit | 0.881 (suite JSON 0.888) | 0.896 (0.901) | 0.873 (0.876) |
| 4-bit | 0.973 (0.967) | 0.970 (0.959) | 0.964 (0.966) |
Other observations (recall at k=64):
calibrate()matters more on MedCPT. TQ+ minus TQ is +3.8 points of recall@1 at 4-bit and +5.4 at 2-bit, against −0.3 and +1.5 on OpenAI. TQ+ also beats FAISSIndexPQby more: 0.936 vs 0.883 at 4-bit, and 0.737 vs 0.653 at 2-bit.- Centring hurts final recall. On MedCPT, mean-centring the documents lowers recall@1 from 0.936 to 0.895 at 4-bit and from 0.737 to 0.680 at 2-bit. On OpenAI it makes no real difference (0.970 → 0.973 and 0.896 → 0.891). This was a naive user-side variant, not the OSQ-style quantizer quoted in LOG_search.md, so it says little about that idea.
- Duplicate vectors. 605 of the 101K MedCPT rows are exact duplicates of another row, mostly errata and corrections notices without abstracts, non-English records and identically titled letters. They give 1.0–1.4% of queries an exact tie for top-1, which is why the hit test is tie-aware.
- Smaller models on PubMed. I compared bge-small with its biomedical fine-tune, MedEmbed-small, on 11K abstracts (below the planes threshold). Their recall@1 was within 0.5 points of each other at every bit width, so the fine-tune made no measurable difference.
Scripts and raw JSON: https://github.com/nickjlamb/turbovec-biomed-bench. The MedCPT slice comes via range reads, so there's no 3 GB download.
Activity
ryanc-ant commentedon Oct 5, 2026
Will investigate, thanks for posting
RyanCodrai commentedon Oct 5, 2026
Thanks — this is a careful report and it reproduces exactly: on your MedCPT slice at 4-bit, k=10, identical id sets 82.6%, recall10@10 0.931 staged against 0.944 for the whole-index scan, and the true nearest neighbour missing from 0.40% of staged top-10s. 1.0.0 returns precisely the whole-index scan's ids at every k, so this is new in 1.1.0 and it is the staged search.
Where it is lost. Your diagnosis is right, and it is the first stage specifically. Holding everything else fixed and widening the sign-bit shortlist:
The later stages are adequate once the candidates are present (mid 96 → 1024 buys the last two points of agreement, not recall); cutting the exact rescore below 32 loses agreement fast, because the lower-plane ranking misorders inside the top 32.
Why the sign bits lose them here. Depth of the exact top-10 inside a pure sign-bit ranking (64 self-queries, 100K vectors): OpenAI d=1536 median 15, p90 28, max 67; MedCPT median 44, p90 119, max 784 — so a 256 shortlist is roomy on one and not on the other, which is your bunching.
What the fix will be. Not a fixed wider floor for everyone: on this laptop, stage one at 2048 costs about a quarter of the speed at k=10 and ~15% at k=64, and OpenAI-class corpora gain nothing for it. The plan is a wider default (2048) that the index shrinks from the sample it already draws when its planes cache is built — measuring the same depth statistic on self-queries — so corpora like MedCPT keep the width and corpora like OpenAI keep 1.1.1's speed, with
calibrate()re-measuring andmax(256, 20k)as the floor under everything. The gate will be agreement and the suite's timing cells on OpenAI 1536/3072, all-mpnet-base-v2 and your MedCPT slice; a MedCPT row goes into the api.md tables. The bge-small run you offer would be useful as a fifth corpus — bunched but general-purpose.nickjlamb commentedon Oct 5, 2026
Thanks — that's a really clear breakdown, and the depth statistic explains it better than my two hypotheses did. I'm running bge-small-en-v1.5 now on the same 101K PubMed articles as the MedCPT slice (same title/abstract text NCBI fed MedCPT), so the two differ only in the model. I'll post agreement and recall in the same format as your table, and add the script to the repo so it can sit alongside the suite cells.
search: stage widths for 99.9% of the scan's recall; stage two stays …
nickjlamb commentedon Oct 6, 2026
bge-small-en-v1.5 results, on the same 101K PubMed articles as the MedCPT slice. I embedded the title + abstract text NCBI fed MedCPT (
pubmed_chunk_{30,36}.json), truncated to 256 tokens, so the two corpora differ only in the model. d=384, TQ+ index, 3 seeds × 1,000 held-out queries, staged (1.1.1) vs whole-index scan. MedCPT is repeated for reference:So the general-purpose model is hit harder than MedCPT. At 4-bit k=10, agreement is 75.6% (per seed: 73.1 / 78.1 / 75.6%) and recall10@10 drops by 1.9 points. As before, comparing exact scores gives the same picture (76.0%), so duplicates aren't the cause. At k=100 the lists still share 98.0% of ids.
Geometry, on the same text:
bge is slightly less bunched and has wider margins than MedCPT, yet loses more agreement. So neither bunching nor margins alone predicts it. One plausible factor is dimension: at d=384 the sign plane carries half as many bits per vector as at d=768. Your depth statistic captures the effect directly either way.
I also tried to approximate your depth statistic, but treat these as relative numbers only. I used a random orthogonal rotation in place of turbovec's, scored (Rq)·sign(Rx), and took the worst sign-ranking depth of the exact top-10 over 64 self-queries:
My OpenAI figures match yours (15 / 28 / 67), but my MedCPT figures don't (yours: 44 / 119 / 784). I'd guess that's because
calibrate()changes what the sign plane sees. Either way, bge sits with MedCPT, well past a 256 shortlist.One caveat: 232 of the 101K articles have empty text in NCBI's file (non-English articles whose title is "[Not Available]."), so they're empty-input vectors under both models. The tie-aware scoring handles them. I've reported this upstream as ncbi/MedCPT#8.
Scripts and results:
embed_bge_medcpt_slice.py,sign_depth.pyandresults/agree_bge-small-pubmed-101k_*bit.jsonin https://github.com/nickjlamb/turbovec-biomed-bench.search: staged search holds the scan's recall on bunched corpora (#562)…
release: 1.1.2 (#565)
nickjlamb commentedon Oct 6, 2026
Verified the fix on
b30362b, built from source because 1.1.2 isn't on PyPI yet. I used the same harness, slices and seeds as above: TQ+, 3 seeds × 1,000 queries, staged vs whole-index scan, k=10.The MedCPT numbers match the changelog: 99.1% and 0.943. bge-small, which wasn't in the gate, recovers almost as fully. At 4-bit it lands at 99.8% of the scan's recall10@10 (0.9345 vs 0.9361) and 99.85% at k=100 (0.9449 vs 0.9463), so it's just outside the "within 0.1%" stated for MedCPT. I'm flagging that in case you want bge in the gate; I don't think it's a problem. Results JSON:
results/verify_b30362b/in https://github.com/nickjlamb/turbovec-biomed-bench.It's also nice that the fix ended up correcting for the corpus's shared direction in the first stage, which the mean-centring test had hinted at.
One offer: I'd be happy to add MedCPT and bge-small recall/agreement cells to
benchmarks/suite/alongside the existing corpora, using the range-read loader so they don't need a 3 GB download. Could I get contributor access for that?RyanCodrai commentedon Oct 6, 2026
Thank you for checking the fix independently, and for running bge-small on top. The bge numbers show it holds on a general-purpose model that wasn't in the gate, not just on MedCPT. The fix shipped in 1.1.2, now on PyPI and crates.io.
On the benchmark cells, yes please. Before I invite you, I'm tightening how contributor access relates to the repo's agent tokens. Once that's locked down I'll send you an invite, and you can go ahead.