Skip to main content

nodedb_lite/engine/sparse_vector/
index.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! In-memory inverted index for sparse vectors (learned sparse retrieval).
4//!
5//! Maps each dimension to a posting list of `(internal_doc_id, weight)` pairs.
6//! Scoring accumulates the dot product over **only the query's non-zero
7//! dimensions**, so cost is proportional to the query's nnz times the average
8//! posting-list length — never to the number of indexed documents.
9//!
10//! Maintained synchronously on document write / delete and checkpoint-
11//! serializable so a reopen never needs a rebuild.
12
13use std::cmp::Ordering;
14use std::collections::HashMap;
15
16use nodedb_types::SparseVector;
17
18/// A scored hit from the sparse inverted index.
19#[derive(Debug, Clone, PartialEq)]
20pub struct SparseHit {
21    /// The original string document ID.
22    pub doc_id: String,
23    /// Dot product of the query vector with this document's sparse vector.
24    pub score: f32,
25}
26
27/// Inverted index over sparse vectors for a single `(collection, field)` pair.
28///
29/// Documents are addressed externally by their string ID and internally by a
30/// dense `u32` so posting lists stay compact.
31pub struct SparseInvertedIndex {
32    /// Dimension → posting list of `(internal_id, weight)`.
33    postings: HashMap<u32, Vec<(u32, f32)>>,
34    /// Internal doc ID → the document's own sorted `(dimension, weight)` entries.
35    ///
36    /// Retained (rather than dimensions alone) so both deletion and checkpoint
37    /// serialization are O(nnz) with no reconstruction pass over `postings`.
38    doc_entries: HashMap<u32, Vec<(u32, f32)>>,
39    /// String doc ID → internal doc ID.
40    doc_id_forward: HashMap<String, u32>,
41    /// Internal doc ID → string doc ID.
42    doc_id_reverse: HashMap<u32, String>,
43    /// Next internal doc ID to hand out.
44    next_id: u32,
45}
46
47impl Default for SparseInvertedIndex {
48    fn default() -> Self {
49        Self::new()
50    }
51}
52
53impl SparseInvertedIndex {
54    /// Create an empty index.
55    pub fn new() -> Self {
56        Self {
57            postings: HashMap::new(),
58            doc_entries: HashMap::new(),
59            doc_id_forward: HashMap::new(),
60            doc_id_reverse: HashMap::new(),
61            next_id: 0,
62        }
63    }
64
65    /// Insert or replace the sparse vector for `doc_id`.
66    ///
67    /// Upsert semantics: any postings previously recorded for `doc_id` are
68    /// removed before the new ones are appended, so re-indexing a document
69    /// never duplicates its postings.
70    pub fn insert(&mut self, doc_id: &str, vector: &SparseVector) {
71        let internal_id = match self.doc_id_forward.get(doc_id).copied() {
72            Some(existing) => {
73                self.detach_postings(existing);
74                existing
75            }
76            None => {
77                let id = self.next_id;
78                self.next_id = self.next_id.saturating_add(1);
79                self.doc_id_forward.insert(doc_id.to_owned(), id);
80                self.doc_id_reverse.insert(id, doc_id.to_owned());
81                id
82            }
83        };
84
85        let entries = vector.entries().to_vec();
86        for &(dim, weight) in &entries {
87            self.postings
88                .entry(dim)
89                .or_default()
90                .push((internal_id, weight));
91        }
92        self.doc_entries.insert(internal_id, entries);
93    }
94
95    /// Remove a document entirely. Returns `true` when it was present.
96    pub fn delete(&mut self, doc_id: &str) -> bool {
97        let Some(internal_id) = self.doc_id_forward.remove(doc_id) else {
98            return false;
99        };
100        self.detach_postings(internal_id);
101        self.doc_entries.remove(&internal_id);
102        self.doc_id_reverse.remove(&internal_id);
103        true
104    }
105
106    /// Drop every posting-list entry pointing at `internal_id`, leaving the
107    /// doc-ID mappings intact (the caller decides whether the document stays).
108    fn detach_postings(&mut self, internal_id: u32) {
109        let Some(entries) = self.doc_entries.get(&internal_id) else {
110            return;
111        };
112        let dims: Vec<u32> = entries.iter().map(|&(dim, _)| dim).collect();
113        for dim in dims {
114            if let Some(list) = self.postings.get_mut(&dim) {
115                list.retain(|&(id, _)| id != internal_id);
116                if list.is_empty() {
117                    self.postings.remove(&dim);
118                }
119            }
120        }
121    }
122
123    /// Top-`k` documents by dot product against `query`, descending.
124    ///
125    /// Only the query's non-zero dimensions are visited. Documents sharing no
126    /// dimension with the query never enter the accumulator and are therefore
127    /// excluded, as are documents whose weights cancel to exactly zero.
128    /// Ties on score are broken by ascending `doc_id` so ordering is stable
129    /// across runs and across a checkpoint round-trip.
130    pub fn search(&self, query: &SparseVector, top_k: usize) -> Vec<SparseHit> {
131        if top_k == 0 || query.is_empty() {
132            return Vec::new();
133        }
134
135        let mut accumulator: HashMap<u32, f32> = HashMap::new();
136        for &(dim, query_weight) in query.entries() {
137            let Some(list) = self.postings.get(&dim) else {
138                continue;
139            };
140            for &(internal_id, doc_weight) in list {
141                *accumulator.entry(internal_id).or_insert(0.0) += query_weight * doc_weight;
142            }
143        }
144
145        let mut hits: Vec<SparseHit> = accumulator
146            .into_iter()
147            .filter(|&(_, score)| score != 0.0)
148            .filter_map(|(internal_id, score)| {
149                self.doc_id_reverse
150                    .get(&internal_id)
151                    .map(|doc_id| SparseHit {
152                        doc_id: doc_id.clone(),
153                        score,
154                    })
155            })
156            .collect();
157
158        hits.sort_by(|a, b| {
159            b.score
160                .partial_cmp(&a.score)
161                .unwrap_or(Ordering::Equal)
162                .then_with(|| a.doc_id.cmp(&b.doc_id))
163        });
164        hits.truncate(top_k);
165        hits
166    }
167
168    /// Number of documents currently indexed.
169    pub fn doc_count(&self) -> usize {
170        self.doc_id_forward.len()
171    }
172
173    /// Whether the index holds no documents.
174    pub fn is_empty(&self) -> bool {
175        self.doc_id_forward.is_empty()
176    }
177
178    /// Every indexed document as `(doc_id, entries)`, sorted by `doc_id` so the
179    /// checkpoint blob is byte-stable for a given logical state.
180    pub fn documents(&self) -> Vec<(String, Vec<(u32, f32)>)> {
181        let mut out: Vec<(String, Vec<(u32, f32)>)> = self
182            .doc_id_forward
183            .iter()
184            .filter_map(|(doc_id, internal_id)| {
185                self.doc_entries
186                    .get(internal_id)
187                    .map(|entries| (doc_id.clone(), entries.clone()))
188            })
189            .collect();
190        out.sort_by(|a, b| a.0.cmp(&b.0));
191        out
192    }
193
194    /// Rebuild an index from checkpointed `(doc_id, entries)` pairs.
195    ///
196    /// Entries are already normalized (sorted, deduplicated, finite) by
197    /// [`SparseVector`] at insert time; malformed rows are dropped rather than
198    /// failing the whole restore, since a partially readable index is strictly
199    /// better than none.
200    pub fn from_documents(documents: Vec<(String, Vec<(u32, f32)>)>) -> Self {
201        let mut index = Self::new();
202        for (doc_id, entries) in documents {
203            let Ok(vector) = SparseVector::from_entries(entries) else {
204                tracing::warn!(
205                    doc_id = %doc_id,
206                    "sparse checkpoint: document entries rejected — skipping"
207                );
208                continue;
209            };
210            index.insert(&doc_id, &vector);
211        }
212        index
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    fn sv(entries: &[(u32, f32)]) -> SparseVector {
221        SparseVector::from_entries(entries.to_vec()).expect("valid sparse vector")
222    }
223
224    #[test]
225    fn search_ranks_by_dot_product_descending() {
226        let mut idx = SparseInvertedIndex::new();
227        idx.insert("low", &sv(&[(1, 0.1)]));
228        idx.insert("high", &sv(&[(1, 5.0)]));
229        idx.insert("mid", &sv(&[(1, 1.0)]));
230
231        let hits = idx.search(&sv(&[(1, 1.0)]), 10);
232        let ids: Vec<&str> = hits.iter().map(|h| h.doc_id.as_str()).collect();
233        assert_eq!(ids, vec!["high", "mid", "low"]);
234    }
235
236    #[test]
237    fn disjoint_document_is_excluded() {
238        let mut idx = SparseInvertedIndex::new();
239        idx.insert("overlap", &sv(&[(1, 1.0)]));
240        idx.insert("disjoint", &sv(&[(99, 1.0)]));
241
242        let hits = idx.search(&sv(&[(1, 1.0)]), 10);
243        assert_eq!(hits.len(), 1);
244        assert_eq!(hits[0].doc_id, "overlap");
245    }
246
247    #[test]
248    fn upsert_replaces_postings_instead_of_duplicating() {
249        let mut idx = SparseInvertedIndex::new();
250        idx.insert("d", &sv(&[(1, 1.0)]));
251        idx.insert("d", &sv(&[(1, 2.0)]));
252
253        assert_eq!(idx.doc_count(), 1);
254        let hits = idx.search(&sv(&[(1, 1.0)]), 10);
255        assert_eq!(hits.len(), 1);
256        assert!((hits[0].score - 2.0).abs() < 1e-6);
257    }
258
259    #[test]
260    fn upsert_drops_dimensions_no_longer_present() {
261        let mut idx = SparseInvertedIndex::new();
262        idx.insert("d", &sv(&[(1, 1.0), (2, 1.0)]));
263        idx.insert("d", &sv(&[(1, 1.0)]));
264
265        assert!(idx.search(&sv(&[(2, 1.0)]), 10).is_empty());
266    }
267
268    #[test]
269    fn delete_removes_document() {
270        let mut idx = SparseInvertedIndex::new();
271        idx.insert("d", &sv(&[(1, 1.0)]));
272        assert!(idx.delete("d"));
273        assert!(!idx.delete("d"));
274        assert!(idx.search(&sv(&[(1, 1.0)]), 10).is_empty());
275        assert!(idx.is_empty());
276    }
277
278    #[test]
279    fn ties_broken_by_doc_id() {
280        let mut idx = SparseInvertedIndex::new();
281        idx.insert("b", &sv(&[(1, 1.0)]));
282        idx.insert("a", &sv(&[(1, 1.0)]));
283        idx.insert("c", &sv(&[(1, 1.0)]));
284
285        let hits = idx.search(&sv(&[(1, 1.0)]), 10);
286        let ids: Vec<&str> = hits.iter().map(|h| h.doc_id.as_str()).collect();
287        assert_eq!(ids, vec!["a", "b", "c"]);
288    }
289
290    #[test]
291    fn top_k_truncates() {
292        let mut idx = SparseInvertedIndex::new();
293        for i in 0..10u32 {
294            idx.insert(&format!("d{i}"), &sv(&[(1, i as f32 + 1.0)]));
295        }
296        assert_eq!(idx.search(&sv(&[(1, 1.0)]), 3).len(), 3);
297        assert!(idx.search(&sv(&[(1, 1.0)]), 0).is_empty());
298    }
299
300    #[test]
301    fn documents_round_trip_through_from_documents() {
302        let mut idx = SparseInvertedIndex::new();
303        idx.insert("a", &sv(&[(1, 0.5), (7, 0.25)]));
304        idx.insert("b", &sv(&[(7, 1.0)]));
305
306        let restored = SparseInvertedIndex::from_documents(idx.documents());
307        assert_eq!(restored.doc_count(), 2);
308        assert_eq!(
309            restored.search(&sv(&[(7, 1.0)]), 10),
310            idx.search(&sv(&[(7, 1.0)]), 10)
311        );
312    }
313}