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    // The allowed set is copied, sorted and deduplicated here — once per
121    // query. On a work domain of hundreds of thousands of ids that is the
122    // dominant cost, and it is paid again at every search for a set that
123    // does not change. A caller that hands over sorted, unique ids (the
124    // usual shape: they come out of a query, a bitmap or a sorted column)
125    // pays a linear check instead, and no allocation at all.
126    let sorted_unique = allowed.windows(2).all(|w| w[0] < w[1]);
127    let owned: Vec<u64>;
128    let ids: &[u64] = if sorted_unique {
129        allowed
130    } else {
131        let mut v = allowed.to_vec();
132        v.sort_unstable();
133        v.dedup();
134        owned = v;
135        &owned
136    };
137    // A seek is a binary search per lane; a window walk touches every
138    // posting of every lane once. Compare the two, seek weighed by a
139    // conservative constant.
140    let postings: usize = lanes
141        .iter()
142        .filter_map(|&(dim, _)| cursors(dim))
143        .map(|c| c.remaining())
144        .sum();
145    let seek_work = ids.len().saturating_mul(lanes.len()).saturating_mul(8);
146    SCRATCH.with(|cell| {
147        let mut scratch = cell.borrow_mut();
148        if seek_work < postings {
149            wand::search_ids(&lanes, ids, cursors, TopKSink::new(limit), &mut scratch)
150        } else if sorted_unique {
151            // Sorted ids answer membership by binary search, with nothing
152            // built: a hash set of a large domain is rebuilt at every query
153            // and that build is the cost, not the lookups.
154            wand::search_with(
155                &lanes,
156                |id| ids.binary_search(&id).is_ok(),
157                cursors,
158                TopKSink::new(limit),
159                SearchOptions::default(),
160                &mut scratch,
161            )
162        } else {
163            let set: std::collections::HashSet<u64> = ids.iter().copied().collect();
164            wand::search_with(
165                &lanes,
166                |id| set.contains(&id),
167                cursors,
168                TopKSink::new(limit),
169                SearchOptions::default(),
170                &mut scratch,
171            )
172        }
173    })
174}
175
176/// In-memory inverted index for sparse vectors.
177#[derive(Debug, Serialize, Deserialize)]
178pub struct SparseIndex {
179    /// Dimension remapping: global token_id → dense index into `postings`.
180    dim_map: HashMap<u32, usize>,
181    /// Reverse map: dense index → global token_id.
182    dim_reverse: Vec<u32>,
183    /// Posting lists indexed by remapped dimension.
184    #[serde(with = "postings_serde")]
185    postings: Vec<Postings>,
186    /// Original vectors stored for delete/update support.
187    vectors: HashMap<u64, SparseVector>,
188}
189
190/// Posting lists travel as `Vec<Vec<(id, weight)>>`: the ceilings are
191/// derived data, and the shape is the one older `sparse.bin` files carry.
192mod postings_serde {
193    use serde::{Deserialize, Deserializer, Serializer};
194
195    use crate::wand::Postings;
196
197    pub fn serialize<S: Serializer>(postings: &[Postings], s: S) -> Result<S::Ok, S::Error> {
198        s.collect_seq(postings.iter().map(|p| {
199            p.as_slice()
200                .iter()
201                .map(|x| (x.id, x.weight))
202                .collect::<Vec<(u64, f32)>>()
203        }))
204    }
205
206    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<Postings>, D::Error> {
207        let lists: Vec<Vec<(u64, f32)>> = Deserialize::deserialize(d)?;
208        Ok(lists.into_iter().map(Postings::from_pairs).collect())
209    }
210}
211
212impl Default for SparseIndex {
213    fn default() -> Self {
214        Self::new()
215    }
216}
217
218impl SparseIndex {
219    pub fn new() -> Self {
220        Self {
221            dim_map: HashMap::new(),
222            dim_reverse: Vec::new(),
223            postings: Vec::new(),
224            vectors: HashMap::new(),
225        }
226    }
227
228    /// Reconstruct from stored parts (dims + vectors, postings separate).
229    pub fn from_parts(
230        dim_map: HashMap<u32, usize>,
231        dim_reverse: Vec<u32>,
232        postings: Vec<Postings>,
233        vectors: HashMap<u64, SparseVector>,
234    ) -> Self {
235        Self {
236            dim_map,
237            dim_reverse,
238            postings,
239            vectors,
240        }
241    }
242
243    // -- Accessors for the persistence layer --
244
245    pub fn dim_map(&self) -> &HashMap<u32, usize> {
246        &self.dim_map
247    }
248
249    pub fn dim_reverse(&self) -> &[u32] {
250        &self.dim_reverse
251    }
252
253    pub fn postings(&self) -> &[Postings] {
254        &self.postings
255    }
256
257    pub fn postings_mut(&mut self) -> &mut Vec<Postings> {
258        &mut self.postings
259    }
260
261    pub fn vectors(&self) -> &HashMap<u64, SparseVector> {
262        &self.vectors
263    }
264
265    pub fn set_vectors(&mut self, vectors: HashMap<u64, SparseVector>) {
266        self.vectors = vectors;
267    }
268
269    pub fn len(&self) -> usize {
270        self.vectors.len()
271    }
272
273    pub fn is_empty(&self) -> bool {
274        self.vectors.is_empty()
275    }
276
277    /// Dense index of `token_id`, allocating one on first sight.
278    fn get_or_create_dim(&mut self, token_id: u32) -> usize {
279        if let Some(&idx) = self.dim_map.get(&token_id) {
280            return idx;
281        }
282        let idx = self.postings.len();
283        self.dim_map.insert(token_id, idx);
284        self.dim_reverse.push(token_id);
285        self.postings.push(Postings::new());
286        idx
287    }
288
289    /// Dense index of `token_id`, if it has been seen.
290    fn get_dim(&self, token_id: u32) -> Option<usize> {
291        self.dim_map.get(&token_id).copied()
292    }
293
294    /// Index a record's vector, replacing any previous vector under the
295    /// same id. Zero weights are not indexed (see the module docs).
296    pub fn insert(&mut self, node_id: u64, vector: &SparseVector) {
297        if self.vectors.contains_key(&node_id) {
298            self.remove(node_id);
299        }
300
301        for (&token_id, &weight) in vector.indices.iter().zip(&vector.values) {
302            if weight == 0.0 {
303                continue;
304            }
305            let dim_idx = self.get_or_create_dim(token_id);
306            self.postings[dim_idx].upsert(node_id, weight);
307        }
308
309        self.vectors.insert(node_id, vector.clone());
310    }
311
312    /// Remove a record. Returns true if it existed.
313    pub fn remove(&mut self, node_id: u64) -> bool {
314        let Some(vector) = self.vectors.remove(&node_id) else {
315            return false;
316        };
317        for &token_id in &vector.indices {
318            if let Some(dim_idx) = self.get_dim(token_id) {
319                self.postings[dim_idx].delete(node_id);
320            }
321        }
322        true
323    }
324
325    /// Top-`limit` records by dot product with `query`, score descending
326    /// then id ascending.
327    pub fn search(&self, query: &SparseVector, limit: usize) -> Vec<(u64, f32)> {
328        self.search_with_filter(query, limit, &|_| true)
329    }
330
331    /// [`search`](Self::search) restricted to `allowed_ids`.
332    pub fn search_filtered(
333        &self,
334        query: &SparseVector,
335        limit: usize,
336        allowed_ids: &[u64],
337    ) -> Vec<(u64, f32)> {
338        if allowed_ids.is_empty() || query.is_empty() || self.is_empty() {
339            return Vec::new();
340        }
341        run_search_allowed(query, &self.dim_map, limit, allowed_ids, |dim| {
342            self.postings
343                .get(dim as usize)
344                .filter(|p| !p.is_empty())
345                .map(Postings::cursor)
346        })
347    }
348
349    fn search_with_filter<F: Fn(u64) -> bool>(
350        &self,
351        query: &SparseVector,
352        limit: usize,
353        filter: &F,
354    ) -> Vec<(u64, f32)> {
355        if query.is_empty() || self.is_empty() {
356            return Vec::new();
357        }
358        run_search(query, &self.dim_map, limit, filter, |dim| {
359            self.postings
360                .get(dim as usize)
361                .filter(|p| !p.is_empty())
362                .map(Postings::cursor)
363        })
364    }
365
366    /// Clear the entire index.
367    pub fn clear(&mut self) {
368        self.dim_map.clear();
369        self.dim_reverse.clear();
370        self.postings.clear();
371        self.vectors.clear();
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    #[test]
380    fn sparse_vector_basics() {
381        let v = SparseVector::new(vec![1, 3, 5], vec![0.5, 0.3, 0.2]);
382        assert_eq!(v.nnz(), 3);
383        assert!(!v.is_empty());
384
385        let empty = SparseVector::new(vec![], vec![]);
386        assert!(empty.is_empty());
387    }
388
389    #[test]
390    #[should_panic(expected = "indices and values must have same length")]
391    fn sparse_vector_mismatched_lengths() {
392        SparseVector::new(vec![1, 2], vec![0.5]);
393    }
394
395    #[test]
396    fn index_insert_and_search() {
397        let mut index = SparseIndex::new();
398        index.insert(1, &SparseVector::new(vec![1, 2, 3], vec![0.5, 0.3, 0.2]));
399        index.insert(2, &SparseVector::new(vec![2, 3, 4], vec![0.4, 0.6, 0.1]));
400        index.insert(3, &SparseVector::new(vec![1, 4, 5], vec![0.9, 0.1, 0.1]));
401        assert_eq!(index.len(), 3);
402
403        let query = SparseVector::new(vec![1, 2], vec![1.0, 1.0]);
404        let results = index.search(&query, 10);
405
406        // doc1: 0.5 + 0.3 = 0.8
407        // doc2: 0.4 = 0.4
408        // doc3: 0.9 = 0.9
409        assert_eq!(results.len(), 3);
410        assert_eq!(results[0].0, 3);
411        assert!((results[0].1 - 0.9).abs() < 1e-6);
412        assert_eq!(results[1].0, 1);
413        assert!((results[1].1 - 0.8).abs() < 1e-6);
414        assert_eq!(results[2].0, 2);
415        assert!((results[2].1 - 0.4).abs() < 1e-6);
416    }
417
418    #[test]
419    fn index_remove() {
420        let mut index = SparseIndex::new();
421        index.insert(1, &SparseVector::new(vec![1, 2], vec![0.5, 0.3]));
422        index.insert(2, &SparseVector::new(vec![1, 3], vec![0.9, 0.1]));
423        assert_eq!(index.len(), 2);
424
425        assert!(index.remove(1));
426        assert_eq!(index.len(), 1);
427        assert!(!index.remove(1));
428
429        let query = SparseVector::new(vec![1], vec![1.0]);
430        let results = index.search(&query, 10);
431        assert_eq!(results.len(), 1);
432        assert_eq!(results[0].0, 2);
433    }
434
435    #[test]
436    fn index_insert_replaces() {
437        let mut index = SparseIndex::new();
438        index.insert(1, &SparseVector::new(vec![1], vec![0.5]));
439        index.insert(1, &SparseVector::new(vec![2], vec![0.9]));
440        assert_eq!(index.len(), 1);
441
442        let query = SparseVector::new(vec![1, 2], vec![1.0, 1.0]);
443        let results = index.search(&query, 10);
444        assert_eq!(results.len(), 1);
445        assert!((results[0].1 - 0.9).abs() < 1e-6);
446    }
447
448    #[test]
449    fn index_search_limit() {
450        let mut index = SparseIndex::new();
451        for i in 0..100u64 {
452            index.insert(i, &SparseVector::new(vec![1], vec![i as f32]));
453        }
454        let query = SparseVector::new(vec![1], vec![1.0]);
455        let results = index.search(&query, 5);
456        assert_eq!(results.len(), 5);
457        assert_eq!(results[0].0, 99);
458        for p in index.postings() {
459            p.check_invariants().unwrap();
460        }
461    }
462
463    #[test]
464    fn index_search_disjoint() {
465        let mut index = SparseIndex::new();
466        index.insert(1, &SparseVector::new(vec![1, 2], vec![1.0, 1.0]));
467        let query = SparseVector::new(vec![3, 4], vec![1.0, 1.0]);
468        let results = index.search(&query, 10);
469        assert!(results.is_empty());
470    }
471
472    #[test]
473    fn index_empty_search() {
474        let index = SparseIndex::new();
475        let query = SparseVector::new(vec![1], vec![1.0]);
476        assert!(index.search(&query, 10).is_empty());
477    }
478
479    #[test]
480    fn index_clear() {
481        let mut index = SparseIndex::new();
482        index.insert(1, &SparseVector::new(vec![1], vec![0.5]));
483        index.insert(2, &SparseVector::new(vec![2], vec![0.3]));
484        assert_eq!(index.len(), 2);
485
486        index.clear();
487        assert!(index.is_empty());
488        assert!(index
489            .search(&SparseVector::new(vec![1], vec![1.0]), 10)
490            .is_empty());
491    }
492
493    #[test]
494    fn index_remove_cleans_postings() {
495        let mut index = SparseIndex::new();
496        index.insert(1, &SparseVector::new(vec![42], vec![1.0]));
497        index.remove(1);
498        // The dimension survives, its posting list is empty.
499        let dim_idx = index.get_dim(42).unwrap();
500        assert!(index.postings[dim_idx].is_empty());
501    }
502
503    #[test]
504    fn zero_weights_are_not_indexed() {
505        let mut index = SparseIndex::new();
506        index.insert(1, &SparseVector::new(vec![1, 2], vec![0.0, 0.5]));
507        index.insert(2, &SparseVector::new(vec![1], vec![0.0]));
508        assert_eq!(index.len(), 2, "the records themselves are kept");
509        // Dimension 1 only ever saw zeros: nothing to search there.
510        assert!(index
511            .search(&SparseVector::new(vec![1], vec![1.0]), 10)
512            .is_empty());
513        let hits = index.search(&SparseVector::new(vec![1, 2], vec![1.0, 1.0]), 10);
514        assert_eq!(hits, vec![(1, 0.5)]);
515        // Replacing a record with a non-zero weight on that dimension works.
516        index.insert(2, &SparseVector::new(vec![1], vec![0.7]));
517        let hits = index.search(&SparseVector::new(vec![1], vec![1.0]), 10);
518        assert_eq!(hits, vec![(2, 0.7)]);
519        assert!(index.remove(1));
520        assert!(index.remove(2));
521        assert!(index.is_empty());
522    }
523
524    #[test]
525    fn duplicate_query_dimensions_are_summed() {
526        let mut index = SparseIndex::new();
527        index.insert(1, &SparseVector::new(vec![7], vec![0.5]));
528        let once = index.search(&SparseVector::new(vec![7], vec![1.5]), 10);
529        let twice = index.search(&SparseVector::new(vec![7, 7], vec![1.0, 0.5]), 10);
530        assert_eq!(once, twice);
531        assert_eq!(twice, vec![(1, 0.75)]);
532    }
533
534    #[test]
535    fn search_filtered_basic() {
536        let mut index = SparseIndex::new();
537        index.insert(1, &SparseVector::new(vec![1, 2], vec![0.5, 0.3]));
538        index.insert(2, &SparseVector::new(vec![1, 3], vec![0.9, 0.1]));
539        index.insert(3, &SparseVector::new(vec![1], vec![0.7]));
540
541        let query = SparseVector::new(vec![1], vec![1.0]);
542
543        let results = index.search_filtered(&query, 10, &[1, 3]);
544        assert_eq!(results.len(), 2);
545        assert_eq!(results[0].0, 3); // 0.7
546        assert_eq!(results[1].0, 1); // 0.5
547
548        let results = index.search_filtered(&query, 10, &[2]);
549        assert_eq!(results.len(), 1);
550        assert_eq!(results[0].0, 2);
551    }
552
553    #[test]
554    fn persistence_compat() {
555        // bincode round-trip through the legacy `Vec<Vec<(id, weight)>>` shape.
556        let mut index = SparseIndex::new();
557        index.insert(42, &SparseVector::new(vec![1, 2], vec![0.5, 0.3]));
558        index.insert(99, &SparseVector::new(vec![2, 3], vec![0.8, 0.2]));
559
560        let data = bincode::serialize(&index).unwrap();
561        let index2: SparseIndex = bincode::deserialize(&data).unwrap();
562
563        assert_eq!(index2.len(), 2);
564        assert_eq!(index2.postings(), index.postings());
565        let results = index2.search(&SparseVector::new(vec![2], vec![1.0]), 10);
566        assert_eq!(results.len(), 2);
567        assert_eq!(results[0].0, 99);
568        assert!((results[0].1 - 0.8).abs() < 1e-6);
569    }
570
571    #[test]
572    fn dimension_remapping() {
573        let mut index = SparseIndex::new();
574        // Token IDs can be huge (vocab 250k) — they get remapped to dense indices
575        index.insert(1, &SparseVector::new(vec![100000, 200000], vec![0.5, 0.3]));
576        assert_eq!(index.postings.len(), 2);
577        assert!(index.get_dim(100000).is_some());
578        assert!(index.get_dim(200000).is_some());
579        assert!(index.get_dim(300000).is_none());
580    }
581
582    #[test]
583    fn many_documents_search() {
584        let mut index = SparseIndex::new();
585        // Insert 1000 docs with overlapping dimensions
586        for i in 0..1000u64 {
587            let token = (i % 50) as u32; // 50 unique tokens
588            let weight = (i as f32) / 1000.0;
589            index.insert(i, &SparseVector::new(vec![token, token + 50], vec![weight, weight * 0.5]));
590        }
591
592        let query = SparseVector::new(vec![0, 50], vec![1.0, 1.0]);
593        let results = index.search(&query, 5);
594        assert_eq!(results.len(), 5);
595        // Top result should be doc with highest weight for tokens 0 and 50
596        // Token 0: docs 0, 50, 100, ..., 950. Doc 950 has weight 0.95
597        assert_eq!(results[0].0, 950);
598    }
599}