Skip to main content

vecdb_core/index/
scalar.rs

1//! Scalar (int8) quantization — an optional in-memory index mode.
2//!
3//! Each float32 dimension is linearly mapped to a signed 8-bit code using a
4//! per-dimension min/scale learned from the data (asymmetric quantization: the
5//! query stays full-precision, only stored vectors are quantized). The index is
6//! a flat brute-force scanner over the codes.
7//!
8//! Memory: codes are 1 byte/dim vs 4 bytes/dim for float32 → ~4× smaller
9//! in-memory footprint. The on-disk float32 vectors remain authoritative and
10//! are used to retrain the quantizer on rebuild.
11//!
12//! Distance kernel: reconstructing every stored vector to float per comparison
13//! is wasteful. Instead we expand the distance so the per-vector hot loop is a
14//! single `f32·i8` dot product, using precomputed norms:
15//!
16//! A stored vector reconstructs as `x_i = o_i + s_i·c_i` where `o_i = min_i +
17//! 128·s_i` and `s_i` is the per-dim scale. Then `⟨q,x⟩ = ⟨q,o⟩ + Σ_i (q_i·s_i)·c_i`.
18//! `⟨q,o⟩`, `‖q‖²`, and `b_i = q_i·s_i` are computed once per query; `‖x‖²` is
19//! precomputed per stored vector. From `⟨q,x⟩`, `‖q‖²`, `‖x‖²` we recover the
20//! exact L2 / cosine / dot score — same numbers as reconstructing, one mul-add
21//! per dim over 1-byte codes.
22
23use std::collections::{HashMap, HashSet};
24use std::path::Path;
25
26use serde::{Deserialize, Serialize};
27
28use crate::errors::{Result, VecDbError};
29use crate::index::backend::IndexBackend;
30use crate::index::distance;
31use crate::types::{CollectionConfig, DistanceMetric, IndexType, Vector, VectorId};
32
33// ──────────────────────────────────────────────
34// ScalarQuantizer — per-dimension int8 codec
35// ──────────────────────────────────────────────
36
37/// Per-dimension linear int8 quantizer. Codes are `i8` in `[-128, 127]`.
38#[derive(Debug, Clone, Serialize, Deserialize, Default)]
39pub struct ScalarQuantizer {
40    /// Per-dimension minimum value seen during fit.
41    pub dmin: Vec<f32>,
42    /// Per-dimension step size: `(max - min) / 255`. Zero-range dims use 1.0.
43    pub dscale: Vec<f32>,
44}
45
46impl ScalarQuantizer {
47    /// Learn per-dimension min/scale from a sample of vectors.
48    pub fn fit(dimension: usize, sample: &[Vector]) -> Self {
49        let mut dmin = vec![f32::INFINITY; dimension];
50        let mut dmax = vec![f32::NEG_INFINITY; dimension];
51        for v in sample {
52            for (i, &x) in v.iter().enumerate().take(dimension) {
53                if x < dmin[i] {
54                    dmin[i] = x;
55                }
56                if x > dmax[i] {
57                    dmax[i] = x;
58                }
59            }
60        }
61        let mut dscale = vec![1.0f32; dimension];
62        for i in 0..dimension {
63            if !dmin[i].is_finite() {
64                dmin[i] = 0.0;
65            }
66            let range = dmax[i] - dmin[i];
67            dscale[i] = if range > 0.0 { range / 255.0 } else { 1.0 };
68        }
69        Self { dmin, dscale }
70    }
71
72    #[inline]
73    pub fn dimension(&self) -> usize {
74        self.dmin.len()
75    }
76
77    /// Quantize one full-precision vector to int8 codes.
78    pub fn quantize(&self, v: &[f32]) -> Vec<i8> {
79        let d = self.dimension();
80        let mut out = vec![0i8; d];
81        for i in 0..d {
82            let u = ((v[i] - self.dmin[i]) / self.dscale[i]).round();
83            let code = (u.clamp(0.0, 255.0) as i32) - 128;
84            out[i] = code as i8;
85        }
86        out
87    }
88
89    /// Reconstruct dimension `i` of a code back to full precision.
90    #[inline]
91    pub fn reconstruct_at(&self, code: i8, i: usize) -> f32 {
92        self.dmin[i] + ((code as i32 + 128) as f32) * self.dscale[i]
93    }
94
95    /// Reconstruct a full code slice into a float vector.
96    pub fn dequantize(&self, codes: &[i8]) -> Vec<f32> {
97        (0..self.dimension())
98            .map(|i| self.reconstruct_at(codes[i], i))
99            .collect()
100    }
101
102    /// Per-dimension offset `o_i = min_i + 128·scale_i` (the constant part of
103    /// the reconstruction `x_i = o_i + scale_i·c_i`).
104    fn offsets(&self) -> Vec<f32> {
105        (0..self.dimension())
106            .map(|i| self.dmin[i] + 128.0 * self.dscale[i])
107            .collect()
108    }
109}
110
111// ──────────────────────────────────────────────
112// ScalarQuantizedIndex — flat int8 scanner
113// ──────────────────────────────────────────────
114
115pub struct ScalarQuantizedIndex {
116    dimension: usize,
117    metric: DistanceMetric,
118    quantizer: ScalarQuantizer,
119    trained: bool,
120    /// int8 codes, row-major: vector `pos` occupies `codes[pos*d .. (pos+1)*d]`.
121    codes: Vec<i8>,
122    /// ‖x‖² of each stored (reconstructed) vector, parallel to `pos_to_id`.
123    dnorm: Vec<f32>,
124    id_to_pos: HashMap<VectorId, usize>,
125    pos_to_id: Vec<VectorId>,
126    deleted: HashSet<VectorId>,
127    /// Vectors inserted before the quantizer is trained, kept until the next
128    /// (re)train materializes them into codes.
129    pending: Vec<(VectorId, Vector)>,
130}
131
132/// Train once the pending buffer reaches this many vectors (also triggered
133/// lazily when a search forces materialization).
134const TRAIN_THRESHOLD: usize = 256;
135
136impl ScalarQuantizedIndex {
137    pub fn new(config: &CollectionConfig) -> Self {
138        Self {
139            dimension: config.dimension,
140            metric: config.metric.clone(),
141            quantizer: ScalarQuantizer::default(),
142            trained: false,
143            codes: Vec::new(),
144            dnorm: Vec::new(),
145            id_to_pos: HashMap::new(),
146            pos_to_id: Vec::new(),
147            deleted: HashSet::new(),
148            pending: Vec::new(),
149        }
150    }
151
152    /// Approximate resident memory of the code store, in bytes (the figure the
153    /// quantization mode is meant to shrink). Counts codes + per-dim params +
154    /// the per-vector norm cache; excludes id strings, which the float path
155    /// also carries.
156    pub fn code_bytes(&self) -> usize {
157        self.codes.len() * std::mem::size_of::<i8>()
158            + self.dnorm.len() * std::mem::size_of::<f32>()
159            + self.quantizer.dmin.len() * std::mem::size_of::<f32>() * 2
160    }
161
162    fn maybe_normalize(&self, v: Vec<f32>) -> Vec<f32> {
163        if matches!(self.metric, DistanceMetric::Cosine) {
164            distance::normalize(&v)
165        } else {
166            v
167        }
168    }
169
170    fn to_score(&self, dist: f32) -> f32 {
171        match self.metric {
172            DistanceMetric::Cosine => 1.0 - dist,
173            DistanceMetric::Euclidean => 1.0 / (1.0 + dist),
174            DistanceMetric::DotProduct => 1.0 - dist,
175        }
176    }
177
178    /// (Re)train the quantizer from `vectors` and encode them all, replacing any
179    /// existing state. Cosine vectors are normalized before encoding.
180    fn fit_and_encode(&mut self, vectors: Vec<(VectorId, Vector)>) -> Result<()> {
181        self.id_to_pos.clear();
182        self.pos_to_id.clear();
183        self.codes.clear();
184        self.dnorm.clear();
185        self.deleted.clear();
186        self.pending.clear();
187
188        if vectors.is_empty() {
189            self.trained = false;
190            return Ok(());
191        }
192
193        let normalized: Vec<(VectorId, Vector)> = vectors
194            .into_iter()
195            .map(|(id, v)| {
196                if v.len() != self.dimension {
197                    return Err(VecDbError::DimensionMismatch {
198                        expected: self.dimension,
199                        got: v.len(),
200                    });
201                }
202                Ok((id, self.maybe_normalize(v)))
203            })
204            .collect::<Result<_>>()?;
205
206        let sample: Vec<Vector> = normalized.iter().map(|(_, v)| v.clone()).collect();
207        self.quantizer = ScalarQuantizer::fit(self.dimension, &sample);
208
209        self.codes.reserve(normalized.len() * self.dimension);
210        self.dnorm.reserve(normalized.len());
211        for (id, v) in normalized {
212            let pos = self.pos_to_id.len();
213            let code = self.quantizer.quantize(&v);
214            self.push_code(pos, &code);
215            self.id_to_pos.insert(id.clone(), pos);
216            self.pos_to_id.push(id);
217        }
218        self.trained = true;
219        Ok(())
220    }
221
222    /// Append code row + its reconstructed ‖x‖² cache.
223    fn push_code(&mut self, _pos: usize, code: &[i8]) {
224        self.codes.extend_from_slice(code);
225        self.dnorm.push(self.code_norm_sq(code));
226    }
227
228    /// ‖x‖² of the reconstruction of a code row.
229    fn code_norm_sq(&self, code: &[i8]) -> f32 {
230        let mut s = 0.0f32;
231        for (i, &c) in code.iter().enumerate() {
232            let x = self.quantizer.reconstruct_at(c, i);
233            s += x * x;
234        }
235        s
236    }
237
238    fn ensure_trained(&mut self) -> Result<()> {
239        if !self.pending.is_empty() {
240            let batch = std::mem::take(&mut self.pending);
241            let mut all: Vec<(VectorId, Vector)> = self.live_vectors_reconstructed();
242            all.extend(batch);
243            self.fit_and_encode(all)?;
244        }
245        Ok(())
246    }
247
248    fn live_vectors_reconstructed(&self) -> Vec<(VectorId, Vector)> {
249        self.pos_to_id
250            .iter()
251            .enumerate()
252            .filter(|(_, id)| !self.deleted.contains(*id))
253            .map(|(pos, id)| {
254                let start = pos * self.dimension;
255                let codes = &self.codes[start..start + self.dimension];
256                (id.clone(), self.quantizer.dequantize(codes))
257            })
258            .collect()
259    }
260
261    /// Score every live stored vector against `query` using the precomputed-norm
262    /// dot expansion, and return the top-`k` (id, score) with the shared score
263    /// convention (higher = better).
264    fn scan(&self, query: &[f32], k: usize) -> Vec<(VectorId, f32)> {
265        let d = self.dimension;
266        let o = self.quantizer.offsets();
267        let s = &self.quantizer.dscale;
268
269        // Per-query precomputation.
270        let qnorm: f32 = query.iter().map(|v| v * v).sum();
271        let qo: f32 = query.iter().zip(o.iter()).map(|(q, o)| q * o).sum();
272        let b: Vec<f32> = query.iter().zip(s.iter()).map(|(q, s)| q * s).collect();
273
274        let mut scored: Vec<(VectorId, f32)> = Vec::with_capacity(self.pos_to_id.len());
275        for (pos, id) in self.pos_to_id.iter().enumerate() {
276            if self.deleted.contains(id) {
277                continue;
278            }
279            let start = pos * d;
280            let codes = &self.codes[start..start + d];
281            // dot(b, codes): the only per-vector inner loop, f32·i8.
282            let mut acc = 0.0f32;
283            for i in 0..d {
284                acc += b[i] * codes[i] as f32;
285            }
286            let dot_qx = qo + acc;
287            let xnorm = self.dnorm[pos];
288            let score = match self.metric {
289                DistanceMetric::Euclidean => {
290                    let dist2 = (qnorm - 2.0 * dot_qx + xnorm).max(0.0);
291                    self.to_score(dist2.sqrt())
292                }
293                DistanceMetric::Cosine => {
294                    let denom = qnorm.sqrt() * xnorm.sqrt();
295                    if denom == 0.0 {
296                        0.0
297                    } else {
298                        dot_qx / denom
299                    }
300                }
301                DistanceMetric::DotProduct => dot_qx,
302            };
303            scored.push((id.clone(), score));
304        }
305        scored.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
306        scored.truncate(k);
307        scored
308    }
309}
310
311impl IndexBackend for ScalarQuantizedIndex {
312    fn build(&mut self, vectors: Vec<(VectorId, Vector)>) -> Result<()> {
313        self.fit_and_encode(vectors)
314    }
315
316    fn insert(&mut self, id: VectorId, vector: Vector) -> Result<()> {
317        if vector.len() != self.dimension {
318            return Err(VecDbError::DimensionMismatch {
319                expected: self.dimension,
320                got: vector.len(),
321            });
322        }
323
324        if self.trained {
325            let v = self.maybe_normalize(vector);
326            let code = self.quantizer.quantize(&v);
327            let norm = self.code_norm_sq(&code);
328            if let Some(&pos) = self.id_to_pos.get(&id) {
329                self.deleted.remove(&id);
330                let start = pos * self.dimension;
331                self.codes[start..start + self.dimension].copy_from_slice(&code);
332                self.dnorm[pos] = norm;
333            } else {
334                let pos = self.pos_to_id.len();
335                self.push_code(pos, &code);
336                self.id_to_pos.insert(id.clone(), pos);
337                self.pos_to_id.push(id);
338            }
339            Ok(())
340        } else {
341            self.pending.push((id, vector));
342            if self.pending.len() >= TRAIN_THRESHOLD {
343                self.ensure_trained()?;
344            }
345            Ok(())
346        }
347    }
348
349    fn search(&self, query: &Vector, k: usize) -> Result<Vec<(VectorId, f32)>> {
350        if query.len() != self.dimension {
351            return Err(VecDbError::DimensionMismatch {
352                expected: self.dimension,
353                got: query.len(),
354            });
355        }
356
357        // Untrained pending vectors: full-precision brute force over them (the
358        // search path takes &self, so we cannot train here).
359        if !self.trained {
360            let q = self.maybe_normalize(query.clone());
361            let mut scored: Vec<(VectorId, f32)> = self
362                .pending
363                .iter()
364                .filter(|(id, _)| !self.deleted.contains(id))
365                .map(|(id, v)| {
366                    let vn = self.maybe_normalize(v.clone());
367                    (
368                        id.clone(),
369                        self.to_score(distance::compute_distance(&q, &vn, &self.metric)),
370                    )
371                })
372                .collect();
373            scored.sort_unstable_by(|a, b| {
374                b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
375            });
376            scored.truncate(k);
377            return Ok(scored);
378        }
379
380        let q = self.maybe_normalize(query.clone());
381        Ok(self.scan(&q, k))
382    }
383
384    fn delete(&mut self, id: &VectorId) -> Result<()> {
385        if self.id_to_pos.contains_key(id) || self.pending.iter().any(|(pid, _)| pid == id) {
386            self.deleted.insert(id.clone());
387            Ok(())
388        } else {
389            Err(VecDbError::NotFound { id: id.clone() })
390        }
391    }
392
393    fn save(&self, path: &Path) -> Result<()> {
394        let saved = SerializedScalarIndex {
395            dimension: self.dimension,
396            metric: self.metric.clone(),
397            quantizer: self.quantizer.clone(),
398            trained: self.trained,
399            codes: self.codes.clone(),
400            dnorm: self.dnorm.clone(),
401            pos_to_id: self.pos_to_id.clone(),
402            deleted: self.deleted.iter().cloned().collect(),
403            pending: self.pending.clone(),
404            version: 1,
405        };
406        let json = serde_json::to_string(&saved)
407            .map_err(|e| VecDbError::SerializationError(e.to_string()))?;
408        std::fs::write(path, json)
409            .map_err(|e| VecDbError::StorageError(format!("scalar index save failed: {e}")))?;
410        Ok(())
411    }
412
413    fn load_from(path: &Path, _config: &CollectionConfig) -> Result<Box<dyn IndexBackend>>
414    where
415        Self: Sized,
416    {
417        Ok(Box::new(Self::load_file(path)?))
418    }
419
420    fn len(&self) -> usize {
421        let total = self.pos_to_id.len() + self.pending.len();
422        total.saturating_sub(self.deleted.len())
423    }
424
425    fn index_type(&self) -> IndexType {
426        // Quantization is tracked on CollectionConfig; reuse HNSW discriminant
427        // for the coarse stats field.
428        IndexType::HNSW
429    }
430}
431
432impl ScalarQuantizedIndex {
433    pub fn load_file(path: &Path) -> Result<Self> {
434        let json = std::fs::read_to_string(path)
435            .map_err(|e| VecDbError::StorageError(format!("scalar index load failed: {e}")))?;
436        let saved: SerializedScalarIndex = serde_json::from_str(&json)
437            .map_err(|e| VecDbError::SerializationError(e.to_string()))?;
438        let mut id_to_pos = HashMap::with_capacity(saved.pos_to_id.len());
439        for (pos, id) in saved.pos_to_id.iter().enumerate() {
440            id_to_pos.insert(id.clone(), pos);
441        }
442        Ok(Self {
443            dimension: saved.dimension,
444            metric: saved.metric,
445            quantizer: saved.quantizer,
446            trained: saved.trained,
447            codes: saved.codes,
448            dnorm: saved.dnorm,
449            id_to_pos,
450            pos_to_id: saved.pos_to_id,
451            deleted: saved.deleted.into_iter().collect(),
452            pending: saved.pending,
453        })
454    }
455}
456
457#[derive(Serialize, Deserialize)]
458struct SerializedScalarIndex {
459    dimension: usize,
460    metric: DistanceMetric,
461    quantizer: ScalarQuantizer,
462    trained: bool,
463    codes: Vec<i8>,
464    dnorm: Vec<f32>,
465    pos_to_id: Vec<VectorId>,
466    deleted: Vec<VectorId>,
467    pending: Vec<(VectorId, Vector)>,
468    version: u32,
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474
475    fn cfg(dim: usize, metric: DistanceMetric) -> CollectionConfig {
476        let mut c = CollectionConfig::new("q", dim);
477        c.metric = metric;
478        c
479    }
480
481    #[test]
482    fn quantizer_roundtrip_is_close() {
483        let sample = vec![
484            vec![0.0, 10.0, -5.0],
485            vec![1.0, 8.0, -1.0],
486            vec![0.5, 9.0, -3.0],
487        ];
488        let q = ScalarQuantizer::fit(3, &sample);
489        for v in &sample {
490            let deq = q.dequantize(&q.quantize(v));
491            for (a, b) in v.iter().zip(deq.iter()) {
492                assert!((a - b).abs() < 0.1, "roundtrip {a} vs {b}");
493            }
494        }
495    }
496
497    #[test]
498    fn bulk_build_search_euclidean() {
499        let mut idx = ScalarQuantizedIndex::new(&cfg(4, DistanceMetric::Euclidean));
500        let mut data = Vec::new();
501        for i in 0..500u32 {
502            let f = i as f32;
503            data.push((format!("v{i}"), vec![f, f + 1.0, f + 2.0, f + 3.0]));
504        }
505        idx.build(data).unwrap();
506        assert_eq!(idx.len(), 500);
507        // 500 points linearly fill the value range, so adjacent vectors differ
508        // by less than one int8 step (range/255) — the top hit lands within a
509        // quantization-adjacent neighborhood of the true answer.
510        let hits = idx.search(&vec![100.0, 101.0, 102.0, 103.0], 1).unwrap();
511        let top: u32 = hits[0].0.trim_start_matches('v').parse().unwrap();
512        assert!((99..=101).contains(&top), "top hit v{top} not near v100");
513        assert!(idx.code_bytes() < 500 * 4 * 4, "codes must be << f32 size");
514    }
515
516    #[test]
517    fn kernel_matches_reconstruction() {
518        // The dot-expansion score must equal scoring via explicit dequantize.
519        let mut idx = ScalarQuantizedIndex::new(&cfg(8, DistanceMetric::Euclidean));
520        let data: Vec<_> = (0..300u32)
521            .map(|i| {
522                let f = i as f32;
523                (
524                    format!("v{i}"),
525                    (0..8).map(|j| f + j as f32 * 0.3).collect(),
526                )
527            })
528            .collect();
529        idx.build(data).unwrap();
530        let q: Vec<f32> = (0..8).map(|j| 50.0 + j as f32).collect();
531        let hits = idx.search(&q, 5).unwrap();
532        // Recompute the top hit's score by explicit reconstruction.
533        let (id, score) = &hits[0];
534        let pos = idx.id_to_pos[id];
535        let codes = &idx.codes[pos * 8..pos * 8 + 8];
536        let x = idx.quantizer.dequantize(codes);
537        let dist = distance::compute_distance(&q, &x, &DistanceMetric::Euclidean);
538        let expect = idx.to_score(dist);
539        assert!((score - expect).abs() < 1e-3, "{score} vs {expect}");
540    }
541
542    #[test]
543    fn streaming_insert_trains_then_queries() {
544        let mut idx = ScalarQuantizedIndex::new(&cfg(3, DistanceMetric::Euclidean));
545        idx.insert("a".into(), vec![1.0, 0.0, 0.0]).unwrap();
546        idx.insert("b".into(), vec![0.0, 1.0, 0.0]).unwrap();
547        assert_eq!(idx.search(&vec![1.0, 0.0, 0.0], 1).unwrap()[0].0, "a");
548        for i in 0..TRAIN_THRESHOLD {
549            idx.insert(format!("x{i}"), vec![i as f32, i as f32, i as f32])
550                .unwrap();
551        }
552        assert!(idx.trained);
553        assert_eq!(idx.search(&vec![1.0, 0.0, 0.0], 1).unwrap()[0].0, "a");
554    }
555
556    #[test]
557    fn delete_excludes_from_results() {
558        let mut idx = ScalarQuantizedIndex::new(&cfg(2, DistanceMetric::Euclidean));
559        let data: Vec<_> = (0..300u32)
560            .map(|i| (format!("v{i}"), vec![i as f32, 0.0]))
561            .collect();
562        idx.build(data).unwrap();
563        idx.delete(&"v5".to_string()).unwrap();
564        let hits = idx.search(&vec![5.0, 0.0], 5).unwrap();
565        assert!(hits.iter().all(|(id, _)| id != "v5"));
566    }
567
568    #[test]
569    fn save_load_roundtrip() {
570        let dir = tempfile::tempdir().unwrap();
571        let path = dir.path().join("q.sq.json");
572        let mut idx = ScalarQuantizedIndex::new(&cfg(4, DistanceMetric::Euclidean));
573        let data: Vec<_> = (0..300u32)
574            .map(|i| (format!("v{i}"), vec![i as f32, 1.0, 2.0, 3.0]))
575            .collect();
576        idx.build(data).unwrap();
577        let before = idx.search(&vec![50.0, 1.0, 2.0, 3.0], 3).unwrap();
578        idx.save(&path).unwrap();
579        let loaded = ScalarQuantizedIndex::load_file(&path).unwrap();
580        let after = loaded.search(&vec![50.0, 1.0, 2.0, 3.0], 3).unwrap();
581        assert_eq!(before[0].0, after[0].0);
582        assert_eq!(before.len(), after.len());
583    }
584}