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