Skip to main content

mongreldb_core/index/
sparse.rs

1//! Sparse inverted index for SPLADE-style learned-sparse retrieval.
2//!
3//! Each document is a sparse vector `(token → weight)`; the index is an inverted
4//! list `token → [(row_id, weight)]`. A query (also a sparse vector) scores
5//! documents by sparse dot product over shared tokens and returns the top-k.
6//! Real SPLADE produces these sparse vectors from a trained model; here any
7//! tokenizer works (the demo uses a hashing trick), so the retrieval machinery
8//! is model-agnostic — plug in real SPLADE weights as the sparse vectors.
9//!
10//! Like the other indexes, results resolve to the shared [`crate::rowid::RowId`]
11//! space, so `sparse_match ∩ fm_contains ∩ bitmap_eq` composes in one query.
12
13use crate::rowid::RowId;
14use std::collections::HashMap;
15
16/// Inverted index over weighted sparse vectors, keyed by token id.
17pub struct SparseIndex {
18    postings: HashMap<u32, Vec<(RowId, f32)>>,
19}
20
21impl SparseIndex {
22    pub fn new() -> Self {
23        Self {
24            postings: HashMap::new(),
25        }
26    }
27
28    /// Insert a document's sparse vector (`terms` need not be sorted; duplicate
29    /// tokens within one doc accumulate).
30    pub fn insert(&mut self, terms: &[(u32, f32)], row_id: RowId) {
31        for &(token, weight) in terms {
32            self.postings
33                .entry(token)
34                .or_default()
35                .push((row_id, weight));
36        }
37    }
38
39    /// Top-k row ids by sparse dot product with `query` (highest score first).
40    pub fn search(&self, query: &[(u32, f32)], k: usize) -> Vec<(RowId, f32)> {
41        let mut scores: HashMap<u64, f32> = HashMap::new();
42        for &(token, q_weight) in query {
43            if let Some(list) = self.postings.get(&token) {
44                for &(rid, d_weight) in list {
45                    *scores.entry(rid.0).or_insert(0.0) += q_weight * d_weight;
46                }
47            }
48        }
49        let mut ranked: Vec<(RowId, f32)> = scores
50            .into_iter()
51            .map(|(rid, score)| (RowId(rid), score))
52            .collect();
53        ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
54        ranked.truncate(k);
55        ranked
56    }
57
58    pub fn is_empty(&self) -> bool {
59        self.postings.is_empty()
60    }
61
62    /// Snapshot the inverted lists for checkpointing to `_idx/global.idx`.
63    pub fn entries(&self) -> Vec<(u32, Vec<(RowId, f32)>)> {
64        self.postings
65            .iter()
66            .map(|(t, list)| (*t, list.clone()))
67            .collect()
68    }
69
70    /// Rebuild from a snapshot produced by [`SparseIndex::entries`].
71    pub fn from_entries(entries: Vec<(u32, Vec<(RowId, f32)>)>) -> Self {
72        let mut postings = HashMap::new();
73        for (t, list) in entries {
74            postings.insert(t, list);
75        }
76        Self { postings }
77    }
78}
79
80impl Default for SparseIndex {
81    fn default() -> Self {
82        Self::new()
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn ranks_by_sparse_overlap() {
92        let mut idx = SparseIndex::new();
93        // doc 0: {a:2, b:1}; doc 1: {a:1, c:3}; doc 2: {b:5}
94        idx.insert(&[(1, 2.0), (2, 1.0)], RowId(0));
95        idx.insert(&[(1, 1.0), (3, 3.0)], RowId(1));
96        idx.insert(&[(2, 5.0)], RowId(2));
97        // query {a:1, b:1}: doc0 = 2*1+1*1=3, doc1 = 1*1=1, doc2 = 5*1=5
98        let top = idx.search(&[(1, 1.0), (2, 1.0)], 3);
99        assert_eq!(top[0], (RowId(2), 5.0));
100        assert_eq!(top[1], (RowId(0), 3.0));
101        assert_eq!(top[2], (RowId(1), 1.0));
102    }
103}