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 crate::Result;
15use std::collections::HashMap;
16
17/// Inverted index over weighted sparse vectors, keyed by token id.
18#[derive(Clone)]
19pub struct SparseIndex {
20    postings: HashMap<u32, Vec<(RowId, f32)>>,
21}
22
23impl SparseIndex {
24    pub fn new() -> Self {
25        Self {
26            postings: HashMap::new(),
27        }
28    }
29
30    /// Insert a document's sparse vector (`terms` need not be sorted; duplicate
31    /// tokens within one doc accumulate).
32    pub fn insert(&mut self, terms: &[(u32, f32)], row_id: RowId) {
33        for &(token, weight) in terms {
34            self.postings
35                .entry(token)
36                .or_default()
37                .push((row_id, weight));
38        }
39    }
40
41    /// Top-k row ids by sparse dot product with `query` (highest score first).
42    pub fn search(&self, query: &[(u32, f32)], k: usize) -> Vec<(RowId, f64)> {
43        self.search_filtered(query, k, |_| true)
44    }
45
46    pub fn search_filtered(
47        &self,
48        query: &[(u32, f32)],
49        k: usize,
50        allowed: impl Fn(RowId) -> bool,
51    ) -> Vec<(RowId, f64)> {
52        let mut scores: HashMap<u64, f64> = HashMap::new();
53        for &(token, q_weight) in query {
54            if let Some(list) = self.postings.get(&token) {
55                for &(rid, d_weight) in list {
56                    if allowed(rid) {
57                        *scores.entry(rid.0).or_insert(0.0) +=
58                            f64::from(q_weight) * f64::from(d_weight);
59                    }
60                }
61            }
62        }
63        let mut ranked: Vec<(RowId, f64)> = scores
64            .into_iter()
65            .map(|(rid, score)| (RowId(rid), score))
66            .collect();
67        ranked.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
68        ranked.truncate(k);
69        ranked
70    }
71
72    pub fn search_with_context(
73        &self,
74        query: &[(u32, f32)],
75        k: usize,
76        context: Option<&crate::query::AiExecutionContext>,
77    ) -> Result<Vec<(RowId, f64)>> {
78        let mut scores: HashMap<u64, f64> = HashMap::new();
79        for &(token, q_weight) in query {
80            if let Some(context) = context {
81                context.checkpoint()?;
82            }
83            if let Some(list) = self.postings.get(&token) {
84                for chunk in list.chunks(256) {
85                    if let Some(context) = context {
86                        context.consume(chunk.len())?;
87                    }
88                    for &(rid, d_weight) in chunk {
89                        if !scores.contains_key(&rid.0)
90                            && scores.len() >= crate::query::MAX_RAW_INDEX_CANDIDATES
91                        {
92                            return Err(crate::MongrelError::WorkBudgetExceeded);
93                        }
94                        *scores.entry(rid.0).or_insert(0.0) +=
95                            f64::from(q_weight) * f64::from(d_weight);
96                    }
97                }
98            }
99        }
100        let mut ranked: Vec<_> = scores
101            .into_iter()
102            .map(|(rid, score)| (RowId(rid), score))
103            .collect();
104        if let Some(context) = context {
105            context.consume(ranked.len())?;
106        }
107        let order = |left: &(RowId, f64), right: &(RowId, f64)| {
108            right
109                .1
110                .total_cmp(&left.1)
111                .then_with(|| left.0.cmp(&right.0))
112        };
113        if ranked.len() > k {
114            ranked.select_nth_unstable_by(k, order);
115            ranked.truncate(k);
116        }
117        ranked.sort_by(order);
118        Ok(ranked)
119    }
120
121    pub fn candidate_row_ids(&self, query: &[(u32, f32)]) -> Vec<RowId> {
122        let mut row_ids = std::collections::HashSet::new();
123        for (token, _) in query {
124            if let Some(postings) = self.postings.get(token) {
125                row_ids.extend(postings.iter().map(|(row_id, _)| *row_id));
126            }
127        }
128        row_ids.into_iter().collect()
129    }
130
131    pub fn is_empty(&self) -> bool {
132        self.postings.is_empty()
133    }
134
135    /// Snapshot the inverted lists for checkpointing to `_idx/global.idx`.
136    pub fn entries(&self) -> Vec<(u32, Vec<(RowId, f32)>)> {
137        self.postings
138            .iter()
139            .map(|(t, list)| (*t, list.clone()))
140            .collect()
141    }
142
143    /// Rebuild from a snapshot produced by [`SparseIndex::entries`].
144    pub fn from_entries(entries: Vec<(u32, Vec<(RowId, f32)>)>) -> Self {
145        let mut postings = HashMap::new();
146        for (t, list) in entries {
147            postings.insert(t, list);
148        }
149        Self { postings }
150    }
151}
152
153impl Default for SparseIndex {
154    fn default() -> Self {
155        Self::new()
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn ranks_by_sparse_overlap() {
165        let mut idx = SparseIndex::new();
166        // doc 0: {a:2, b:1}; doc 1: {a:1, c:3}; doc 2: {b:5}
167        idx.insert(&[(1, 2.0), (2, 1.0)], RowId(0));
168        idx.insert(&[(1, 1.0), (3, 3.0)], RowId(1));
169        idx.insert(&[(2, 5.0)], RowId(2));
170        // query {a:1, b:1}: doc0 = 2*1+1*1=3, doc1 = 1*1=1, doc2 = 5*1=5
171        let top = idx.search(&[(1, 1.0), (2, 1.0)], 3);
172        assert_eq!(top[0], (RowId(2), 5.0));
173        assert_eq!(top[1], (RowId(0), 3.0));
174        assert_eq!(top[2], (RowId(1), 1.0));
175    }
176
177    #[test]
178    fn unique_candidates_stop_at_raw_ceiling() {
179        let mut idx = SparseIndex::new();
180        for row_id in 0..=crate::query::MAX_RAW_INDEX_CANDIDATES {
181            idx.insert(&[(1, 1.0)], RowId(row_id as u64));
182        }
183        let context = crate::query::AiExecutionContext::new(None, usize::MAX);
184        assert!(matches!(
185            idx.search_with_context(&[(1, 1.0)], 1, Some(&context)),
186            Err(crate::MongrelError::WorkBudgetExceeded)
187        ));
188    }
189}