Skip to main content

velesdb_core/quantization/
mod.rs

1//! Scalar Quantization (SQ8) and Binary Quantization for memory-efficient vector storage.
2//!
3//! This module implements quantization strategies to reduce memory usage:
4//!
5//! ## Benefits
6//!
7//! | Metric | f32 | SQ8 | Binary |
8//! |--------|-----|-----|--------|
9//! | RAM/vector (768d) | 3 KB | 770 bytes | 96 bytes |
10//! | Cache efficiency | Baseline | ~4x better | ~32x better |
11//! | Recall loss | 0% | ~0.5-1% | ~5-10% |
12//!
13//! ## Engine integration status
14//!
15//! The figures above describe the quantization primitives themselves. In the
16//! collection query path: `RaBitQ` (binary traversal backend) and PQ (ADC
17//! rescoring) are wired end-to-end. Persistence across reopens covers
18//! TRAIN-QUANTIZER-produced artifacts (`rabitq.idx`, `codebook.pq`); a PQ
19//! quantizer trained lazily from inserts (no TRAIN statement) is in-memory
20//! only and retrains after a restart. SQ8/Binary collection modes currently
21//! maintain caches that no search path consumes — collection search stays
22//! full-precision f32 for those modes. See `docs/guides/QUANTIZATION.md`.
23
24use std::io;
25
26use serde::{Deserialize, Serialize};
27
28/// Validate that a flat row-major rotation matrix has exactly `dimension^2`
29/// elements, returning [`crate::error::Error::IndexCorrupted`] otherwise.
30///
31/// Shared by the PQ (OPQ) and `RaBitQ` load-time validators so the unchecked
32/// `matrix[i * d + j]` indexing in their rotation kernels stays in bounds.
33pub(crate) fn validate_rotation_len(
34    len: usize,
35    dimension: usize,
36    label: &str,
37) -> Result<(), crate::error::Error> {
38    // `checked_mul`: `dimension` is attacker-controlled post-deserialize; a wrapping
39    // `dimension * dimension` (esp. on 32-bit targets) could yield a small `expected`
40    // that a tampered `len` matches, false-passing the shape check that the unchecked
41    // `matrix[i * d + j]` indexing relies on.
42    let Some(expected) = dimension.checked_mul(dimension) else {
43        return Err(crate::error::Error::IndexCorrupted(format!(
44            "{label} rotation dimension {dimension} squared overflows usize"
45        )));
46    };
47    if len != expected {
48        return Err(crate::error::Error::IndexCorrupted(format!(
49            "{label} rotation has {len} elements, expected dimension^2 = {expected}"
50        )));
51    }
52    Ok(())
53}
54
55mod binary;
56pub(crate) mod codec_helpers;
57mod pq;
58pub(crate) mod pq_kmeans;
59pub(crate) mod pq_opq;
60#[cfg(feature = "persistence")]
61mod pq_persistence;
62mod rabitq;
63pub(crate) mod rabitq_store;
64mod scalar;
65
66// Re-export binary quantization
67pub use binary::BinaryQuantizedVector;
68#[allow(unused_imports)] // Called from vector.rs search path (persistence-gated).
69pub(crate) use pq::distance_pq_l2;
70#[allow(unused_imports)] // Called from vector.rs search path (persistence-gated).
71pub(crate) use pq::pq_adc_batch_rescore;
72pub use pq::{PQCodebook, PQVector, ProductQuantizer};
73#[cfg(feature = "persistence")]
74pub use pq_opq::train_opq;
75
76// Re-export RaBitQ quantization
77#[cfg(feature = "persistence")]
78pub(crate) use rabitq::PreparedQuery;
79pub use rabitq::{RaBitQCorrection, RaBitQIndex, RaBitQVector};
80#[cfg(feature = "persistence")]
81pub(crate) use rabitq_store::RaBitQVectorStore;
82
83// Re-export scalar quantization
84pub use scalar::{
85    cosine_similarity_quantized, cosine_similarity_quantized_simd, dot_product_quantized,
86    dot_product_quantized_simd, euclidean_squared_quantized, euclidean_squared_quantized_simd,
87    QuantizedVector,
88};
89
90/// Trait for serializing and deserializing quantized vectors to/from bytes.
91///
92/// Provides a uniform interface for byte-level serialization across
93/// different quantization strategies (SQ8, Binary).
94pub trait QuantizationCodec: Sized {
95    /// Serializes the quantized vector to a byte representation.
96    fn to_bytes(&self) -> Vec<u8>;
97
98    /// Deserializes a quantized vector from bytes.
99    ///
100    /// # Errors
101    ///
102    /// Returns an error if the byte slice is too short or contains invalid data.
103    fn from_bytes(bytes: &[u8]) -> io::Result<Self>;
104}
105
106/// Canonical names of every [`StorageMode`] variant, in declaration order.
107///
108/// Single source of truth for the storage-mode name set exported to downstream
109/// crates and bindings (Python `velesdb.STORAGE_MODES`, the integrations
110/// security guard). Each entry is the variant's
111/// [`canonical_name`](StorageMode::canonical_name); a unit test asserts the
112/// slice stays exhaustive so adding a variant without updating it fails CI.
113pub const STORAGE_MODE_NAMES: &[&str] = &["full", "sq8", "binary", "pq", "rabitq"];
114
115/// Storage mode for vectors.
116///
117/// # Capacity mode vs search-path mode
118///
119/// | Mode | Kind | Collection search path |
120/// |------|------|------------------------|
121/// | `Full` | full-precision | f32 (baseline) |
122/// | `SQ8` | **Capacity Mode** | full-precision f32 (memory only, no throughput gain) |
123/// | `Binary` | **Capacity Mode** | full-precision f32 (memory only, no throughput gain) |
124/// | `ProductQuantization` | search-path mode | ADC-rescored (wired) |
125/// | `RaBitQ` | search-path mode | quantized traversal (wired end-to-end) |
126///
127/// **Capacity Modes (`SQ8`, `Binary`)** reduce the in-memory footprint of the
128/// quantization primitives, but the collection search path stays
129/// full-precision f32 for those modes — selecting them does not gain search
130/// throughput. **Search-path modes (`RaBitQ`, `ProductQuantization`)** are the
131/// quantized paths wired into the query hot path. See
132/// `docs/guides/QUANTIZATION.md`.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
134#[serde(rename_all = "lowercase")]
135#[non_exhaustive]
136pub enum StorageMode {
137    /// Full precision f32 storage (default).
138    #[default]
139    Full,
140    /// **Capacity Mode.** 8-bit scalar quantization for 4x memory reduction.
141    /// Reduces the quantization primitive's footprint only; the collection
142    /// search path stays full-precision f32 and gains no search throughput.
143    SQ8,
144    /// **Capacity Mode.** 1-bit binary quantization for 32x memory reduction.
145    /// Best for edge/IoT devices with limited RAM. Reduces footprint only; the
146    /// collection search path stays full-precision f32 and gains no search
147    /// throughput (use `RaBitQ` for a quantized search path).
148    Binary,
149    /// Product Quantization (PQ) for aggressive lossy compression (8x-16x
150    /// typical). Search-path mode: wired into the query hot path for ADC
151    /// (Asymmetric Distance Computation) rescoring.
152    ProductQuantization,
153    /// `RaBitQ` binary quantization for 32x compression with scalar correction.
154    /// Search-path mode: the performant quantized search path, wired
155    /// end-to-end into the query hot path.
156    RaBitQ,
157}
158
159impl StorageMode {
160    /// Returns the canonical lowercase name for this storage mode.
161    ///
162    /// This is the single source of truth for string representations,
163    /// used by [`std::fmt::Display`], [`std::str::FromStr`], and downstream crates.
164    #[must_use]
165    pub const fn canonical_name(self) -> &'static str {
166        match self {
167            Self::Full => "full",
168            Self::SQ8 => "sq8",
169            Self::Binary => "binary",
170            Self::ProductQuantization => "pq",
171            Self::RaBitQ => "rabitq",
172        }
173    }
174
175    /// Parses a storage mode string with alias support.
176    ///
177    /// Accepted aliases (case-insensitive):
178    /// - `full`, `f32` -> `Full`
179    /// - `sq8`, `int8` -> `SQ8`
180    /// - `binary`, `bit` -> `Binary`
181    /// - `pq`, `product_quantization` -> `ProductQuantization`
182    /// - `rabitq` -> `RaBitQ`
183    ///
184    /// # Examples
185    ///
186    /// ```
187    /// use velesdb_core::StorageMode;
188    ///
189    /// assert_eq!(StorageMode::parse_alias("sq8"), Some(StorageMode::SQ8));
190    /// assert_eq!(StorageMode::parse_alias("INT8"), Some(StorageMode::SQ8));
191    /// assert_eq!(StorageMode::parse_alias("unknown"), None);
192    /// ```
193    #[must_use]
194    pub fn parse_alias(value: &str) -> Option<Self> {
195        match value.trim().to_lowercase().as_str() {
196            "full" | "f32" => Some(Self::Full),
197            "sq8" | "int8" => Some(Self::SQ8),
198            "binary" | "bit" => Some(Self::Binary),
199            "pq" | "product_quantization" => Some(Self::ProductQuantization),
200            "rabitq" => Some(Self::RaBitQ),
201            _ => None,
202        }
203    }
204}
205
206impl std::fmt::Display for StorageMode {
207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208        f.write_str(self.canonical_name())
209    }
210}
211
212impl std::str::FromStr for StorageMode {
213    type Err = String;
214
215    fn from_str(s: &str) -> Result<Self, Self::Err> {
216        Self::parse_alias(s).ok_or_else(|| {
217            format!(
218                "Unknown storage mode '{s}'. Valid options: full, f32, sq8, int8, binary, bit, pq, product_quantization, rabitq"
219            )
220        })
221    }
222}
223
224#[cfg(test)]
225mod storage_mode_parsing_tests {
226    use super::{StorageMode, STORAGE_MODE_NAMES};
227
228    /// Forces this test to be revisited whenever a variant is added: the
229    /// exhaustive `match` (no wildcard arm) fails to compile until the new
230    /// variant is listed here, which in turn flags the missing const entry.
231    fn ordinal(mode: StorageMode) -> usize {
232        match mode {
233            StorageMode::Full => 0,
234            StorageMode::SQ8 => 1,
235            StorageMode::Binary => 2,
236            StorageMode::ProductQuantization => 3,
237            StorageMode::RaBitQ => 4,
238        }
239    }
240
241    #[test]
242    fn storage_mode_names_is_exhaustive_and_canonical() {
243        let variants = [
244            StorageMode::Full,
245            StorageMode::SQ8,
246            StorageMode::Binary,
247            StorageMode::ProductQuantization,
248            StorageMode::RaBitQ,
249        ];
250        assert_eq!(variants.len(), STORAGE_MODE_NAMES.len());
251        for (i, variant) in variants.into_iter().enumerate() {
252            assert_eq!(ordinal(variant), i);
253            assert_eq!(STORAGE_MODE_NAMES[i], variant.canonical_name());
254        }
255    }
256
257    #[test]
258    fn test_parse_all_canonical_names() {
259        assert_eq!("full".parse::<StorageMode>().unwrap(), StorageMode::Full);
260        assert_eq!("sq8".parse::<StorageMode>().unwrap(), StorageMode::SQ8);
261        assert_eq!(
262            "binary".parse::<StorageMode>().unwrap(),
263            StorageMode::Binary
264        );
265        assert_eq!(
266            "pq".parse::<StorageMode>().unwrap(),
267            StorageMode::ProductQuantization
268        );
269        assert_eq!(
270            "rabitq".parse::<StorageMode>().unwrap(),
271            StorageMode::RaBitQ
272        );
273    }
274
275    #[test]
276    fn test_parse_aliases() {
277        assert_eq!("f32".parse::<StorageMode>().unwrap(), StorageMode::Full);
278        assert_eq!("int8".parse::<StorageMode>().unwrap(), StorageMode::SQ8);
279        assert_eq!("bit".parse::<StorageMode>().unwrap(), StorageMode::Binary);
280        assert_eq!(
281            "product_quantization".parse::<StorageMode>().unwrap(),
282            StorageMode::ProductQuantization
283        );
284    }
285
286    #[test]
287    fn test_parse_case_insensitive() {
288        assert_eq!("SQ8".parse::<StorageMode>().unwrap(), StorageMode::SQ8);
289        assert_eq!("FULL".parse::<StorageMode>().unwrap(), StorageMode::Full);
290        assert_eq!(
291            "RaBitQ".parse::<StorageMode>().unwrap(),
292            StorageMode::RaBitQ
293        );
294    }
295
296    #[test]
297    fn test_parse_unknown_returns_error() {
298        assert!("unknown".parse::<StorageMode>().is_err());
299        assert!("".parse::<StorageMode>().is_err());
300    }
301
302    #[test]
303    fn test_canonical_name_roundtrip() {
304        for mode in [
305            StorageMode::Full,
306            StorageMode::SQ8,
307            StorageMode::Binary,
308            StorageMode::ProductQuantization,
309            StorageMode::RaBitQ,
310        ] {
311            let name = mode.canonical_name();
312            assert_eq!(name.parse::<StorageMode>().unwrap(), mode);
313        }
314    }
315
316    #[test]
317    fn test_display_uses_canonical_name() {
318        assert_eq!(format!("{}", StorageMode::Full), "full");
319        assert_eq!(format!("{}", StorageMode::SQ8), "sq8");
320        assert_eq!(format!("{}", StorageMode::Binary), "binary");
321        assert_eq!(format!("{}", StorageMode::ProductQuantization), "pq");
322        assert_eq!(format!("{}", StorageMode::RaBitQ), "rabitq");
323    }
324}