Skip to main content

nodedb_vector/planner/
routing.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Filter routing decision tree for the vector query planner.
4//!
5//! Given information about the query's filter predicates, this module decides
6//! which execution path to use: brute-force scan, a pre-built SIEVE subindex,
7//! Compass IVF+B+-tree, or NaviX adaptive-local on the global graph.
8
9use super::query_options::QuantizationKind;
10
11// ── Filter routing ────────────────────────────────────────────────────────────
12
13/// The execution path chosen by the filter router.
14#[derive(Debug, Clone, PartialEq)]
15#[non_exhaustive]
16pub enum FilterRoute {
17    /// Brute-force scan over the filtered set.
18    ///
19    /// Selected when `global_selectivity < brute_force_threshold`.  At very
20    /// low selectivity the filtered candidate set is so small that a linear
21    /// scan over it is cheaper than HNSW traversal.
22    BruteForce,
23
24    /// A pre-built SIEVE subindex matched the query's predicate signature.
25    ///
26    /// The planner routes directly to the dedicated HNSW subindex rather than
27    /// the global graph, avoiding wasted traversal into non-matching partitions.
28    SieveSubindex {
29        /// Predicate signature string that identified the matching subindex
30        /// (e.g. `"tenant_id=42"`).
31        signature: String,
32    },
33
34    /// Run NaviX adaptive-local filtered traversal on the global HNSW graph.
35    ///
36    /// Default path when no subindex matches and the candidate set is not
37    /// small enough for brute-force.
38    NavixGlobal,
39
40    /// Compass IVF + per-cluster B+-tree filtered search.
41    ///
42    /// Returned only when `compass_enabled == true` and the query has three
43    /// or more numeric range predicates.  Gated because Compass (VLDB 2025)
44    /// requires a separate index build step.
45    Compass,
46}
47
48/// Inputs to the filter routing decision tree.
49pub struct FilterRouteInputs<'a> {
50    /// Fraction of the total collection that passes the filter `[0.0, 1.0]`.
51    pub global_selectivity: f32,
52
53    /// Selectivity below which brute-force is preferred over HNSW traversal.
54    ///
55    /// A reasonable default is `0.001` (0.1%).
56    pub brute_force_threshold: f32,
57
58    /// Predicate signature of a SIEVE subindex that matched this query, if any.
59    pub matched_sieve_signature: Option<&'a str>,
60
61    /// Whether the Compass index is available for this collection.
62    pub compass_enabled: bool,
63
64    /// Number of numeric range predicates in the query.
65    ///
66    /// Compass is only advantageous when three or more numeric predicates
67    /// (e.g. `price BETWEEN 10 AND 100`, `ts > X`, `lat < Y`) are intersected.
68    pub numeric_predicate_count: u8,
69}
70
71/// Decide which execution path to use for this filtered vector query.
72///
73/// # Decision tree
74///
75/// 1. `global_selectivity < brute_force_threshold` → [`FilterRoute::BruteForce`]
76/// 2. `matched_sieve_signature.is_some()` → [`FilterRoute::SieveSubindex`]
77/// 3. `compass_enabled && numeric_predicate_count >= 3` → [`FilterRoute::Compass`]
78/// 4. otherwise → [`FilterRoute::NavixGlobal`]
79pub fn route_filter(inputs: &FilterRouteInputs<'_>) -> FilterRoute {
80    if inputs.global_selectivity < inputs.brute_force_threshold {
81        return FilterRoute::BruteForce;
82    }
83
84    if let Some(sig) = inputs.matched_sieve_signature {
85        return FilterRoute::SieveSubindex {
86            signature: sig.to_owned(),
87        };
88    }
89
90    if inputs.compass_enabled && inputs.numeric_predicate_count >= 3 {
91        return FilterRoute::Compass;
92    }
93
94    FilterRoute::NavixGlobal
95}
96
97// ── Quantization tier picker ──────────────────────────────────────────────────
98
99/// Pick the best quantization tier for a given candidate set size and target
100/// recall requirement.
101///
102/// # Heuristic
103///
104/// - Small sets (< 1 000): FP32 — the set fits in cache; no compression gain.
105/// - Medium sets (1 000 – 99 999):
106///   - target recall ≥ 0.97 → SQ8 (high fidelity, 4× compression)
107///   - otherwise → PQ (8× compression, slightly lower recall)
108/// - Large sets (≥ 100 000):
109///   - target recall ≥ 0.95 → BBQ (centroid-centered 1-bit + rerank)
110///   - otherwise → RaBitQ (raw 1-bit, fastest scan)
111pub fn pick_quantization(candidate_set_size: usize, target_recall: f32) -> QuantizationKind {
112    if candidate_set_size < 1_000 {
113        QuantizationKind::None
114    } else if candidate_set_size < 100_000 {
115        if target_recall >= 0.97 {
116            QuantizationKind::Sq8
117        } else {
118            QuantizationKind::Pq
119        }
120    } else if target_recall >= 0.95 {
121        QuantizationKind::Bbq
122    } else {
123        QuantizationKind::RaBitQ
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    // ── route_filter tests ────────────────────────────────────────────────────
132
133    fn default_inputs<'a>() -> FilterRouteInputs<'a> {
134        FilterRouteInputs {
135            global_selectivity: 0.5,
136            brute_force_threshold: 0.001,
137            matched_sieve_signature: None,
138            compass_enabled: false,
139            numeric_predicate_count: 0,
140        }
141    }
142
143    #[test]
144    fn brute_force_fires_below_threshold() {
145        let inputs = FilterRouteInputs {
146            global_selectivity: 0.0005,
147            ..default_inputs()
148        };
149        assert_eq!(route_filter(&inputs), FilterRoute::BruteForce);
150    }
151
152    #[test]
153    fn brute_force_fires_exactly_at_threshold_minus_epsilon() {
154        let threshold = 0.001_f32;
155        let inputs = FilterRouteInputs {
156            global_selectivity: threshold - f32::EPSILON,
157            brute_force_threshold: threshold,
158            ..default_inputs()
159        };
160        assert_eq!(route_filter(&inputs), FilterRoute::BruteForce);
161    }
162
163    #[test]
164    fn sieve_subindex_fires_when_signature_matched() {
165        let inputs = FilterRouteInputs {
166            global_selectivity: 0.5,
167            matched_sieve_signature: Some("tenant_id=42"),
168            ..default_inputs()
169        };
170        assert_eq!(
171            route_filter(&inputs),
172            FilterRoute::SieveSubindex {
173                signature: "tenant_id=42".to_owned()
174            }
175        );
176    }
177
178    #[test]
179    fn sieve_takes_priority_over_compass() {
180        // Both SIEVE match and Compass conditions satisfied — SIEVE wins (step 2
181        // before step 3 in the decision tree).
182        let inputs = FilterRouteInputs {
183            global_selectivity: 0.5,
184            matched_sieve_signature: Some("lang=en"),
185            compass_enabled: true,
186            numeric_predicate_count: 5,
187            ..default_inputs()
188        };
189        assert_eq!(
190            route_filter(&inputs),
191            FilterRoute::SieveSubindex {
192                signature: "lang=en".to_owned()
193            }
194        );
195    }
196
197    #[test]
198    fn compass_fires_when_enabled_and_enough_predicates() {
199        let inputs = FilterRouteInputs {
200            global_selectivity: 0.5,
201            matched_sieve_signature: None,
202            compass_enabled: true,
203            numeric_predicate_count: 3,
204            ..default_inputs()
205        };
206        assert_eq!(route_filter(&inputs), FilterRoute::Compass);
207    }
208
209    #[test]
210    fn compass_does_not_fire_with_fewer_than_3_predicates() {
211        let inputs = FilterRouteInputs {
212            global_selectivity: 0.5,
213            matched_sieve_signature: None,
214            compass_enabled: true,
215            numeric_predicate_count: 2,
216            ..default_inputs()
217        };
218        assert_eq!(route_filter(&inputs), FilterRoute::NavixGlobal);
219    }
220
221    #[test]
222    fn compass_does_not_fire_when_disabled() {
223        let inputs = FilterRouteInputs {
224            global_selectivity: 0.5,
225            matched_sieve_signature: None,
226            compass_enabled: false,
227            numeric_predicate_count: 10,
228            ..default_inputs()
229        };
230        assert_eq!(route_filter(&inputs), FilterRoute::NavixGlobal);
231    }
232
233    #[test]
234    fn navix_global_is_default_fallback() {
235        let inputs = default_inputs();
236        assert_eq!(route_filter(&inputs), FilterRoute::NavixGlobal);
237    }
238
239    // ── pick_quantization tests ───────────────────────────────────────────────
240
241    #[test]
242    fn small_set_returns_none() {
243        assert_eq!(pick_quantization(0, 0.99), QuantizationKind::None);
244        assert_eq!(pick_quantization(999, 0.99), QuantizationKind::None);
245    }
246
247    #[test]
248    fn medium_set_high_recall_returns_sq8() {
249        assert_eq!(pick_quantization(1_000, 0.97), QuantizationKind::Sq8);
250        assert_eq!(pick_quantization(50_000, 0.99), QuantizationKind::Sq8);
251        assert_eq!(pick_quantization(99_999, 1.0), QuantizationKind::Sq8);
252    }
253
254    #[test]
255    fn medium_set_lower_recall_returns_pq() {
256        assert_eq!(pick_quantization(1_000, 0.96), QuantizationKind::Pq);
257        assert_eq!(pick_quantization(50_000, 0.90), QuantizationKind::Pq);
258    }
259
260    #[test]
261    fn large_set_high_recall_returns_bbq() {
262        assert_eq!(pick_quantization(100_000, 0.95), QuantizationKind::Bbq);
263        assert_eq!(pick_quantization(1_000_000, 0.99), QuantizationKind::Bbq);
264    }
265
266    #[test]
267    fn large_set_lower_recall_returns_rabitq() {
268        assert_eq!(pick_quantization(100_000, 0.94), QuantizationKind::RaBitQ);
269        assert_eq!(
270            pick_quantization(10_000_000, 0.80),
271            QuantizationKind::RaBitQ
272        );
273    }
274
275    #[test]
276    fn boundary_1000_uses_medium_path() {
277        // Exactly 1_000 crosses into medium tier.
278        assert_ne!(pick_quantization(1_000, 0.99), QuantizationKind::None);
279    }
280
281    #[test]
282    fn boundary_100000_uses_large_path() {
283        // Exactly 100_000 crosses into large tier.
284        let result = pick_quantization(100_000, 0.95);
285        assert!(
286            result == QuantizationKind::Bbq || result == QuantizationKind::RaBitQ,
287            "expected BBQ or RaBitQ at boundary, got {result:?}"
288        );
289    }
290}