Skip to main content

nodedb_vector/codec_index/
search.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Search algorithm for `HnswCodecIndex<C>`.
4//!
5//! Two-stage HNSW search:
6//! - Phase 1 (layers max..1): greedy descent using `fast_symmetric_distance`.
7//! - Phase 2 (layer 0): ef-wide beam search using `fast_symmetric_distance`
8//!   for navigation, then a final rerank pass with `exact_asymmetric_distance`.
9
10use std::cmp::Reverse;
11use std::collections::{BinaryHeap, HashSet};
12
13use nodedb_codec::vector_quant::codec::VectorCodec;
14
15use super::graph::HnswCodecIndex;
16use crate::error::{VectorError, check_dim};
17
18/// A single result from a codec-index search.
19#[derive(Debug, Clone)]
20pub struct CodecSearchResult {
21    /// Caller-supplied id from `HnswCodecIndex::insert`.
22    pub id: u32,
23    /// `exact_asymmetric_distance` between the query and this vector.
24    pub distance: f32,
25}
26
27/// Internal candidate during beam search.
28#[derive(Clone, Copy, PartialEq)]
29struct Cand {
30    dist: f32,
31    /// Dense index into `HnswCodecIndex::nodes`.
32    idx: u32,
33}
34
35impl Eq for Cand {}
36
37impl PartialOrd for Cand {
38    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
39        Some(self.cmp(other))
40    }
41}
42
43impl Ord for Cand {
44    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
45        self.dist
46            .partial_cmp(&other.dist)
47            .unwrap_or(std::cmp::Ordering::Equal)
48            .then(self.idx.cmp(&other.idx))
49    }
50}
51
52impl<C: VectorCodec> HnswCodecIndex<C> {
53    /// K-NN search returning up to `k` results.
54    ///
55    /// `ef_search` controls the beam width at layer 0 (must be >= k).
56    ///
57    /// The returned results are sorted ascending by `exact_asymmetric_distance`.
58    /// A query without the index dimension fails with
59    /// [`VectorError::DimensionMismatch`].
60    pub fn search(
61        &self,
62        query: &[f32],
63        k: usize,
64        ef_search: usize,
65    ) -> Result<Vec<CodecSearchResult>, VectorError> {
66        check_dim(self.dim, query.len())?;
67        if self.is_empty() {
68            return Ok(Vec::new());
69        }
70
71        let Some(ep) = self.entry_point else {
72            return Ok(Vec::new());
73        };
74
75        let ef = ef_search.max(k);
76
77        // Precompute the query forms used by the two phases.
78        let q_encoded = self.codec.encode(query);
79        let q_prepared = self.codec.prepare_query(query);
80
81        // Phase 1: greedy descent through layers max_layer..1.
82        let mut cur_ep = ep;
83        for layer in (1..=self.max_layer).rev() {
84            cur_ep = self.greedy_nearest_search(&q_encoded, cur_ep, layer);
85        }
86
87        // Phase 2: ef-wide beam search at layer 0.
88        let candidates = self.search_layer_0(&q_encoded, cur_ep, ef);
89
90        // Rerank top ef_search candidates with exact asymmetric distance.
91        let mut reranked: Vec<(f32, u32)> = candidates
92            .into_iter()
93            .take(ef)
94            .map(|c| {
95                let asym = self
96                    .codec
97                    .exact_asymmetric_distance(&q_prepared, &self.nodes[c.idx as usize].quantized);
98                (asym, self.nodes[c.idx as usize].id)
99            })
100            .collect();
101
102        reranked.sort_unstable_by(|a, b| a.0.total_cmp(&b.0));
103        reranked.truncate(k);
104
105        Ok(reranked
106            .into_iter()
107            .map(|(distance, id)| CodecSearchResult { id, distance })
108            .collect())
109    }
110
111    /// Greedy single-nearest descent at `layer` using the pre-encoded query.
112    fn greedy_nearest_search(&self, q_enc: &C::Quantized, ep_idx: u32, layer: usize) -> u32 {
113        let mut best_idx = ep_idx;
114        let mut best_dist = self
115            .codec
116            .fast_symmetric_distance(q_enc, &self.nodes[ep_idx as usize].quantized);
117
118        loop {
119            let mut improved = false;
120            for &nb in self.neighbors_at(best_idx, layer) {
121                if self.nodes[nb as usize].deleted {
122                    continue;
123                }
124                let d = self
125                    .codec
126                    .fast_symmetric_distance(q_enc, &self.nodes[nb as usize].quantized);
127                if d < best_dist {
128                    best_dist = d;
129                    best_idx = nb;
130                    improved = true;
131                }
132            }
133            if !improved {
134                break;
135            }
136        }
137
138        best_idx
139    }
140
141    /// Beam search at layer 0 using `fast_symmetric_distance`.
142    ///
143    /// Returns candidates sorted ascending by symmetric distance (used only
144    /// for routing; final ranking is done by `exact_asymmetric_distance` in
145    /// the caller).
146    fn search_layer_0(&self, q_enc: &C::Quantized, ep_idx: u32, ef: usize) -> Vec<Cand> {
147        let mut visited: HashSet<u32> = HashSet::new();
148        visited.insert(ep_idx);
149
150        let ep_dist = self
151            .codec
152            .fast_symmetric_distance(q_enc, &self.nodes[ep_idx as usize].quantized);
153        let ep_cand = Cand {
154            dist: ep_dist,
155            idx: ep_idx,
156        };
157
158        let mut candidates: BinaryHeap<Reverse<Cand>> = BinaryHeap::new();
159        candidates.push(Reverse(ep_cand));
160
161        let mut results: BinaryHeap<Cand> = BinaryHeap::new();
162        if !self.nodes[ep_idx as usize].deleted {
163            results.push(ep_cand);
164        }
165
166        while let Some(Reverse(cur)) = candidates.pop() {
167            let worst = results.peek().map_or(f32::INFINITY, |w| w.dist);
168            if cur.dist > worst && results.len() >= ef {
169                break;
170            }
171
172            for &nb in self.neighbors_at(cur.idx, 0) {
173                if !visited.insert(nb) {
174                    continue;
175                }
176                let d = self
177                    .codec
178                    .fast_symmetric_distance(q_enc, &self.nodes[nb as usize].quantized);
179                let worst_now = results.peek().map_or(f32::INFINITY, |w| w.dist);
180                if d < worst_now || results.len() < ef {
181                    candidates.push(Reverse(Cand { dist: d, idx: nb }));
182                }
183                if !self.nodes[nb as usize].deleted {
184                    results.push(Cand { dist: d, idx: nb });
185                    if results.len() > ef {
186                        results.pop();
187                    }
188                }
189            }
190        }
191
192        let mut out: Vec<Cand> = results.into_vec();
193        out.sort_unstable_by(|a, b| a.dist.total_cmp(&b.dist));
194        out
195    }
196}
197
198// ── Tests ─────────────────────────────────────────────────────────────────────
199
200#[cfg(test)]
201mod tests {
202    use nodedb_codec::vector_quant::{bbq::BbqCodec, rabitq::RaBitQCodec};
203
204    use crate::{codec_index::HnswCodecIndex, distance::l2_squared, quantize::Sq8Codec};
205
206    // ── helpers ───────────────────────────────────────────────────────────────
207
208    fn xorshift(state: &mut u64) -> u64 {
209        *state ^= *state << 13;
210        *state ^= *state >> 7;
211        *state ^= *state << 17;
212        *state
213    }
214
215    fn rand_vec(state: &mut u64, dim: usize) -> Vec<f32> {
216        (0..dim)
217            .map(|_| (xorshift(state) as f32 / u64::MAX as f32) * 2.0 - 1.0)
218            .collect()
219    }
220
221    /// Brute-force top-k by L2-squared.
222    fn ground_truth(vecs: &[Vec<f32>], query: &[f32], k: usize) -> Vec<u32> {
223        let mut scored: Vec<(f32, u32)> = vecs
224            .iter()
225            .enumerate()
226            .map(|(i, v)| (l2_squared(query, v), i as u32))
227            .collect();
228        scored.sort_unstable_by(|a, b| a.0.total_cmp(&b.0));
229        scored.into_iter().take(k).map(|(_, id)| id).collect()
230    }
231
232    // ── Sq8 round-trip ────────────────────────────────────────────────────────
233
234    #[test]
235    fn sq8_top1_exact_match() {
236        let dim = 8;
237        let n = 50usize;
238        let mut state = 0xDEAD_BEEF_u64;
239        let vecs: Vec<Vec<f32>> = (0..n).map(|_| rand_vec(&mut state, dim)).collect();
240        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
241        let codec = Sq8Codec::calibrate(&refs, dim).unwrap();
242        let mut idx: HnswCodecIndex<Sq8Codec> = HnswCodecIndex::new(dim, 8, 100, codec, 7);
243        for (i, v) in vecs.iter().enumerate() {
244            idx.insert(i as u32, v).unwrap();
245        }
246        // Query with vector 17 — top-1 should return id 17.
247        let query = vecs[17].clone();
248        let results = idx.search(&query, 1, 50).unwrap();
249        assert_eq!(results.len(), 1, "expected 1 result");
250        assert_eq!(
251            results[0].id, 17,
252            "top-1 should be the queried vector itself"
253        );
254        assert!(
255            results[0].distance < 0.1,
256            "distance to self should be near 0, got {}",
257            results[0].distance
258        );
259    }
260
261    // ── RaBitQ recall ─────────────────────────────────────────────────────────
262
263    #[test]
264    fn rabitq_recall_at_least_60_percent() {
265        let dim = 16;
266        let n = 100usize;
267        let k = 5usize;
268        let mut state = 0xCAFE_BABE_u64;
269        let vecs: Vec<Vec<f32>> = (0..n).map(|_| rand_vec(&mut state, dim)).collect();
270        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
271        let codec = RaBitQCodec::calibrate(&refs, dim, 0xABCD_1234);
272        let mut idx: HnswCodecIndex<RaBitQCodec> = HnswCodecIndex::new(dim, 8, 150, codec, 99);
273        for (i, v) in vecs.iter().enumerate() {
274            idx.insert(i as u32, v).unwrap();
275        }
276
277        let n_queries = 10usize;
278        let mut total_hits = 0usize;
279        let mut total = 0usize;
280        for _qi in 0..n_queries {
281            let query = rand_vec(&mut state, dim);
282            let truth: std::collections::HashSet<u32> =
283                ground_truth(&vecs, &query, k).into_iter().collect();
284            let results = idx.search(&query, k, k * 4).unwrap();
285            let found: std::collections::HashSet<u32> = results.iter().map(|r| r.id).collect();
286            total_hits += found.intersection(&truth).count();
287            total += k;
288        }
289        let recall = total_hits as f64 / total as f64;
290        // 1-bit codecs at low dim (D=16) are inherently approximate; the
291        // O(1/√D) bound only bites at higher dimensions. This is a sanity
292        // test: at D=16 / n=100 / k=5, RaBitQ typically lands ≥ 30%.
293        assert!(
294            recall >= 0.30,
295            "RaBitQ recall@{k} = {recall:.2}, expected >= 0.30"
296        );
297    }
298
299    // ── BBQ recall ────────────────────────────────────────────────────────────
300
301    #[test]
302    fn bbq_recall_at_least_60_percent() {
303        let dim = 16;
304        let n = 100usize;
305        let k = 5usize;
306        let mut state = 0x1234_5678_u64;
307        let vecs: Vec<Vec<f32>> = (0..n).map(|_| rand_vec(&mut state, dim)).collect();
308        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
309        let codec = BbqCodec::calibrate(&refs, dim, 3);
310        let mut idx: HnswCodecIndex<BbqCodec> = HnswCodecIndex::new(dim, 8, 150, codec, 42);
311        for (i, v) in vecs.iter().enumerate() {
312            idx.insert(i as u32, v).unwrap();
313        }
314
315        let n_queries = 10usize;
316        let mut total_hits = 0usize;
317        let mut total = 0usize;
318        for _qi in 0..n_queries {
319            let query = rand_vec(&mut state, dim);
320            let truth: std::collections::HashSet<u32> =
321                ground_truth(&vecs, &query, k).into_iter().collect();
322            let results = idx.search(&query, k, k * 4).unwrap();
323            let found: std::collections::HashSet<u32> = results.iter().map(|r| r.id).collect();
324            total_hits += found.intersection(&truth).count();
325            total += k;
326        }
327        let recall = total_hits as f64 / total as f64;
328        // BBQ at D=16 is approximate (corrective factors help vs raw binary
329        // but the 1-bit code itself is information-bounded). Sanity test
330        // threshold; production uses BBQ + oversample ×3 rerank pass.
331        assert!(
332            recall >= 0.40,
333            "BBQ recall@{k} = {recall:.2}, expected >= 0.40"
334        );
335    }
336
337    // ── Edge cases ────────────────────────────────────────────────────────────
338
339    #[test]
340    fn empty_index_returns_empty() {
341        let codec = {
342            let vecs: Vec<Vec<f32>> = (0..5).map(|i| vec![i as f32; 4]).collect();
343            let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
344            Sq8Codec::calibrate(&refs, 4).unwrap()
345        };
346        let idx: HnswCodecIndex<Sq8Codec> = HnswCodecIndex::new(4, 8, 50, codec, 1);
347        let results = idx.search(&[0.0, 0.0, 0.0, 0.0], 5, 20).unwrap();
348        assert!(results.is_empty(), "empty index must return no results");
349    }
350
351    #[test]
352    fn single_vector_index_always_returns_it() {
353        let dim = 4;
354        let vecs = [vec![1.0f32, 2.0, 3.0, 4.0]];
355        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
356        let codec = Sq8Codec::calibrate(&refs, dim).unwrap();
357        let mut idx: HnswCodecIndex<Sq8Codec> = HnswCodecIndex::new(dim, 8, 50, codec, 5);
358        idx.insert(0, &vecs[0]).unwrap();
359        // Query with a completely different vector.
360        let results = idx.search(&[10.0, 20.0, 30.0, 40.0], 1, 10).unwrap();
361        assert_eq!(results.len(), 1, "single-node index must return 1 result");
362        assert_eq!(results[0].id, 0, "the only node must be returned");
363    }
364
365    #[test]
366    fn wrong_dimension_is_a_typed_error() {
367        use crate::error::VectorError;
368        let vecs: Vec<Vec<f32>> = (0..5).map(|i| vec![i as f32; 4]).collect();
369        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
370        let codec = Sq8Codec::calibrate(&refs, 4).unwrap();
371        let mut idx: HnswCodecIndex<Sq8Codec> = HnswCodecIndex::new(4, 8, 50, codec, 1);
372        assert!(matches!(
373            idx.search(&[0.0; 3], 5, 20),
374            Err(VectorError::DimensionMismatch {
375                expected: 4,
376                got: 3
377            })
378        ));
379        idx.insert(0, &vecs[0]).unwrap();
380        assert!(matches!(
381            idx.search(&[0.0; 5], 5, 20),
382            Err(VectorError::DimensionMismatch {
383                expected: 4,
384                got: 5
385            })
386        ));
387        assert!(matches!(
388            idx.insert(1, &[0.0; 2]),
389            Err(VectorError::DimensionMismatch {
390                expected: 4,
391                got: 2
392            })
393        ));
394    }
395}