Skip to main content

velesdb_core/quantization/
rabitq.rs

1//! `RaBitQ`: Randomized Binary Quantization for 32x vector compression.
2//!
3//! Based on arXiv:2405.12497, `RaBitQ` encodes vectors as D-bit binary codes
4//! packed in `Vec<u64>` with scalar correction factors. Distance estimation
5//! uses XOR + popcount on u64 words plus affine correction.
6//!
7//! ## Compression
8//!
9//! | Metric | f32 | RaBitQ |
10//! |--------|-----|--------|
11//! | RAM/vector (768d) | 3 KB | ~96 bytes + 8 bytes correction |
12//! | Compression ratio | 1x | 32x |
13
14use crate::error::Error;
15#[cfg(feature = "persistence")]
16use crate::storage::atomic_write::atomic_write;
17use serde::{Deserialize, Serialize};
18
19/// Scalar correction factors for a `RaBitQ`-encoded vector.
20///
21/// `vector_norm` restores the metric scale during distance estimation.
22#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
23pub struct RaBitQCorrection {
24    /// L2 norm of the centered vector before binarization.
25    pub vector_norm: f32,
26    /// Inner product between the binary reconstruction (`±1/√D` scaled) and the
27    /// rotated normalized vector. Measures quantization quality; closer to 1.0
28    /// is better. NOT consumed by the current symmetric (query-binarized)
29    /// estimator, whose de-bias is the arcsine-law transform in
30    /// [`RaBitQIndex::distance_from_prepared_slice`]; it is the per-vector
31    /// rescale the paper's asymmetric estimator (real-valued query) divides
32    /// by, kept in the persisted format for that upgrade path (#2104).
33    pub quantization_ip: f32,
34}
35
36/// Binary-quantized vector with scalar correction factors.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct RaBitQVector {
39    /// Binary codes packed in u64 words. Length = `ceil(D / 64)`.
40    pub bits: Vec<u64>,
41    /// Affine correction factors used to recover an accurate distance estimate.
42    pub correction: RaBitQCorrection,
43}
44
45/// `RaBitQ` index holding the random rotation matrix and dataset centroid.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct RaBitQIndex {
48    /// Random orthogonal rotation matrix (flattened `D x D`, row-major).
49    pub rotation: Vec<f32>,
50    /// Dataset centroid for centering.
51    pub centroid: Vec<f32>,
52    /// Vector dimension.
53    pub dimension: usize,
54}
55
56/// Pack sign bits of a float slice into u64 words.
57///
58/// Bit `i` of word `w` is 1 if `values[w*64 + i] >= 0.0`.
59/// The output length is `ceil(dim / 64)`. Padding bits in the last word are
60/// always zero (values beyond `dim` are not written), which is required by
61/// `xor_popcount_ip` for correct padding adjustment.
62#[must_use]
63pub(crate) fn signs_to_bits(values: &[f32], dim: usize) -> Vec<u64> {
64    let num_words = dim.div_ceil(64);
65    let mut bits = vec![0u64; num_words];
66    for (i, &v) in values.iter().take(dim).enumerate() {
67        if v >= 0.0 {
68            let word = i / 64;
69            let bit = i % 64;
70            bits[word] |= 1u64 << bit;
71        }
72    }
73    bits
74}
75
76/// Bitmask selecting the in-use bits of the last u64 word for `dim` dimensions.
77///
78/// Returns `u64::MAX` when `dim` is a multiple of 64 (the last word is fully
79/// used) or when `dim == 0`. Otherwise returns a mask with the low `dim % 64`
80/// bits set, so a bitwise-AND on the last word zeroes the high padding bits.
81#[must_use]
82pub(crate) const fn last_word_mask(dim: usize) -> u64 {
83    let rem = dim % 64;
84    if rem == 0 {
85        u64::MAX
86    } else {
87        (1u64 << rem) - 1
88    }
89}
90
91/// Apply a flat row-major rotation matrix to a vector.
92///
93/// Computes `result[i] = sum_j rotation[i * dim + j] * vector[j]` for each `i`.
94///
95/// F-12: Uses SIMD dot product per row instead of scalar iterator chain.
96/// For dim=768, this is ~8x faster (SIMD dot product vs scalar sum).
97#[must_use]
98pub(crate) fn apply_rotation_flat(rotation: &[f32], vector: &[f32], dim: usize) -> Vec<f32> {
99    (0..dim)
100        .map(|i| {
101            let row_start = i * dim;
102            crate::simd_native::dot_product_native(&rotation[row_start..row_start + dim], vector)
103        })
104        .collect()
105}
106
107/// Compute XOR+popcount inner product estimate from query bits and encoded bits.
108///
109/// Uses SIMD-dispatched XOR+popcount (AVX-512 VPOPCNTDQ / AVX-512F / AVX2 / NEON / scalar)
110/// via `hamming_binary_native` for the XOR+popcount hot loop.
111///
112/// Returns the binary inner product in `[-1, 1]` range.
113fn xor_popcount_ip(q_bits: &[u64], enc_bits: &[u64], num_words: usize, dim: usize) -> f32 {
114    // hamming_binary_native returns the number of DIFFERING bits (XOR popcount).
115    // Padding bits are 0 in both vectors (signs_to_bits zeroes beyond `dim`),
116    // so they XOR to 0 and are NOT counted as differing.
117    // Therefore: matching_bits = dim - differing_bits.
118    let differing_bits =
119        crate::simd_native::hamming_binary_native(&q_bits[..num_words], &enc_bits[..num_words]);
120
121    // When padding bits are zero, `differing_bits <= dim`, so
122    // `matching_bits = dim - differing_bits` is exact. Clean vectors satisfy
123    // this: `signs_to_bits` zeroes padding on encode and `RaBitQVectorStore::push`
124    // masks it on store (the only paths that populate the bit store).
125    //
126    // This clamp is defense-in-depth for any vector whose padding bits are
127    // nonetheless set (e.g. a tampered in-memory value): set padding bits would
128    // push `differing_bits` above `dim`. We clamp so `matching_bits` cannot
129    // underflow (u32 wraparound → garbage / non-finite distance), keeping the
130    // estimate finite and within `[-1, 1]`. This is graceful degradation, not
131    // exact recovery — exactness comes from the masking on the encode/store path.
132    // Reason: dim fits in u32 for any practical vector dimension (< 2^32).
133    #[allow(clippy::cast_possible_truncation)]
134    let dim_u32 = dim as u32;
135    let matching_bits = dim_u32 - differing_bits.min(dim_u32);
136
137    // Inner product estimate: each matching bit contributes +1/D,
138    // each differing bit contributes -1/D.
139    #[allow(clippy::cast_precision_loss)]
140    let d_f = dim as f32;
141    #[allow(clippy::cast_precision_loss)]
142    let ip = (2.0f32.mul_add(matching_bits as f32, -d_f)) / d_f;
143    ip
144}
145
146/// Preprocessed query data for `RaBitQ` distance computation.
147///
148/// RF-DEDUP: Shared between `distance` and `batch_distance` to eliminate
149/// the repeated center-normalize-rotate-bitsign preprocessing pipeline.
150///
151/// `pub(crate)` for Phase 3 integration (`RaBitQ` HNSW search path).
152pub struct PreparedQuery {
153    /// Squared L2 norm of the centered query.
154    pub(crate) norm_sq: f32,
155    /// L2 norm of the centered query.
156    pub(crate) norm: f32,
157    /// Sign bits of the rotated normalized query.
158    pub(crate) bits: Vec<u64>,
159    /// Number of u64 words in the bit representation.
160    pub(crate) num_words: usize,
161    /// Rotated normalized vector (used by encode for correction factors).
162    pub(crate) rotated: Vec<f32>,
163}
164
165impl RaBitQIndex {
166    /// Centers, normalizes, rotates, and extracts sign bits from a vector.
167    ///
168    /// Returns `None` when the centered vector has near-zero norm.
169    ///
170    /// `pub(crate)` for Phase 3 integration (`RaBitQ` HNSW search path).
171    pub(crate) fn prepare_query(&self, vector: &[f32]) -> Option<PreparedQuery> {
172        let centered: Vec<f32> = vector
173            .iter()
174            .zip(self.centroid.iter())
175            .map(|(&v, &c)| v - c)
176            .collect();
177
178        let norm_sq: f32 = centered.iter().map(|&x| x * x).sum();
179        let norm = norm_sq.sqrt();
180
181        if norm < f32::EPSILON {
182            return None;
183        }
184
185        let normalized: Vec<f32> = centered.iter().map(|&x| x / norm).collect();
186        let rotated = apply_rotation_flat(&self.rotation, &normalized, self.dimension);
187        let bits = signs_to_bits(&rotated, self.dimension);
188        let num_words = self.dimension.div_ceil(64);
189
190        Some(PreparedQuery {
191            norm_sq,
192            norm,
193            bits,
194            num_words,
195            rotated,
196        })
197    }
198
199    /// Computes L2 distance from a prepared query to an encoded vector.
200    ///
201    /// `pub(crate)` for Phase 3 integration (`RaBitQ` HNSW search path).
202    pub(crate) fn distance_from_prepared(&self, pq: &PreparedQuery, encoded: &RaBitQVector) -> f32 {
203        self.distance_from_prepared_slice(pq, &encoded.bits, encoded.correction)
204    }
205
206    /// Zero-copy L2 distance from a prepared query to raw bits + correction.
207    ///
208    /// Avoids the `Vec<u64>` allocation that `distance_from_prepared` would
209    /// require when the caller only has a borrowed slice (e.g. from
210    /// [`RaBitQVectorStore::get_bits_slice`]).
211    ///
212    /// `pub(crate)` for Phase 3 integration (`RaBitQ` HNSW search path).
213    pub(crate) fn distance_from_prepared_slice(
214        &self,
215        pq: &PreparedQuery,
216        bits: &[u64],
217        correction: RaBitQCorrection,
218    ) -> f32 {
219        let ip_binary = xor_popcount_ip(&pq.bits, bits, pq.num_words, self.dimension);
220
221        // De-bias the symmetric binary inner product (#2104). Both sides are
222        // binarized here, so per coordinate of a random rotation the sign
223        // agreement probability is 1 - θ/π, giving E[ip_binary] = 1 - 2θ/π —
224        // NOT cos θ. Using ip_binary directly overestimates every non-zero
225        // angle's distance by a norm-dependent amount, distorting candidate
226        // ranking (e.g. θ = 60°: ip_binary ≈ 1/3 where cos θ = 0.5). Invert
227        // the map: cos θ = cos(π/2 · (1 - ip_binary)) = sin(π/2 · ip_binary).
228        // The transform is monotone on [-1, 1], so same-norm ordering is
229        // preserved while cross-norm comparisons become unbiased.
230        let cos_est = (std::f32::consts::FRAC_PI_2 * ip_binary).sin();
231
232        let v_norm = correction.vector_norm;
233        let estimated_ip = pq.norm * v_norm * cos_est;
234        let l2_sq = v_norm.mul_add(v_norm, pq.norm_sq) - 2.0 * estimated_ip;
235        l2_sq.max(0.0).sqrt()
236    }
237
238    /// Encode a vector into a [`RaBitQVector`].
239    ///
240    /// Steps:
241    /// 1. Center the vector (subtract centroid).
242    /// 2. Compute the L2 norm of the centered vector.
243    /// 3. Normalize (handle zero-norm gracefully).
244    /// 4. Apply rotation matrix.
245    /// 5. Extract sign bits into u64 words.
246    /// 6. Compute correction factors.
247    ///
248    /// # Errors
249    ///
250    /// Returns `Error::InvalidQuantizerConfig` if vector dimension mismatches.
251    pub fn encode(&self, vector: &[f32]) -> Result<RaBitQVector, Error> {
252        if vector.len() != self.dimension {
253            return Err(Error::InvalidQuantizerConfig(format!(
254                "RaBitQ encode: expected dimension {}, got {}",
255                self.dimension,
256                vector.len()
257            )));
258        }
259
260        let Some(pq) = self.prepare_query(vector) else {
261            let num_words = self.dimension.div_ceil(64);
262            return Ok(RaBitQVector {
263                bits: vec![0u64; num_words],
264                correction: RaBitQCorrection {
265                    vector_norm: 0.0,
266                    quantization_ip: 1.0,
267                },
268            });
269        };
270
271        // Compute correction factors
272        // The binary reconstruction maps each sign bit to +1/-1, scaled by 1/sqrt(D).
273        // quantization_inner_product = <binary_reconstruction, rotated_normalized>
274        #[allow(clippy::cast_precision_loss)]
275        let scale = 1.0 / (self.dimension as f32).sqrt();
276        let mut qip: f32 = 0.0;
277        for (i, &rv) in pq.rotated.iter().enumerate().take(self.dimension) {
278            let word = i / 64;
279            let bit = i % 64;
280            let sign = if (pq.bits[word] >> bit) & 1 == 1 {
281                1.0
282            } else {
283                -1.0
284            };
285            qip = (sign * scale).mul_add(rv, qip);
286        }
287
288        Ok(RaBitQVector {
289            bits: pq.bits,
290            correction: RaBitQCorrection {
291                vector_norm: pq.norm,
292                quantization_ip: qip,
293            },
294        })
295    }
296
297    /// Estimate the L2 distance between a raw query vector and an encoded vector.
298    ///
299    /// Uses XOR + popcount for fast Hamming-based inner product estimation,
300    /// then applies affine correction with stored norms.
301    #[must_use]
302    pub fn distance(&self, query: &[f32], encoded: &RaBitQVector) -> f32 {
303        let Some(pq) = self.prepare_query(query) else {
304            // Query is at centroid; distance = norm of encoded vector
305            return encoded.correction.vector_norm;
306        };
307        self.distance_from_prepared(&pq, encoded)
308    }
309
310    /// Batch distance: process query once, then iterate over encoded vectors.
311    ///
312    /// Amortizes query preprocessing (centering, normalization, rotation, sign extraction).
313    #[must_use]
314    pub fn batch_distance(&self, query: &[f32], encoded: &[RaBitQVector]) -> Vec<f32> {
315        let Some(pq) = self.prepare_query(query) else {
316            return encoded.iter().map(|e| e.correction.vector_norm).collect();
317        };
318
319        encoded
320            .iter()
321            .map(|ev| self.distance_from_prepared(&pq, ev))
322            .collect()
323    }
324}
325
326/// Training and persistence methods (require `persistence` feature for rayon).
327#[cfg(feature = "persistence")]
328impl RaBitQIndex {
329    /// Train a `RaBitQ` index from a set of vectors.
330    ///
331    /// Computes dataset centroid and generates a random orthogonal rotation
332    /// matrix via modified Gram-Schmidt orthogonalization of a random matrix.
333    ///
334    /// # Errors
335    ///
336    /// Returns `Error::InvalidQuantizerConfig` if:
337    /// - `vectors` is empty
338    /// - vectors have inconsistent dimensions
339    /// - vector dimension is 0
340    pub fn train(vectors: &[Vec<f32>], seed: u64) -> Result<Self, Error> {
341        if vectors.is_empty() {
342            return Err(Error::InvalidQuantizerConfig(
343                "cannot train RaBitQ with empty dataset".into(),
344            ));
345        }
346
347        let dimension = vectors[0].len();
348        if dimension == 0 {
349            return Err(Error::InvalidQuantizerConfig(
350                "vectors must have non-zero dimension".into(),
351            ));
352        }
353        if !vectors.iter().all(|v| v.len() == dimension) {
354            return Err(Error::InvalidQuantizerConfig(
355                "all vectors must share the same dimension".into(),
356            ));
357        }
358
359        // Compute centroid (element-wise mean)
360        let mut centroid = vec![0.0f32; dimension];
361        for v in vectors {
362            for (ci, &vi) in centroid.iter_mut().zip(v.iter()) {
363                *ci += vi;
364            }
365        }
366        #[allow(clippy::cast_precision_loss)]
367        let inv_n = 1.0 / vectors.len() as f32;
368        for x in &mut centroid {
369            *x *= inv_n;
370        }
371
372        // Generate random orthogonal matrix via modified Gram-Schmidt
373        let rotation = generate_orthogonal_matrix(dimension, seed);
374
375        Ok(Self {
376            rotation,
377            centroid,
378            dimension,
379        })
380    }
381
382    /// Save `RaBitQ` index to `<dir>/rabitq.idx` using postcard with atomic write.
383    ///
384    /// # Errors
385    ///
386    /// Returns `Error::Io` if serialization or file I/O fails.
387    pub fn save(&self, dir: &std::path::Path) -> Result<(), Error> {
388        let data = postcard::to_allocvec(self).map_err(|e| {
389            Error::Io(std::io::Error::new(
390                std::io::ErrorKind::InvalidData,
391                format!("failed to serialize RaBitQ index: {e}"),
392            ))
393        })?;
394        let final_path = dir.join("rabitq.idx");
395        atomic_write(&final_path, &data).map_err(|e| {
396            Error::Io(std::io::Error::new(
397                e.kind(),
398                format!("failed to write RaBitQ index: {e}"),
399            ))
400        })
401    }
402
403    /// Load `RaBitQ` index from `<dir>/rabitq.idx`. Returns `None` if file doesn't exist.
404    ///
405    /// The decoded index is validated (`validate_loaded`) so a corrupt
406    /// rotation/centroid is rejected here rather than producing out-of-bounds
407    /// indexing in `apply_rotation_flat` during search.
408    ///
409    /// # Errors
410    ///
411    /// Returns `Error::Io` if deserialization or file I/O fails, or
412    /// `Error::IndexCorrupted` if the file exceeds the size cap or the decoded
413    /// rotation/centroid lengths are inconsistent with `dimension`.
414    pub fn load(dir: &std::path::Path) -> Result<Option<Self>, Error> {
415        let path = dir.join("rabitq.idx");
416        if !path.exists() {
417            return Ok(None);
418        }
419        let file_len = std::fs::metadata(&path)?.len();
420        if file_len > MAX_RABITQ_INDEX_BYTES {
421            return Err(Error::IndexCorrupted(format!(
422                "RaBitQ index file is {file_len} bytes, exceeds cap {MAX_RABITQ_INDEX_BYTES}"
423            )));
424        }
425        let data = std::fs::read(&path)?;
426        let index: Self = postcard::from_bytes(&data).map_err(|e| {
427            Error::Io(std::io::Error::new(
428                std::io::ErrorKind::InvalidData,
429                format!("failed to deserialize RaBitQ index: {e}"),
430            ))
431        })?;
432        index.validate_loaded()?;
433        Ok(Some(index))
434    }
435
436    /// Validate a deserialized index's structural invariants.
437    ///
438    /// # Errors
439    ///
440    /// Returns `Error::IndexCorrupted` if `dimension` is zero, the centroid
441    /// length is not `dimension`, or the rotation length is not `dimension^2`.
442    fn validate_loaded(&self) -> Result<(), Error> {
443        if self.dimension == 0 {
444            return Err(Error::IndexCorrupted(
445                "RaBitQ index has zero dimension".into(),
446            ));
447        }
448        if self.centroid.len() != self.dimension {
449            return Err(Error::IndexCorrupted(format!(
450                "RaBitQ centroid has {} elements, expected dimension {}",
451                self.centroid.len(),
452                self.dimension
453            )));
454        }
455        super::validate_rotation_len(self.rotation.len(), self.dimension, "RaBitQ")?;
456        Ok(())
457    }
458}
459
460/// Maximum accepted size of a persisted `RaBitQ` index file.
461///
462/// The index stores a `dimension^2` rotation matrix plus a `dimension`
463/// centroid. For the documented stability ceiling (`dimension <= 2048`) the
464/// rotation is `2048^2` f32 ≈ 16 MiB; the 256 MiB cap rejects hostile files
465/// before they trigger a huge allocation. (Addresses the alloc-cap concern of
466/// #897.)
467#[cfg(feature = "persistence")]
468const MAX_RABITQ_INDEX_BYTES: u64 = 256 * 1024 * 1024;
469
470/// Generate a random orthogonal matrix using modified Gram-Schmidt.
471///
472/// Creates a D x D random matrix from a seeded RNG, then orthogonalizes it
473/// using modified Gram-Schmidt (numerically stable for D <= 2048).
474/// Returns the matrix flattened in row-major order.
475///
476/// # Complexity
477///
478/// Time complexity: O(d³) for Modified Gram-Schmidt on a d×d matrix.
479/// For d=1024 this is ~10⁹ f64 operations. Only called during training.
480///
481/// # Numerical stability
482///
483/// MGS produces near-orthogonal matrices for D up to ~1024 at f32 precision.
484/// For D > 1024, accumulated rounding errors can make the result noticeably
485/// non-orthogonal (‖Rᵀ R − I‖_F may exceed 1e-3). If higher-dimensional
486/// rotations are required, consider Householder QR or double-precision MGS.
487#[cfg(feature = "persistence")]
488fn generate_orthogonal_matrix(dim: usize, seed: u64) -> Vec<f32> {
489    use rand::{RngExt, SeedableRng};
490
491    let mut rng = rand::rngs::StdRng::seed_from_u64(seed);
492
493    // Generate random D x D matrix (column-major for easier Gram-Schmidt)
494    // columns[j][i] = element at row i, column j
495    let mut columns: Vec<Vec<f32>> = (0..dim)
496        .map(|_| (0..dim).map(|_| rng.random::<f32>() * 2.0 - 1.0).collect())
497        .collect();
498
499    // Modified Gram-Schmidt orthogonalization
500    for j in 0..dim {
501        // Normalize column j
502        let norm: f32 = columns[j].iter().map(|&x| x * x).sum::<f32>().sqrt();
503        if norm > f32::EPSILON {
504            for x in &mut columns[j] {
505                *x /= norm;
506            }
507        }
508
509        // Subtract projection of remaining columns onto column j
510        for k in (j + 1)..dim {
511            let dot: f32 = columns[j]
512                .iter()
513                .zip(columns[k].iter())
514                .map(|(&a, &b)| a * b)
515                .sum();
516            let proj: Vec<f32> = columns[j].iter().map(|&x| dot * x).collect();
517            for (ck, p) in columns[k].iter_mut().zip(proj.iter()) {
518                *ck -= p;
519            }
520        }
521    }
522
523    // Convert column-major to row-major flattened format
524    let mut rotation = vec![0.0f32; dim * dim];
525    for i in 0..dim {
526        for j in 0..dim {
527            rotation[i * dim + j] = columns[j][i];
528        }
529    }
530
531    rotation
532}
533
534#[cfg(test)]
535#[path = "rabitq_tests.rs"]
536mod tests;