Skip to main content

nodedb_vector/hnsw/
build.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! HNSW insert algorithm (Malkov & Yashunin, Algorithm 1).
4
5use crate::dtype::cast_from_f32;
6use crate::error::VectorError;
7use crate::hnsw::graph::{Candidate, HnswIndex, Node, NodeStorage};
8use crate::hnsw::search::search_layer;
9use nodedb_types::vector_dtype::VectorStorageDtype;
10
11impl HnswIndex {
12    /// Insert a vector into the index.
13    ///
14    /// 1. Assign a random layer using the exponential distribution
15    /// 2. Greedily descend from the entry point to the new node's layer + 1
16    /// 3. At each layer from the node's layer down to 0, search for nearest
17    ///    neighbors, select via the diversity heuristic, and add bidirectional edges
18    /// 4. Prune over-connected nodes to maintain the M/M0 invariant
19    pub fn insert(&mut self, vector: Vec<f32>) -> Result<(), VectorError> {
20        // Materialize flat neighbor storage on first mutation.
21        self.ensure_mutable_neighbors();
22
23        if vector.len() != self.dim {
24            return Err(VectorError::DimensionMismatch {
25                expected: self.dim,
26                got: vector.len(),
27            });
28        }
29
30        let new_id = self.nodes.len() as u32;
31        let new_layer = self.random_layer();
32
33        let storage = match self.params.dtype {
34            VectorStorageDtype::F32 => NodeStorage::F32(vector.clone()),
35            dtype => NodeStorage::Bytes {
36                dtype,
37                bytes: cast_from_f32(&vector, dtype),
38            },
39        };
40        let node = Node {
41            storage,
42            neighbors: (0..=new_layer).map(|_| Vec::new()).collect(),
43            deleted: false,
44        };
45        self.nodes.push(node);
46
47        let Some(ep) = self.entry_point else {
48            self.entry_point = Some(new_id);
49            self.max_layer = new_layer;
50            return Ok(());
51        };
52
53        // One probe serves every distance against the new vector below.
54        let probe = self.probe(&vector);
55
56        let mut current_ep = ep;
57
58        // Phase 1: Greedy descent from top layer to new_layer + 1.
59        if self.max_layer > new_layer {
60            for layer in (new_layer + 1..=self.max_layer).rev() {
61                let results = search_layer(self, &probe, current_ep, 1, layer, None, 0);
62                if let Some(nearest) = results.first() {
63                    current_ep = nearest.id;
64                }
65            }
66        }
67
68        // Phase 2: Insert at each layer from min(new_layer, max_layer) down to 0.
69        let insert_top = new_layer.min(self.max_layer);
70        let mut reached_live = false;
71        for layer in (0..=insert_top).rev() {
72            let ef = self.params.ef_construction;
73            let candidates = search_layer(self, &probe, current_ep, ef, layer, None, 0);
74            if layer == 0 {
75                reached_live = !candidates.is_empty();
76            }
77
78            let m = self.max_neighbors(layer);
79            let selected = select_neighbors_heuristic(self, &candidates, m);
80
81            self.nodes[new_id as usize].neighbors[layer] = selected.iter().map(|c| c.id).collect();
82
83            for neighbor in &selected {
84                let nid = neighbor.id as usize;
85                self.nodes[nid].neighbors[layer].push(new_id);
86
87                if self.nodes[nid].neighbors[layer].len() > m {
88                    self.prune_neighbors(neighbor.id, layer, m);
89                }
90            }
91
92            if let Some(nearest) = candidates.first() {
93                current_ep = nearest.id;
94            }
95        }
96
97        // `search_layer` admits only live nodes, and with fewer than `ef`
98        // results it visits every node it can reach. No layer-0 candidate
99        // means no live node is reachable from the entry point, so the new
100        // node becomes the entry point.
101        if !reached_live || new_layer > self.max_layer {
102            self.entry_point = Some(new_id);
103            self.max_layer = new_layer;
104        }
105
106        Ok(())
107    }
108
109    /// Prune a node's neighbor list using the diversity heuristic.
110    fn prune_neighbors(&mut self, node_id: u32, layer: usize, m: usize) {
111        let metric = self.params.metric;
112        let node_idx = node_id as usize;
113        let selected: Vec<u32> = {
114            let probe = self.node_probe(node_id);
115            let mut candidates: Vec<Candidate> = self.nodes[node_idx].neighbors[layer]
116                .iter()
117                .map(|&nid| Candidate {
118                    id: nid,
119                    dist: probe
120                        .as_ref()
121                        .and_then(|p| self.dist_to_node(p, nid, metric))
122                        .unwrap_or(f32::INFINITY),
123                })
124                .collect();
125            candidates.sort_unstable_by(|a, b| a.dist.total_cmp(&b.dist));
126            select_neighbors_heuristic(self, &candidates, m)
127                .iter()
128                .map(|c| c.id)
129                .collect()
130        };
131        self.nodes[node_idx].neighbors[layer] = selected;
132    }
133}
134
135/// Heuristic neighbor selection (Malkov & Yashunin, Algorithm 4).
136pub(crate) fn select_neighbors_heuristic(
137    index: &HnswIndex,
138    candidates: &[Candidate],
139    m: usize,
140) -> Vec<Candidate> {
141    let metric = index.params.metric;
142    let mut selected: Vec<Candidate> = Vec::with_capacity(m);
143
144    for candidate in candidates {
145        if selected.len() >= m {
146            break;
147        }
148
149        // Borrowed from node storage: the pairwise loop below allocates nothing.
150        let candidate_probe = index.node_probe(candidate.id);
151        let is_diverse = selected.iter().all(|s| {
152            let dist_to_selected = candidate_probe
153                .as_ref()
154                .and_then(|p| index.dist_to_node(p, s.id, metric))
155                .unwrap_or(f32::MAX);
156            candidate.dist <= dist_to_selected
157        });
158
159        if is_diverse {
160            selected.push(*candidate);
161        }
162    }
163
164    // Backfill if heuristic was too aggressive.
165    if selected.len() < m {
166        let selected_ids: std::collections::HashSet<u32> = selected.iter().map(|c| c.id).collect();
167        for candidate in candidates {
168            if selected.len() >= m {
169                break;
170            }
171            if !selected_ids.contains(&candidate.id) {
172                selected.push(*candidate);
173            }
174        }
175    }
176
177    selected
178}
179
180#[cfg(test)]
181mod tests {
182    use nodedb_types::vector_dtype::VectorStorageDtype;
183
184    use crate::distance::DistanceMetric;
185    use crate::hnsw::{HnswIndex, HnswParams};
186
187    fn make_index() -> HnswIndex {
188        HnswIndex::with_seed(
189            3,
190            HnswParams {
191                m: 4,
192                m0: 8,
193                ef_construction: 32,
194                metric: DistanceMetric::L2,
195                dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32,
196            },
197            12345,
198        )
199    }
200
201    #[test]
202    fn insert_single() {
203        let mut idx = make_index();
204        idx.insert(vec![1.0, 0.0, 0.0]).unwrap();
205        assert_eq!(idx.len(), 1);
206        assert_eq!(idx.entry_point(), Some(0));
207    }
208
209    #[test]
210    fn insert_many_maintains_invariants() {
211        let mut idx = make_index();
212        for i in 0..100 {
213            let v = vec![(i as f32) * 0.1, (i as f32) * 0.2, (i as f32) * 0.3];
214            idx.insert(v).unwrap();
215        }
216        assert_eq!(idx.len(), 100);
217        assert!(idx.entry_point().is_some());
218
219        for node in &idx.nodes {
220            assert!(node.neighbors[0].len() <= idx.params.m0);
221        }
222        for node in &idx.nodes {
223            for (layer, neighbors) in node.neighbors.iter().enumerate().skip(1) {
224                assert!(
225                    neighbors.len() <= idx.params.m,
226                    "layer {layer} neighbor count {} exceeds m={}",
227                    neighbors.len(),
228                    idx.params.m
229                );
230            }
231        }
232    }
233
234    #[test]
235    fn all_nodes_reachable_from_entry() {
236        let mut idx = make_index();
237        for i in 0..20 {
238            idx.insert(vec![i as f32, 0.0, 0.0]).unwrap();
239        }
240
241        for target in 0..20u32 {
242            let query = idx.get_vector(target).unwrap().to_vec();
243            let results = idx.search(&query, 1, 32).unwrap();
244            assert_eq!(results[0].id, target, "node {target} not reachable");
245        }
246    }
247
248    /// F16/BF16 inserts score node pairs through the borrowed encoded bytes.
249    /// Every node must stay reachable by an exact query.
250    #[test]
251    fn narrow_dtype_inserts_reach_every_node() {
252        use crate::test_support::{seeded_index, seeded_vectors};
253
254        let vectors = seeded_vectors(120, 8, 0x2545_F491_4F6C_DD1D);
255        for dtype in [VectorStorageDtype::F16, VectorStorageDtype::BF16] {
256            for metric in [DistanceMetric::L2, DistanceMetric::Cosine] {
257                let idx = seeded_index(&vectors, metric, dtype);
258                for (id, v) in vectors.iter().enumerate() {
259                    // `ef` covers every node, so a miss means a node is unreachable.
260                    let hits = idx.search(v, 1, 128).unwrap();
261                    assert_eq!(
262                        hits.first().map(|h| h.id),
263                        Some(id as u32),
264                        "dtype={dtype:?} metric={metric:?}: exact query misses node {id}"
265                    );
266                }
267                for node in &idx.nodes {
268                    assert!(node.neighbors[0].len() <= idx.params.m0);
269                }
270            }
271        }
272    }
273
274    /// A graph-only restore with an attached backing must insert exactly like a
275    /// full restore. Restored nodes hold no local vector, so neighbor pruning
276    /// and the diversity heuristic read them through the backing. A new node
277    /// must gain incoming edges, or search can never reach it.
278    ///
279    /// `segment_backing` is gated off on wasm32, so the test is too.
280    #[cfg(not(target_arch = "wasm32"))]
281    #[test]
282    fn inserts_after_graph_only_restore_match_a_full_restore() {
283        use crate::test_support::{graph_only_restore_with_backing, seeded_index, seeded_vectors};
284
285        const DIM: usize = 8;
286        let base = seeded_vectors(200, DIM, 0x9E37_79B9_7F4A_7C15);
287        let extra = seeded_vectors(5, DIM, 0xD1B5_4A32_D192_ED03);
288
289        let src = seeded_index(&base, DistanceMetric::L2, VectorStorageDtype::F32);
290        let mut full = HnswIndex::from_checkpoint(&src.checkpoint_to_bytes().unwrap())
291            .unwrap()
292            .expect("full checkpoint must be recognized");
293        let mut graph_only = graph_only_restore_with_backing(&src, &base);
294
295        for v in &extra {
296            full.insert(v.clone()).unwrap();
297            graph_only.insert(v.clone()).unwrap();
298        }
299
300        let neighbors = graph_only.export_neighbors();
301        assert_eq!(
302            full.export_neighbors(),
303            neighbors,
304            "a graph-only restore must build the same graph as a full restore"
305        );
306
307        for (offset, v) in extra.iter().enumerate() {
308            let new_id = (base.len() + offset) as u32;
309            let hits = graph_only.search(v, 1, 64).unwrap();
310            assert_eq!(
311                hits.first().map(|h| h.id),
312                Some(new_id),
313                "exact query for inserted node {new_id} returns it, got {hits:?}"
314            );
315            assert!(
316                hits[0].distance < 1e-6,
317                "exact query for node {new_id} scores ~0, got {}",
318                hits[0].distance
319            );
320            assert!(
321                neighbors
322                    .iter()
323                    .any(|layers| layers.first().is_some_and(|l0| l0.contains(&new_id))),
324                "node {new_id} has no incoming layer-0 edge"
325            );
326        }
327
328        let m = graph_only.params().m;
329        let m0 = graph_only.params().m0;
330        for (id, layers) in neighbors.iter().enumerate() {
331            for (layer, list) in layers.iter().enumerate() {
332                let cap = if layer == 0 { m0 } else { m };
333                assert!(
334                    list.len() <= cap,
335                    "node {id} layer {layer} has {} neighbors, cap {cap}",
336                    list.len()
337                );
338            }
339        }
340    }
341
342    #[test]
343    fn compact_removes_tombstones() {
344        let mut idx = make_index();
345        for i in 0..20u32 {
346            idx.insert(vec![i as f32, 0.0, 0.0]).unwrap();
347        }
348
349        for i in (0..20u32).step_by(2) {
350            assert!(idx.delete(i));
351        }
352        assert_eq!(idx.compact(), 10);
353        assert_eq!(idx.len(), 10);
354
355        for target_old_id in (1..20u32).step_by(2) {
356            let query = vec![target_old_id as f32, 0.0, 0.0];
357            let results = idx.search(&query, 1, 32).unwrap();
358            assert!(!results.is_empty());
359            let found_vec = idx.get_vector(results[0].id).unwrap();
360            assert_eq!(found_vec[0], target_old_id as f32);
361        }
362    }
363
364    /// An index whose every node is deleted keeps serving vectors inserted
365    /// afterwards. Many seeds, because one seed can give a new node a layer
366    /// above the deleted entry point, which hides the defect.
367    #[test]
368    fn insert_after_every_node_is_deleted_stays_searchable() {
369        for seed in 1..=32u64 {
370            let mut idx = HnswIndex::with_seed(
371                3,
372                HnswParams {
373                    m: 4,
374                    m0: 8,
375                    ef_construction: 32,
376                    metric: DistanceMetric::L2,
377                    dtype: VectorStorageDtype::F32,
378                },
379                seed,
380            );
381            idx.insert(vec![1.0, 0.0, 0.0]).unwrap();
382            assert!(idx.delete(0));
383            let fresh = [[0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [0.5, 0.5, 0.0]];
384            for v in fresh {
385                idx.insert(v.to_vec()).unwrap();
386            }
387            let ep = idx.entry_point().unwrap();
388            assert!(!idx.is_deleted(ep), "seed {seed}: entry point is deleted");
389            for (i, v) in fresh.iter().enumerate() {
390                let hits = idx.search(v, 1, 32).unwrap();
391                assert_eq!(
392                    hits.first().map(|h| h.id),
393                    Some(i as u32 + 1),
394                    "seed {seed}: node {} unreachable",
395                    i + 1
396                );
397            }
398        }
399    }
400}