Skip to main content

nodedb_spatial/
hybrid.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Hybrid spatial-vector search: spatial R-tree as a pre-filter for
4//! HNSW vector search.
5//!
6//! Query pattern:
7//! ```sql
8//! SELECT * FROM restaurants
9//! WHERE ST_DWithin(location, geo_point(-73.98, 40.75), 500)
10//! ORDER BY vector_distance(embedding, $query_vec) LIMIT 10
11//! ```
12//!
13//! Pipeline:
14//! 1. R-tree range search → candidate entry IDs
15//! 2. Convert entry IDs to RoaringBitmap
16//! 3. Pass bitmap into vector search as pre-filter
17//! 4. HNSW traversal skips entries not in bitmap
18//!
19//! This is a unique NodeDB differentiator — PostGIS + pgvector can't do
20//! this in a single index scan.
21
22use nodedb_mem::ScopedMemory;
23#[cfg(test)]
24use nodedb_types::BoundingBox;
25use nodedb_types::geometry::Geometry;
26use nodedb_types::geometry_bbox;
27
28use crate::predicates;
29use crate::rtree::RTree;
30
31/// Result of the spatial pre-filter phase.
32///
33/// Contains the candidate entry IDs that passed the spatial predicate.
34/// These are then passed as a filter bitmap to the vector search engine.
35pub struct SpatialPreFilterResult {
36    /// Entry IDs that passed the spatial predicate.
37    pub candidate_ids: Vec<u64>,
38    /// Number of entries evaluated by R-tree range search.
39    pub rtree_candidates: usize,
40    /// Number of entries that passed exact predicate refinement.
41    pub exact_matches: usize,
42}
43
44/// Execute the spatial pre-filter phase.
45///
46/// 1. Compute search bounding box from query geometry + optional distance expansion
47/// 2. R-tree range search → rough candidates
48/// 3. For each candidate, load geometry and apply exact predicate
49/// 4. Return filtered entry IDs
50///
51/// The caller (vector engine) uses the candidate IDs to build a RoaringBitmap
52/// that restricts HNSW graph traversal.
53///
54/// The `candidate_ids` allocation is budgeted via [`ScopedMemory::reserve`]
55/// before the `Vec::with_capacity`. Budget pressure is a backpressure
56/// signal; the allocation still proceeds on budget exhaustion.
57pub fn spatial_prefilter(
58    rtree: &RTree,
59    query_geometry: &Geometry,
60    distance_meters: Option<f64>,
61    exact_geometries: &dyn Fn(u64) -> Option<Geometry>,
62    memory: &ScopedMemory,
63) -> SpatialPreFilterResult {
64    // Step 1: Compute search bbox.
65    let search_bbox = if let Some(dist) = distance_meters {
66        geometry_bbox(query_geometry).expand_meters(dist)
67    } else {
68        geometry_bbox(query_geometry)
69    };
70
71    // Step 2: R-tree range search.
72    let rtree_results = rtree.search(&search_bbox);
73    let rtree_candidates = rtree_results.len();
74
75    // Step 3: Exact predicate refinement.
76    let bytes = rtree_candidates * std::mem::size_of::<u64>();
77    let _guard = memory.reserve(bytes).ok();
78    let mut candidate_ids = Vec::with_capacity(rtree_candidates);
79    for entry in &rtree_results {
80        if let Some(doc_geom) = exact_geometries(entry.id) {
81            let passes = if let Some(dist) = distance_meters {
82                predicates::st_dwithin(&doc_geom, query_geometry, dist)
83            } else {
84                predicates::st_intersects(&doc_geom, query_geometry)
85            };
86            if passes {
87                candidate_ids.push(entry.id);
88            }
89        }
90    }
91
92    let exact_matches = candidate_ids.len();
93    SpatialPreFilterResult {
94        candidate_ids,
95        rtree_candidates,
96        exact_matches,
97    }
98}
99
100/// Convert candidate entry IDs to a packed bitmap suitable for vector
101/// engine pre-filtering.
102///
103/// The bitmap format matches the existing `filter_bitmap` used by
104/// `PhysicalPlan::VectorSearch`: byte array where bit N is set if
105/// entry N is a candidate. This is a simple bitset, not RoaringBitmap,
106/// for compatibility with the existing HNSW filter path.
107pub fn ids_to_bitmap(candidate_ids: &[u64], max_id: u64) -> Vec<u8> {
108    let num_bytes = ((max_id + 8) / 8) as usize;
109    let mut bitmap = vec![0u8; num_bytes];
110    for &id in candidate_ids {
111        let byte_idx = (id / 8) as usize;
112        let bit_idx = (id % 8) as u32;
113        if byte_idx < bitmap.len() {
114            bitmap[byte_idx] |= 1 << bit_idx;
115        }
116    }
117    bitmap
118}
119
120/// Check if an entry ID is set in a bitmap.
121pub fn bitmap_contains(bitmap: &[u8], id: u64) -> bool {
122    let byte_idx = (id / 8) as usize;
123    let bit_idx = (id % 8) as u32;
124    if byte_idx < bitmap.len() {
125        bitmap[byte_idx] & (1 << bit_idx) != 0
126    } else {
127        false
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::rtree::RTreeEntry;
135    use crate::test_support::test_memory;
136
137    fn make_tree() -> RTree {
138        let mut tree = RTree::new(test_memory());
139        // 10 points in a grid from (0,0) to (9,9).
140        for i in 0..10 {
141            tree.insert(RTreeEntry {
142                id: i,
143                bbox: BoundingBox::from_point(i as f64, i as f64),
144            });
145        }
146        tree
147    }
148
149    #[test]
150    fn prefilter_with_distance() {
151        let tree = make_tree();
152        let query = Geometry::point(5.0, 5.0);
153
154        // Geometries: each entry ID maps to Point(id, id).
155        let get_geom = |id: u64| -> Option<Geometry> {
156            if id < 10 {
157                Some(Geometry::point(id as f64, id as f64))
158            } else {
159                None
160            }
161        };
162
163        // 200km radius should capture several nearby points.
164        let result = spatial_prefilter(&tree, &query, Some(200_000.0), &get_geom, &test_memory());
165        assert!(!result.candidate_ids.is_empty());
166        // Point (5,5) should definitely be in results (distance = 0).
167        assert!(result.candidate_ids.contains(&5));
168    }
169
170    #[test]
171    fn prefilter_intersects_no_distance() {
172        let tree = make_tree();
173        // A polygon covering (3,3) to (7,7).
174        let query = Geometry::polygon(vec![vec![
175            [3.0, 3.0],
176            [7.0, 3.0],
177            [7.0, 7.0],
178            [3.0, 7.0],
179            [3.0, 3.0],
180        ]]);
181
182        let get_geom = |id: u64| -> Option<Geometry> {
183            if id < 10 {
184                Some(Geometry::point(id as f64, id as f64))
185            } else {
186                None
187            }
188        };
189
190        let result = spatial_prefilter(&tree, &query, None, &get_geom, &test_memory());
191        // Points 4, 5, 6 should be inside (3 is on edge → not contained by intersects returns true).
192        assert!(result.candidate_ids.contains(&4));
193        assert!(result.candidate_ids.contains(&5));
194        assert!(result.candidate_ids.contains(&6));
195        // Points 0, 1, 2, 8, 9 should not be in results.
196        assert!(!result.candidate_ids.contains(&0));
197        assert!(!result.candidate_ids.contains(&9));
198    }
199
200    #[test]
201    fn bitmap_roundtrip() {
202        let ids = vec![0, 5, 7, 63, 100];
203        let bitmap = ids_to_bitmap(&ids, 128);
204
205        for &id in &ids {
206            assert!(bitmap_contains(&bitmap, id), "id {id} should be set");
207        }
208        assert!(!bitmap_contains(&bitmap, 1));
209        assert!(!bitmap_contains(&bitmap, 64));
210    }
211
212    #[test]
213    fn empty_bitmap() {
214        let bitmap = ids_to_bitmap(&[], 0);
215        assert!(!bitmap_contains(&bitmap, 0));
216    }
217}