Skip to main content

nodedb_lite/engine/sparse_vector/
manager.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Per-`(collection, field)` sparse inverted index manager for Lite.
4//!
5//! Mirrors the FTS manager's shape: one index per named field, keyed
6//! `"{collection}:{field}"`, maintained incrementally on document write and
7//! delete, and checkpointed to storage on flush so a reopen is free.
8
9use std::collections::HashMap;
10
11use nodedb_types::SparseVector;
12use nodedb_types::Value;
13
14use super::index::{SparseHit, SparseInvertedIndex};
15
16/// Index key used when a caller does not name a field.
17const DEFAULT_FIELD: &str = "_sparse";
18
19/// Manages sparse inverted indexes for every `(collection, field)` pair.
20pub struct SparseVectorManager {
21    /// Key: `"{collection}:{field}"` → inverted index.
22    indices: HashMap<String, SparseInvertedIndex>,
23}
24
25impl Default for SparseVectorManager {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl SparseVectorManager {
32    /// Create an empty manager.
33    pub fn new() -> Self {
34        Self {
35            indices: HashMap::new(),
36        }
37    }
38
39    /// Build the `"{collection}:{field}"` index key.
40    ///
41    /// An empty `field` maps to the default field name so callers that do not
42    /// name a field still address a stable index.
43    pub fn index_key(collection: &str, field: &str) -> String {
44        if field.is_empty() {
45            format!("{collection}:{DEFAULT_FIELD}")
46        } else {
47            format!("{collection}:{field}")
48        }
49    }
50
51    /// Whether no collection has any sparse index.
52    pub fn is_empty(&self) -> bool {
53        self.indices.values().all(SparseInvertedIndex::is_empty)
54    }
55
56    /// Number of live indexes.
57    pub fn index_count(&self) -> usize {
58        self.indices.len()
59    }
60
61    /// Insert or replace `doc_id`'s sparse vector for `(collection, field)`.
62    pub fn index_document(
63        &mut self,
64        collection: &str,
65        field: &str,
66        doc_id: &str,
67        vector: &SparseVector,
68    ) {
69        let key = Self::index_key(collection, field);
70        self.indices.entry(key).or_default().insert(doc_id, vector);
71    }
72
73    /// Remove `doc_id` from one `(collection, field)` index.
74    ///
75    /// Returns `true` when the document was present.
76    pub fn remove_document(&mut self, collection: &str, field: &str, doc_id: &str) -> bool {
77        let key = Self::index_key(collection, field);
78        match self.indices.get_mut(&key) {
79            Some(index) => index.delete(doc_id),
80            None => false,
81        }
82    }
83
84    /// Remove `doc_id` from every sparse index belonging to `collection`.
85    ///
86    /// Used by the document-delete path, which knows the collection and the
87    /// document ID but not which fields carried sparse vectors.
88    pub fn remove_document_all_fields(&mut self, collection: &str, doc_id: &str) -> usize {
89        let prefix = format!("{collection}:");
90        let mut removed = 0usize;
91        for (key, index) in self.indices.iter_mut() {
92            if key.starts_with(&prefix) && index.delete(doc_id) {
93                removed += 1;
94            }
95        }
96        removed
97    }
98
99    /// Reconcile every sparse index of `collection` against a document's fields.
100    ///
101    /// String fields that parse as a sparse-vector literal (`'{12: 0.5}'`) are
102    /// indexed under their own field name. A field that does not parse is not
103    /// an error — it is simply not a sparse column — but the document is then
104    /// removed from that field's index so a column that stops holding a sparse
105    /// vector cannot leave stale postings behind.
106    pub fn index_document_fields(
107        &mut self,
108        collection: &str,
109        doc_id: &str,
110        fields: &HashMap<String, Value>,
111    ) {
112        let mut indexed_fields: Vec<String> = Vec::new();
113
114        for (field, value) in fields {
115            let Value::String(literal) = value else {
116                continue;
117            };
118            let Ok(vector) = SparseVector::parse_literal(literal) else {
119                continue;
120            };
121            self.index_document(collection, field, doc_id, &vector);
122            indexed_fields.push(Self::index_key(collection, field));
123        }
124
125        // Drop the document from any other sparse index of this collection.
126        let prefix = format!("{collection}:");
127        for (key, index) in self.indices.iter_mut() {
128            if key.starts_with(&prefix) && !indexed_fields.iter().any(|k| k == key) {
129                index.delete(doc_id);
130            }
131        }
132    }
133
134    /// Top-`k` documents for `(collection, field)` by dot product, descending.
135    ///
136    /// An absent index yields no hits rather than an error — a collection that
137    /// has never been written simply has nothing to match.
138    pub fn search(
139        &self,
140        collection: &str,
141        field: &str,
142        query: &SparseVector,
143        top_k: usize,
144    ) -> Vec<SparseHit> {
145        let key = Self::index_key(collection, field);
146        match self.indices.get(&key) {
147            Some(index) => index.search(query, top_k),
148            None => Vec::new(),
149        }
150    }
151
152    /// Borrow every index for checkpoint serialization.
153    pub fn checkpoint_data(&self) -> &HashMap<String, SparseInvertedIndex> {
154        &self.indices
155    }
156
157    /// Install indexes restored from a checkpoint, replacing current state.
158    pub fn load_checkpoint(&mut self, indices: HashMap<String, SparseInvertedIndex>) {
159        self.indices = indices;
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    fn sv(entries: &[(u32, f32)]) -> SparseVector {
168        SparseVector::from_entries(entries.to_vec()).expect("valid sparse vector")
169    }
170
171    #[test]
172    fn fields_are_isolated_per_index() {
173        let mut mgr = SparseVectorManager::new();
174        mgr.index_document("docs", "title_sparse", "d1", &sv(&[(1, 1.0)]));
175        mgr.index_document("docs", "body_sparse", "d1", &sv(&[(2, 1.0)]));
176
177        assert_eq!(mgr.index_count(), 2);
178        assert!(
179            mgr.search("docs", "title_sparse", &sv(&[(2, 1.0)]), 10)
180                .is_empty()
181        );
182        assert_eq!(
183            mgr.search("docs", "body_sparse", &sv(&[(2, 1.0)]), 10)
184                .len(),
185            1
186        );
187    }
188
189    #[test]
190    fn collections_are_isolated() {
191        let mut mgr = SparseVectorManager::new();
192        mgr.index_document("a", "f", "d1", &sv(&[(1, 1.0)]));
193        mgr.index_document("b", "f", "d1", &sv(&[(1, 1.0)]));
194
195        mgr.remove_document_all_fields("a", "d1");
196        assert!(mgr.search("a", "f", &sv(&[(1, 1.0)]), 10).is_empty());
197        assert_eq!(mgr.search("b", "f", &sv(&[(1, 1.0)]), 10).len(), 1);
198    }
199
200    #[test]
201    fn missing_index_returns_no_hits() {
202        let mgr = SparseVectorManager::new();
203        assert!(mgr.search("nope", "f", &sv(&[(1, 1.0)]), 10).is_empty());
204    }
205
206    #[test]
207    fn non_sparse_string_fields_are_skipped() {
208        let mut mgr = SparseVectorManager::new();
209        let mut fields = HashMap::new();
210        fields.insert("title".to_string(), Value::String("hello world".into()));
211        fields.insert("n".to_string(), Value::Integer(3));
212        fields.insert("emb".to_string(), Value::String("{1: 0.5}".into()));
213
214        mgr.index_document_fields("docs", "d1", &fields);
215
216        assert_eq!(mgr.index_count(), 1);
217        assert_eq!(mgr.search("docs", "emb", &sv(&[(1, 1.0)]), 10).len(), 1);
218    }
219
220    #[test]
221    fn field_that_stops_being_sparse_is_unindexed() {
222        let mut mgr = SparseVectorManager::new();
223        let mut fields = HashMap::new();
224        fields.insert("emb".to_string(), Value::String("{1: 0.5}".into()));
225        mgr.index_document_fields("docs", "d1", &fields);
226
227        fields.insert("emb".to_string(), Value::String("plain text".into()));
228        mgr.index_document_fields("docs", "d1", &fields);
229
230        assert!(mgr.search("docs", "emb", &sv(&[(1, 1.0)]), 10).is_empty());
231    }
232
233    #[test]
234    fn remove_document_reports_presence() {
235        let mut mgr = SparseVectorManager::new();
236        mgr.index_document("docs", "emb", "d1", &sv(&[(1, 1.0)]));
237        assert!(mgr.remove_document("docs", "emb", "d1"));
238        assert!(!mgr.remove_document("docs", "emb", "d1"));
239    }
240}