nodedb_lite/engine/sparse_vector/
index.rs1use std::cmp::Ordering;
14use std::collections::HashMap;
15
16use nodedb_types::SparseVector;
17
18#[derive(Debug, Clone, PartialEq)]
20pub struct SparseHit {
21 pub doc_id: String,
23 pub score: f32,
25}
26
27pub struct SparseInvertedIndex {
32 postings: HashMap<u32, Vec<(u32, f32)>>,
34 doc_entries: HashMap<u32, Vec<(u32, f32)>>,
39 doc_id_forward: HashMap<String, u32>,
41 doc_id_reverse: HashMap<u32, String>,
43 next_id: u32,
45}
46
47impl Default for SparseInvertedIndex {
48 fn default() -> Self {
49 Self::new()
50 }
51}
52
53impl SparseInvertedIndex {
54 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 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 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 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 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 pub fn doc_count(&self) -> usize {
170 self.doc_id_forward.len()
171 }
172
173 pub fn is_empty(&self) -> bool {
175 self.doc_id_forward.is_empty()
176 }
177
178 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 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}