Skip to main content

foxstash_core/vector/
rabitq.rs

1//! RaBitQ quantization — theoretically-grounded 1-bit quantization with an
2//! unbiased distance estimator (Gao & Long, "RaBitQ", SIGMOD 2024).
3//!
4//! Unlike [`BinaryQuantizer`](super::quantize::BinaryQuantizer), which packs
5//! sign bits and compares them with crude Hamming distance, RaBitQ:
6//!
7//! 1. Subtracts a shared **centroid** and works on unit residuals.
8//! 2. Applies a shared random **orthonormal rotation** `R`, which spreads
9//!    information evenly across coordinates (so no axis is privileged) and is
10//!    what makes the 1-bit code informative.
11//! 3. Stores, per vector, the sign bits plus two scalars that drive an
12//!    **unbiased estimator** of the inner product — giving a real distance
13//!    estimate (with a provable error bound) at the same 32x compression as
14//!    binary, rather than a Hamming proxy.
15//!
16//! The estimate is good enough to use as the first stage of a two-phase
17//! search: rank by the RaBitQ estimate, then rerank the top candidates with
18//! exact full-precision distance (see [`RaBitQuantizer::prepare_query`]).
19//!
20//! # Derivation (folded for the hot path)
21//!
22//! Let `c` be the centroid, `o_res = o - c`, `dtc = ‖o_res‖`, and `ro = R·o_res`.
23//! With sign bits `bᵢ = [roᵢ ≥ 0]` and `L1 = Σ|roᵢ|`, define the per-vector
24//! `est_factor = dtc² / L1`. For a query with `q_res = q - c` and rotated
25//! `rq = R·q_res`, let `S = Σ(2bᵢ − 1)·rqᵢ`. Then
26//!
27//! ```text
28//! ⟨o_res, q_res⟩ ≈ est_factor · S
29//! ‖o − q‖²       ≈ dtc² + ‖q_res‖² − 2 · est_factor · S
30//! ```
31//!
32//! The query norm cancels in the cross term, so per-candidate cost is one O(D)
33//! signed accumulation over `rq`; the only O(D²) work is rotating the query once.
34//!
35//! This module implements base (1-bit) RaBitQ. Extended RaBitQ (configurable
36//! B-bit) builds on the same rotation + estimator and is a planned follow-up.
37
38use rand::{rngs::StdRng, RngExt, SeedableRng};
39use serde::{Deserialize, Serialize};
40
41
42/// Default seed for the rotation, so builds are reproducible by default.
43const DEFAULT_SEED: u64 = 0x5241_4249_5451_5121; // "RABITQ!" ish
44
45/// RaBitQ 1-bit quantizer (32x compression, unbiased distance estimator).
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct RaBitQuantizer {
48    dim: usize,
49    /// Shared centroid (mean of training data).
50    centroid: Vec<f32>,
51    /// Row-major `dim x dim` orthonormal rotation matrix `R`.
52    rotation: Vec<f32>,
53}
54
55/// RaBitQ code for one vector: sign bits plus the two estimator scalars.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct RaBitCode {
58    /// Packed sign bits of the rotated residual (`ceil(dim/8)` bytes).
59    pub bits: Vec<u8>,
60    /// `dtc²` — squared distance from the centroid.
61    pub dtc_sq: f32,
62    /// `est_factor = dtc² / ‖R·(o−c)‖₁` — the inner-product rescale.
63    pub est_factor: f32,
64}
65
66/// A query rotated into RaBitQ space, reusable across many candidate estimates.
67pub struct PreparedQuery {
68    /// `R·(q − c)` — rotated raw residual (length `dim`).
69    rq: Vec<f32>,
70    /// `‖q − c‖²`.
71    qn_sq: f32,
72}
73
74impl PreparedQuery {
75    /// `R·(q − c)` — the rotated query residual, borrowed.
76    ///
77    /// Exposed for callers that fold the estimator into their own SIMD kernel rather than
78    /// going through [`RaBitQuantizer::estimate_dist_sq`] — that method takes `&RaBitCode`,
79    /// which owns a `Vec<u8>`, so building one per candidate to call it would allocate on
80    /// every distance computation in a hot graph-traversal loop.
81    pub fn rq(&self) -> &[f32] {
82        &self.rq
83    }
84
85    /// `‖q − c‖²`.
86    pub fn qn_sq(&self) -> f32 {
87        self.qn_sq
88    }
89}
90
91impl RaBitQuantizer {
92    /// Fit a quantizer from training vectors using the default rotation seed.
93    ///
94    /// Computes the centroid as the per-dimension mean and generates a seeded
95    /// random orthonormal rotation. Reproducible across runs.
96    ///
97    /// # Panics
98    /// Panics if `training_vectors` is empty or has inconsistent dimensions.
99    pub fn fit(training_vectors: &[Vec<f32>]) -> Self {
100        Self::fit_with_seed(training_vectors, DEFAULT_SEED)
101    }
102
103    /// Fit with an explicit rotation seed.
104    pub fn fit_with_seed(training_vectors: &[Vec<f32>], seed: u64) -> Self {
105        assert!(
106            !training_vectors.is_empty(),
107            "Need at least one training vector"
108        );
109        let dim = training_vectors[0].len();
110        assert!(dim > 0, "Dimension must be positive");
111
112        let mut centroid = vec![0.0f32; dim];
113        for v in training_vectors {
114            assert_eq!(v.len(), dim, "Inconsistent vector dimensions");
115            for (c, &x) in centroid.iter_mut().zip(v.iter()) {
116                *c += x;
117            }
118        }
119        let inv_n = 1.0 / training_vectors.len() as f32;
120        for c in &mut centroid {
121            *c *= inv_n;
122        }
123
124        let rotation = random_orthonormal(dim, seed);
125        Self {
126            dim,
127            centroid,
128            rotation,
129        }
130    }
131
132
133    /// Dimensionality.
134    pub fn dim(&self) -> usize {
135        self.dim
136    }
137
138    /// Encode a vector into a RaBitQ code.
139    pub fn encode(&self, vector: &[f32]) -> RaBitCode {
140        debug_assert_eq!(vector.len(), self.dim);
141
142        // Residual from centroid, and its norm.
143        let mut res = vec![0.0f32; self.dim];
144        let mut dtc_sq = 0.0f32;
145        for ((r, &v), &c) in res.iter_mut().zip(vector).zip(&self.centroid) {
146            *r = v - c;
147            dtc_sq += (v - c) * (v - c);
148        }
149
150        // Rotate the residual: ro = R · res.
151        let ro = self.matvec(&res);
152
153        // Sign bits + L1 norm of the rotated residual.
154        let mut bits = vec![0u8; self.bytes()];
155        let mut l1 = 0.0f32;
156        for (i, &x) in ro.iter().enumerate() {
157            l1 += x.abs();
158            if x >= 0.0 {
159                bits[i / 8] |= 1 << (i % 8);
160            }
161        }
162
163        // est_factor = dtc² / L1, guarded against the degenerate (vector == centroid) case.
164        let est_factor = if l1 > f32::EPSILON { dtc_sq / l1 } else { 0.0 };
165
166        RaBitCode {
167            bits,
168            dtc_sq,
169            est_factor,
170        }
171    }
172
173    /// Prepare a query once for repeated [`estimate_dist_sq`](Self::estimate_dist_sq) calls.
174    pub fn prepare_query(&self, query: &[f32]) -> PreparedQuery {
175        debug_assert_eq!(query.len(), self.dim);
176        let mut res = vec![0.0f32; self.dim];
177        let mut qn_sq = 0.0f32;
178        for ((r, &q), &c) in res.iter_mut().zip(query).zip(&self.centroid) {
179            *r = q - c;
180            qn_sq += (q - c) * (q - c);
181        }
182        let rq = self.matvec(&res);
183        PreparedQuery { rq, qn_sq }
184    }
185
186    /// Estimate squared L2 distance between a prepared query and a code.
187    ///
188    /// `S = Σ (2bᵢ − 1) · rqᵢ`, then `‖o−q‖² ≈ dtc² + ‖q−c‖² − 2·est_factor·S`.
189    pub fn estimate_dist_sq(&self, query: &PreparedQuery, code: &RaBitCode) -> f32 {
190        let mut s = 0.0f32;
191        for (i, &rq) in query.rq.iter().enumerate() {
192            let bit = (code.bits[i / 8] >> (i % 8)) & 1;
193            // (2b - 1): +rq when bit set, -rq when clear.
194            if bit == 1 {
195                s += rq;
196            } else {
197                s -= rq;
198            }
199        }
200        let dsq = code.dtc_sq + query.qn_sq - 2.0 * code.est_factor * s;
201        dsq.max(0.0)
202    }
203
204    /// Number of bytes in a packed code.
205    fn bytes(&self) -> usize {
206        self.dim.div_ceil(8)
207    }
208
209    /// `R · v` (row-major matrix-vector product).
210    fn matvec(&self, v: &[f32]) -> Vec<f32> {
211        let d = self.dim;
212        let mut out = vec![0.0f32; d];
213        for (r, o) in out.iter_mut().enumerate() {
214            let row = &self.rotation[r * d..(r + 1) * d];
215            *o = super::simd::dot_product_simd(row, v);
216        }
217        out
218    }
219
220    /// `Rᵀ · v` — used only for the (lossy) dequantize path.
221    fn matvec_transpose(&self, v: &[f32]) -> Vec<f32> {
222        let d = self.dim;
223        let mut out = vec![0.0f32; d];
224        for (r, &vr) in v.iter().enumerate() {
225            let row = &self.rotation[r * d..(r + 1) * d];
226            for (o, &rc) in out.iter_mut().zip(row) {
227                *o += rc * vr;
228            }
229        }
230        out
231    }
232}
233
234/// Inherent, not a trait impl.
235///
236/// These used to satisfy a `Quantizer` trait in `vector::quantize`, which had three implementors
237/// and was used polymorphically by nothing -- no `dyn Quantizer`, no `T: Quantizer` bound anywhere
238/// in the workspace. The other two implementors (`ScalarQuantizer`, `BinaryQuantizer`) were a
239/// SECOND implementation of SQ8, which the index never called: `hnsw.rs` has its own SoA layout
240/// and its own AVX2 kernels in `vector::simd`. Two copies of one idea, one of them shipped and
241/// one of them merely benchmarked. That is the shape of every bug in the 1.0 audit, so the copy
242/// nobody ran was deleted and the abstraction over it went with it.
243impl RaBitQuantizer {
244    /// Encode a vector to its 1-bit code. See [`Self::encode`].
245    pub fn quantize(&self, vector: &[f32]) -> RaBitCode {
246        self.encode(vector)
247    }
248
249    /// Reconstruct an approximate vector from its code. Lossy (sign-only) but directionally
250    /// correct; used by [`Self::distance_symmetric`].
251    pub fn dequantize(&self, quantized: &RaBitCode) -> Vec<f32> {
252        // Reconstruct o ≈ c + dtc · Rᵀ · x̄, where x̄ᵢ = ±1/√D from the sign bits.
253        // Lossy (sign-only), but directionally correct.
254        let d = self.dim;
255        let inv_sqrt_d = 1.0 / (d as f32).sqrt();
256        let mut xbar = vec![0.0f32; d];
257        for (i, x) in xbar.iter_mut().enumerate() {
258            let bit = (quantized.bits[i / 8] >> (i % 8)) & 1;
259            *x = if bit == 1 { inv_sqrt_d } else { -inv_sqrt_d };
260        }
261        let dir = self.matvec_transpose(&xbar);
262        let dtc = quantized.dtc_sq.sqrt();
263        dir.iter()
264            .zip(&self.centroid)
265            .map(|(&u, &c)| c + dtc * u)
266            .collect()
267    }
268
269    /// Distance between two codes, with one side dequantized -- RaBitQ's estimator is asymmetric,
270    /// so there is no honest code-to-code distance.
271    pub fn distance_quantized(&self, a: &RaBitCode, b: &RaBitCode) -> f32 {
272        let a_full = self.dequantize(a);
273        self.distance_asymmetric(&a_full, b)
274    }
275
276    /// Distance from a full-precision query to a code. This is the estimator the index uses,
277    /// though the hot path goes through `simd::rabitq_asymmetric_l2_simd` rather than here.
278    pub fn distance_asymmetric(&self, query: &[f32], quantized: &RaBitCode) -> f32 {
279        let prepared = self.prepare_query(query);
280        self.estimate_dist_sq(&prepared, quantized).sqrt()
281    }
282}
283
284/// Generate a `dim x dim` orthonormal matrix (row-major) via Gram–Schmidt on
285/// seeded Gaussian vectors. Deterministic for a given `(dim, seed)`.
286fn random_orthonormal(dim: usize, seed: u64) -> Vec<f32> {
287    let mut rng = StdRng::seed_from_u64(seed);
288    let mut rows: Vec<Vec<f32>> = Vec::with_capacity(dim);
289
290    for _ in 0..dim {
291        // Fresh Gaussian row.
292        let mut v: Vec<f32> = (0..dim).map(|_| gaussian(&mut rng)).collect();
293
294        // Orthogonalize against previously accepted rows (modified Gram–Schmidt).
295        for prev in &rows {
296            let proj = dot(&v, prev);
297            for (vi, &pi) in v.iter_mut().zip(prev) {
298                *vi -= proj * pi;
299            }
300        }
301
302        // Normalize; if it collapsed (near-dependent), resample.
303        let mut norm = dot(&v, &v).sqrt();
304        while norm < 1e-6 {
305            v = (0..dim).map(|_| gaussian(&mut rng)).collect();
306            for prev in &rows {
307                let proj = dot(&v, prev);
308                for (vi, &pi) in v.iter_mut().zip(prev) {
309                    *vi -= proj * pi;
310                }
311            }
312            norm = dot(&v, &v).sqrt();
313        }
314        let inv = 1.0 / norm;
315        for vi in &mut v {
316            *vi *= inv;
317        }
318        rows.push(v);
319    }
320
321    let mut flat = Vec::with_capacity(dim * dim);
322    for row in rows {
323        flat.extend_from_slice(&row);
324    }
325    flat
326}
327
328#[inline]
329fn dot(a: &[f32], b: &[f32]) -> f32 {
330    a.iter().zip(b).map(|(&x, &y)| x * y).sum()
331}
332
333/// Standard-normal sample via Box–Muller.
334#[inline]
335fn gaussian(rng: &mut StdRng) -> f32 {
336    let u1: f32 = rng.random::<f32>().max(1e-7);
337    let u2: f32 = rng.random::<f32>();
338    (-2.0 * u1.ln()).sqrt() * (std::f32::consts::TAU * u2).cos()
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    fn rng_vec(rng: &mut StdRng, dim: usize) -> Vec<f32> {
346        (0..dim).map(|_| rng.random::<f32>() * 2.0 - 1.0).collect()
347    }
348
349    #[test]
350    fn rotation_is_orthonormal() {
351        let d = 64;
352        let r = random_orthonormal(d, 123);
353        // Rows should be unit-norm and mutually orthogonal: R·Rᵀ ≈ I.
354        for i in 0..d {
355            for j in 0..d {
356                let ri = &r[i * d..(i + 1) * d];
357                let rj = &r[j * d..(j + 1) * d];
358                let prod = dot(ri, rj);
359                let expected = if i == j { 1.0 } else { 0.0 };
360                assert!(
361                    (prod - expected).abs() < 1e-3,
362                    "R·Rᵀ[{i},{j}] = {prod}, expected {expected}"
363                );
364            }
365        }
366    }
367
368    #[test]
369    fn rotation_is_deterministic() {
370        assert_eq!(random_orthonormal(32, 42), random_orthonormal(32, 42));
371    }
372
373    #[test]
374    fn estimator_is_approximately_unbiased() {
375        // The mean estimated squared distance should track the true one closely.
376        let mut rng = StdRng::seed_from_u64(7);
377        let dim = 128;
378        let train: Vec<Vec<f32>> = (0..500).map(|_| rng_vec(&mut rng, dim)).collect();
379        let q = RaBitQuantizer::fit(&train);
380
381        let mut rel_errs = Vec::new();
382        for _ in 0..200 {
383            let o = rng_vec(&mut rng, dim);
384            let query = rng_vec(&mut rng, dim);
385            let code = q.encode(&o);
386            let prep = q.prepare_query(&query);
387            let est = q.estimate_dist_sq(&prep, &code);
388            let truth: f32 = o.iter().zip(&query).map(|(a, b)| (a - b) * (a - b)).sum();
389            rel_errs.push((est - truth) / truth);
390        }
391        let mean_bias: f32 = rel_errs.iter().sum::<f32>() / rel_errs.len() as f32;
392        // Unbiased ⇒ mean relative error near zero (loose bound for 1-bit).
393        assert!(
394            mean_bias.abs() < 0.10,
395            "estimator mean relative bias too large: {mean_bias}"
396        );
397    }
398
399    #[test]
400    fn rerank_recall_beats_hamming_floor() {
401        // End-to-end gate: rank by RaBitQ estimate, rerank top candidates by
402        // exact distance, and require high recall@10. This is the property that
403        // makes RaBitQ usable as a first-stage filter.
404        let mut rng = StdRng::seed_from_u64(99);
405        let dim = 128;
406        let n = 2000;
407        let base: Vec<Vec<f32>> = (0..n).map(|_| rng_vec(&mut rng, dim)).collect();
408        let q = RaBitQuantizer::fit(&base);
409        let codes: Vec<RaBitCode> = base.iter().map(|v| q.encode(v)).collect();
410
411        let k = 10;
412        let rerank = 100; // first-stage candidate pool
413        let mut total_recall = 0.0;
414        let trials = 50;
415        for _ in 0..trials {
416            let query = rng_vec(&mut rng, dim);
417
418            // Ground truth top-k by exact L2².
419            let mut exact: Vec<(f32, usize)> = base
420                .iter()
421                .enumerate()
422                .map(|(i, v)| {
423                    (
424                        v.iter().zip(&query).map(|(a, b)| (a - b) * (a - b)).sum(),
425                        i,
426                    )
427                })
428                .collect();
429            exact.sort_by(|a, b| a.0.total_cmp(&b.0));
430            let truth: std::collections::HashSet<usize> =
431                exact.iter().take(k).map(|(_, i)| *i).collect();
432
433            // Stage 1: rank all by RaBitQ estimate, keep top `rerank`.
434            let prep = q.prepare_query(&query);
435            let mut est: Vec<(f32, usize)> = codes
436                .iter()
437                .enumerate()
438                .map(|(i, c)| (q.estimate_dist_sq(&prep, c), i))
439                .collect();
440            est.sort_by(|a, b| a.0.total_cmp(&b.0));
441
442            // Stage 2: rerank the pool by exact distance, take top-k.
443            let mut pool: Vec<(f32, usize)> = est
444                .iter()
445                .take(rerank)
446                .map(|&(_, i)| {
447                    let d: f32 = base[i]
448                        .iter()
449                        .zip(&query)
450                        .map(|(a, b)| (a - b) * (a - b))
451                        .sum();
452                    (d, i)
453                })
454                .collect();
455            pool.sort_by(|a, b| a.0.total_cmp(&b.0));
456            let got: std::collections::HashSet<usize> =
457                pool.iter().take(k).map(|(_, i)| *i).collect();
458
459            total_recall += truth.intersection(&got).count() as f32 / k as f32;
460        }
461        let recall = total_recall / trials as f32;
462        assert!(recall > 0.80, "RaBitQ rerank recall@10 too low: {recall}");
463    }
464
465    #[test]
466    fn quantizer_trait_roundtrip() {
467        let mut rng = StdRng::seed_from_u64(5);
468        let dim = 96;
469        let train: Vec<Vec<f32>> = (0..200).map(|_| rng_vec(&mut rng, dim)).collect();
470        let q = RaBitQuantizer::fit(&train);
471
472        let v = rng_vec(&mut rng, dim);
473        let code = q.quantize(&v);
474        assert_eq!(code.bits.len(), dim.div_ceil(8));
475
476        // Asymmetric self-distance should be small relative to a random pair.
477        let self_d = q.distance_asymmetric(&v, &code);
478        let other = rng_vec(&mut rng, dim);
479        let other_d = q.distance_asymmetric(&other, &code);
480        assert!(
481            self_d < other_d,
482            "self distance {self_d} should be < cross distance {other_d}"
483        );
484
485        // Dequantize returns the right shape.
486        assert_eq!(q.dequantize(&code).len(), dim);
487    }
488}