Skip to main content

nodedb_vector/hnsw/
search.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! HNSW search algorithm (Malkov & Yashunin, Algorithm 2).
4//!
5//! Beam search through the multi-layer graph with optional Roaring bitmap
6//! pre-filtering for efficient filtered k-NN queries.
7
8use std::cmp::Reverse;
9use std::collections::BinaryHeap;
10
11/// Issue a non-faulting prefetch hint for the memory region starting at `ptr`
12/// into all cache levels (T0). No-op on architectures without a prefetch
13/// intrinsic.
14#[inline(always)]
15fn prefetch_t0(ptr: *const u8) {
16    #[cfg(target_arch = "x86_64")]
17    {
18        // SAFETY: _mm_prefetch is a pure hint — it never faults even on an
19        // invalid address, and it does not dereference the pointer.
20        unsafe {
21            core::arch::x86_64::_mm_prefetch::<{ core::arch::x86_64::_MM_HINT_T0 }>(
22                ptr as *const i8,
23            );
24        }
25    }
26    // aarch64 hardware prefetchers handle sequential + pointer-chasing patterns
27    // well without explicit hints. wasm32 and all other targets: intentional no-op.
28    #[cfg(not(target_arch = "x86_64"))]
29    let _ = ptr;
30}
31
32use roaring::RoaringBitmap;
33
34use crate::error::{VectorError, check_dim};
35use crate::hnsw::graph::{Candidate, HnswIndex, Probe, SearchResult};
36
37/// Maximum beam width to prevent runaway search cost.
38const MAX_EF: usize = 8192;
39
40impl HnswIndex {
41    /// K-NN search: find the `k` closest vectors to `query`.
42    ///
43    /// `ef` controls the search beam width (higher = better recall, slower).
44    /// Must be >= k. Typical values: ef = 2*k to 10*k.
45    ///
46    /// Returns [`VectorError::DimensionMismatch`] when `query` does not have
47    /// the index dimension.
48    pub fn search(
49        &self,
50        query: &[f32],
51        k: usize,
52        ef: usize,
53    ) -> Result<Vec<SearchResult>, VectorError> {
54        self.search_inner(query, k, ef.max(k).min(MAX_EF), None, 0)
55    }
56
57    /// Filtered K-NN search with Roaring bitmap pre-filtering.
58    pub fn search_filtered(
59        &self,
60        query: &[f32],
61        k: usize,
62        ef: usize,
63        filter: &RoaringBitmap,
64    ) -> Result<Vec<SearchResult>, VectorError> {
65        self.search_filtered_offset(query, k, ef, filter, 0)
66    }
67
68    /// Filtered K-NN search where the bitmap is keyed in a shifted ID space.
69    ///
70    /// `id_offset` is added to local node IDs before testing `filter.contains`.
71    /// Used by multi-segment collections where the bitmap holds GLOBAL ids
72    /// and each segment's HNSW nodes are numbered starting at `base_id`.
73    pub fn search_filtered_offset(
74        &self,
75        query: &[f32],
76        k: usize,
77        ef: usize,
78        filter: &RoaringBitmap,
79        id_offset: u32,
80    ) -> Result<Vec<SearchResult>, VectorError> {
81        self.search_inner(query, k, ef.max(k), Some(filter), id_offset)
82    }
83
84    /// Deserialize a Roaring bitmap from bytes and perform filtered search.
85    pub fn search_with_bitmap_bytes(
86        &self,
87        query: &[f32],
88        k: usize,
89        ef: usize,
90        bitmap_bytes: &[u8],
91    ) -> Result<Vec<SearchResult>, VectorError> {
92        self.search_with_bitmap_bytes_offset(query, k, ef, bitmap_bytes, 0)
93    }
94
95    /// Deserialize a Roaring bitmap and search with an ID offset applied
96    /// before testing membership. See `search_filtered_offset` for rationale.
97    ///
98    /// Bytes that do not decode fail with
99    /// [`VectorError::InvalidFilterBitmap`]: an unfiltered search would
100    /// return rows the filter excludes.
101    pub fn search_with_bitmap_bytes_offset(
102        &self,
103        query: &[f32],
104        k: usize,
105        ef: usize,
106        bitmap_bytes: &[u8],
107        id_offset: u32,
108    ) -> Result<Vec<SearchResult>, VectorError> {
109        let bitmap = decode_filter_bitmap(bitmap_bytes)?;
110        self.search_filtered_offset(query, k, ef, &bitmap, id_offset)
111    }
112
113    /// Greedy descent to layer 1, then a beam search of width `ef` at layer
114    /// 0, restricted to `filter` when one is given.
115    fn search_inner(
116        &self,
117        query: &[f32],
118        k: usize,
119        ef: usize,
120        filter: Option<&RoaringBitmap>,
121        id_offset: u32,
122    ) -> Result<Vec<SearchResult>, VectorError> {
123        check_dim(self.dim, query.len())?;
124        if self.is_empty() {
125            return Ok(Vec::new());
126        }
127        let Some(ep) = self.entry_point else {
128            return Ok(Vec::new());
129        };
130
131        let probe = self.probe(query);
132
133        // Phase 1: Greedy descent from top layer to layer 1.
134        let mut current_ep = ep;
135        for layer in (1..=self.max_layer).rev() {
136            let results = search_layer(self, &probe, current_ep, 1, layer, None, 0);
137            if let Some(nearest) = results.first() {
138                current_ep = nearest.id;
139            }
140        }
141
142        // Phase 2: Beam search at layer 0.
143        let results = search_layer(self, &probe, current_ep, ef, 0, filter, id_offset);
144
145        Ok(results
146            .into_iter()
147            .take(k)
148            .map(|c| SearchResult {
149                id: c.id,
150                distance: c.dist,
151            })
152            .collect())
153    }
154}
155
156/// Decode a serialized Roaring pre-filter bitmap.
157pub fn decode_filter_bitmap(bytes: &[u8]) -> Result<RoaringBitmap, VectorError> {
158    RoaringBitmap::deserialize_from(bytes).map_err(|e| VectorError::InvalidFilterBitmap {
159        detail: e.to_string(),
160    })
161}
162
163/// Unified HNSW beam search on a single layer with optional pre-filter.
164///
165/// When `filter` is `None`, all non-deleted nodes enter the result set.
166/// When `filter` is `Some`, only nodes present in the bitmap enter results,
167/// but all nodes are still traversed for graph connectivity.
168///
169/// Distances use the build metric. A node without a vector is traversed at
170/// infinite distance and never enters the results.
171///
172/// Scratch buffers are drawn from `index.arena` (a `RefCell`-guarded
173/// `BeamSearchArena`).  The arena is reset at entry and its `Vec`/`HashSet`
174/// capacity grows to the high-water mark over successive calls, giving
175/// amortised zero-allocation steady state.
176pub(crate) fn search_layer(
177    index: &HnswIndex,
178    probe: &Probe<'_>,
179    entry_point: u32,
180    ef: usize,
181    layer: usize,
182    filter: Option<&RoaringBitmap>,
183    id_offset: u32,
184) -> Vec<Candidate> {
185    let metric = index.params.metric;
186    let mut arena = index.arena.borrow_mut();
187
188    // Reset scratch buffers — retains Vec/HashSet capacity from prior calls.
189    arena.reset();
190
191    arena.visited.insert(entry_point);
192
193    let ep_dist = index.dist_to_node(probe, entry_point, metric);
194    let ep_candidate = Candidate {
195        dist: ep_dist.unwrap_or(f32::INFINITY),
196        id: entry_point,
197    };
198
199    // Strategy (c): take the arena Vecs, build BinaryHeaps on them, do the
200    // search, then move the Vecs back.  The Vecs retain their allocated
201    // capacity across the take/into_vec round-trip, so steady-state searches
202    // incur zero heap allocation.
203    let mut cand_vec = std::mem::take(&mut arena.candidates);
204    cand_vec.push(Reverse(ep_candidate));
205    let mut candidates = BinaryHeap::from(cand_vec);
206
207    let mut res_vec = std::mem::take(&mut arena.results);
208
209    let passes_filter = |id: u32| -> bool {
210        if index.nodes[id as usize].deleted {
211            return false;
212        }
213        match filter {
214            Some(f) => f.contains(id + id_offset),
215            None => true,
216        }
217    };
218
219    if ep_dist.is_some() && passes_filter(entry_point) {
220        res_vec.push(ep_candidate);
221    }
222    let mut results = BinaryHeap::from(res_vec);
223
224    while let Some(Reverse(current)) = candidates.pop() {
225        if let Some(worst) = results.peek()
226            && current.dist > worst.dist
227            && results.len() >= ef
228        {
229            break;
230        }
231
232        // Prefetch the vector bytes of the next candidate before touching this
233        // iteration's neighbor list, so it lands in cache by the time the
234        // inner loop calls dist_to_node on it.
235        if let Some(Reverse(next)) = candidates.peek()
236            && let Some(node) = index.nodes.get(next.id as usize)
237        {
238            prefetch_t0(node.storage.as_bytes().as_ptr());
239        }
240
241        let neighbors = index.neighbors_at(current.id, layer);
242        if neighbors.is_empty() && layer >= index.node_num_layers(current.id) {
243            continue;
244        }
245
246        for &neighbor_id in neighbors {
247            if !arena.visited.insert(neighbor_id) {
248                continue;
249            }
250
251            let scored = index.dist_to_node(probe, neighbor_id, metric);
252            let neighbor = Candidate {
253                dist: scored.unwrap_or(f32::INFINITY),
254                id: neighbor_id,
255            };
256
257            let worst_dist = results.peek().map_or(f32::INFINITY, |w| w.dist);
258            let should_explore = neighbor.dist < worst_dist || results.len() < ef;
259
260            if should_explore {
261                candidates.push(Reverse(neighbor));
262            }
263
264            if scored.is_some() && passes_filter(neighbor_id) {
265                results.push(neighbor);
266                if results.len() > ef {
267                    results.pop();
268                }
269            }
270        }
271    }
272
273    // Restore the candidates Vec to the arena before releasing the borrow.
274    arena.candidates = candidates.into_vec();
275
276    let mut result_vec = results.into_vec();
277    // arena.results stays empty — reset() will clear it on the next call.
278    drop(arena);
279
280    result_vec.sort_unstable_by(|a, b| a.dist.total_cmp(&b.dist));
281    result_vec
282}
283
284#[cfg(test)]
285mod tests {
286    use crate::distance::DistanceMetric;
287    use crate::hnsw::{HnswIndex, HnswParams};
288    use roaring::RoaringBitmap;
289
290    fn build_index(n: usize, dim: usize) -> HnswIndex {
291        let mut idx = HnswIndex::with_seed(
292            dim,
293            HnswParams {
294                m: 16,
295                m0: 32,
296                ef_construction: 100,
297                metric: DistanceMetric::L2,
298                dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32,
299            },
300            42,
301        );
302        for i in 0..n {
303            let v: Vec<f32> = (0..dim).map(|d| (i * dim + d) as f32).collect();
304            idx.insert(v).unwrap();
305        }
306        idx
307    }
308
309    #[test]
310    fn search_empty_index() {
311        let idx = HnswIndex::new(3, HnswParams::default());
312        let results = idx.search(&[1.0, 2.0, 3.0], 5, 50).unwrap();
313        assert!(results.is_empty());
314    }
315
316    #[test]
317    fn search_single_element() {
318        let mut idx = HnswIndex::with_seed(
319            2,
320            HnswParams {
321                m: 4,
322                m0: 8,
323                ef_construction: 16,
324                metric: DistanceMetric::L2,
325                dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32,
326            },
327            1,
328        );
329        idx.insert(vec![1.0, 0.0]).unwrap();
330        let results = idx.search(&[1.0, 0.0], 1, 10).unwrap();
331        assert_eq!(results.len(), 1);
332        assert_eq!(results[0].id, 0);
333        assert!(results[0].distance < 1e-6);
334    }
335
336    #[test]
337    fn search_finds_exact_match() {
338        let idx = build_index(50, 3);
339        let query = idx.get_vector(25).unwrap().to_vec();
340        let results = idx.search(&query, 1, 50).unwrap();
341        assert_eq!(results.len(), 1);
342        assert_eq!(results[0].id, 25);
343        assert!(results[0].distance < 1e-6);
344    }
345
346    #[test]
347    fn search_returns_sorted_by_distance() {
348        let idx = build_index(100, 4);
349        let query = vec![50.0, 50.0, 50.0, 50.0];
350        let results = idx.search(&query, 10, 64).unwrap();
351        assert_eq!(results.len(), 10);
352        for w in results.windows(2) {
353            assert!(w[0].distance <= w[1].distance);
354        }
355    }
356
357    #[test]
358    fn search_k_larger_than_index() {
359        let idx = build_index(5, 2);
360        let results = idx.search(&[0.0, 0.0], 20, 50).unwrap();
361        assert_eq!(results.len(), 5);
362    }
363
364    #[test]
365    fn search_recall_at_10() {
366        let idx = build_index(500, 3);
367        let query = vec![100.0, 100.0, 100.0];
368        let results = idx.search(&query, 10, 128).unwrap();
369
370        let mut truth: Vec<(u32, f32)> = (0..500)
371            .map(|i| {
372                let v = idx.get_vector(i).unwrap();
373                let d = crate::distance::l2_squared(&query, v);
374                (i, d)
375            })
376            .collect();
377        truth.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
378        let truth_top10: std::collections::HashSet<u32> = truth[..10].iter().map(|t| t.0).collect();
379
380        let found: std::collections::HashSet<u32> = results.iter().map(|r| r.id).collect();
381        let recall = found.intersection(&truth_top10).count() as f64 / 10.0;
382        assert!(recall >= 0.8, "recall@10 = {recall:.2}, expected >= 0.80");
383    }
384
385    #[test]
386    fn search_excludes_tombstoned() {
387        let mut idx = build_index(20, 3);
388        idx.delete(0);
389        let results = idx.search(&[0.0, 0.0, 0.0], 5, 32).unwrap();
390        for r in &results {
391            assert_ne!(r.id, 0, "tombstoned node appeared in results");
392        }
393    }
394
395    #[test]
396    fn search_filtered_respects_bitmap() {
397        let idx = build_index(50, 3);
398        let mut filter = RoaringBitmap::new();
399        for i in (0..50u32).step_by(2) {
400            filter.insert(i);
401        }
402        let results = idx
403            .search_filtered(&[0.0, 0.0, 0.0], 5, 64, &filter)
404            .unwrap();
405        assert_eq!(results.len(), 5);
406        for r in &results {
407            assert!(r.id % 2 == 0, "got odd id {}", r.id);
408        }
409    }
410
411    #[test]
412    fn search_filtered_empty_returns_empty() {
413        let idx = build_index(20, 3);
414        let filter = RoaringBitmap::new();
415        let results = idx
416            .search_filtered(&[0.0, 0.0, 0.0], 5, 64, &filter)
417            .unwrap();
418        assert!(results.is_empty());
419    }
420
421    #[test]
422    fn bitmap_bytes_roundtrip() {
423        let idx = build_index(50, 3);
424        let mut filter = RoaringBitmap::new();
425        for i in 0..25u32 {
426            filter.insert(i);
427        }
428        let mut bytes = Vec::new();
429        filter.serialize_into(&mut bytes).unwrap();
430        let results = idx
431            .search_with_bitmap_bytes(&[0.0, 0.0, 0.0], 5, 32, &bytes)
432            .unwrap();
433        for r in &results {
434            assert!(r.id < 25, "got filtered-out node {}", r.id);
435        }
436    }
437
438    /// A query of the wrong dimension is a typed error on every entry point,
439    /// on an empty index and a populated one alike.
440    #[test]
441    fn wrong_dimension_query_is_a_typed_error() {
442        use crate::error::VectorError;
443        let empty = HnswIndex::new(3, HnswParams::default());
444        let idx = build_index(20, 3);
445        let filter: RoaringBitmap = (0..20u32).collect();
446        let mut bytes = Vec::new();
447        filter.serialize_into(&mut bytes).unwrap();
448        let short = [0.0_f32, 0.0];
449        for result in [
450            empty.search(&short, 5, 32),
451            idx.search(&short, 5, 32),
452            idx.search_filtered(&short, 5, 32, &filter),
453            idx.search_filtered_offset(&short, 5, 32, &filter, 0),
454            idx.search_with_bitmap_bytes(&short, 5, 32, &bytes),
455            idx.search_with_bitmap_bytes_offset(&short, 5, 32, &bytes, 0),
456        ] {
457            assert!(
458                matches!(
459                    result,
460                    Err(VectorError::DimensionMismatch {
461                        expected: 3,
462                        got: 2
463                    })
464                ),
465                "{result:?}"
466            );
467        }
468    }
469
470    /// Filter bytes that do not decode fail the search: an unfiltered
471    /// search would return rows the filter excludes.
472    #[test]
473    fn undecodable_filter_bitmap_is_a_typed_error() {
474        use crate::error::VectorError;
475        let idx = build_index(20, 3);
476        let result = idx.search_with_bitmap_bytes(&[0.0, 0.0, 0.0], 5, 32, &[0xff, 0x01]);
477        assert!(
478            matches!(result, Err(VectorError::InvalidFilterBitmap { .. })),
479            "{result:?}"
480        );
481    }
482}