Skip to main content

nodedb_vector/hnsw/graph/index/
compact.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Tombstone compaction for [`HnswIndex`]: drops deleted nodes and remaps ids.
4
5use super::HnswIndex;
6use crate::hnsw::graph::types::Node;
7
8impl HnswIndex {
9    /// Compact the index by removing all tombstoned nodes.
10    ///
11    /// Returns the number of removed nodes. See `compact_with_map` for the
12    /// variant that also returns the old→new id remapping.
13    pub fn compact(&mut self) -> usize {
14        self.compact_with_map().0
15    }
16
17    /// Compact and return both the removed count and the old→new id map.
18    ///
19    /// `id_map[old_local]` = new_local, or `u32::MAX` if the node was
20    /// tombstoned (removed). Repairs edges so the entry point reaches every
21    /// remaining node.
22    pub fn compact_with_map(&mut self) -> (usize, Vec<u32>) {
23        let tombstone_count = self.tombstone_count();
24        if tombstone_count == 0 {
25            let identity: Vec<u32> = (0..self.nodes.len() as u32).collect();
26            return (0, identity);
27        }
28        self.ensure_mutable_neighbors();
29        self.bridge_deleted_neighbors();
30
31        let mut id_map: Vec<u32> = Vec::with_capacity(self.nodes.len());
32        let mut new_id = 0u32;
33        for node in &self.nodes {
34            if node.deleted {
35                id_map.push(u32::MAX);
36            } else {
37                id_map.push(new_id);
38                new_id += 1;
39            }
40        }
41
42        let mut new_nodes: Vec<Node> = Vec::with_capacity(new_id as usize);
43        for node in self.nodes.drain(..) {
44            if node.deleted {
45                continue;
46            }
47            let remapped_neighbors: Vec<Vec<u32>> = node
48                .neighbors
49                .into_iter()
50                .map(|layer_neighbors| {
51                    layer_neighbors
52                        .into_iter()
53                        .filter_map(|old_nid| {
54                            let new_nid = id_map[old_nid as usize];
55                            if new_nid == u32::MAX {
56                                None
57                            } else {
58                                Some(new_nid)
59                            }
60                        })
61                        .collect()
62                })
63                .collect();
64            new_nodes.push(Node {
65                storage: node.storage,
66                neighbors: remapped_neighbors,
67                deleted: false,
68            });
69        }
70
71        self.entry_point = if let Some(old_ep) = self.entry_point {
72            let new_ep = id_map[old_ep as usize];
73            if new_ep == u32::MAX {
74                new_nodes
75                    .iter()
76                    .enumerate()
77                    .max_by_key(|(_, n)| n.neighbors.len())
78                    .map(|(i, _)| i as u32)
79            } else {
80                Some(new_ep)
81            }
82        } else {
83            None
84        };
85
86        self.max_layer = new_nodes
87            .iter()
88            .map(|n| n.neighbors.len().saturating_sub(1))
89            .max()
90            .unwrap_or(0);
91
92        self.nodes = new_nodes;
93        self.connect_unreachable();
94        (tombstone_count, id_map)
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use std::collections::VecDeque;
101
102    use nodedb_types::vector_dtype::VectorStorageDtype;
103
104    use crate::distance::DistanceMetric;
105    use crate::hnsw::{HnswIndex, HnswParams};
106
107    fn params(m: usize, m0: usize) -> HnswParams {
108        HnswParams {
109            m,
110            m0,
111            ef_construction: 32,
112            metric: DistanceMetric::L2,
113            dtype: VectorStorageDtype::F32,
114        }
115    }
116
117    /// Nodes the entry point reaches over layer-0 edges.
118    fn reached_at_layer0(idx: &HnswIndex) -> Vec<bool> {
119        let mut reached = vec![false; idx.nodes.len()];
120        let Some(ep) = idx.entry_point() else {
121            return reached;
122        };
123        reached[ep as usize] = true;
124        let mut queue = VecDeque::from([ep]);
125        while let Some(cur) = queue.pop_front() {
126            for &next in &idx.nodes[cur as usize].neighbors[0] {
127                if !reached[next as usize] {
128                    reached[next as usize] = true;
129                    queue.push_back(next);
130                }
131            }
132        }
133        reached
134    }
135
136    /// Points 0, 1, 2, 3 on a line with `m0 = 2`. Node 1 prunes its edge to
137    /// node 3, so node 2 holds the only edge into node 3. Search walks
138    /// through deleted node 2 before compaction. Compaction drops node 2 and
139    /// must not strand node 3.
140    #[test]
141    fn compaction_keeps_node_whose_only_in_edge_came_from_a_deleted_node() {
142        // `m = 2^40` puts every node on layer 0, so the graph is fixed.
143        let mut idx = HnswIndex::with_seed(3, params(1 << 40, 2), 1);
144        for x in 0..4u8 {
145            idx.insert(vec![f32::from(x), 0.0, 0.0]).unwrap();
146        }
147        assert_eq!(idx.max_layer(), 0);
148        let into_3: Vec<usize> = idx
149            .nodes
150            .iter()
151            .enumerate()
152            .filter(|(_, n)| n.neighbors[0].contains(&3))
153            .map(|(id, _)| id)
154            .collect();
155        assert_eq!(
156            into_3,
157            vec![2],
158            "fixture: node 2 holds the only edge into node 3"
159        );
160
161        assert!(idx.delete(2));
162        let query = [3.0, 0.0, 0.0];
163        assert_eq!(idx.search(&query, 1, 32).unwrap()[0].id, 3);
164
165        let (removed, id_map) = idx.compact_with_map();
166        assert_eq!(removed, 1);
167        let hits = idx.search(&query, 1, 32).unwrap();
168        assert_eq!(
169            hits.first().map(|h| h.id),
170            Some(id_map[3]),
171            "node 3 is unreachable after compaction, got {hits:?}"
172        );
173        assert!(reached_at_layer0(&idx).iter().all(|&r| r));
174    }
175
176    /// Heavy deletes cut many paths. After compaction the entry point reaches
177    /// every node, an exact query finds every node, and no list exceeds its cap.
178    #[test]
179    fn compaction_after_heavy_deletes_keeps_every_node_reachable() {
180        use crate::test_support::seeded_vectors;
181
182        let vectors = seeded_vectors(400, 8, 0x9E37_79B9_7F4A_7C15);
183        let mut idx = HnswIndex::with_seed(
184            8,
185            HnswParams {
186                ef_construction: 64,
187                ..params(4, 8)
188            },
189            7,
190        );
191        for v in &vectors {
192            idx.insert(v.clone()).unwrap();
193        }
194        for id in 0..vectors.len() as u32 {
195            if id % 5 != 0 {
196                assert!(idx.delete(id));
197            }
198        }
199        let (_, id_map) = idx.compact_with_map();
200
201        let reached = reached_at_layer0(&idx);
202        let lost = reached.iter().filter(|&&r| !r).count();
203        assert_eq!(lost, 0, "{lost} of {} nodes unreachable", reached.len());
204        for old in (0..vectors.len()).step_by(5) {
205            let hits = idx.search(&vectors[old], 1, idx.len()).unwrap();
206            assert_eq!(
207                hits.first().map(|h| h.id),
208                Some(id_map[old]),
209                "old node {old} not found"
210            );
211        }
212        let (m, m0) = (idx.params().m, idx.params().m0);
213        for node in &idx.nodes {
214            for (layer, list) in node.neighbors.iter().enumerate() {
215                assert!(list.len() <= if layer == 0 { m0 } else { m });
216            }
217        }
218    }
219}