Skip to main content

nodedb_vector/sieve/
router.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! SieveRouter — routes a filtered ANN query to a specialized subindex when the
4//! predicate signature matches, or falls back to NaviX on the global index.
5
6use roaring::RoaringBitmap;
7
8use super::collection::{PredicateSignature, SieveCollection};
9use crate::hnsw::graph::{HnswIndex, SearchResult};
10use crate::navix::traversal::{NavixSearchOptions, navix_search};
11use nodedb_types::vector_distance::DistanceMetric;
12
13/// Routes a filtered ANN query to the right index.
14///
15/// - If `predicate_signature` is `Some(sig)` and a subindex exists for `sig`,
16///   the query is executed directly on that subindex (no bitmap needed).
17/// - Otherwise the query falls back to `navix_search` on the global fallback
18///   index using `allowed` as the sideways-information bitmap.
19pub struct SieveRouter<'a> {
20    /// Collection of specialized subindices.
21    pub collection: &'a SieveCollection,
22    /// Global index used when no subindex matches.
23    pub fallback: &'a HnswIndex,
24}
25
26impl<'a> SieveRouter<'a> {
27    /// Execute a filtered k-NN query.
28    ///
29    /// # Parameters
30    ///
31    /// - `query`               — query vector.
32    /// - `predicate_signature` — optional stable predicate; if present and a
33    ///   subindex exists for it, that subindex is used directly.
34    /// - `allowed`             — bitmap of allowed IDs for the NaviX fallback
35    ///   path.  Ignored when a subindex is matched.
36    /// - `k`                   — number of nearest neighbours to return.
37    /// - `ef_search`           — beam width for HNSW/NaviX traversal.
38    /// - `metric`              — distance metric.
39    ///
40    /// # Returns
41    ///
42    /// Up to `k` nearest-neighbour results, sorted by ascending distance, or
43    /// [`VectorError::DimensionMismatch`](crate::error::VectorError::DimensionMismatch)
44    /// when `query` does not have the index dimension.
45    pub fn route(
46        &self,
47        query: &[f32],
48        predicate_signature: Option<&PredicateSignature>,
49        allowed: RoaringBitmap,
50        k: usize,
51        ef_search: usize,
52        metric: DistanceMetric,
53    ) -> Result<Vec<SearchResult>, crate::error::VectorError> {
54        // Fast path: subindex hit.
55        if let Some(sig) = predicate_signature
56            && let Some(subindex) = self.collection.get(sig)
57        {
58            return subindex.search(query, k, ef_search);
59        }
60
61        // Slow path: NaviX adaptive-local filtered search on the global index.
62        let opts = NavixSearchOptions {
63            k,
64            ef_search,
65            allowed,
66            brute_force_threshold: 0.001,
67        };
68        Ok(navix_search(self.fallback, query, &opts, metric)?
69            .into_iter()
70            .map(|r| SearchResult {
71                id: r.id,
72                distance: r.distance,
73            })
74            .collect())
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use crate::hnsw::{HnswIndex, HnswParams};
82    use crate::sieve::collection::SieveCollection;
83    use nodedb_types::vector_distance::DistanceMetric;
84
85    fn build_fallback(n: usize) -> HnswIndex {
86        let mut idx = HnswIndex::with_seed(
87            3,
88            HnswParams {
89                m: 8,
90                m0: 16,
91                ef_construction: 50,
92                metric: DistanceMetric::L2,
93                dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32,
94            },
95            99,
96        );
97        for i in 0..n {
98            idx.insert(vec![i as f32, 0.0, 0.0]).unwrap();
99        }
100        idx
101    }
102
103    fn all_allowed(n: u32) -> RoaringBitmap {
104        let mut b = RoaringBitmap::new();
105        for i in 0..n {
106            b.insert(i);
107        }
108        b
109    }
110
111    /// When the predicate matches an existing subindex, results come from that
112    /// subindex (not the fallback).  The subindex contains only 5 vectors, so
113    /// the result IDs are all < 5.
114    #[test]
115    fn route_hits_subindex() {
116        // Build a subindex with 5 vectors [0,0,0]..[4,0,0].
117        let mut coll = SieveCollection::new(8);
118        let sub_vecs: Vec<(u32, Vec<f32>)> =
119            (0u32..5).map(|i| (i, vec![i as f32, 0.0, 0.0])).collect();
120        coll.build_subindex("T".to_string(), &sub_vecs, 3, DistanceMetric::L2)
121            .expect("build subindex");
122
123        let fallback = build_fallback(20);
124        let router = SieveRouter {
125            collection: &coll,
126            fallback: &fallback,
127        };
128
129        let results = router
130            .route(
131                &[2.0, 0.0, 0.0],
132                Some(&"T".to_string()),
133                all_allowed(20), // bitmap irrelevant on subindex path
134                3,
135                32,
136                DistanceMetric::L2,
137            )
138            .unwrap();
139
140        assert!(!results.is_empty());
141        // All result IDs must be within the subindex range [0..5).
142        for r in &results {
143            assert!(r.id < 5, "expected subindex id < 5, got {}", r.id);
144        }
145    }
146
147    /// When the predicate does not match any subindex, NaviX on the fallback
148    /// index is used.  With full allowed set, the nearest vector must be found.
149    #[test]
150    fn route_falls_back_to_navix() {
151        let coll = SieveCollection::new(8); // empty — no subindices
152        let fallback = build_fallback(20);
153        let router = SieveRouter {
154            collection: &coll,
155            fallback: &fallback,
156        };
157
158        let allowed = all_allowed(20);
159        let results = router
160            .route(
161                &[10.0, 0.0, 0.0],
162                Some(&"unknown_sig".to_string()),
163                allowed,
164                3,
165                64,
166                DistanceMetric::L2,
167            )
168            .unwrap();
169
170        assert!(!results.is_empty());
171        // The nearest vector to [10,0,0] in [0..20] is id=10.
172        assert_eq!(results[0].id, 10);
173    }
174
175    /// With no predicate signature, NaviX fallback is always used.
176    #[test]
177    fn route_no_signature_uses_navix() {
178        let mut coll = SieveCollection::new(8);
179        let sub_vecs: Vec<(u32, Vec<f32>)> =
180            (0u32..5).map(|i| (i, vec![i as f32, 0.0, 0.0])).collect();
181        coll.build_subindex("T".to_string(), &sub_vecs, 3, DistanceMetric::L2)
182            .expect("build subindex");
183
184        let fallback = build_fallback(20);
185        let router = SieveRouter {
186            collection: &coll,
187            fallback: &fallback,
188        };
189
190        let allowed = all_allowed(20);
191        let results = router
192            .route(&[5.0, 0.0, 0.0], None, allowed, 3, 64, DistanceMetric::L2)
193            .unwrap();
194
195        assert!(!results.is_empty());
196        // Must include id=5 since fallback has all 20 vectors.
197        assert_eq!(results[0].id, 5);
198    }
199}