Skip to main content

nodedb_vector/
flat.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Flat (brute-force) vector index for small collections.
4//!
5//! Simple linear scan over all stored vectors. No graph overhead, exact
6//! results. Automatically used when a collection has fewer than
7//! `DEFAULT_FLAT_INDEX_THRESHOLD` vectors (default 10K). Also serves as the
8//! search method for growing segments before HNSW construction.
9//!
10//! Complexity: O(N × D) per query where N = vectors, D = dimensions.
11
12use roaring::RoaringBitmap;
13
14use crate::distance::{DistanceMetric, distance};
15use crate::error::{VectorError, check_dim};
16use crate::hnsw::SearchResult;
17use crate::hnsw::search::decode_filter_bitmap;
18
19/// Default threshold below which collections use flat index instead of HNSW.
20pub const DEFAULT_FLAT_INDEX_THRESHOLD: usize = 10_000;
21
22/// Flat vector index: append-only buffer with brute-force search.
23pub struct FlatIndex {
24    dim: usize,
25    metric: DistanceMetric,
26    /// Vectors stored contiguously for cache-friendly sequential scan.
27    data: Vec<f32>,
28    /// Tombstone bitmap: `deleted[i]` = true means vector i is soft-deleted.
29    deleted: Vec<bool>,
30    /// Number of live (non-deleted) vectors.
31    live_count: usize,
32}
33
34impl FlatIndex {
35    /// Create a new empty flat index.
36    pub fn new(dim: usize, metric: DistanceMetric) -> Self {
37        Self {
38            dim,
39            metric,
40            data: Vec::new(),
41            deleted: Vec::new(),
42            live_count: 0,
43        }
44    }
45
46    /// Insert a vector. Returns the assigned vector ID, or
47    /// [`VectorError::DimensionMismatch`] when `vector` does not have the
48    /// index dimension.
49    pub fn insert(&mut self, vector: Vec<f32>) -> Result<u32, VectorError> {
50        check_dim(self.dim, vector.len())?;
51        let id = self.len() as u32;
52        self.data.extend_from_slice(&vector);
53        self.deleted.push(false);
54        self.live_count += 1;
55        Ok(id)
56    }
57
58    /// Soft-delete a vector by ID.
59    pub fn delete(&mut self, id: u32) -> bool {
60        let idx = id as usize;
61        if idx < self.deleted.len() && !self.deleted[idx] {
62            self.deleted[idx] = true;
63            self.live_count -= 1;
64            true
65        } else {
66            false
67        }
68    }
69
70    /// Un-delete (clear the soft-delete tombstone of) a vector by ID. Reverses
71    /// [`FlatIndex::delete`] — used for transaction rollback so a rolled-back
72    /// delete restores the vector to the searchable set. Returns `true` if a
73    /// tombstone was actually cleared, `false` if the id was out of range or
74    /// already live.
75    pub fn undelete(&mut self, id: u32) -> bool {
76        let idx = id as usize;
77        if idx < self.deleted.len() && self.deleted[idx] {
78            self.deleted[idx] = false;
79            self.live_count += 1;
80            true
81        } else {
82            false
83        }
84    }
85
86    /// Brute-force k-NN search with an explicit distance metric override.
87    /// Overrides the `self.metric` configured at collection creation time.
88    pub fn search_with_metric(
89        &self,
90        query: &[f32],
91        top_k: usize,
92        metric: DistanceMetric,
93    ) -> Result<Vec<SearchResult>, VectorError> {
94        self.scan(query, top_k, metric, None)
95    }
96
97    /// Brute-force k-NN search. Exact results — no approximation.
98    pub fn search(&self, query: &[f32], top_k: usize) -> Result<Vec<SearchResult>, VectorError> {
99        self.scan(query, top_k, self.metric, None)
100    }
101
102    /// Search with a pre-filter bitmap (byte-array format).
103    pub fn search_filtered(
104        &self,
105        query: &[f32],
106        top_k: usize,
107        bitmap: &[u8],
108    ) -> Result<Vec<SearchResult>, VectorError> {
109        self.search_filtered_offset(query, top_k, bitmap, 0)
110    }
111
112    /// Filtered search with an explicit metric override.
113    pub fn search_filtered_offset_with_metric(
114        &self,
115        query: &[f32],
116        top_k: usize,
117        bitmap: &[u8],
118        id_offset: u32,
119        metric: DistanceMetric,
120    ) -> Result<Vec<SearchResult>, VectorError> {
121        let filter = decode_filter_bitmap(bitmap)?;
122        self.scan(query, top_k, metric, Some((&filter, id_offset)))
123    }
124
125    /// Search with a pre-filter bitmap applying a global id offset.
126    ///
127    /// `bitmap` is a serialized `RoaringBitmap` (matching the HNSW filter
128    /// format). Bit `i + id_offset` tests local id `i`. Used by multi-segment
129    /// collections where the bitmap holds GLOBAL vector ids. Bytes that do
130    /// not decode fail with [`VectorError::InvalidFilterBitmap`].
131    pub fn search_filtered_offset(
132        &self,
133        query: &[f32],
134        top_k: usize,
135        bitmap: &[u8],
136        id_offset: u32,
137    ) -> Result<Vec<SearchResult>, VectorError> {
138        self.search_filtered_offset_with_metric(query, top_k, bitmap, id_offset, self.metric)
139    }
140
141    /// Exact scan of every live vector under `metric`, restricted to ids
142    /// whose `local + offset` is in the filter when one is given.
143    fn scan(
144        &self,
145        query: &[f32],
146        top_k: usize,
147        metric: DistanceMetric,
148        filter: Option<(&RoaringBitmap, u32)>,
149    ) -> Result<Vec<SearchResult>, VectorError> {
150        check_dim(self.dim, query.len())?;
151        let n = self.len();
152        if n == 0 || top_k == 0 {
153            return Ok(Vec::new());
154        }
155
156        let mut candidates: Vec<SearchResult> = Vec::with_capacity(n.min(top_k * 2));
157        for i in 0..n {
158            if self.deleted[i] {
159                continue;
160            }
161            if let Some((bitmap, id_offset)) = filter
162                && !bitmap.contains((i as u32).saturating_add(id_offset))
163            {
164                continue;
165            }
166            let start = i * self.dim;
167            let vec_slice = &self.data[start..start + self.dim];
168            candidates.push(SearchResult {
169                id: i as u32,
170                distance: distance(query, vec_slice, metric),
171            });
172        }
173
174        if candidates.len() > top_k {
175            candidates.select_nth_unstable_by(top_k, |a, b| {
176                a.distance
177                    .partial_cmp(&b.distance)
178                    .unwrap_or(std::cmp::Ordering::Equal)
179            });
180            candidates.truncate(top_k);
181        }
182        candidates.sort_by(|a, b| {
183            a.distance
184                .partial_cmp(&b.distance)
185                .unwrap_or(std::cmp::Ordering::Equal)
186        });
187        Ok(candidates)
188    }
189
190    pub fn len(&self) -> usize {
191        self.deleted.len()
192    }
193
194    /// Drop every vector at position `len` or later, as if it was never
195    /// inserted. A rollback uses it to withdraw the newest inserts.
196    pub fn truncate(&mut self, len: usize) {
197        if len >= self.deleted.len() {
198            return;
199        }
200        let dropped_live = self.deleted[len..]
201            .iter()
202            .filter(|deleted| !**deleted)
203            .count();
204        self.live_count -= dropped_live;
205        self.deleted.truncate(len);
206        self.data.truncate(len * self.dim);
207    }
208
209    pub fn live_count(&self) -> usize {
210        self.live_count
211    }
212
213    pub fn is_empty(&self) -> bool {
214        self.live_count == 0
215    }
216
217    pub fn get_vector(&self, id: u32) -> Option<&[f32]> {
218        let idx = id as usize;
219        if idx < self.deleted.len() && !self.deleted[idx] {
220            let start = idx * self.dim;
221            Some(&self.data[start..start + self.dim])
222        } else {
223            None
224        }
225    }
226
227    /// Raw access bypassing tombstone filter — used by snapshot/restore.
228    pub fn get_vector_raw(&self, id: u32) -> Option<&[f32]> {
229        let idx = id as usize;
230        if idx < self.deleted.len() {
231            let start = idx * self.dim;
232            Some(&self.data[start..start + self.dim])
233        } else {
234            None
235        }
236    }
237
238    /// Whether the given local id has been tombstoned.
239    pub fn is_deleted(&self, id: u32) -> bool {
240        let idx = id as usize;
241        idx < self.deleted.len() && self.deleted[idx]
242    }
243
244    /// Insert a vector that is already tombstoned (for checkpoint restore).
245    pub fn insert_tombstoned(&mut self, vector: Vec<f32>) -> Result<u32, VectorError> {
246        check_dim(self.dim, vector.len())?;
247        let id = self.len() as u32;
248        self.data.extend_from_slice(&vector);
249        self.deleted.push(true);
250        // No live_count increment — it's dead on arrival.
251        Ok(id)
252    }
253
254    pub fn dim(&self) -> usize {
255        self.dim
256    }
257
258    pub fn metric(&self) -> DistanceMetric {
259        self.metric
260    }
261
262    pub fn tombstone_count(&self) -> usize {
263        self.len().saturating_sub(self.live_count)
264    }
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn insert_and_search() {
273        let mut idx = FlatIndex::new(3, DistanceMetric::L2);
274        for i in 0..100u32 {
275            idx.insert(vec![i as f32, 0.0, 0.0]).unwrap();
276        }
277        assert_eq!(idx.len(), 100);
278        assert_eq!(idx.live_count(), 100);
279
280        let results = idx.search(&[50.0, 0.0, 0.0], 3).unwrap();
281        assert_eq!(results.len(), 3);
282        assert_eq!(results[0].id, 50);
283        assert!(results[0].distance < 0.01);
284    }
285
286    #[test]
287    fn delete_excludes_from_search() {
288        let mut idx = FlatIndex::new(2, DistanceMetric::L2);
289        idx.insert(vec![0.0, 0.0]).unwrap();
290        idx.insert(vec![1.0, 0.0]).unwrap();
291        idx.insert(vec![2.0, 0.0]).unwrap();
292
293        assert!(idx.delete(1));
294        assert_eq!(idx.live_count(), 2);
295
296        let results = idx.search(&[1.0, 0.0], 3).unwrap();
297        assert_eq!(results.len(), 2);
298        assert!(results.iter().all(|r| r.id != 1));
299    }
300
301    #[test]
302    fn exact_results() {
303        let mut idx = FlatIndex::new(2, DistanceMetric::Cosine);
304        idx.insert(vec![1.0, 0.0]).unwrap();
305        idx.insert(vec![0.0, 1.0]).unwrap();
306        idx.insert(vec![1.0, 1.0]).unwrap();
307
308        let results = idx.search(&[1.0, 0.0], 1).unwrap();
309        assert_eq!(results.len(), 1);
310        assert_eq!(results[0].id, 0);
311    }
312
313    #[test]
314    fn empty_search() {
315        let idx = FlatIndex::new(3, DistanceMetric::L2);
316        let results = idx.search(&[1.0, 0.0, 0.0], 5).unwrap();
317        assert!(results.is_empty());
318    }
319
320    #[test]
321    fn filtered_search() {
322        let mut idx = FlatIndex::new(2, DistanceMetric::L2);
323        for i in 0..8u32 {
324            idx.insert(vec![i as f32, 0.0]).unwrap();
325        }
326        let filter: RoaringBitmap = [2u32, 3, 6, 7].into_iter().collect();
327        let mut bitmap = Vec::new();
328        filter.serialize_into(&mut bitmap).unwrap();
329        let results = idx.search_filtered(&[4.0, 0.0], 2, &bitmap).unwrap();
330        assert_eq!(results.len(), 2);
331        assert_eq!(results[0].id, 3);
332        assert!(results.iter().all(|r| filter.contains(r.id)), "{results:?}");
333    }
334
335    #[test]
336    fn wrong_dimension_is_a_typed_error() {
337        let mut idx = FlatIndex::new(2, DistanceMetric::L2);
338        assert!(matches!(
339            idx.insert(vec![1.0, 2.0, 3.0]),
340            Err(VectorError::DimensionMismatch {
341                expected: 2,
342                got: 3
343            })
344        ));
345        assert!(matches!(
346            idx.insert_tombstoned(vec![1.0]),
347            Err(VectorError::DimensionMismatch {
348                expected: 2,
349                got: 1
350            })
351        ));
352        idx.insert(vec![1.0, 0.0]).unwrap();
353        let filter: RoaringBitmap = [0u32].into_iter().collect();
354        let mut bitmap = Vec::new();
355        filter.serialize_into(&mut bitmap).unwrap();
356        let short = [1.0_f32];
357        for result in [
358            idx.search(&short, 1),
359            idx.search_with_metric(&short, 1, DistanceMetric::Cosine),
360            idx.search_filtered(&short, 1, &bitmap),
361            idx.search_filtered_offset(&short, 1, &bitmap, 0),
362            idx.search_filtered_offset_with_metric(&short, 1, &bitmap, 0, DistanceMetric::L2),
363        ] {
364            assert!(
365                matches!(
366                    result,
367                    Err(VectorError::DimensionMismatch {
368                        expected: 2,
369                        got: 1
370                    })
371                ),
372                "{result:?}"
373            );
374        }
375    }
376
377    #[test]
378    fn undecodable_filter_bitmap_is_a_typed_error() {
379        let mut idx = FlatIndex::new(2, DistanceMetric::L2);
380        idx.insert(vec![1.0, 0.0]).unwrap();
381        let result = idx.search_filtered(&[1.0, 0.0], 1, &[0b1100_1100]);
382        assert!(
383            matches!(result, Err(VectorError::InvalidFilterBitmap { .. })),
384            "{result:?}"
385        );
386    }
387}