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