Skip to main content

sparse_vector/
index.rs

1//! In-RAM sparse-vector inverted index.
2//!
3//! Token ids (which can be sparse and large) are remapped to dense
4//! dimension indices; each dimension owns one [`Postings`] list of the
5//! `wand` module, and the original vectors are kept so a record can be
6//! removed or replaced. Searches run through [`wand::search_with`] with a
7//! per-thread [`Scratch`].
8//!
9//! # Zero weights
10//!
11//! A coordinate whose weight is exactly `0.0` contributes nothing to any
12//! dot product, so it is not indexed: the record does not appear in that
13//! dimension's postings and a query on that dimension alone does not
14//! return it. The stored vector keeps the coordinate as given.
15
16use std::cell::RefCell;
17use std::collections::HashMap;
18
19use serde::{Deserialize, Serialize};
20
21use crate::wand::{self, DimId, PostingCursor, Postings, Scratch, SearchOptions, TopKSink};
22
23/// A sparse vector: parallel arrays of token IDs and weights.
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct SparseVector {
26    pub indices: Vec<u32>,
27    pub values: Vec<f32>,
28}
29
30impl SparseVector {
31    pub fn new(indices: Vec<u32>, values: Vec<f32>) -> Self {
32        assert_eq!(
33            indices.len(),
34            values.len(),
35            "indices and values must have same length"
36        );
37        Self { indices, values }
38    }
39
40    pub fn nnz(&self) -> usize {
41        self.indices.len()
42    }
43
44    pub fn is_empty(&self) -> bool {
45        self.indices.is_empty()
46    }
47}
48
49thread_local! {
50    /// Window buffers reused by every search on this thread.
51    static SCRATCH: RefCell<Scratch> = RefCell::new(Scratch::new());
52}
53
54/// Top-`limit` search with the crate's default options and the thread's
55/// scratch buffers. Token ids of `query` are translated through `dim_map`;
56/// unknown ones are ignored, and `cursors` receives the dense dimension
57/// indices. Repeated token ids in the query are summed (see
58/// [`wand::search_with`]).
59pub(crate) fn run_search<C, F, R>(
60    query: &SparseVector,
61    dim_map: &HashMap<u32, usize>,
62    limit: usize,
63    filter: F,
64    cursors: R,
65) -> Vec<(u64, f32)>
66where
67    C: PostingCursor,
68    F: Fn(u64) -> bool,
69    R: FnMut(DimId) -> Option<C>,
70{
71    let lanes: Vec<(DimId, f32)> = query
72        .indices
73        .iter()
74        .zip(&query.values)
75        .filter_map(|(token, &w)| dim_map.get(token).map(|&dim| (dim as DimId, w)))
76        .collect();
77    if lanes.is_empty() {
78        return Vec::new();
79    }
80    SCRATCH.with(|cell| {
81        let mut scratch = cell.borrow_mut();
82        wand::search_with(
83            &lanes,
84            filter,
85            cursors,
86            TopKSink::new(limit),
87            SearchOptions::default(),
88            &mut scratch,
89        )
90    })
91}
92
93/// [`run_search`] restricted to `allowed`: seeks the lanes to each allowed
94/// id when that is cheaper than walking the postings (a database
95/// pre-filter with few survivors), otherwise a window search with a set
96/// filter. Both give the same scores.
97pub(crate) fn run_search_allowed<C, R>(
98    query: &SparseVector,
99    dim_map: &HashMap<u32, usize>,
100    limit: usize,
101    allowed: &[u64],
102    mut cursors: R,
103) -> Vec<(u64, f32)>
104where
105    C: PostingCursor,
106    R: FnMut(DimId) -> Option<C>,
107{
108    if allowed.is_empty() {
109        return Vec::new();
110    }
111    let lanes: Vec<(DimId, f32)> = query
112        .indices
113        .iter()
114        .zip(&query.values)
115        .filter_map(|(token, &w)| dim_map.get(token).map(|&dim| (dim as DimId, w)))
116        .collect();
117    if lanes.is_empty() {
118        return Vec::new();
119    }
120    let mut ids: Vec<u64> = allowed.to_vec();
121    ids.sort_unstable();
122    ids.dedup();
123    // A seek is a binary search per lane; a window walk touches every
124    // posting of every lane once. Compare the two, seek weighed by a
125    // conservative constant.
126    let postings: usize = lanes
127        .iter()
128        .filter_map(|&(dim, _)| cursors(dim))
129        .map(|c| c.remaining())
130        .sum();
131    let seek_work = ids.len().saturating_mul(lanes.len()).saturating_mul(8);
132    SCRATCH.with(|cell| {
133        let mut scratch = cell.borrow_mut();
134        if seek_work < postings {
135            wand::search_ids(&lanes, &ids, cursors, TopKSink::new(limit), &mut scratch)
136        } else {
137            let set: std::collections::HashSet<u64> = ids.into_iter().collect();
138            wand::search_with(
139                &lanes,
140                |id| set.contains(&id),
141                cursors,
142                TopKSink::new(limit),
143                SearchOptions::default(),
144                &mut scratch,
145            )
146        }
147    })
148}
149
150/// In-memory inverted index for sparse vectors.
151#[derive(Debug, Serialize, Deserialize)]
152pub struct SparseIndex {
153    /// Dimension remapping: global token_id → dense index into `postings`.
154    dim_map: HashMap<u32, usize>,
155    /// Reverse map: dense index → global token_id.
156    dim_reverse: Vec<u32>,
157    /// Posting lists indexed by remapped dimension.
158    #[serde(with = "postings_serde")]
159    postings: Vec<Postings>,
160    /// Original vectors stored for delete/update support.
161    vectors: HashMap<u64, SparseVector>,
162}
163
164/// Posting lists travel as `Vec<Vec<(id, weight)>>`: the ceilings are
165/// derived data, and the shape is the one older `sparse.bin` files carry.
166mod postings_serde {
167    use serde::{Deserialize, Deserializer, Serializer};
168
169    use crate::wand::Postings;
170
171    pub fn serialize<S: Serializer>(postings: &[Postings], s: S) -> Result<S::Ok, S::Error> {
172        s.collect_seq(postings.iter().map(|p| {
173            p.as_slice()
174                .iter()
175                .map(|x| (x.id, x.weight))
176                .collect::<Vec<(u64, f32)>>()
177        }))
178    }
179
180    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<Postings>, D::Error> {
181        let lists: Vec<Vec<(u64, f32)>> = Deserialize::deserialize(d)?;
182        Ok(lists.into_iter().map(Postings::from_pairs).collect())
183    }
184}
185
186impl Default for SparseIndex {
187    fn default() -> Self {
188        Self::new()
189    }
190}
191
192impl SparseIndex {
193    pub fn new() -> Self {
194        Self {
195            dim_map: HashMap::new(),
196            dim_reverse: Vec::new(),
197            postings: Vec::new(),
198            vectors: HashMap::new(),
199        }
200    }
201
202    /// Reconstruct from stored parts (dims + vectors, postings separate).
203    pub fn from_parts(
204        dim_map: HashMap<u32, usize>,
205        dim_reverse: Vec<u32>,
206        postings: Vec<Postings>,
207        vectors: HashMap<u64, SparseVector>,
208    ) -> Self {
209        Self {
210            dim_map,
211            dim_reverse,
212            postings,
213            vectors,
214        }
215    }
216
217    // -- Accessors for the persistence layer --
218
219    pub fn dim_map(&self) -> &HashMap<u32, usize> {
220        &self.dim_map
221    }
222
223    pub fn dim_reverse(&self) -> &[u32] {
224        &self.dim_reverse
225    }
226
227    pub fn postings(&self) -> &[Postings] {
228        &self.postings
229    }
230
231    pub fn postings_mut(&mut self) -> &mut Vec<Postings> {
232        &mut self.postings
233    }
234
235    pub fn vectors(&self) -> &HashMap<u64, SparseVector> {
236        &self.vectors
237    }
238
239    pub fn set_vectors(&mut self, vectors: HashMap<u64, SparseVector>) {
240        self.vectors = vectors;
241    }
242
243    pub fn len(&self) -> usize {
244        self.vectors.len()
245    }
246
247    pub fn is_empty(&self) -> bool {
248        self.vectors.is_empty()
249    }
250
251    /// Dense index of `token_id`, allocating one on first sight.
252    fn get_or_create_dim(&mut self, token_id: u32) -> usize {
253        if let Some(&idx) = self.dim_map.get(&token_id) {
254            return idx;
255        }
256        let idx = self.postings.len();
257        self.dim_map.insert(token_id, idx);
258        self.dim_reverse.push(token_id);
259        self.postings.push(Postings::new());
260        idx
261    }
262
263    /// Dense index of `token_id`, if it has been seen.
264    fn get_dim(&self, token_id: u32) -> Option<usize> {
265        self.dim_map.get(&token_id).copied()
266    }
267
268    /// Index a record's vector, replacing any previous vector under the
269    /// same id. Zero weights are not indexed (see the module docs).
270    pub fn insert(&mut self, node_id: u64, vector: &SparseVector) {
271        if self.vectors.contains_key(&node_id) {
272            self.remove(node_id);
273        }
274
275        for (&token_id, &weight) in vector.indices.iter().zip(&vector.values) {
276            if weight == 0.0 {
277                continue;
278            }
279            let dim_idx = self.get_or_create_dim(token_id);
280            self.postings[dim_idx].upsert(node_id, weight);
281        }
282
283        self.vectors.insert(node_id, vector.clone());
284    }
285
286    /// Remove a record. Returns true if it existed.
287    pub fn remove(&mut self, node_id: u64) -> bool {
288        let Some(vector) = self.vectors.remove(&node_id) else {
289            return false;
290        };
291        for &token_id in &vector.indices {
292            if let Some(dim_idx) = self.get_dim(token_id) {
293                self.postings[dim_idx].delete(node_id);
294            }
295        }
296        true
297    }
298
299    /// Top-`limit` records by dot product with `query`, score descending
300    /// then id ascending.
301    pub fn search(&self, query: &SparseVector, limit: usize) -> Vec<(u64, f32)> {
302        self.search_with_filter(query, limit, &|_| true)
303    }
304
305    /// [`search`](Self::search) restricted to `allowed_ids`.
306    pub fn search_filtered(
307        &self,
308        query: &SparseVector,
309        limit: usize,
310        allowed_ids: &[u64],
311    ) -> Vec<(u64, f32)> {
312        if allowed_ids.is_empty() || query.is_empty() || self.is_empty() {
313            return Vec::new();
314        }
315        run_search_allowed(query, &self.dim_map, limit, allowed_ids, |dim| {
316            self.postings
317                .get(dim as usize)
318                .filter(|p| !p.is_empty())
319                .map(Postings::cursor)
320        })
321    }
322
323    fn search_with_filter<F: Fn(u64) -> bool>(
324        &self,
325        query: &SparseVector,
326        limit: usize,
327        filter: &F,
328    ) -> Vec<(u64, f32)> {
329        if query.is_empty() || self.is_empty() {
330            return Vec::new();
331        }
332        run_search(query, &self.dim_map, limit, filter, |dim| {
333            self.postings
334                .get(dim as usize)
335                .filter(|p| !p.is_empty())
336                .map(Postings::cursor)
337        })
338    }
339
340    /// Clear the entire index.
341    pub fn clear(&mut self) {
342        self.dim_map.clear();
343        self.dim_reverse.clear();
344        self.postings.clear();
345        self.vectors.clear();
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352
353    #[test]
354    fn sparse_vector_basics() {
355        let v = SparseVector::new(vec![1, 3, 5], vec![0.5, 0.3, 0.2]);
356        assert_eq!(v.nnz(), 3);
357        assert!(!v.is_empty());
358
359        let empty = SparseVector::new(vec![], vec![]);
360        assert!(empty.is_empty());
361    }
362
363    #[test]
364    #[should_panic(expected = "indices and values must have same length")]
365    fn sparse_vector_mismatched_lengths() {
366        SparseVector::new(vec![1, 2], vec![0.5]);
367    }
368
369    #[test]
370    fn index_insert_and_search() {
371        let mut index = SparseIndex::new();
372        index.insert(1, &SparseVector::new(vec![1, 2, 3], vec![0.5, 0.3, 0.2]));
373        index.insert(2, &SparseVector::new(vec![2, 3, 4], vec![0.4, 0.6, 0.1]));
374        index.insert(3, &SparseVector::new(vec![1, 4, 5], vec![0.9, 0.1, 0.1]));
375        assert_eq!(index.len(), 3);
376
377        let query = SparseVector::new(vec![1, 2], vec![1.0, 1.0]);
378        let results = index.search(&query, 10);
379
380        // doc1: 0.5 + 0.3 = 0.8
381        // doc2: 0.4 = 0.4
382        // doc3: 0.9 = 0.9
383        assert_eq!(results.len(), 3);
384        assert_eq!(results[0].0, 3);
385        assert!((results[0].1 - 0.9).abs() < 1e-6);
386        assert_eq!(results[1].0, 1);
387        assert!((results[1].1 - 0.8).abs() < 1e-6);
388        assert_eq!(results[2].0, 2);
389        assert!((results[2].1 - 0.4).abs() < 1e-6);
390    }
391
392    #[test]
393    fn index_remove() {
394        let mut index = SparseIndex::new();
395        index.insert(1, &SparseVector::new(vec![1, 2], vec![0.5, 0.3]));
396        index.insert(2, &SparseVector::new(vec![1, 3], vec![0.9, 0.1]));
397        assert_eq!(index.len(), 2);
398
399        assert!(index.remove(1));
400        assert_eq!(index.len(), 1);
401        assert!(!index.remove(1));
402
403        let query = SparseVector::new(vec![1], vec![1.0]);
404        let results = index.search(&query, 10);
405        assert_eq!(results.len(), 1);
406        assert_eq!(results[0].0, 2);
407    }
408
409    #[test]
410    fn index_insert_replaces() {
411        let mut index = SparseIndex::new();
412        index.insert(1, &SparseVector::new(vec![1], vec![0.5]));
413        index.insert(1, &SparseVector::new(vec![2], vec![0.9]));
414        assert_eq!(index.len(), 1);
415
416        let query = SparseVector::new(vec![1, 2], vec![1.0, 1.0]);
417        let results = index.search(&query, 10);
418        assert_eq!(results.len(), 1);
419        assert!((results[0].1 - 0.9).abs() < 1e-6);
420    }
421
422    #[test]
423    fn index_search_limit() {
424        let mut index = SparseIndex::new();
425        for i in 0..100u64 {
426            index.insert(i, &SparseVector::new(vec![1], vec![i as f32]));
427        }
428        let query = SparseVector::new(vec![1], vec![1.0]);
429        let results = index.search(&query, 5);
430        assert_eq!(results.len(), 5);
431        assert_eq!(results[0].0, 99);
432        for p in index.postings() {
433            p.check_invariants().unwrap();
434        }
435    }
436
437    #[test]
438    fn index_search_disjoint() {
439        let mut index = SparseIndex::new();
440        index.insert(1, &SparseVector::new(vec![1, 2], vec![1.0, 1.0]));
441        let query = SparseVector::new(vec![3, 4], vec![1.0, 1.0]);
442        let results = index.search(&query, 10);
443        assert!(results.is_empty());
444    }
445
446    #[test]
447    fn index_empty_search() {
448        let index = SparseIndex::new();
449        let query = SparseVector::new(vec![1], vec![1.0]);
450        assert!(index.search(&query, 10).is_empty());
451    }
452
453    #[test]
454    fn index_clear() {
455        let mut index = SparseIndex::new();
456        index.insert(1, &SparseVector::new(vec![1], vec![0.5]));
457        index.insert(2, &SparseVector::new(vec![2], vec![0.3]));
458        assert_eq!(index.len(), 2);
459
460        index.clear();
461        assert!(index.is_empty());
462        assert!(index
463            .search(&SparseVector::new(vec![1], vec![1.0]), 10)
464            .is_empty());
465    }
466
467    #[test]
468    fn index_remove_cleans_postings() {
469        let mut index = SparseIndex::new();
470        index.insert(1, &SparseVector::new(vec![42], vec![1.0]));
471        index.remove(1);
472        // The dimension survives, its posting list is empty.
473        let dim_idx = index.get_dim(42).unwrap();
474        assert!(index.postings[dim_idx].is_empty());
475    }
476
477    #[test]
478    fn zero_weights_are_not_indexed() {
479        let mut index = SparseIndex::new();
480        index.insert(1, &SparseVector::new(vec![1, 2], vec![0.0, 0.5]));
481        index.insert(2, &SparseVector::new(vec![1], vec![0.0]));
482        assert_eq!(index.len(), 2, "the records themselves are kept");
483        // Dimension 1 only ever saw zeros: nothing to search there.
484        assert!(index
485            .search(&SparseVector::new(vec![1], vec![1.0]), 10)
486            .is_empty());
487        let hits = index.search(&SparseVector::new(vec![1, 2], vec![1.0, 1.0]), 10);
488        assert_eq!(hits, vec![(1, 0.5)]);
489        // Replacing a record with a non-zero weight on that dimension works.
490        index.insert(2, &SparseVector::new(vec![1], vec![0.7]));
491        let hits = index.search(&SparseVector::new(vec![1], vec![1.0]), 10);
492        assert_eq!(hits, vec![(2, 0.7)]);
493        assert!(index.remove(1));
494        assert!(index.remove(2));
495        assert!(index.is_empty());
496    }
497
498    #[test]
499    fn duplicate_query_dimensions_are_summed() {
500        let mut index = SparseIndex::new();
501        index.insert(1, &SparseVector::new(vec![7], vec![0.5]));
502        let once = index.search(&SparseVector::new(vec![7], vec![1.5]), 10);
503        let twice = index.search(&SparseVector::new(vec![7, 7], vec![1.0, 0.5]), 10);
504        assert_eq!(once, twice);
505        assert_eq!(twice, vec![(1, 0.75)]);
506    }
507
508    #[test]
509    fn search_filtered_basic() {
510        let mut index = SparseIndex::new();
511        index.insert(1, &SparseVector::new(vec![1, 2], vec![0.5, 0.3]));
512        index.insert(2, &SparseVector::new(vec![1, 3], vec![0.9, 0.1]));
513        index.insert(3, &SparseVector::new(vec![1], vec![0.7]));
514
515        let query = SparseVector::new(vec![1], vec![1.0]);
516
517        let results = index.search_filtered(&query, 10, &[1, 3]);
518        assert_eq!(results.len(), 2);
519        assert_eq!(results[0].0, 3); // 0.7
520        assert_eq!(results[1].0, 1); // 0.5
521
522        let results = index.search_filtered(&query, 10, &[2]);
523        assert_eq!(results.len(), 1);
524        assert_eq!(results[0].0, 2);
525    }
526
527    #[test]
528    fn persistence_compat() {
529        // bincode round-trip through the legacy `Vec<Vec<(id, weight)>>` shape.
530        let mut index = SparseIndex::new();
531        index.insert(42, &SparseVector::new(vec![1, 2], vec![0.5, 0.3]));
532        index.insert(99, &SparseVector::new(vec![2, 3], vec![0.8, 0.2]));
533
534        let data = bincode::serialize(&index).unwrap();
535        let index2: SparseIndex = bincode::deserialize(&data).unwrap();
536
537        assert_eq!(index2.len(), 2);
538        assert_eq!(index2.postings(), index.postings());
539        let results = index2.search(&SparseVector::new(vec![2], vec![1.0]), 10);
540        assert_eq!(results.len(), 2);
541        assert_eq!(results[0].0, 99);
542        assert!((results[0].1 - 0.8).abs() < 1e-6);
543    }
544
545    #[test]
546    fn dimension_remapping() {
547        let mut index = SparseIndex::new();
548        // Token IDs can be huge (vocab 250k) — they get remapped to dense indices
549        index.insert(1, &SparseVector::new(vec![100000, 200000], vec![0.5, 0.3]));
550        assert_eq!(index.postings.len(), 2);
551        assert!(index.get_dim(100000).is_some());
552        assert!(index.get_dim(200000).is_some());
553        assert!(index.get_dim(300000).is_none());
554    }
555
556    #[test]
557    fn many_documents_search() {
558        let mut index = SparseIndex::new();
559        // Insert 1000 docs with overlapping dimensions
560        for i in 0..1000u64 {
561            let token = (i % 50) as u32; // 50 unique tokens
562            let weight = (i as f32) / 1000.0;
563            index.insert(i, &SparseVector::new(vec![token, token + 50], vec![weight, weight * 0.5]));
564        }
565
566        let query = SparseVector::new(vec![0, 50], vec![1.0, 1.0]);
567        let results = index.search(&query, 5);
568        assert_eq!(results.len(), 5);
569        // Top result should be doc with highest weight for tokens 0 and 50
570        // Token 0: docs 0, 50, 100, ..., 950. Doc 950 has weight 0.95
571        assert_eq!(results[0].0, 950);
572    }
573}