Skip to main content

vecdb_core/index/
binary.rs

1//! Binary (1-bit) quantization — a second optional in-memory index mode.
2//!
3//! Each dimension is thresholded at its learned per-dimension mean and stored as
4//! a single bit (1 if above the mean, else 0), packed into `u64` words. Distance
5//! is Hamming distance (popcount of XORed codes), which approximates angular
6//! similarity. This is an extreme compressor: `D` bits/vector = `D/8` bytes vs
7//! `D·4` for float32 → ~32× smaller.
8//!
9//! Binary codes are coarse: on their own they give a fast, tiny filter with
10//! modest recall. The intended pipeline is binary-Hamming for candidate
11//! generation followed by a full-precision rerank of the top candidates — see
12//! `examples/quant_bench.rs`, which measures both raw and reranked recall.
13
14use std::collections::{HashMap, HashSet};
15use std::path::Path;
16
17use serde::{Deserialize, Serialize};
18
19use crate::errors::{Result, VecDbError};
20use crate::index::backend::IndexBackend;
21use crate::index::distance;
22use crate::types::{CollectionConfig, DistanceMetric, IndexType, Vector, VectorId};
23
24/// Per-dimension mean-threshold binary quantizer.
25#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26pub struct BinaryQuantizer {
27    /// Per-dimension threshold (the mean seen during fit).
28    pub thresholds: Vec<f32>,
29}
30
31impl BinaryQuantizer {
32    pub fn fit(dimension: usize, sample: &[Vector]) -> Self {
33        let mut sums = vec![0.0f64; dimension];
34        for v in sample {
35            for (i, &x) in v.iter().enumerate().take(dimension) {
36                sums[i] += x as f64;
37            }
38        }
39        let n = sample.len().max(1) as f64;
40        let thresholds = sums.iter().map(|s| (s / n) as f32).collect();
41        Self { thresholds }
42    }
43
44    #[inline]
45    pub fn dimension(&self) -> usize {
46        self.thresholds.len()
47    }
48
49    /// Number of `u64` words needed to hold one vector's bits.
50    #[inline]
51    pub fn words(&self) -> usize {
52        self.dimension().div_ceil(64)
53    }
54
55    /// Pack one vector into bit words: bit `i` set iff `v[i] > threshold[i]`.
56    pub fn quantize(&self, v: &[f32]) -> Vec<u64> {
57        let mut out = vec![0u64; self.words()];
58        for i in 0..self.dimension() {
59            if v[i] > self.thresholds[i] {
60                out[i / 64] |= 1u64 << (i % 64);
61            }
62        }
63        out
64    }
65}
66
67pub struct BinaryQuantizedIndex {
68    dimension: usize,
69    metric: DistanceMetric,
70    quantizer: BinaryQuantizer,
71    trained: bool,
72    words: usize,
73    /// Packed bit codes, row-major: vector `pos` occupies `codes[pos*words ..]`.
74    codes: Vec<u64>,
75    id_to_pos: HashMap<VectorId, usize>,
76    pos_to_id: Vec<VectorId>,
77    deleted: HashSet<VectorId>,
78    pending: Vec<(VectorId, Vector)>,
79}
80
81const TRAIN_THRESHOLD: usize = 256;
82
83impl BinaryQuantizedIndex {
84    pub fn new(config: &CollectionConfig) -> Self {
85        Self {
86            dimension: config.dimension,
87            metric: config.metric.clone(),
88            quantizer: BinaryQuantizer::default(),
89            trained: false,
90            words: config.dimension.div_ceil(64),
91            codes: Vec::new(),
92            id_to_pos: HashMap::new(),
93            pos_to_id: Vec::new(),
94            deleted: HashSet::new(),
95            pending: Vec::new(),
96        }
97    }
98
99    /// Resident memory of the bit-code store in bytes (the figure this mode
100    /// shrinks). Excludes id strings, which the float path also carries.
101    pub fn code_bytes(&self) -> usize {
102        self.codes.len() * std::mem::size_of::<u64>()
103            + self.quantizer.thresholds.len() * std::mem::size_of::<f32>()
104    }
105
106    fn fit_and_encode(&mut self, vectors: Vec<(VectorId, Vector)>) -> Result<()> {
107        self.id_to_pos.clear();
108        self.pos_to_id.clear();
109        self.codes.clear();
110        self.deleted.clear();
111        self.pending.clear();
112
113        if vectors.is_empty() {
114            self.trained = false;
115            return Ok(());
116        }
117        for (_, v) in &vectors {
118            if v.len() != self.dimension {
119                return Err(VecDbError::DimensionMismatch {
120                    expected: self.dimension,
121                    got: v.len(),
122                });
123            }
124        }
125
126        let sample: Vec<Vector> = vectors.iter().map(|(_, v)| v.clone()).collect();
127        self.quantizer = BinaryQuantizer::fit(self.dimension, &sample);
128        self.words = self.quantizer.words();
129
130        self.codes.reserve(vectors.len() * self.words);
131        for (id, v) in vectors {
132            let pos = self.pos_to_id.len();
133            self.codes.extend_from_slice(&self.quantizer.quantize(&v));
134            self.id_to_pos.insert(id.clone(), pos);
135            self.pos_to_id.push(id);
136        }
137        self.trained = true;
138        Ok(())
139    }
140
141    fn ensure_trained(&mut self) -> Result<()> {
142        if !self.pending.is_empty() {
143            let batch = std::mem::take(&mut self.pending);
144            self.fit_and_encode(batch)?;
145        }
146        Ok(())
147    }
148
149    /// Fraction of matching bits, in `[0, 1]` (1 = identical codes). Higher is
150    /// better, matching the shared score convention.
151    #[inline]
152    fn similarity(&self, qcode: &[u64], pos: usize) -> f32 {
153        let start = pos * self.words;
154        let row = &self.codes[start..start + self.words];
155        let mut ham = 0u32;
156        for w in 0..self.words {
157            ham += (qcode[w] ^ row[w]).count_ones();
158        }
159        1.0 - (ham as f32 / self.dimension as f32)
160    }
161}
162
163impl IndexBackend for BinaryQuantizedIndex {
164    fn build(&mut self, vectors: Vec<(VectorId, Vector)>) -> Result<()> {
165        self.fit_and_encode(vectors)
166    }
167
168    fn insert(&mut self, id: VectorId, vector: Vector) -> Result<()> {
169        if vector.len() != self.dimension {
170            return Err(VecDbError::DimensionMismatch {
171                expected: self.dimension,
172                got: vector.len(),
173            });
174        }
175        if self.trained {
176            let code = self.quantizer.quantize(&vector);
177            if let Some(&pos) = self.id_to_pos.get(&id) {
178                self.deleted.remove(&id);
179                let start = pos * self.words;
180                self.codes[start..start + self.words].copy_from_slice(&code);
181            } else {
182                let pos = self.pos_to_id.len();
183                self.codes.extend_from_slice(&code);
184                self.id_to_pos.insert(id.clone(), pos);
185                self.pos_to_id.push(id);
186            }
187            Ok(())
188        } else {
189            self.pending.push((id, vector));
190            if self.pending.len() >= TRAIN_THRESHOLD {
191                self.ensure_trained()?;
192            }
193            Ok(())
194        }
195    }
196
197    fn search(&self, query: &Vector, k: usize) -> Result<Vec<(VectorId, f32)>> {
198        if query.len() != self.dimension {
199            return Err(VecDbError::DimensionMismatch {
200                expected: self.dimension,
201                got: query.len(),
202            });
203        }
204
205        if !self.trained {
206            let mut scored: Vec<(VectorId, f32)> = self
207                .pending
208                .iter()
209                .filter(|(id, _)| !self.deleted.contains(id))
210                .map(|(id, v)| {
211                    let dist = distance::compute_distance(query, v, &self.metric);
212                    let score = match self.metric {
213                        DistanceMetric::Euclidean => 1.0 / (1.0 + dist),
214                        _ => 1.0 - dist,
215                    };
216                    (id.clone(), score)
217                })
218                .collect();
219            scored.sort_unstable_by(|a, b| {
220                b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
221            });
222            scored.truncate(k);
223            return Ok(scored);
224        }
225
226        let qcode = self.quantizer.quantize(query);
227        let mut scored: Vec<(VectorId, f32)> = self
228            .pos_to_id
229            .iter()
230            .enumerate()
231            .filter(|(_, id)| !self.deleted.contains(*id))
232            .map(|(pos, id)| (id.clone(), self.similarity(&qcode, pos)))
233            .collect();
234        scored.sort_unstable_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
235        scored.truncate(k);
236        Ok(scored)
237    }
238
239    fn delete(&mut self, id: &VectorId) -> Result<()> {
240        if self.id_to_pos.contains_key(id) || self.pending.iter().any(|(pid, _)| pid == id) {
241            self.deleted.insert(id.clone());
242            Ok(())
243        } else {
244            Err(VecDbError::NotFound { id: id.clone() })
245        }
246    }
247
248    fn save(&self, path: &Path) -> Result<()> {
249        let saved = SerializedBinaryIndex {
250            dimension: self.dimension,
251            metric: self.metric.clone(),
252            quantizer: self.quantizer.clone(),
253            trained: self.trained,
254            words: self.words,
255            codes: self.codes.clone(),
256            pos_to_id: self.pos_to_id.clone(),
257            deleted: self.deleted.iter().cloned().collect(),
258            pending: self.pending.clone(),
259            version: 1,
260        };
261        let json = serde_json::to_string(&saved)
262            .map_err(|e| VecDbError::SerializationError(e.to_string()))?;
263        std::fs::write(path, json)
264            .map_err(|e| VecDbError::StorageError(format!("binary index save failed: {e}")))?;
265        Ok(())
266    }
267
268    fn load_from(path: &Path, _config: &CollectionConfig) -> Result<Box<dyn IndexBackend>>
269    where
270        Self: Sized,
271    {
272        Ok(Box::new(Self::load_file(path)?))
273    }
274
275    fn len(&self) -> usize {
276        let total = self.pos_to_id.len() + self.pending.len();
277        total.saturating_sub(self.deleted.len())
278    }
279
280    fn index_type(&self) -> IndexType {
281        IndexType::HNSW
282    }
283}
284
285impl BinaryQuantizedIndex {
286    pub fn load_file(path: &Path) -> Result<Self> {
287        let json = std::fs::read_to_string(path)
288            .map_err(|e| VecDbError::StorageError(format!("binary index load failed: {e}")))?;
289        let saved: SerializedBinaryIndex = serde_json::from_str(&json)
290            .map_err(|e| VecDbError::SerializationError(e.to_string()))?;
291        let mut id_to_pos = HashMap::with_capacity(saved.pos_to_id.len());
292        for (pos, id) in saved.pos_to_id.iter().enumerate() {
293            id_to_pos.insert(id.clone(), pos);
294        }
295        Ok(Self {
296            dimension: saved.dimension,
297            metric: saved.metric,
298            quantizer: saved.quantizer,
299            trained: saved.trained,
300            words: saved.words,
301            codes: saved.codes,
302            id_to_pos,
303            pos_to_id: saved.pos_to_id,
304            deleted: saved.deleted.into_iter().collect(),
305            pending: saved.pending,
306        })
307    }
308}
309
310#[derive(Serialize, Deserialize)]
311struct SerializedBinaryIndex {
312    dimension: usize,
313    metric: DistanceMetric,
314    quantizer: BinaryQuantizer,
315    trained: bool,
316    words: usize,
317    codes: Vec<u64>,
318    pos_to_id: Vec<VectorId>,
319    deleted: Vec<VectorId>,
320    pending: Vec<(VectorId, Vector)>,
321    version: u32,
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    fn cfg(dim: usize) -> CollectionConfig {
329        let mut c = CollectionConfig::new("b", dim);
330        c.metric = DistanceMetric::Euclidean;
331        c
332    }
333
334    #[test]
335    fn packs_and_matches_identical() {
336        let q = BinaryQuantizer::fit(4, &[vec![0.0, 10.0, 0.0, 10.0]]);
337        // With a single sample the threshold equals the value; > is false for
338        // all dims → all-zero code. Just assert packing is deterministic.
339        let a = q.quantize(&[1.0, 20.0, -1.0, 20.0]);
340        let b = q.quantize(&[1.0, 20.0, -1.0, 20.0]);
341        assert_eq!(a, b);
342    }
343
344    #[test]
345    fn separates_two_clusters() {
346        // Two well-separated clusters: binary codes must rank the right cluster
347        // higher for a query near cluster A.
348        let mut idx = BinaryQuantizedIndex::new(&cfg(8));
349        let mut data = Vec::new();
350        for i in 0..150u32 {
351            data.push((
352                format!("a{i}"),
353                vec![10.0, 10.0, 10.0, 10.0, 0.0, 0.0, 0.0, 0.0],
354            ));
355        }
356        for i in 0..150u32 {
357            data.push((
358                format!("b{i}"),
359                vec![0.0, 0.0, 0.0, 0.0, 10.0, 10.0, 10.0, 10.0],
360            ));
361        }
362        idx.build(data).unwrap();
363        let hits = idx
364            .search(&vec![9.0, 9.0, 9.0, 9.0, 1.0, 1.0, 1.0, 1.0], 10)
365            .unwrap();
366        assert!(
367            hits.iter().all(|(id, _)| id.starts_with('a')),
368            "top hits must be cluster A"
369        );
370        // 8 bits/vec → 1 u64 word/vec = 8 bytes; far below 8*4=32 f32 bytes.
371        assert!(idx.code_bytes() <= 300 * 8 + 64);
372    }
373
374    #[test]
375    fn save_load_roundtrip() {
376        let dir = tempfile::tempdir().unwrap();
377        let path = dir.path().join("b.bq.json");
378        let mut idx = BinaryQuantizedIndex::new(&cfg(16));
379        let data: Vec<_> = (0..300u32)
380            .map(|i| {
381                let f = (i % 7) as f32;
382                (format!("v{i}"), (0..16).map(|j| f + j as f32).collect())
383            })
384            .collect();
385        idx.build(data).unwrap();
386        let q: Vec<f32> = (0..16).map(|j| 3.0 + j as f32).collect();
387        let before = idx.search(&q, 5).unwrap();
388        idx.save(&path).unwrap();
389        let loaded = BinaryQuantizedIndex::load_file(&path).unwrap();
390        let after = loaded.search(&q, 5).unwrap();
391        assert_eq!(before[0].0, after[0].0);
392    }
393}