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), SQ8 (int8
17//! traversal backend, Euclidean/Cosine) and PQ (ADC rescoring) are wired
18//! end-to-end. Persistence across reopens covers TRAIN-QUANTIZER-produced
19//! artifacts (`rabitq.idx`, `sq8.idx`, `codebook.pq`) plus lazily-trained
20//! `RaBitQ`/SQ8 quantizers (persisted by the full flush); a PQ quantizer
21//! trained lazily from inserts (no TRAIN statement) is in-memory only and
22//! retrains after a restart. The Binary collection mode stays full-precision
23//! f32 in the search path. See `docs/guides/QUANTIZATION.md`.
24
25use std::io;
26
27use serde::{Deserialize, Serialize};
28
29/// Validate that a flat row-major rotation matrix has exactly `dimension^2`
30/// elements, returning [`crate::error::Error::IndexCorrupted`] otherwise.
31///
32/// Shared by the PQ (OPQ) and `RaBitQ` load-time validators so the unchecked
33/// `matrix[i * d + j]` indexing in their rotation kernels stays in bounds.
34pub(crate) fn validate_rotation_len(
35 len: usize,
36 dimension: usize,
37 label: &str,
38) -> Result<(), crate::error::Error> {
39 // `checked_mul`: `dimension` is attacker-controlled post-deserialize; a wrapping
40 // `dimension * dimension` (esp. on 32-bit targets) could yield a small `expected`
41 // that a tampered `len` matches, false-passing the shape check that the unchecked
42 // `matrix[i * d + j]` indexing relies on.
43 let Some(expected) = dimension.checked_mul(dimension) else {
44 return Err(crate::error::Error::IndexCorrupted(format!(
45 "{label} rotation dimension {dimension} squared overflows usize"
46 )));
47 };
48 if len != expected {
49 return Err(crate::error::Error::IndexCorrupted(format!(
50 "{label} rotation has {len} elements, expected dimension^2 = {expected}"
51 )));
52 }
53 Ok(())
54}
55
56mod binary;
57pub(crate) mod codec_helpers;
58mod pq;
59pub(crate) mod pq_kmeans;
60pub(crate) mod pq_opq;
61#[cfg(feature = "persistence")]
62mod pq_persistence;
63mod rabitq;
64pub(crate) mod rabitq_store;
65mod scalar;
66
67// Re-export binary quantization
68pub use binary::BinaryQuantizedVector;
69#[allow(unused_imports)] // Called from vector.rs search path (persistence-gated).
70pub(crate) use pq::distance_pq_l2;
71#[allow(unused_imports)] // Called from vector.rs search path (persistence-gated).
72pub(crate) use pq::pq_adc_batch_rescore;
73pub use pq::{PQCodebook, PQVector, ProductQuantizer};
74#[cfg(feature = "persistence")]
75pub use pq_opq::train_opq;
76
77// Re-export RaBitQ quantization
78#[cfg(feature = "persistence")]
79pub use rabitq::PreparedQuery;
80pub use rabitq::{RaBitQCorrection, RaBitQIndex, RaBitQVector};
81#[cfg(feature = "persistence")]
82pub(crate) use rabitq_store::RaBitQVectorStore;
83
84// Re-export scalar quantization
85pub use scalar::{
86 cosine_similarity_quantized, cosine_similarity_quantized_simd, dot_product_quantized,
87 dot_product_quantized_simd, euclidean_squared_quantized, euclidean_squared_quantized_simd,
88 QuantizedVector,
89};
90
91/// Trait for serializing and deserializing quantized vectors to/from bytes.
92///
93/// Provides a uniform interface for byte-level serialization across
94/// different quantization strategies (SQ8, Binary).
95pub trait QuantizationCodec: Sized {
96 /// Serializes the quantized vector to a byte representation.
97 fn to_bytes(&self) -> Vec<u8>;
98
99 /// Deserializes a quantized vector from bytes.
100 ///
101 /// # Errors
102 ///
103 /// Returns an error if the byte slice is too short or contains invalid data.
104 fn from_bytes(bytes: &[u8]) -> io::Result<Self>;
105}
106
107/// Canonical names of every [`StorageMode`] variant, in declaration order.
108///
109/// Single source of truth for the storage-mode name set exported to downstream
110/// crates and bindings (Python `velesdb.STORAGE_MODES`, the integrations
111/// security guard). Each entry is the variant's
112/// [`canonical_name`](StorageMode::canonical_name); a unit test asserts the
113/// slice stays exhaustive so adding a variant without updating it fails CI.
114pub const STORAGE_MODE_NAMES: &[&str] = &["full", "sq8", "binary", "pq", "rabitq"];
115
116/// Storage mode for vectors.
117///
118/// # What each mode actually does
119///
120/// | Mode | Collection storage + search path |
121/// |------|----------------------------------|
122/// | `Full` | f32 (baseline) |
123/// | `SQ8` | int8 graph traversal + exact f32 re-ranking (Euclidean/Cosine; other metrics stay f32) |
124/// | `Binary` | f32 — behaves as `Full` today (use `RaBitQ` for compressed search) |
125/// | `ProductQuantization` | f32 storage + ADC-rescored search (wired) |
126/// | `RaBitQ` | quantized traversal, wired end-to-end |
127///
128/// **Search-path modes (`RaBitQ`, `SQ8`, `ProductQuantization`)** are the
129/// quantized paths wired into the query hot path. All of them keep the f32
130/// vectors for exact re-ranking, so total resident memory is not reduced —
131/// for `RaBitQ` and `SQ8` it rises, since the codes are additive. What those
132/// two shrink is the *un-evictable* floor: their f32 lives in a file-backed
133/// arena the kernel can reclaim. Measured at 100 000 x 768-d, anonymous RSS
134/// falls 61% (385 -> 150 MiB) while total RSS rises 11%. See the measured
135/// tables in `docs/guides/QUANTIZATION.md`. `Binary` is accepted and persisted so
136/// the intent survives a reopen, but changes neither memory use nor the
137/// search path today.
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
139#[serde(rename_all = "lowercase")]
140#[non_exhaustive]
141pub enum StorageMode {
142 /// Full precision f32 storage (default).
143 #[default]
144 Full,
145 /// 8-bit scalar quantization. Search-path mode for Euclidean and Cosine:
146 /// graph traversal compares int8 codes (1 byte/dimension read instead of
147 /// 4) and the final top-k is re-ranked with exact f32 distances. The
148 /// quantizer trains lazily after 1000 inserts (or via `TRAIN QUANTIZER
149 /// type=sq8`) and persists to `sq8.idx`; traversal engages at 10 000+
150 /// vectors, below which search stays exact f32. On other metrics (int8
151 /// L2 cannot preserve their ordering) the collection behaves as
152 /// [`Full`]. The f32 kept for re-ranking sits in a file-backed arena, so
153 /// it is evictable rather than pinned. Measured at 100 000 x 768-d against
154 /// [`Full`]: anonymous RSS 385 -> 150 MiB (-61%), total RSS +11% because
155 /// the codes are additive, and the first re-rank after a reclaim pays
156 /// 8-10 ms per 100 candidates.
157 ///
158 /// [`Full`]: StorageMode::Full
159 SQ8,
160 /// Accepted and persisted, but currently behaves exactly like [`Full`] —
161 /// same status as [`SQ8`](StorageMode::SQ8). For a real quantized search
162 /// path use [`RaBitQ`](StorageMode::RaBitQ) (32x, wired end-to-end).
163 ///
164 /// [`Full`]: StorageMode::Full
165 Binary,
166 /// Product Quantization (PQ) for aggressive lossy compression (8x-16x
167 /// typical). Search-path mode: wired into the query hot path for ADC
168 /// (Asymmetric Distance Computation) rescoring.
169 ProductQuantization,
170 /// `RaBitQ` binary quantization for 32x compression with scalar correction.
171 /// Search-path mode: the performant quantized search path, wired
172 /// end-to-end into the query hot path.
173 RaBitQ,
174}
175
176impl StorageMode {
177 /// Returns the canonical lowercase name for this storage mode.
178 ///
179 /// This is the single source of truth for string representations,
180 /// used by [`std::fmt::Display`], [`std::str::FromStr`], and downstream crates.
181 #[must_use]
182 pub const fn canonical_name(self) -> &'static str {
183 match self {
184 Self::Full => "full",
185 Self::SQ8 => "sq8",
186 Self::Binary => "binary",
187 Self::ProductQuantization => "pq",
188 Self::RaBitQ => "rabitq",
189 }
190 }
191
192 /// Parses a storage mode string with alias support.
193 ///
194 /// Accepted aliases (case-insensitive):
195 /// - `full`, `f32` -> `Full`
196 /// - `sq8`, `int8` -> `SQ8`
197 /// - `binary`, `bit` -> `Binary`
198 /// - `pq`, `product_quantization` -> `ProductQuantization`
199 /// - `rabitq` -> `RaBitQ`
200 ///
201 /// # Examples
202 ///
203 /// ```
204 /// use velesdb_core::StorageMode;
205 ///
206 /// assert_eq!(StorageMode::parse_alias("sq8"), Some(StorageMode::SQ8));
207 /// assert_eq!(StorageMode::parse_alias("INT8"), Some(StorageMode::SQ8));
208 /// assert_eq!(StorageMode::parse_alias("unknown"), None);
209 /// ```
210 #[must_use]
211 pub fn parse_alias(value: &str) -> Option<Self> {
212 match value.trim().to_lowercase().as_str() {
213 "full" | "f32" => Some(Self::Full),
214 "sq8" | "int8" => Some(Self::SQ8),
215 "binary" | "bit" => Some(Self::Binary),
216 "pq" | "product_quantization" => Some(Self::ProductQuantization),
217 "rabitq" => Some(Self::RaBitQ),
218 _ => None,
219 }
220 }
221}
222
223impl std::fmt::Display for StorageMode {
224 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225 f.write_str(self.canonical_name())
226 }
227}
228
229impl std::str::FromStr for StorageMode {
230 type Err = String;
231
232 fn from_str(s: &str) -> Result<Self, Self::Err> {
233 Self::parse_alias(s).ok_or_else(|| {
234 format!(
235 "Unknown storage mode '{s}'. Valid options: full, f32, sq8, int8, binary, bit, pq, product_quantization, rabitq"
236 )
237 })
238 }
239}
240
241#[cfg(test)]
242#[path = "storage_mode_parsing_tests.rs"]
243mod storage_mode_parsing_tests;