Skip to content

Staged search matches the whole-index scan on ~83% of queries at k=10 on PubMed (MedCPT) embeddings #562

Closed

Description

@nickjlamb

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-large d=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) and search(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:

  1. 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.
  2. 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

  1. 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 and max(128, 12.8 × k) in the 2-bit two-stage search.
  2. Would a MedCPT row in the api.md agreement tables be useful?
  3. 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 FAISS IndexPQ by 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

  1. ryanc-ant commented on Oct 5, 2026

    @ryanc-ant

    Will investigate, thanks for posting

  2. RyanCodrai commented on Oct 5, 2026

    @RyanCodrai
    Owner

    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:

    stage one / mid / rescore (k=10) identical ids recall10@10 NN missed
    256 / 96 / 32 (1.1.1) 82.6% 0.931 0.40%
    1024 / 96 / 32 96.7% 0.942 0.03%
    2048 / 96 / 32 97.2% 0.942 0.03%
    2048 / 1024 / 32 99.5% 0.944 0%
    whole-index scan — 0.944 0%

    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 and max(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.

  3. nickjlamb commented on Oct 5, 2026

    @nickjlamb
    Author

    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.

  4. nickjlamb commented on Oct 6, 2026

    @nickjlamb
    Author

    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:

    corpus bits identical ids k=1 / 10 / 100 recall10@10 staged / full NN missed from top-10, staged / full
    MedCPT d=768 4 99.2% / 82.6% / 57.2% 0.931 / 0.944 0.37% / 0%
    bge-small d=384 4 99.2% / 75.6% / 32.2% 0.917 / 0.936 0.53% / 0%
    MedCPT d=768 2 99.6% / 83.1% / 79.1% 0.775 / 0.779 0.50% / 0.20%
    bge-small d=384 2 99.2% / 69.0% / 47.5% 0.767 / 0.776 1.10% / 0.33%

    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:

    mean pairwise cos ‖mean of unit vectors‖ median top-1 − top-2 gap
    MedCPT 0.647 0.80 0.0069
    bge-small 0.572 0.76 0.0105

    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:

    median p90 max
    OpenAI 14 33 67
    MedCPT 68 310 712
    bge-small 79 287 682

    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.py and results/agree_bge-small-pubmed-101k_*bit.json in https://github.com/nickjlamb/turbovec-biomed-bench.

  5. added a commit that references this issue on Oct 6, 2026
  6. nickjlamb commented on Oct 6, 2026

    @nickjlamb
    Author

    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.

    corpus bits identical ids, 1.1.1 → fix recall10@10, 1.1.1 → fix (scan) NN missed from top-10, 1.1.1 → fix (scan)
    MedCPT d=768 4 82.6% → 99.1% 0.931 → 0.943 (0.944) 0.37% → 0% (0%)
    MedCPT d=768 2 83.1% → 98.8% 0.775 → 0.778 (0.779) 0.50% → 0.23% (0.20%)
    bge-small d=384 4 75.6% → 96.6% 0.917 → 0.935 (0.936) 0.53% → 0% (0%)
    bge-small d=384 2 69.0% → 98.3% 0.767 → 0.776 (0.776) 1.10% → 0.33% (0.33%)
    OpenAI d=1536 4 / 2 100% → 100% unchanged unchanged

    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?

  7. RyanCodrai commented on Oct 6, 2026

    @RyanCodrai
    Owner

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions