Skip to main content

nodedb_vector/ivf/
index.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! IVF-PQ index: inverted file with product quantization.
4//!
5//! k-means centroids partition the vectors into Voronoi cells. Each entry
6//! holds a PQ code of its residual against the cell centroid, plus the FP32
7//! vector. A search probes the nearest cells, ranks their entries by PQ
8//! distance, and reranks the best of them by exact distance.
9//!
10//! Entries carry caller-assigned ids, so a collection can move its vectors
11//! into the index under the ids they already have.
12
13use std::collections::HashMap;
14
15use nodedb_mem::ScopedMemory;
16use roaring::RoaringBitmap;
17
18use crate::distance::distance;
19use crate::error::{VectorError, check_dim};
20use crate::quantize::pq::PqCodec;
21
22use super::kmeans::kmeans_centroids;
23use super::params::IvfPqParams;
24
25/// k-means iterations for the coarse centroids and the PQ codebooks.
26pub(super) const TRAIN_ITERATIONS: usize = 20;
27
28/// The entries of one Voronoi cell, stored column-wise. Entry `i` has id
29/// `ids[i]`, PQ code `codes[i * m .. (i + 1) * m]`, and FP32 vector
30/// `vectors[i * dim .. (i + 1) * dim]`.
31#[derive(Debug, Clone, Default, zerompk::ToMessagePack, zerompk::FromMessagePack)]
32pub struct IvfCell {
33    pub(super) ids: Vec<u32>,
34    pub(super) codes: Vec<u8>,
35    pub(super) vectors: Vec<f32>,
36}
37
38/// IVF-PQ index: inverted file with product quantization.
39pub struct IvfPqIndex {
40    pub(super) dim: usize,
41    pub(super) params: IvfPqParams,
42    /// Coarse centroids: `n_cells` × `dim` FP32 vectors.
43    pub(super) centroids: Vec<Vec<f32>>,
44    /// PQ codec trained on the residuals. `None` until [`Self::train`].
45    pub(super) pq: Option<PqCodec>,
46    pub(super) cells: Vec<IvfCell>,
47    /// Entry id → (cell, position in the cell).
48    pub(super) slots: HashMap<u32, (u32, u32)>,
49    /// Soft-deleted entry ids.
50    pub(super) deleted: RoaringBitmap,
51    /// Id [`Self::add`] assigns next: one past the highest id ever held.
52    pub(super) next_id: u32,
53    /// Vectors the codebooks were trained on.
54    pub(super) trained_on: usize,
55    /// Unix milliseconds of the training, as the caller stamped it. `0` when
56    /// never stamped.
57    pub(super) trained_at_ms: u64,
58}
59
60impl IvfPqIndex {
61    /// Create an empty, untrained IVF-PQ index.
62    pub fn new(dim: usize, params: IvfPqParams) -> Self {
63        Self {
64            dim,
65            params,
66            centroids: Vec::new(),
67            pq: None,
68            cells: Vec::new(),
69            slots: HashMap::new(),
70            deleted: RoaringBitmap::new(),
71            next_id: 0,
72            trained_on: 0,
73            trained_at_ms: 0,
74        }
75    }
76
77    /// Train the coarse centroids and the PQ codebooks on `vectors`, tracking
78    /// PQ codebook allocations against `memory`.
79    ///
80    /// Fails with [`VectorError::InvalidInput`] when the set is empty, the
81    /// dimension is zero or not divisible by `pq_m`, the set is smaller than
82    /// `pq_k`, or the index already holds vectors (their codes would not
83    /// match new codebooks). A vector without the index dimension fails with
84    /// [`VectorError::DimensionMismatch`]. A failed training changes nothing.
85    pub fn train(&mut self, vectors: &[&[f32]], memory: ScopedMemory) -> Result<(), VectorError> {
86        if vectors.is_empty() {
87            return Err(VectorError::InvalidInput {
88                detail: "IVF-PQ training needs at least one vector".into(),
89            });
90        }
91        if !self.slots.is_empty() {
92            return Err(VectorError::InvalidInput {
93                detail: format!(
94                    "IVF-PQ index already holds {} vectors; train an empty index",
95                    self.slots.len()
96                ),
97            });
98        }
99        if self.dim == 0 || self.params.pq_m == 0 || !self.dim.is_multiple_of(self.params.pq_m) {
100            return Err(VectorError::InvalidInput {
101                detail: format!(
102                    "IVF-PQ dimension {} must be non-zero and divisible by pq_m {}",
103                    self.dim, self.params.pq_m
104                ),
105            });
106        }
107        for v in vectors {
108            check_dim(self.dim, v.len())?;
109        }
110
111        let n_cells = self.params.n_cells.min(vectors.len());
112        let centroids = kmeans_centroids(vectors, self.dim, n_cells, TRAIN_ITERATIONS);
113        let residuals: Vec<Vec<f32>> = vectors
114            .iter()
115            .map(|v| residual(v, &centroids[nearest(&centroids, v, &self.params)]))
116            .collect();
117        let res_refs: Vec<&[f32]> = residuals.iter().map(|r| r.as_slice()).collect();
118        let pq = PqCodec::train(
119            &res_refs,
120            self.dim,
121            self.params.pq_m,
122            self.params.pq_k,
123            TRAIN_ITERATIONS,
124            memory,
125        )?;
126
127        self.cells = vec![IvfCell::default(); centroids.len()];
128        self.centroids = centroids;
129        self.pq = Some(pq);
130        self.trained_on = vectors.len();
131        Ok(())
132    }
133
134    /// Add `vector` under the caller-assigned `id`.
135    ///
136    /// Fails with [`VectorError::DimensionMismatch`] for a vector without the
137    /// index dimension, and with [`VectorError::InvalidInput`] when the index
138    /// is untrained or already holds `id`. A failed add changes nothing.
139    pub fn insert_with_id(&mut self, id: u32, vector: Vec<f32>) -> Result<(), VectorError> {
140        check_dim(self.dim, vector.len())?;
141        let Some(pq) = self.pq.as_ref() else {
142            return Err(VectorError::InvalidInput {
143                detail: "IVF-PQ index must be trained before add".into(),
144            });
145        };
146        if self.slots.contains_key(&id) {
147            return Err(VectorError::InvalidInput {
148                detail: format!("IVF-PQ index already holds vector id {id}"),
149            });
150        }
151        let cell_idx = nearest(&self.centroids, &vector, &self.params);
152        let code = pq.encode(&residual(&vector, &self.centroids[cell_idx]));
153        let cell = &mut self.cells[cell_idx];
154        let pos = cell.ids.len() as u32;
155        cell.ids.push(id);
156        cell.codes.extend_from_slice(&code);
157        cell.vectors.extend_from_slice(&vector);
158        self.slots.insert(id, (cell_idx as u32, pos));
159        self.next_id = self.next_id.max(id.saturating_add(1));
160        Ok(())
161    }
162
163    /// Add a vector under the next free id. Returns the id.
164    ///
165    /// Fails as [`Self::insert_with_id`] does.
166    pub fn add(&mut self, vector: &[f32]) -> Result<u32, VectorError> {
167        let id = self.next_id;
168        self.insert_with_id(id, vector.to_vec())?;
169        Ok(id)
170    }
171
172    /// Add vectors under consecutive free ids. Stops at the first vector
173    /// that fails to add.
174    pub fn add_batch(&mut self, vectors: &[&[f32]]) -> Result<(), VectorError> {
175        for v in vectors {
176            self.add(v)?;
177        }
178        Ok(())
179    }
180
181    /// Whether the index holds a trained codebook.
182    pub fn is_trained(&self) -> bool {
183        self.pq.is_some()
184    }
185
186    /// Whether the index holds `id`, live or soft-deleted.
187    pub fn contains(&self, id: u32) -> bool {
188        self.slots.contains_key(&id)
189    }
190
191    /// Whether `id` is held and soft-deleted.
192    pub fn is_deleted(&self, id: u32) -> bool {
193        self.deleted.contains(id)
194    }
195
196    /// Soft-delete `id`. `false` when the index does not hold it live.
197    pub fn delete(&mut self, id: u32) -> bool {
198        self.contains(id) && self.deleted.insert(id)
199    }
200
201    /// Reverse a soft delete of `id`. `false` when `id` was not deleted.
202    pub fn undelete(&mut self, id: u32) -> bool {
203        self.deleted.remove(id)
204    }
205
206    /// The FP32 vector of a live `id`.
207    pub fn get_vector(&self, id: u32) -> Option<&[f32]> {
208        if self.deleted.contains(id) {
209            return None;
210        }
211        let &(cell, pos) = self.slots.get(&id)?;
212        let start = pos as usize * self.dim;
213        self.cells
214            .get(cell as usize)?
215            .vectors
216            .get(start..start + self.dim)
217    }
218
219    /// Every live entry as `(id, vector)`, ordered by id.
220    pub fn live_vectors(&self) -> Vec<(u32, Vec<f32>)> {
221        let mut out: Vec<(u32, Vec<f32>)> = Vec::with_capacity(self.live_count());
222        for cell in &self.cells {
223            for (pos, &id) in cell.ids.iter().enumerate() {
224                if !self.deleted.contains(id) {
225                    let start = pos * self.dim;
226                    out.push((id, cell.vectors[start..start + self.dim].to_vec()));
227                }
228            }
229        }
230        out.sort_unstable_by_key(|(id, _)| *id);
231        out
232    }
233
234    /// Drop every entry with id `next_id` or later, so the next
235    /// [`Self::add`] takes `next_id` again. The training stays. A rollback
236    /// uses it to withdraw the newest adds.
237    pub fn roll_back_to(&mut self, next_id: u32) {
238        self.retain_entries(|id| id < next_id);
239        self.deleted.remove_range(next_id..);
240        self.next_id = self.next_id.min(next_id);
241    }
242
243    /// Remove every soft-deleted entry. Returns the number removed. Ids of
244    /// the remaining entries do not change.
245    pub fn compact(&mut self) -> usize {
246        let deleted = std::mem::take(&mut self.deleted);
247        self.retain_entries(|id| !deleted.contains(id))
248    }
249
250    /// Keep the entries whose id satisfies `keep` and rebuild the slot map.
251    /// Returns the number of entries dropped.
252    fn retain_entries(&mut self, keep: impl Fn(u32) -> bool) -> usize {
253        let m = self.pq.as_ref().map_or(0, |pq| pq.m);
254        let dim = self.dim;
255        let mut removed = 0;
256        self.slots.clear();
257        for (cell_idx, cell) in self.cells.iter_mut().enumerate() {
258            let mut kept = IvfCell::default();
259            for (pos, &id) in cell.ids.iter().enumerate() {
260                if !keep(id) {
261                    removed += 1;
262                    continue;
263                }
264                self.slots
265                    .insert(id, (cell_idx as u32, kept.ids.len() as u32));
266                kept.ids.push(id);
267                kept.codes
268                    .extend_from_slice(&cell.codes[pos * m..(pos + 1) * m]);
269                kept.vectors
270                    .extend_from_slice(&cell.vectors[pos * dim..(pos + 1) * dim]);
271            }
272            *cell = kept;
273        }
274        removed
275    }
276
277    /// Entries held, live or soft-deleted.
278    pub fn len(&self) -> usize {
279        self.slots.len()
280    }
281
282    /// Entries held and not soft-deleted.
283    pub fn live_count(&self) -> usize {
284        self.slots.len() - self.deleted.len() as usize
285    }
286
287    /// Soft-deleted entries held.
288    pub fn tombstone_count(&self) -> usize {
289        self.deleted.len() as usize
290    }
291
292    pub fn is_empty(&self) -> bool {
293        self.slots.is_empty()
294    }
295
296    pub fn dim(&self) -> usize {
297        self.dim
298    }
299
300    pub fn n_cells(&self) -> usize {
301        self.centroids.len()
302    }
303
304    pub fn params(&self) -> &IvfPqParams {
305        &self.params
306    }
307
308    /// Vectors the codebooks were trained on. `0` when untrained.
309    pub fn trained_on(&self) -> usize {
310        self.trained_on
311    }
312
313    /// Unix milliseconds the caller stamped the training with.
314    pub fn trained_at_ms(&self) -> u64 {
315        self.trained_at_ms
316    }
317
318    /// Stamp the training time, in Unix milliseconds.
319    pub fn set_trained_at_ms(&mut self, ms: u64) {
320        self.trained_at_ms = ms;
321    }
322
323    /// Approximate heap bytes of the centroids, codes and vectors.
324    pub fn memory_bytes(&self) -> usize {
325        let f32_size = std::mem::size_of::<f32>();
326        let centroids = self.centroids.len() * self.dim * f32_size;
327        let entries: usize = self
328            .cells
329            .iter()
330            .map(|c| {
331                c.ids.len() * std::mem::size_of::<u32>()
332                    + c.codes.len()
333                    + c.vectors.len() * f32_size
334            })
335            .sum();
336        centroids + entries
337    }
338}
339
340/// Index of the centroid in `centroids` nearest to `vector`. `0` when there
341/// are none.
342fn nearest(centroids: &[Vec<f32>], vector: &[f32], params: &IvfPqParams) -> usize {
343    let mut best = 0;
344    let mut best_dist = f32::MAX;
345    for (i, c) in centroids.iter().enumerate() {
346        let d = distance(vector, c, params.metric);
347        if d < best_dist {
348            best_dist = d;
349            best = i;
350        }
351    }
352    best
353}
354
355/// `vector - centroid`, component-wise.
356pub(super) fn residual(vector: &[f32], centroid: &[f32]) -> Vec<f32> {
357    vector.iter().zip(centroid).map(|(a, b)| a - b).collect()
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::distance::DistanceMetric;
364    use crate::test_support::test_memory;
365
366    fn make_vectors(n: usize, dim: usize) -> Vec<Vec<f32>> {
367        (0..n)
368            .map(|i| (0..dim).map(|d| ((i * dim + d) as f32) * 0.01).collect())
369            .collect()
370    }
371
372    fn small_params() -> IvfPqParams {
373        IvfPqParams {
374            n_cells: 4,
375            pq_m: 4,
376            pq_k: 8,
377            nprobe: 4,
378            metric: DistanceMetric::L2,
379        }
380    }
381
382    fn trained(vecs: &[Vec<f32>]) -> IvfPqIndex {
383        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
384        let mut idx = IvfPqIndex::new(8, small_params());
385        idx.train(&refs, test_memory()).unwrap();
386        idx
387    }
388
389    #[test]
390    fn rolling_back_withdraws_every_vector_added_after_the_mark() {
391        let vecs = make_vectors(64, 8);
392        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
393        let mut idx = trained(&vecs);
394        idx.add_batch(&refs[..40]).unwrap();
395        let mark = idx.len() as u32;
396        let before: Vec<u32> = idx
397            .search(&vecs[5], 40)
398            .unwrap()
399            .iter()
400            .map(|r| r.id)
401            .collect();
402
403        idx.add_batch(&refs[40..]).unwrap();
404        idx.roll_back_to(mark);
405
406        assert_eq!(idx.len(), 40);
407        assert!(idx.is_trained(), "the training the index held stays");
408        let after_ids: Vec<u32> = idx
409            .search(&vecs[5], 64)
410            .unwrap()
411            .iter()
412            .map(|r| r.id)
413            .collect();
414        assert!(after_ids.iter().all(|id| *id < mark));
415        assert_eq!(after_ids, before, "the search reads as before the adds");
416
417        // The next add takes the first id past the mark again.
418        assert_eq!(idx.add(&vecs[63]).unwrap(), mark);
419    }
420
421    #[test]
422    fn caller_ids_survive_delete_compact_and_reads() {
423        let vecs = make_vectors(16, 8);
424        let mut idx = trained(&vecs);
425        for (i, v) in vecs.iter().enumerate() {
426            idx.insert_with_id(100 + i as u32, v.clone()).unwrap();
427        }
428        assert!(matches!(
429            idx.insert_with_id(100, vecs[0].clone()),
430            Err(VectorError::InvalidInput { .. })
431        ));
432        assert!(idx.delete(103));
433        assert!(!idx.delete(103), "a second delete finds nothing live");
434        assert!(idx.get_vector(103).is_none());
435        assert_eq!(idx.live_count(), 15);
436
437        assert_eq!(idx.compact(), 1);
438        assert_eq!(idx.len(), 15);
439        assert!(!idx.contains(103));
440        assert_eq!(idx.get_vector(104), Some(vecs[4].as_slice()));
441        assert_eq!(idx.add(&vecs[0]).unwrap(), 116);
442    }
443
444    #[test]
445    fn a_trained_index_holding_vectors_refuses_to_retrain() {
446        let vecs = make_vectors(16, 8);
447        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
448        let mut idx = trained(&vecs);
449        idx.add(&vecs[0]).unwrap();
450        assert!(matches!(
451            idx.train(&refs, test_memory()),
452            Err(VectorError::InvalidInput { .. })
453        ));
454        assert_eq!(idx.len(), 1);
455    }
456
457    #[test]
458    fn wrong_dimension_is_a_typed_error() {
459        let vecs: Vec<Vec<f32>> = (0..32)
460            .map(|i| (0..8).map(|d| ((i * 8 + d) % 17) as f32).collect())
461            .collect();
462        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
463        let mut idx = IvfPqIndex::new(8, small_params());
464        assert!(matches!(
465            idx.add(&[0.0; 8]),
466            Err(VectorError::InvalidInput { .. })
467        ));
468        idx.train(&refs, test_memory()).unwrap();
469        idx.add_batch(&refs).unwrap();
470        assert!(matches!(
471            idx.search(&[0.0; 3], 5),
472            Err(VectorError::DimensionMismatch {
473                expected: 8,
474                got: 3
475            })
476        ));
477        assert!(matches!(
478            idx.add(&[0.0; 3]),
479            Err(VectorError::DimensionMismatch {
480                expected: 8,
481                got: 3
482            })
483        ));
484        let short = [0.0_f32; 3];
485        let mut untrained = IvfPqIndex::new(8, IvfPqParams::default());
486        assert!(matches!(
487            untrained.train(&[&short], test_memory()),
488            Err(VectorError::DimensionMismatch {
489                expected: 8,
490                got: 3
491            })
492        ));
493    }
494}