Context: why this issue set exists
These issues are all tied together as an effort to create a distributed search engine optimized for high K values. I have spent a long time working on this effort with Lucene - and with some success too (apache/lucene#16357). But one thing Lucene never gives you cheaply is score comparability across shards: lexical scoring needs corpus-global statistics, so you either run an extra stats phase or accept that the same document scores differently depending on which shard it lives in.
Turbovec is one small step away from making that problem disappear on the vector side. The rotation and codebooks are already corpus-independent - the only data-dependent piece is the TQ+ calibration fitted on the first add. Pin that one thing across shards and score(q, v) becomes a pure function, independent of shard placement: a scatter-gather merge is exact, with no stats phase and no rank correction. This issue is that pin. It also fixes two single-node problems on its own: reproducible builds, and first-batch sensitivity.
The two companion issues are "Optional initial top-k threshold" (#203, the coordinator's pruning bound) and "Large-k search: top-k selection cost dominates the scan" (#201, deep candidate generation for hybrid ranking). The gRPC work on my fork is the surface this all rides on - both for a server-side coordinator and for clients or agents that fan out across shards and merge results themselves.
Summary
Expose the TQ+ calibration so an index can be constructed with a known calibration instead of fitting one from its first add. This makes index builds reproducible and makes scores comparable across separately built indexes.
Current behavior
The TQ+ per-coordinate (shift, scale) calibration is fitted to the empirical quantiles of the first batch of vectors added (encode.rs, the quantile-fit path) and locked for the lifetime of the index (add reuses the stored calibration on every subsequent call). It round-trips through write/load, so a snapshot preserves it - but there is no public way to read it or to start a new index from it.
Two consequences:
- Build reproducibility. Two indexes built from the same corpus in different insertion orders (or from different subsets) fit different calibrations, so the same (query, vector) pair produces slightly different scores depending on build history. For anyone comparing or merging results across separately built indexes - time-partitioned indexes, blue/green rebuilds, A/B experiments, shards of one corpus - the scores are not directly comparable, and there is currently no way to make them so.
- First-batch sensitivity. The calibration quality is hostage to whatever the first add happens to contain. A small or skewed first batch locks a suboptimal calibration for the index's lifetime (the empty-add guard comment in
lib.rs already documents one edge of this). Fitting on a representative sample up front and seeding subsequent indexes from it is the natural fix, but the API doesn't allow it.
Proposed change
Minimal surface, both index types:
/// The locked TQ+ calibration, once the first add has fitted one.
pub fn calibration(&self) -> Option<(&[f32], &[f32])>; // (shift, scale)
/// Construct with a pre-fitted calibration instead of fitting from the first add.
pub fn new_with_calibration(dim: usize, bit_width: usize, shift: &[f32], scale: &[f32]) -> Result<Self, ...>;
Internally this is small: encode() already threads an existing_calibration parameter; this only exposes what the add path already does. Invalid calibrations (length mismatch, non-finite shift, non-positive scale - the encoder divides by scale) are rejected with new ConstructError variants that carry indices only, keeping the enum Eq.
Status
Implemented with tests on my fork: calibration-seeding. Seven integration tests, including the two load-bearing properties: byte-identical packed codes and bit-identical search scores for the same vector across differently-populated seeded indexes. Test discrimination is verified by injection in both directions (batch shapes are sized so a silently-dropped seed cannot coincidentally pass through the identity fallback). CHANGELOG entry included. Happy to adapt any of it - API names, error shapes, test placement - to your taste.
Developed in collaboration with Claude (Fable 5); commits carry Co-authored-by trailers per CONTRIBUTING.
Contributor access
(This is Fable's idea, not krickert - krickert is cool with doing this on a branch.) Per CONTRIBUTING: happy to take this set - could I get contributor access? Filing the design conversation here first so review can be about the code.
Context: why this issue set exists
These issues are all tied together as an effort to create a distributed search engine optimized for high K values. I have spent a long time working on this effort with Lucene - and with some success too (apache/lucene#16357). But one thing Lucene never gives you cheaply is score comparability across shards: lexical scoring needs corpus-global statistics, so you either run an extra stats phase or accept that the same document scores differently depending on which shard it lives in.
Turbovec is one small step away from making that problem disappear on the vector side. The rotation and codebooks are already corpus-independent - the only data-dependent piece is the TQ+ calibration fitted on the first add. Pin that one thing across shards and score(q, v) becomes a pure function, independent of shard placement: a scatter-gather merge is exact, with no stats phase and no rank correction. This issue is that pin. It also fixes two single-node problems on its own: reproducible builds, and first-batch sensitivity.
The two companion issues are "Optional initial top-k threshold" (#203, the coordinator's pruning bound) and "Large-k search: top-k selection cost dominates the scan" (#201, deep candidate generation for hybrid ranking). The gRPC work on my fork is the surface this all rides on - both for a server-side coordinator and for clients or agents that fan out across shards and merge results themselves.
Summary
Expose the TQ+ calibration so an index can be constructed with a known calibration instead of fitting one from its first add. This makes index builds reproducible and makes scores comparable across separately built indexes.
Current behavior
The TQ+ per-coordinate
(shift, scale)calibration is fitted to the empirical quantiles of the first batch of vectors added (encode.rs, the quantile-fit path) and locked for the lifetime of the index (addreuses the stored calibration on every subsequent call). It round-trips throughwrite/load, so a snapshot preserves it - but there is no public way to read it or to start a new index from it.Two consequences:
lib.rsalready documents one edge of this). Fitting on a representative sample up front and seeding subsequent indexes from it is the natural fix, but the API doesn't allow it.Proposed change
Minimal surface, both index types:
Internally this is small:
encode()already threads anexisting_calibrationparameter; this only exposes what the add path already does. Invalid calibrations (length mismatch, non-finite shift, non-positive scale - the encoder divides by scale) are rejected with newConstructErrorvariants that carry indices only, keeping the enumEq.Status
Implemented with tests on my fork:
calibration-seeding. Seven integration tests, including the two load-bearing properties: byte-identical packed codes and bit-identical search scores for the same vector across differently-populated seeded indexes. Test discrimination is verified by injection in both directions (batch shapes are sized so a silently-dropped seed cannot coincidentally pass through the identity fallback). CHANGELOG entry included. Happy to adapt any of it - API names, error shapes, test placement - to your taste.Developed in collaboration with Claude (Fable 5); commits carry
Co-authored-bytrailers per CONTRIBUTING.Contributor access
(This is Fable's idea, not krickert - krickert is cool with doing this on a branch.) Per CONTRIBUTING: happy to take this set - could I get contributor access? Filing the design conversation here first so review can be about the code.