Skip to main content

nodedb_vector/codec_index/
build.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Insertion algorithm for `HnswCodecIndex<C>`.
4//!
5//! Implements the standard HNSW insert (Malkov & Yashunin 2018, Algorithm 1)
6//! using `codec.fast_symmetric_distance` for all neighbor-selection passes.
7
8use std::cmp::Reverse;
9use std::collections::{BinaryHeap, HashSet};
10
11use nodedb_codec::vector_quant::codec::VectorCodec;
12
13use super::graph::{HnswCodecIndex, NodeC};
14use crate::error::{VectorError, check_dim};
15
16/// Ordered pair for priority queues (dist, node_idx in `nodes` vec).
17#[derive(Clone, Copy, PartialEq)]
18struct Cand {
19    dist: f32,
20    /// Index into `HnswCodecIndex::nodes`.
21    idx: u32,
22}
23
24impl Eq for Cand {}
25
26impl PartialOrd for Cand {
27    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
28        Some(self.cmp(other))
29    }
30}
31
32impl Ord for Cand {
33    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
34        self.dist
35            .partial_cmp(&other.dist)
36            .unwrap_or(std::cmp::Ordering::Equal)
37            .then(self.idx.cmp(&other.idx))
38    }
39}
40
41impl<C: VectorCodec> HnswCodecIndex<C> {
42    /// Insert a vector with the given caller-supplied `id`.
43    ///
44    /// Encodes `v` via `codec.encode`, assigns a random layer, and runs the
45    /// standard HNSW neighbour-selection algorithm. A vector without the
46    /// index dimension fails with [`VectorError::DimensionMismatch`].
47    pub fn insert(&mut self, id: u32, v: &[f32]) -> Result<(), VectorError> {
48        check_dim(self.dim, v.len())?;
49        let quantized = self.codec.encode(v);
50        let node_layer = self.random_layer();
51
52        // Allocate the node first so we can use its index in the graph wiring.
53        let new_idx = self.nodes.len() as u32;
54
55        // Build empty neighbor lists: one per layer 0..=node_layer.
56        let neighbors = vec![Vec::new(); node_layer + 1];
57
58        self.nodes.push(NodeC {
59            id,
60            deleted: false,
61            layer: node_layer,
62            quantized,
63            neighbors,
64        });
65
66        let Some(ep) = self.entry_point else {
67            // First node: it becomes the entry point.
68            self.entry_point = Some(new_idx);
69            self.max_layer = node_layer;
70            return Ok(());
71        };
72
73        // Phase 1: greedy descent from max_layer down to node_layer + 1.
74        // Carry a single nearest candidate per layer (ef = 1).
75        let mut cur_ep = ep;
76        for layer in (node_layer + 1..=self.max_layer).rev() {
77            cur_ep = self.greedy_nearest(new_idx, cur_ep, layer);
78        }
79
80        // Phase 2: ef_construction search from node_layer down to 0.
81        let ef = self.ef_construction;
82        for layer in (0..=node_layer.min(self.max_layer)).rev() {
83            let candidates = self.search_layer_build(new_idx, cur_ep, ef, layer);
84
85            // Choose the m (or m0 at layer 0) nearest as neighbours.
86            let max_nb = self.max_neighbors(layer);
87            let chosen: Vec<u32> = candidates
88                .iter()
89                .filter(|c| c.idx != new_idx)
90                .take(max_nb)
91                .map(|c| c.idx)
92                .collect();
93
94            // Set new node's neighbours at this layer.
95            self.nodes[new_idx as usize].neighbors[layer] = chosen.clone();
96
97            // Update chosen neighbours reciprocally.
98            for &nb_idx in &chosen {
99                let new_dist = {
100                    let nb_q = &self.nodes[nb_idx as usize].quantized as *const C::Quantized;
101                    let new_q = &self.nodes[new_idx as usize].quantized as *const C::Quantized;
102                    // SAFETY: we hold exclusive access to `self`; the two
103                    // borrows are to distinct nodes.
104                    unsafe { self.codec.fast_symmetric_distance(&*nb_q, &*new_q) }
105                };
106
107                if layer < self.nodes[nb_idx as usize].neighbors.len() {
108                    let nb_layer = &mut self.nodes[nb_idx as usize].neighbors[layer];
109                    if !nb_layer.contains(&new_idx) {
110                        nb_layer.push(new_idx);
111                    }
112                    // Prune if over capacity.
113                    if nb_layer.len() > max_nb {
114                        self.prune_neighbors(nb_idx, layer, max_nb, new_dist, new_idx);
115                    }
116                }
117            }
118
119            // Update entry point for the next lower layer.
120            if let Some(best) = candidates.first() {
121                cur_ep = best.idx;
122            }
123        }
124
125        // If the new node's layer exceeds the current max, promote it.
126        if node_layer > self.max_layer {
127            self.entry_point = Some(new_idx);
128            self.max_layer = node_layer;
129        }
130        Ok(())
131    }
132
133    /// Greedy descent: starting at `ep_idx`, find the single nearest node to
134    /// `query_idx` at the given `layer`.
135    fn greedy_nearest(&self, query_idx: u32, ep_idx: u32, layer: usize) -> u32 {
136        let mut best_idx = ep_idx;
137        let mut best_dist = self.sym_dist(query_idx, ep_idx);
138
139        loop {
140            let mut improved = false;
141            for &nb in self.neighbors_at(best_idx, layer) {
142                if self.nodes[nb as usize].deleted {
143                    continue;
144                }
145                let d = self.sym_dist(query_idx, nb);
146                if d < best_dist {
147                    best_dist = d;
148                    best_idx = nb;
149                    improved = true;
150                }
151            }
152            if !improved {
153                break;
154            }
155        }
156
157        best_idx
158    }
159
160    /// Beam search over `layer` with the given `ef`, returning candidates
161    /// sorted by ascending distance from `query_idx` (excluding deleted nodes).
162    fn search_layer_build(
163        &self,
164        query_idx: u32,
165        ep_idx: u32,
166        ef: usize,
167        layer: usize,
168    ) -> Vec<Cand> {
169        let mut visited: HashSet<u32> = HashSet::new();
170        visited.insert(ep_idx);
171
172        let ep_dist = self.sym_dist(query_idx, ep_idx);
173        let ep_cand = Cand {
174            dist: ep_dist,
175            idx: ep_idx,
176        };
177
178        let mut candidates: BinaryHeap<Reverse<Cand>> = BinaryHeap::new();
179        candidates.push(Reverse(ep_cand));
180
181        let mut results: BinaryHeap<Cand> = BinaryHeap::new();
182        if !self.nodes[ep_idx as usize].deleted {
183            results.push(ep_cand);
184        }
185
186        while let Some(Reverse(cur)) = candidates.pop() {
187            let worst = results.peek().map_or(f32::INFINITY, |w| w.dist);
188            if cur.dist > worst && results.len() >= ef {
189                break;
190            }
191
192            for &nb in self.neighbors_at(cur.idx, layer) {
193                if !visited.insert(nb) {
194                    continue;
195                }
196                let d = self.sym_dist(query_idx, nb);
197                let worst_now = results.peek().map_or(f32::INFINITY, |w| w.dist);
198                if d < worst_now || results.len() < ef {
199                    candidates.push(Reverse(Cand { dist: d, idx: nb }));
200                }
201                if !self.nodes[nb as usize].deleted {
202                    results.push(Cand { dist: d, idx: nb });
203                    if results.len() > ef {
204                        results.pop();
205                    }
206                }
207            }
208        }
209
210        let mut out: Vec<Cand> = results.into_vec();
211        out.sort_unstable_by(|a, b| a.dist.total_cmp(&b.dist));
212        out
213    }
214
215    /// Prune the neighbor list of `nb_idx` at `layer` to `max_nb` entries,
216    /// removing the farthest neighbours (simple distance-based strategy).
217    fn prune_neighbors(
218        &mut self,
219        nb_idx: u32,
220        layer: usize,
221        max_nb: usize,
222        _hint_dist: f32,
223        _hint_id: u32,
224    ) {
225        // Collect (dist_to_nb, idx) for every current neighbour.
226        let nb_list = self.nodes[nb_idx as usize].neighbors[layer].clone();
227        let mut scored: Vec<(f32, u32)> = nb_list
228            .iter()
229            .map(|&cand_idx| {
230                let d = {
231                    let a = &self.nodes[nb_idx as usize].quantized as *const C::Quantized;
232                    let b = &self.nodes[cand_idx as usize].quantized as *const C::Quantized;
233                    // SAFETY: a and b point to distinct nodes in `self.nodes`.
234                    unsafe { self.codec.fast_symmetric_distance(&*a, &*b) }
235                };
236                (d, cand_idx)
237            })
238            .collect();
239
240        // Keep the `max_nb` nearest.
241        scored.sort_unstable_by(|a, b| a.0.total_cmp(&b.0));
242        scored.truncate(max_nb);
243
244        self.nodes[nb_idx as usize].neighbors[layer] =
245            scored.into_iter().map(|(_, idx)| idx).collect();
246    }
247
248    /// Symmetric distance between two nodes identified by their dense indices.
249    #[inline]
250    pub(crate) fn sym_dist(&self, a_idx: u32, b_idx: u32) -> f32 {
251        let a = &self.nodes[a_idx as usize].quantized;
252        let b = &self.nodes[b_idx as usize].quantized;
253        // Both borrows are read-only; safe even through raw pointers if
254        // called for distinct indices, but here we just borrow directly.
255        self.codec.fast_symmetric_distance(a, b)
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use crate::quantize::Sq8Codec;
263
264    fn make_sq8(dim: usize, n: usize) -> Sq8Codec {
265        let vecs: Vec<Vec<f32>> = (0..n)
266            .map(|i| (0..dim).map(|d| (i * dim + d) as f32 * 0.1).collect())
267            .collect();
268        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
269        Sq8Codec::calibrate(&refs, dim).unwrap()
270    }
271
272    #[test]
273    fn insert_sets_entry_point() {
274        let codec = make_sq8(4, 10);
275        let mut idx: HnswCodecIndex<Sq8Codec> = HnswCodecIndex::new(4, 8, 50, codec, 1);
276        idx.insert(0, &[0.1, 0.2, 0.3, 0.4]).unwrap();
277        assert!(idx.entry_point.is_some());
278        assert_eq!(idx.len(), 1);
279    }
280
281    #[test]
282    fn insert_multiple_grows_nodes() {
283        let codec = make_sq8(4, 30);
284        let mut idx: HnswCodecIndex<Sq8Codec> = HnswCodecIndex::new(4, 8, 50, codec, 42);
285        for i in 0..20u32 {
286            let v: Vec<f32> = (0..4).map(|d| (i as usize * 4 + d) as f32).collect();
287            idx.insert(i, &v).unwrap();
288        }
289        assert_eq!(idx.len(), 20);
290        assert!(idx.entry_point.is_some());
291    }
292}