nodedb_vector/ivf/
checkpoint.rs1use std::collections::HashMap;
8
9use nodedb_mem::ScopedMemory;
10use roaring::RoaringBitmap;
11
12use crate::error::VectorError;
13use crate::quantize::pq::PqCodec;
14
15use super::index::{IvfCell, IvfPqIndex};
16use super::params::IvfPqParams;
17
18#[derive(zerompk::ToMessagePack, zerompk::FromMessagePack)]
19struct IvfSnapshot {
20 dim: usize,
21 params: IvfPqParams,
22 centroids: Vec<Vec<f32>>,
23 pq_bytes: Option<Vec<u8>>,
24 cells: Vec<IvfCell>,
25 deleted: Vec<u32>,
26 next_id: u32,
27 trained_on: usize,
28 trained_at_ms: u64,
29}
30
31fn corrupt(detail: String) -> VectorError {
32 VectorError::CheckpointDeserializationError { detail }
33}
34
35impl IvfPqIndex {
36 pub fn to_bytes(&self) -> Result<Vec<u8>, VectorError> {
38 let snapshot = IvfSnapshot {
39 dim: self.dim,
40 params: self.params.clone(),
41 centroids: self.centroids.clone(),
42 pq_bytes: self.pq.as_ref().map(PqCodec::to_bytes).transpose()?,
43 cells: self.cells.clone(),
44 deleted: self.deleted.iter().collect(),
45 next_id: self.next_id,
46 trained_on: self.trained_on,
47 trained_at_ms: self.trained_at_ms,
48 };
49 zerompk::to_msgpack_vec(&snapshot).map_err(|e| VectorError::CheckpointSerializationError {
50 detail: format!("IVF-PQ index encode: {e}"),
51 })
52 }
53
54 pub fn from_bytes(bytes: &[u8], memory: ScopedMemory) -> Result<Self, VectorError> {
61 let snap: IvfSnapshot = zerompk::from_msgpack(bytes)
62 .map_err(|e| corrupt(format!("IVF-PQ index decode: {e}")))?;
63 let pq = snap
64 .pq_bytes
65 .as_deref()
66 .map(|b| PqCodec::from_bytes(b, memory))
67 .transpose()
68 .map_err(|e| corrupt(format!("IVF-PQ codec decode: {e}")))?;
69 let m = pq.as_ref().map_or(0, |pq| pq.m);
70 if snap.cells.len() != snap.centroids.len() {
71 return Err(corrupt(format!(
72 "IVF-PQ index has {} cells for {} centroids",
73 snap.cells.len(),
74 snap.centroids.len()
75 )));
76 }
77 let mut slots = HashMap::new();
78 for (cell_idx, cell) in snap.cells.iter().enumerate() {
79 let n = cell.ids.len();
80 if cell.codes.len() != n * m || cell.vectors.len() != n * snap.dim {
81 return Err(corrupt(format!(
82 "IVF-PQ cell {cell_idx} holds {n} ids, {} code bytes and {} vector \
83 components; expected {} and {}",
84 cell.codes.len(),
85 cell.vectors.len(),
86 n * m,
87 n * snap.dim
88 )));
89 }
90 for (pos, &id) in cell.ids.iter().enumerate() {
91 if slots.insert(id, (cell_idx as u32, pos as u32)).is_some() {
92 return Err(corrupt(format!("IVF-PQ index holds vector id {id} twice")));
93 }
94 }
95 }
96 let deleted: RoaringBitmap = snap.deleted.into_iter().collect();
97 if let Some(id) = deleted.iter().find(|id| !slots.contains_key(id)) {
98 return Err(corrupt(format!(
99 "IVF-PQ index tombstones vector id {id} it does not hold"
100 )));
101 }
102 Ok(Self {
103 dim: snap.dim,
104 params: snap.params,
105 centroids: snap.centroids,
106 pq,
107 cells: snap.cells,
108 slots,
109 deleted,
110 next_id: snap.next_id,
111 trained_on: snap.trained_on,
112 trained_at_ms: snap.trained_at_ms,
113 })
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use crate::distance::DistanceMetric;
121 use crate::test_support::test_memory;
122
123 #[test]
124 fn round_trip_keeps_entries_tombstones_and_training() {
125 let vecs: Vec<Vec<f32>> = (0..32)
126 .map(|i| (0..8).map(|d| ((i * 8 + d) as f32) * 0.01).collect())
127 .collect();
128 let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
129 let mut idx = IvfPqIndex::new(
130 8,
131 IvfPqParams {
132 n_cells: 4,
133 pq_m: 4,
134 pq_k: 8,
135 nprobe: 4,
136 metric: DistanceMetric::L2,
137 },
138 );
139 idx.train(&refs, test_memory()).unwrap();
140 for (i, v) in vecs.iter().enumerate() {
141 idx.insert_with_id(10 + i as u32, v.clone()).unwrap();
142 }
143 idx.delete(12);
144 idx.set_trained_at_ms(1_700_000_000_000);
145
146 let restored = IvfPqIndex::from_bytes(&idx.to_bytes().unwrap(), test_memory()).unwrap();
147 assert_eq!(restored.len(), 32);
148 assert!(restored.is_deleted(12));
149 assert_eq!(restored.trained_on(), 32);
150 assert_eq!(restored.trained_at_ms(), 1_700_000_000_000);
151 assert_eq!(restored.get_vector(20), Some(vecs[10].as_slice()));
152 assert_eq!(
153 restored.search(&vecs[5], 3).unwrap()[0].id,
154 idx.search(&vecs[5], 3).unwrap()[0].id
155 );
156 }
157
158 #[test]
159 fn garbage_is_a_typed_error() {
160 assert!(matches!(
161 IvfPqIndex::from_bytes(b"not an index", test_memory()),
162 Err(VectorError::CheckpointDeserializationError { .. })
163 ));
164 }
165}