nodedb_lite/engine/sparse_vector/
manager.rs1use std::collections::HashMap;
10
11use nodedb_types::SparseVector;
12use nodedb_types::Value;
13
14use super::index::{SparseHit, SparseInvertedIndex};
15
16const DEFAULT_FIELD: &str = "_sparse";
18
19pub struct SparseVectorManager {
21 indices: HashMap<String, SparseInvertedIndex>,
23}
24
25impl Default for SparseVectorManager {
26 fn default() -> Self {
27 Self::new()
28 }
29}
30
31impl SparseVectorManager {
32 pub fn new() -> Self {
34 Self {
35 indices: HashMap::new(),
36 }
37 }
38
39 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 pub fn is_empty(&self) -> bool {
53 self.indices.values().all(SparseInvertedIndex::is_empty)
54 }
55
56 pub fn index_count(&self) -> usize {
58 self.indices.len()
59 }
60
61 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 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 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 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 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 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 pub fn checkpoint_data(&self) -> &HashMap<String, SparseInvertedIndex> {
154 &self.indices
155 }
156
157 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}