Skip to main content

nodedb_vector/hnsw/
checkpoint.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! HNSW checkpoint serialization and deserialization.
4//!
5//! Serialization format: rkyv with a `RKHNS\0` magic header.
6
7use std::cell::RefCell;
8
9use nodedb_types::vector_dtype::VectorStorageDtype;
10
11use crate::distance::DistanceMetric;
12use crate::error::VectorError;
13use crate::hnsw::arena::BeamSearchArena;
14use crate::hnsw::flat_neighbors::FlatNeighborStore;
15use crate::hnsw::graph::{ARENA_INITIAL_CAPACITY, HnswIndex, Node, NodeStorage, Xorshift64};
16
17/// Magic header for rkyv-serialized HNSW snapshots (6 bytes).
18const HNSW_RKYV_MAGIC: &[u8; 6] = b"RKHNS\0";
19/// Current format version for rkyv-serialized HNSW snapshots.
20pub const HNSW_FORMAT_VERSION: u8 = 1;
21
22/// Storage dtype of every index restored from a checkpoint.
23///
24/// The snapshot stores f32 vectors and no dtype. An F16/BF16 index
25/// checkpoints its vectors decoded to f32 and restores as an F32 index.
26const CHECKPOINT_DTYPE: VectorStorageDtype = VectorStorageDtype::F32;
27
28/// Decode a checkpoint metric code written as `DistanceMetric as u8`.
29///
30/// # Errors
31///
32/// [`VectorError::CheckpointDeserializationError`] for a code no metric uses.
33pub(crate) fn metric_from_code(code: u8) -> Result<DistanceMetric, VectorError> {
34    match code {
35        0 => Ok(DistanceMetric::L2),
36        1 => Ok(DistanceMetric::Cosine),
37        2 => Ok(DistanceMetric::InnerProduct),
38        3 => Ok(DistanceMetric::Manhattan),
39        4 => Ok(DistanceMetric::Chebyshev),
40        5 => Ok(DistanceMetric::Hamming),
41        6 => Ok(DistanceMetric::Jaccard),
42        7 => Ok(DistanceMetric::Pearson),
43        _ => Err(VectorError::CheckpointDeserializationError {
44            detail: format!("unknown distance metric code {code}"),
45        }),
46    }
47}
48
49/// Check that a decoded snapshot describes one consistent graph.
50fn validate_snapshot(snap: &HnswSnapshotRkyv) -> Result<(), VectorError> {
51    let count = snap.node_vectors.len();
52    if snap.node_neighbors.len() != count || snap.node_deleted.len() != count {
53        return Err(VectorError::CheckpointDeserializationError {
54            detail: format!(
55                "node counts disagree: {count} vectors, {} neighbor lists, {} deleted flags",
56                snap.node_neighbors.len(),
57                snap.node_deleted.len()
58            ),
59        });
60    }
61    // An empty vector is a graph-only placeholder served by a backing.
62    if let Some(v) = snap
63        .node_vectors
64        .iter()
65        .find(|v| !v.is_empty() && v.len() != snap.dim)
66    {
67        return Err(VectorError::StoredDimensionMismatch {
68            expected: snap.dim,
69            got: v.len(),
70        });
71    }
72    let in_range = |id: u32| (id as usize) < count;
73    let neighbors_in_range = snap
74        .node_neighbors
75        .iter()
76        .flatten()
77        .flatten()
78        .all(|&id| in_range(id));
79    if !neighbors_in_range || snap.entry_point.is_some_and(|ep| !in_range(ep)) {
80        return Err(VectorError::CheckpointDeserializationError {
81            detail: format!("a neighbor or entry point id is outside {count} nodes"),
82        });
83    }
84    Ok(())
85}
86
87/// rkyv-serialized HNSW snapshot. SoA layout for better rkyv compatibility
88/// (flat Vecs instead of Vec<struct>).
89#[derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)]
90pub(crate) struct HnswSnapshotRkyv {
91    pub dim: usize,
92    pub m: usize,
93    pub m0: usize,
94    pub ef_construction: usize,
95    pub metric: u8,
96    pub entry_point: Option<u32>,
97    pub max_layer: usize,
98    pub rng_state: u64,
99    /// Per-node vectors (SoA).
100    pub node_vectors: Vec<Vec<f32>>,
101    /// Per-node neighbor lists (SoA).
102    pub node_neighbors: Vec<Vec<Vec<u32>>>,
103    /// Per-node deleted flags (SoA).
104    pub node_deleted: Vec<bool>,
105}
106
107impl HnswIndex {
108    /// Serialize ONLY the graph topology (neighbors, entry point, params) to
109    /// rkyv bytes.  Vector data is intentionally omitted — `node_vectors` is
110    /// filled with empty `Vec<f32>` placeholders.
111    ///
112    /// Pair with [`Self::from_graph_checkpoint`] on restore.  The same
113    /// `RKHNS\0` magic and version byte are used so `from_checkpoint` can
114    /// decode the bytes, but the resulting index has empty node storage.
115    ///
116    /// This is the Lite pagedb-segment write path: vectors live in the segment,
117    /// graph topology lives in the B+ tree.  Origin never calls this method.
118    pub fn graph_checkpoint_to_bytes(&self) -> Result<Vec<u8>, VectorError> {
119        let snapshot = HnswSnapshotRkyv {
120            dim: self.dim,
121            m: self.params.m,
122            m0: self.params.m0,
123            ef_construction: self.params.ef_construction,
124            metric: self.params.metric as u8,
125            entry_point: self.entry_point,
126            max_layer: self.max_layer,
127            rng_state: self.rng.0,
128            // Placeholder: one empty vec per node so the node count is preserved.
129            node_vectors: vec![Vec::new(); self.nodes.len()],
130            node_neighbors: if let Some(ref flat) = self.flat_neighbors {
131                flat.to_nested(self.nodes.len())
132            } else {
133                self.nodes.iter().map(|n| n.neighbors.clone()).collect()
134            },
135            node_deleted: self.nodes.iter().map(|n| n.deleted).collect(),
136        };
137        let rkyv_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&snapshot).map_err(|e| {
138            VectorError::CheckpointSerializationError {
139                detail: e.to_string(),
140            }
141        })?;
142        let mut buf = Vec::with_capacity(HNSW_RKYV_MAGIC.len() + 1 + rkyv_bytes.len());
143        buf.extend_from_slice(HNSW_RKYV_MAGIC);
144        buf.push(HNSW_FORMAT_VERSION);
145        buf.extend_from_slice(&rkyv_bytes);
146        Ok(buf)
147    }
148
149    /// Serialize the index to rkyv bytes (with magic header) for storage.
150    ///
151    /// Magic header `RKHNS\0` allows `from_checkpoint` to detect format.
152    pub fn checkpoint_to_bytes(&self) -> Result<Vec<u8>, VectorError> {
153        let snapshot = HnswSnapshotRkyv {
154            dim: self.dim,
155            m: self.params.m,
156            m0: self.params.m0,
157            ef_construction: self.params.ef_construction,
158            metric: self.params.metric as u8,
159            entry_point: self.entry_point,
160            max_layer: self.max_layer,
161            rng_state: self.rng.0,
162            node_vectors: self.export_vectors()?,
163            node_neighbors: if let Some(ref flat) = self.flat_neighbors {
164                flat.to_nested(self.nodes.len())
165            } else {
166                self.nodes.iter().map(|n| n.neighbors.clone()).collect()
167            },
168            node_deleted: self.nodes.iter().map(|n| n.deleted).collect(),
169        };
170        let rkyv_bytes = rkyv::to_bytes::<rkyv::rancor::Error>(&snapshot).map_err(|e| {
171            VectorError::CheckpointSerializationError {
172                detail: e.to_string(),
173            }
174        })?;
175        let mut buf = Vec::with_capacity(HNSW_RKYV_MAGIC.len() + 1 + rkyv_bytes.len());
176        buf.extend_from_slice(HNSW_RKYV_MAGIC);
177        buf.push(HNSW_FORMAT_VERSION);
178        buf.extend_from_slice(&rkyv_bytes);
179        Ok(buf)
180    }
181
182    /// Restore an index from a checkpoint snapshot.
183    ///
184    /// Returns:
185    /// - `Ok(Some(index))` — successfully decoded (rkyv format).
186    /// - `Ok(None)` — bytes do not start with the `RKHNS\0` magic header.
187    /// - `Err(VectorError::UnsupportedVersion)` — magic matches `RKHNS\0` but
188    ///   the version byte is not `HNSW_FORMAT_VERSION`; the caller must reject
189    ///   the buffer.
190    /// - `Err(VectorError::CheckpointDeserializationError)` — magic and
191    ///   version matched but the rkyv payload itself failed to decode
192    ///   (structurally corrupt checkpoint).
193    pub fn from_checkpoint(bytes: &[u8]) -> Result<Option<Self>, crate::error::VectorError> {
194        let header_len = HNSW_RKYV_MAGIC.len() + 1; // magic + version byte
195        if bytes.len() > header_len && &bytes[..HNSW_RKYV_MAGIC.len()] == HNSW_RKYV_MAGIC {
196            let version = bytes[HNSW_RKYV_MAGIC.len()];
197            if version != HNSW_FORMAT_VERSION {
198                return Err(crate::error::VectorError::UnsupportedVersion {
199                    found: version,
200                    expected: HNSW_FORMAT_VERSION,
201                });
202            }
203            return Ok(Some(Self::from_rkyv_checkpoint(&bytes[header_len..])?));
204        }
205        // No recognized magic prefix — no index to restore.
206        Ok(None)
207    }
208
209    /// Restore from rkyv-serialized bytes.
210    fn from_rkyv_checkpoint(bytes: &[u8]) -> Result<Self, VectorError> {
211        let mut aligned = rkyv::util::AlignedVec::<16>::with_capacity(bytes.len());
212        aligned.extend_from_slice(bytes);
213        let snap: HnswSnapshotRkyv =
214            rkyv::from_bytes::<HnswSnapshotRkyv, rkyv::rancor::Error>(&aligned).map_err(|e| {
215                VectorError::CheckpointDeserializationError {
216                    detail: format!("hnsw rkyv decode: {e}"),
217                }
218            })?;
219        Self::from_hnsw_snapshot(snap)
220    }
221
222    /// Reconstruct HnswIndex from deserialized snapshot fields.
223    ///
224    /// The snapshot is untrusted input. Inconsistent node counts, a vector of
225    /// the wrong dimension, or an out-of-range node id fail the restore.
226    fn from_hnsw_snapshot(snap: HnswSnapshotRkyv) -> Result<Self, VectorError> {
227        use nodedb_types::hnsw::HnswParams;
228
229        let metric = metric_from_code(snap.metric)?;
230        validate_snapshot(&snap)?;
231
232        let flat = FlatNeighborStore::from_nested(&snap.node_neighbors);
233
234        let nodes: Vec<Node> = snap
235            .node_vectors
236            .into_iter()
237            .zip(snap.node_deleted)
238            .map(|(vector, deleted)| Node {
239                storage: NodeStorage::F32(vector),
240                neighbors: Vec::new(),
241                deleted,
242            })
243            .collect();
244
245        let initial_capacity = snap.ef_construction.max(ARENA_INITIAL_CAPACITY);
246        Ok(Self {
247            dim: snap.dim,
248            params: HnswParams {
249                m: snap.m,
250                m0: snap.m0,
251                ef_construction: snap.ef_construction,
252                metric,
253                dtype: CHECKPOINT_DTYPE,
254            },
255            nodes,
256            entry_point: snap.entry_point,
257            max_layer: snap.max_layer,
258            rng: Xorshift64::new(snap.rng_state),
259            flat_neighbors: Some(flat),
260            arena: RefCell::new(BeamSearchArena::new(initial_capacity)),
261            #[cfg(not(target_arch = "wasm32"))]
262            backing: None,
263        })
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::HnswSnapshotRkyv;
270    use crate::distance::DistanceMetric;
271    use crate::error::VectorError;
272    use crate::hnsw::{HnswIndex, HnswParams};
273
274    fn make_index() -> HnswIndex {
275        HnswIndex::with_seed(
276            3,
277            HnswParams {
278                m: 4,
279                m0: 8,
280                ef_construction: 32,
281                metric: DistanceMetric::L2,
282                dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32,
283            },
284            12345,
285        )
286    }
287
288    #[test]
289    fn checkpoint_roundtrip() {
290        let mut idx = make_index();
291        for i in 0..50 {
292            idx.insert(vec![(i as f32) * 0.1, (i as f32) * 0.2, (i as f32) * 0.3])
293                .unwrap();
294        }
295
296        let bytes = idx.checkpoint_to_bytes().unwrap();
297        assert!(!bytes.is_empty());
298
299        let restored = HnswIndex::from_checkpoint(&bytes).unwrap().unwrap();
300        assert_eq!(restored.len(), 50);
301        assert_eq!(restored.dim(), 3);
302        assert_eq!(restored.entry_point(), idx.entry_point());
303        assert_eq!(restored.max_layer(), idx.max_layer());
304
305        let query = vec![1.0, 2.0, 3.0];
306        let orig_results = idx.search(&query, 5, 32).unwrap();
307        let rest_results = restored.search(&query, 5, 32).unwrap();
308        assert_eq!(orig_results.len(), rest_results.len());
309        for (a, b) in orig_results.iter().zip(rest_results.iter()) {
310            assert_eq!(a.id, b.id);
311            assert!((a.distance - b.distance).abs() < 1e-6);
312        }
313    }
314
315    #[test]
316    fn golden_header_layout() {
317        let mut idx = make_index();
318        idx.insert(vec![1.0, 2.0, 3.0]).unwrap();
319        let bytes = idx.checkpoint_to_bytes().unwrap();
320        // Magic at bytes[0..6].
321        assert_eq!(&bytes[0..6], b"RKHNS\0");
322        // Version byte at bytes[6].
323        assert_eq!(bytes[6], super::HNSW_FORMAT_VERSION);
324        // rkyv payload follows immediately.
325        assert!(bytes.len() > 7);
326    }
327
328    #[test]
329    fn version_mismatch_returns_error() {
330        let mut idx = make_index();
331        idx.insert(vec![1.0, 2.0, 3.0]).unwrap();
332        let mut bytes = idx.checkpoint_to_bytes().unwrap();
333        // Corrupt the version byte to an unsupported value.
334        bytes[6] = 0;
335        match HnswIndex::from_checkpoint(&bytes) {
336            Err(crate::error::VectorError::UnsupportedVersion { found, expected }) => {
337                assert_eq!(found, 0);
338                assert_eq!(expected, super::HNSW_FORMAT_VERSION);
339            }
340            Err(other) => panic!("unexpected error: {other}"),
341            Ok(_) => panic!("expected UnsupportedVersion error, got Ok"),
342        }
343    }
344
345    #[test]
346    fn corrupt_rkyv_payload_returns_deserialization_error() {
347        // Valid magic + valid version byte, but the rkyv payload itself is
348        // garbage. This must surface as a typed decode error, not silently
349        // collapse to `Ok(None)` (which would look identical to "no
350        // checkpoint present" and drop the restore on the floor).
351        let mut bytes = Vec::new();
352        bytes.extend_from_slice(b"RKHNS\0");
353        bytes.push(super::HNSW_FORMAT_VERSION);
354        bytes.extend_from_slice(&[0xFFu8; 64]);
355
356        match HnswIndex::from_checkpoint(&bytes) {
357            Err(crate::error::VectorError::CheckpointDeserializationError { .. }) => {}
358            Err(other) => panic!("unexpected error: {other}"),
359            Ok(_) => panic!("expected CheckpointDeserializationError, got Ok"),
360        }
361    }
362
363    /// Every metric survives a checkpoint round trip.
364    #[test]
365    fn every_metric_round_trips() {
366        for metric in [
367            DistanceMetric::L2,
368            DistanceMetric::Cosine,
369            DistanceMetric::InnerProduct,
370            DistanceMetric::Manhattan,
371            DistanceMetric::Chebyshev,
372            DistanceMetric::Hamming,
373            DistanceMetric::Jaccard,
374            DistanceMetric::Pearson,
375        ] {
376            let mut idx = HnswIndex::with_seed(
377                3,
378                HnswParams {
379                    metric,
380                    ..make_index().params().clone()
381                },
382                1,
383            );
384            idx.insert(vec![1.0, 2.0, 3.0]).unwrap();
385            let restored = HnswIndex::from_checkpoint(&idx.checkpoint_to_bytes().unwrap())
386                .unwrap()
387                .unwrap();
388            assert_eq!(restored.metric(), metric);
389        }
390    }
391
392    /// A valid snapshot of three 2-dim nodes in a line, entry point 0.
393    fn snapshot() -> HnswSnapshotRkyv {
394        HnswSnapshotRkyv {
395            dim: 2,
396            m: 4,
397            m0: 8,
398            ef_construction: 32,
399            metric: DistanceMetric::L2 as u8,
400            entry_point: Some(0),
401            max_layer: 0,
402            rng_state: 1,
403            node_vectors: vec![vec![0.0, 0.0], vec![1.0, 0.0], vec![2.0, 0.0]],
404            node_neighbors: vec![vec![vec![1]], vec![vec![0, 2]], vec![vec![1]]],
405            node_deleted: vec![false; 3],
406        }
407    }
408
409    fn encode(snap: &HnswSnapshotRkyv) -> Vec<u8> {
410        let mut buf = super::HNSW_RKYV_MAGIC.to_vec();
411        buf.push(super::HNSW_FORMAT_VERSION);
412        buf.extend_from_slice(&rkyv::to_bytes::<rkyv::rancor::Error>(snap).unwrap());
413        buf
414    }
415
416    #[test]
417    fn a_valid_snapshot_restores() {
418        let idx = HnswIndex::from_checkpoint(&encode(&snapshot()))
419            .unwrap()
420            .unwrap();
421        assert_eq!(idx.search(&[2.0, 0.0], 1, 8).unwrap()[0].id, 2);
422    }
423
424    /// A corrupt snapshot fails the restore instead of panicking a later search.
425    #[test]
426    fn a_corrupt_snapshot_is_a_typed_error() {
427        let mut wrong_dim = snapshot();
428        wrong_dim.node_vectors[1] = vec![1.0, 0.0, 0.0];
429        let mut short_flags = snapshot();
430        short_flags.node_deleted.pop();
431        let mut bad_neighbor = snapshot();
432        bad_neighbor.node_neighbors[2][0].push(9);
433        let mut bad_entry = snapshot();
434        bad_entry.entry_point = Some(3);
435        let mut bad_metric = snapshot();
436        bad_metric.metric = 200;
437
438        assert!(matches!(
439            HnswIndex::from_checkpoint(&encode(&wrong_dim)),
440            Err(VectorError::StoredDimensionMismatch {
441                expected: 2,
442                got: 3
443            })
444        ));
445        for (name, snap) in [
446            ("short flags", short_flags),
447            ("bad neighbor", bad_neighbor),
448            ("bad entry", bad_entry),
449            ("bad metric", bad_metric),
450        ] {
451            assert!(
452                matches!(
453                    HnswIndex::from_checkpoint(&encode(&snap)),
454                    Err(VectorError::CheckpointDeserializationError { .. })
455                ),
456                "{name} must fail the restore"
457            );
458        }
459    }
460}