Skip to main content

nodedb_vector/delta/
index.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! In-memory delta index for SPFresh streaming inserts.
4//!
5//! Fresh vectors accumulate here until `DeltaIndex::is_full()` signals that
6//! compaction should flush them into the main HNSW.  Tombstones are lazily
7//! tracked here and filtered at query time.
8
9use std::collections::HashSet;
10
11use crate::distance::distance;
12use crate::error::{VectorError, check_dim};
13use nodedb_types::vector_distance::DistanceMetric;
14
15/// Secondary in-memory index that absorbs fresh inserts before they are
16/// patched into the main HNSW graph.
17pub struct DeltaIndex {
18    pub dim: usize,
19    /// Newly inserted vectors awaiting merge: `(id, vector)`.
20    fresh: Vec<(u32, Vec<f32>)>,
21    /// IDs marked tombstoned (lazy physical removal).
22    tombstones: HashSet<u32>,
23    /// Soft cap; callers should flush when `is_full()` returns `true`.
24    max_fresh: usize,
25}
26
27impl DeltaIndex {
28    /// Create a new delta index for vectors of `dim` dimensions.
29    ///
30    /// `max_fresh` is the capacity threshold above which `is_full()` returns
31    /// `true`, signalling that a compaction flush should be triggered.
32    pub fn new(dim: usize, max_fresh: usize) -> Self {
33        Self {
34            dim,
35            fresh: Vec::new(),
36            tombstones: HashSet::new(),
37            max_fresh,
38        }
39    }
40
41    /// Stage a fresh insert.  Does not deduplicate — callers must ensure IDs
42    /// are unique across the delta and the main HNSW. A vector without the
43    /// index dimension fails with [`VectorError::DimensionMismatch`].
44    pub fn insert(&mut self, id: u32, vector: Vec<f32>) -> Result<(), VectorError> {
45        check_dim(self.dim, vector.len())?;
46        self.fresh.push((id, vector));
47        Ok(())
48    }
49
50    /// Mark `id` as tombstoned.  It will be excluded from `search` results
51    /// and from the patch applied to the main HNSW.
52    pub fn tombstone(&mut self, id: u32) {
53        self.tombstones.insert(id);
54    }
55
56    /// Returns `true` when the number of fresh vectors has reached `max_fresh`.
57    pub fn is_full(&self) -> bool {
58        self.fresh.len() >= self.max_fresh
59    }
60
61    /// Number of un-drained fresh vectors.
62    pub fn fresh_len(&self) -> usize {
63        self.fresh.len()
64    }
65
66    /// Brute-force scan over fresh vectors (excluding tombstones), returning
67    /// the top-`k` results sorted ascending by distance. A query without
68    /// the index dimension fails with [`VectorError::DimensionMismatch`].
69    pub fn search(
70        &self,
71        query: &[f32],
72        k: usize,
73        metric: DistanceMetric,
74    ) -> Result<Vec<(u32, f32)>, VectorError> {
75        check_dim(self.dim, query.len())?;
76        if k == 0 {
77            return Ok(Vec::new());
78        }
79
80        let mut scored: Vec<(u32, f32)> = self
81            .fresh
82            .iter()
83            .filter(|(id, _)| !self.tombstones.contains(id))
84            .map(|(id, vec)| (*id, distance(query, vec, metric)))
85            .collect();
86
87        // Partial sort: cheapest path to top-k.
88        if k < scored.len() {
89            scored.select_nth_unstable_by(k, |a, b| {
90                a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)
91            });
92            scored.truncate(k);
93        }
94
95        scored.sort_unstable_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
96
97        Ok(scored)
98    }
99
100    /// Drain all staged fresh vectors for patching into the main HNSW.
101    /// After this call `fresh_len()` returns 0.
102    pub fn drain_fresh(&mut self) -> Vec<(u32, Vec<f32>)> {
103        std::mem::take(&mut self.fresh)
104    }
105
106    /// Drain the current tombstone set.
107    /// After this call no IDs are considered tombstoned in this delta.
108    pub fn drain_tombstones(&mut self) -> Vec<u32> {
109        std::mem::take(&mut self.tombstones).into_iter().collect()
110    }
111
112    /// Returns `true` if `id` is currently tombstoned in this delta.
113    pub fn is_tombstoned(&self, id: u32) -> bool {
114        self.tombstones.contains(&id)
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use nodedb_types::vector_distance::DistanceMetric;
122
123    fn make_delta() -> DeltaIndex {
124        let mut d = DeltaIndex::new(3, 16);
125        for i in 0u32..10 {
126            let v = vec![i as f32, 0.0, 0.0];
127            d.insert(i, v).unwrap();
128        }
129        d
130    }
131
132    #[test]
133    fn top_k_returns_nearest() {
134        let d = make_delta();
135        let query = [0.0f32, 0.0, 0.0];
136        let results = d.search(&query, 3, DistanceMetric::L2).unwrap();
137        assert_eq!(results.len(), 3);
138        // Nearest to [0,0,0] with L2^2 are ids 0,1,2
139        assert_eq!(results[0].0, 0);
140        assert_eq!(results[1].0, 1);
141        assert_eq!(results[2].0, 2);
142    }
143
144    #[test]
145    fn tombstone_excluded_from_search() {
146        let mut d = make_delta();
147        d.tombstone(0);
148        let query = [0.0f32, 0.0, 0.0];
149        let results = d.search(&query, 3, DistanceMetric::L2).unwrap();
150        assert!(results.iter().all(|(id, _)| *id != 0));
151    }
152
153    #[test]
154    fn is_full_triggers_at_threshold() {
155        let mut d = DeltaIndex::new(3, 3);
156        assert!(!d.is_full());
157        d.insert(0, vec![0.0, 0.0, 0.0]).unwrap();
158        d.insert(1, vec![1.0, 0.0, 0.0]).unwrap();
159        assert!(!d.is_full());
160        d.insert(2, vec![2.0, 0.0, 0.0]).unwrap();
161        assert!(d.is_full());
162    }
163
164    #[test]
165    fn drain_fresh_empties_buffer() {
166        let mut d = make_delta();
167        let drained = d.drain_fresh();
168        assert_eq!(drained.len(), 10);
169        assert_eq!(d.fresh_len(), 0);
170    }
171
172    #[test]
173    fn drain_tombstones_empties_set() {
174        let mut d = make_delta();
175        d.tombstone(3);
176        d.tombstone(7);
177        let ts = d.drain_tombstones();
178        assert_eq!(ts.len(), 2);
179        assert!(!d.is_tombstoned(3));
180        assert!(!d.is_tombstoned(7));
181    }
182
183    #[test]
184    fn wrong_dimension_is_a_typed_error() {
185        let mut d = DeltaIndex::new(3, 8);
186        assert!(matches!(
187            d.insert(0, vec![0.0; 2]),
188            Err(VectorError::DimensionMismatch {
189                expected: 3,
190                got: 2
191            })
192        ));
193        assert!(matches!(
194            d.search(&[0.0; 4], 1, DistanceMetric::L2),
195            Err(VectorError::DimensionMismatch {
196                expected: 3,
197                got: 4
198            })
199        ));
200    }
201}