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}