Skip to main content

nodedb_vector/quantize/
sq8.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Scalar Quantization (SQ8): FP32 → INT8 per-dimension.
4//!
5//! Each dimension is independently quantized to `[0, 255]` using per-dimension
6//! min/max calibration. This is the **default production quantization** for
7//! HNSW traversal: 4x RAM reduction with <1% recall loss.
8//!
9//! Distance computation uses asymmetric mode: query stays in FP32,
10//! candidates are in INT8. This avoids quantizing the query and
11//! preserves accuracy at the cost of a dequantize-per-dimension
12//! during distance computation.
13//!
14//! Storage: D bytes per vector (vs 4D bytes for FP32).
15
16use serde::{Deserialize, Serialize};
17
18use crate::error::{VectorError, check_dim};
19
20/// Magic bytes identifying a serialized [`Sq8Codec`] blob.
21///
22/// Format: `[NDSQ\0\0 (6 bytes)][version: u8 = 1][msgpack payload]`
23pub const MAGIC: &[u8; 6] = b"NDSQ\0\0";
24
25/// Wire format version for [`Sq8Codec`] serialization.
26pub const SQ8_FORMAT_VERSION: u8 = 1;
27
28/// SQ8 calibration parameters: per-dimension min/max.
29#[derive(Clone, Serialize, Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack)]
30pub struct Sq8Codec {
31    pub dim: usize,
32    /// Per-dimension minimum observed during calibration.
33    mins: Vec<f32>,
34    /// Per-dimension maximum observed during calibration.
35    maxs: Vec<f32>,
36    /// Pre-computed per-dimension scale: `(max - min) / 255.0`.
37    /// Zero if max == min (constant dimension → all quantize to 0).
38    scales: Vec<f32>,
39    /// Pre-computed per-dimension inverse scale: `255.0 / (max - min)`.
40    inv_scales: Vec<f32>,
41}
42
43impl Sq8Codec {
44    /// Calibrate min/max from a set of training vectors.
45    ///
46    /// Scans all vectors to find per-dimension min/max bounds.
47    /// At least 1000 vectors recommended for stable calibration;
48    /// for fewer vectors the bounds may be tight, causing clipping
49    /// on future inserts outside the calibration range.
50    ///
51    /// An empty set or a zero `dim` fails with [`VectorError::InvalidInput`];
52    /// a vector without `dim` components fails with
53    /// [`VectorError::DimensionMismatch`].
54    pub fn calibrate(vectors: &[&[f32]], dim: usize) -> Result<Self, VectorError> {
55        if vectors.is_empty() {
56            return Err(VectorError::InvalidInput {
57                detail: "SQ8 calibration needs at least one vector".into(),
58            });
59        }
60        if dim == 0 {
61            return Err(VectorError::InvalidInput {
62                detail: "SQ8 calibration needs a non-zero dimension".into(),
63            });
64        }
65        for v in vectors {
66            check_dim(dim, v.len())?;
67        }
68
69        let mut mins = vec![f32::MAX; dim];
70        let mut maxs = vec![f32::MIN; dim];
71
72        for v in vectors {
73            for d in 0..dim {
74                if v[d] < mins[d] {
75                    mins[d] = v[d];
76                }
77                if v[d] > maxs[d] {
78                    maxs[d] = v[d];
79                }
80            }
81        }
82
83        let mut scales = vec![0.0f32; dim];
84        let mut inv_scales = vec![0.0f32; dim];
85        for d in 0..dim {
86            let range = maxs[d] - mins[d];
87            if range > f32::EPSILON {
88                scales[d] = range / 255.0;
89                inv_scales[d] = 255.0 / range;
90            }
91        }
92
93        Ok(Self {
94            dim,
95            mins,
96            maxs,
97            scales,
98            inv_scales,
99        })
100    }
101
102    /// A codec over the unit range `[0, 1]` on every dimension, for
103    /// normalized embeddings before any calibration data exists.
104    pub fn unit_range(dim: usize) -> Self {
105        Self {
106            dim,
107            mins: vec![0.0; dim],
108            maxs: vec![1.0; dim],
109            scales: vec![1.0 / 255.0; dim],
110            inv_scales: vec![255.0; dim],
111        }
112    }
113
114    /// Quantize a single FP32 vector to INT8.
115    pub fn quantize(&self, vector: &[f32]) -> Vec<u8> {
116        debug_assert_eq!(vector.len(), self.dim);
117        let mut out = Vec::with_capacity(self.dim);
118        for ((&v, &min), (&max, &inv_scale)) in vector
119            .iter()
120            .zip(self.mins.iter())
121            .zip(self.maxs.iter().zip(self.inv_scales.iter()))
122        {
123            let clamped = v.clamp(min, max);
124            let q = ((clamped - min) * inv_scale).round() as u8;
125            out.push(q);
126        }
127        out
128    }
129
130    /// Batch quantize: quantize all vectors into a contiguous byte array.
131    ///
132    /// Returns `dim * N` bytes laid out as `[v0_d0, v0_d1, ..., v1_d0, ...]`.
133    pub fn quantize_batch(&self, vectors: &[&[f32]]) -> Vec<u8> {
134        let mut out = Vec::with_capacity(self.dim * vectors.len());
135        for v in vectors {
136            out.extend(self.quantize(v));
137        }
138        out
139    }
140
141    /// Dequantize INT8 back to FP32 (lossy reconstruction).
142    pub fn dequantize(&self, quantized: &[u8]) -> Vec<f32> {
143        debug_assert_eq!(quantized.len(), self.dim);
144        let mut out = Vec::with_capacity(self.dim);
145        for ((&q, &min), &scale) in quantized
146            .iter()
147            .zip(self.mins.iter())
148            .zip(self.scales.iter())
149        {
150            out.push(min + q as f32 * scale);
151        }
152        out
153    }
154
155    /// Asymmetric L2 squared distance: query (FP32) vs candidate (INT8).
156    ///
157    /// This is the hot-path function used during HNSW traversal.
158    /// The query stays in full precision; only the candidate is quantized.
159    #[inline]
160    pub fn asymmetric_l2(&self, query: &[f32], candidate: &[u8]) -> f32 {
161        debug_assert_eq!(query.len(), self.dim);
162        debug_assert_eq!(candidate.len(), self.dim);
163        let mut sum = 0.0f32;
164        for d in 0..self.dim {
165            let dequant = self.mins[d] + candidate[d] as f32 * self.scales[d];
166            let diff = query[d] - dequant;
167            sum += diff * diff;
168        }
169        sum
170    }
171
172    /// Asymmetric cosine distance: query (FP32) vs candidate (INT8).
173    #[inline]
174    pub fn asymmetric_cosine(&self, query: &[f32], candidate: &[u8]) -> f32 {
175        debug_assert_eq!(query.len(), self.dim);
176        debug_assert_eq!(candidate.len(), self.dim);
177        let mut dot = 0.0f32;
178        let mut norm_q = 0.0f32;
179        let mut norm_c = 0.0f32;
180        for d in 0..self.dim {
181            let dequant = self.mins[d] + candidate[d] as f32 * self.scales[d];
182            dot += query[d] * dequant;
183            norm_q += query[d] * query[d];
184            norm_c += dequant * dequant;
185        }
186        let denom = (norm_q * norm_c).sqrt();
187        if denom < f32::EPSILON {
188            return 1.0;
189        }
190        (1.0 - dot / denom).max(0.0)
191    }
192
193    /// Asymmetric negative inner product: query (FP32) vs candidate (INT8).
194    #[inline]
195    pub fn asymmetric_ip(&self, query: &[f32], candidate: &[u8]) -> f32 {
196        debug_assert_eq!(query.len(), self.dim);
197        debug_assert_eq!(candidate.len(), self.dim);
198        let mut dot = 0.0f32;
199        for d in 0..self.dim {
200            let dequant = self.mins[d] + candidate[d] as f32 * self.scales[d];
201            dot += query[d] * dequant;
202        }
203        -dot
204    }
205
206    /// Dimension count.
207    pub fn dim(&self) -> usize {
208        self.dim
209    }
210
211    /// Serialize the codec to bytes with a versioned magic header.
212    ///
213    /// Format: `[NDSQ\0\0 (6 bytes)][version: u8 = 1][msgpack payload]`
214    pub fn to_bytes(&self) -> Vec<u8> {
215        let payload = zerompk::to_msgpack_vec(self).unwrap_or_default();
216        let mut out = Vec::with_capacity(7 + payload.len());
217        out.extend_from_slice(MAGIC);
218        out.push(SQ8_FORMAT_VERSION);
219        out.extend_from_slice(&payload);
220        out
221    }
222
223    /// Deserialize the codec from bytes produced by [`Self::to_bytes`].
224    ///
225    /// Returns `VectorError::InvalidMagic` if the header does not match
226    /// `NDSQ\0\0`, and `VectorError::UnsupportedVersion` for unknown versions.
227    pub fn from_bytes(bytes: &[u8]) -> Result<Self, VectorError> {
228        if bytes.len() < 7 || &bytes[0..6] != MAGIC {
229            return Err(VectorError::InvalidMagic);
230        }
231        let version = bytes[6];
232        if version != SQ8_FORMAT_VERSION {
233            return Err(VectorError::UnsupportedVersion {
234                found: version,
235                expected: SQ8_FORMAT_VERSION,
236            });
237        }
238        zerompk::from_msgpack::<Self>(&bytes[7..])
239            .map_err(|e| VectorError::DeserializationFailed(e.to_string()))
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    fn make_codec() -> Sq8Codec {
248        let vecs: Vec<Vec<f32>> = (0..100)
249            .map(|i| vec![i as f32 * 0.1, (i as f32).sin(), (i as f32).cos()])
250            .collect();
251        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
252        Sq8Codec::calibrate(&refs, 3).unwrap()
253    }
254
255    #[test]
256    fn sq8_codec_golden_format() {
257        let codec = make_codec();
258        let bytes = codec.to_bytes();
259        // First 6 bytes are magic.
260        assert_eq!(&bytes[0..6], MAGIC);
261        // Byte 6 is the version.
262        assert_eq!(bytes[6], SQ8_FORMAT_VERSION);
263        // Bytes 7+ must decode back to a valid Sq8Codec.
264        let decoded = zerompk::from_msgpack::<Sq8Codec>(&bytes[7..]).unwrap();
265        assert_eq!(decoded.dim, 3);
266    }
267
268    #[test]
269    fn sq8_roundtrip() {
270        let codec = make_codec();
271        let bytes = codec.to_bytes();
272        let restored = Sq8Codec::from_bytes(&bytes).unwrap();
273        assert_eq!(restored.dim, codec.dim);
274        assert_eq!(restored.inv_scales.len(), codec.inv_scales.len());
275        for (a, b) in restored.inv_scales.iter().zip(codec.inv_scales.iter()) {
276            assert!((a - b).abs() < 1e-6, "inv_scales mismatch: {a} vs {b}");
277        }
278    }
279
280    #[test]
281    fn sq8_invalid_magic_returns_error() {
282        let mut bytes = make_codec().to_bytes();
283        bytes[0] = b'X'; // corrupt magic
284        assert!(matches!(
285            Sq8Codec::from_bytes(&bytes),
286            Err(VectorError::InvalidMagic)
287        ));
288    }
289
290    #[test]
291    fn sq8_version_mismatch_returns_error() {
292        let mut bytes = make_codec().to_bytes();
293        bytes[6] = 0; // wrong version
294        assert!(matches!(
295            Sq8Codec::from_bytes(&bytes),
296            Err(VectorError::UnsupportedVersion {
297                found: 0,
298                expected: 1
299            })
300        ));
301    }
302
303    fn make_vectors() -> Vec<Vec<f32>> {
304        (0..100)
305            .map(|i| vec![i as f32 * 0.1, (i as f32).sin(), (i as f32).cos()])
306            .collect()
307    }
308
309    #[test]
310    fn quantize_dequantize_roundtrip() {
311        let vecs = make_vectors();
312        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
313        let codec = Sq8Codec::calibrate(&refs, 3).unwrap();
314
315        for v in &vecs {
316            let q = codec.quantize(v);
317            let dq = codec.dequantize(&q);
318            for d in 0..3 {
319                let error = (v[d] - dq[d]).abs();
320                let range = codec.maxs[d] - codec.mins[d];
321                // Error should be at most half a quantization step.
322                assert!(
323                    error <= range / 255.0 + 1e-6,
324                    "d={d}: error={error}, max_step={}",
325                    range / 255.0
326                );
327            }
328        }
329    }
330
331    #[test]
332    fn asymmetric_l2_close_to_exact() {
333        let vecs = make_vectors();
334        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
335        let codec = Sq8Codec::calibrate(&refs, 3).unwrap();
336
337        let query = &[5.0, 0.5, -0.5];
338        for v in &vecs {
339            let q = codec.quantize(v);
340            let exact = crate::distance::l2_squared(query, v);
341            let approx = codec.asymmetric_l2(query, &q);
342            // Allow up to 5% relative error.
343            let rel_error = if exact > 0.01 {
344                (exact - approx).abs() / exact
345            } else {
346                (exact - approx).abs()
347            };
348            assert!(
349                rel_error < 0.05 || (exact - approx).abs() < 0.1,
350                "exact={exact}, approx={approx}, rel_error={rel_error}"
351            );
352        }
353    }
354
355    #[test]
356    fn batch_quantize() {
357        let vecs = make_vectors();
358        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
359        let codec = Sq8Codec::calibrate(&refs, 3).unwrap();
360
361        let batch = codec.quantize_batch(&refs);
362        assert_eq!(batch.len(), 3 * 100);
363
364        // First vector should match individual quantize.
365        let single = codec.quantize(&vecs[0]);
366        assert_eq!(&batch[0..3], &single[..]);
367    }
368
369    #[test]
370    fn constant_dimension_handled() {
371        // All vectors have the same value in dimension 0.
372        let vecs: Vec<Vec<f32>> = (0..10).map(|i| vec![5.0, i as f32]).collect();
373        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
374        let codec = Sq8Codec::calibrate(&refs, 2).unwrap();
375
376        // Constant dimension should quantize to 0 without NaN/inf.
377        let q = codec.quantize(&[5.0, 3.0]);
378        assert_eq!(q[0], 0); // constant dim
379    }
380
381    #[test]
382    fn calibrate_rejects_bad_input_with_typed_errors() {
383        use crate::error::VectorError;
384        assert!(matches!(
385            Sq8Codec::calibrate(&[], 3),
386            Err(VectorError::InvalidInput { .. })
387        ));
388        let v = [1.0_f32, 2.0];
389        assert!(matches!(
390            Sq8Codec::calibrate(&[&v], 0),
391            Err(VectorError::InvalidInput { .. })
392        ));
393        assert!(matches!(
394            Sq8Codec::calibrate(&[&v], 3),
395            Err(VectorError::DimensionMismatch {
396                expected: 3,
397                got: 2
398            })
399        ));
400    }
401}