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