Skip to main content

vecdb_core/sparse/
inverted.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5
6use crate::errors::{Result, VecDbError};
7use crate::types::VectorId;
8
9use super::tokenizer::Tokenizer;
10
11// ──────────────────────────────────────────────
12// PostingEntry / PostingList
13// ──────────────────────────────────────────────
14
15/// One document's entry in a term's posting list.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct PostingEntry {
18    pub doc_id: VectorId,
19    /// Normalized term frequency (raw_count / doc_length).
20    pub term_frequency: f32,
21    /// Raw occurrence count in the document.
22    pub raw_count: usize,
23}
24
25/// A posting list for one term. Entries are kept sorted by `doc_id`
26/// so binary search is O(log n).
27#[derive(Debug, Clone, Serialize, Deserialize, Default)]
28pub struct PostingList {
29    pub entries: Vec<PostingEntry>,
30}
31
32impl PostingList {
33    /// Insert or update an entry for `doc_id`. Maintains sorted order.
34    pub fn insert(&mut self, doc_id: VectorId, term_frequency: f32, raw_count: usize) {
35        match self.entries.binary_search_by(|e| e.doc_id.cmp(&doc_id)) {
36            Ok(pos) => {
37                self.entries[pos].term_frequency = term_frequency;
38                self.entries[pos].raw_count = raw_count;
39            }
40            Err(pos) => {
41                self.entries.insert(
42                    pos,
43                    PostingEntry {
44                        doc_id,
45                        term_frequency,
46                        raw_count,
47                    },
48                );
49            }
50        }
51    }
52
53    /// Remove the entry for `doc_id` (no-op if absent).
54    pub fn remove(&mut self, doc_id: &VectorId) {
55        if let Ok(pos) = self.entries.binary_search_by(|e| e.doc_id.cmp(doc_id)) {
56            self.entries.remove(pos);
57        }
58    }
59
60    /// Look up an entry by `doc_id`. O(log n).
61    pub fn get(&self, doc_id: &VectorId) -> Option<&PostingEntry> {
62        self.entries
63            .binary_search_by(|e| e.doc_id.cmp(doc_id))
64            .ok()
65            .map(|pos| &self.entries[pos])
66    }
67
68    pub fn len(&self) -> usize {
69        self.entries.len()
70    }
71
72    pub fn is_empty(&self) -> bool {
73        self.entries.is_empty()
74    }
75}
76
77// ──────────────────────────────────────────────
78// InvertedIndex
79// ──────────────────────────────────────────────
80
81/// BM25-scored inverted index.
82///
83/// Scoring formula:
84/// ```text
85/// IDF(t) = ln((N − df + 0.5) / (df + 0.5) + 1)
86/// score(q, d) = Σ IDF(tᵢ) × tf × (k1 + 1) / (tf + k1 × (1 − b + b × |d| / avgdl))
87/// ```
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct InvertedIndex {
90    postings: HashMap<String, PostingList>,
91    idf: HashMap<String, f32>,
92    pub doc_lengths: HashMap<VectorId, usize>,
93    /// Term lists per document (used when removing a document).
94    doc_terms: HashMap<VectorId, Vec<String>>,
95    avg_doc_length: f32,
96    /// Total number of indexed documents.
97    ///
98    /// Renamed from `doc_count` in P23; the serde alias preserves backward-compatible
99    /// deserialization of existing JSON files that still use `"doc_count"`.
100    #[serde(alias = "doc_count")]
101    pub total_docs: usize,
102    /// Running sum of all document lengths; enables O(1) `avg_doc_length` recomputation.
103    ///
104    /// Defaults to 0 for backward compat; `load()` migrates old files that lack this field.
105    #[serde(default)]
106    pub total_tokens: usize,
107    k1: f32,
108    b: f32,
109    /// Tokenizer is not serialized; reconstructed on load.
110    #[serde(skip)]
111    tokenizer: Tokenizer,
112}
113
114impl InvertedIndex {
115    pub fn new(k1: f32, b: f32) -> Self {
116        Self {
117            postings: HashMap::new(),
118            idf: HashMap::new(),
119            doc_lengths: HashMap::new(),
120            doc_terms: HashMap::new(),
121            avg_doc_length: 0.0,
122            total_docs: 0,
123            total_tokens: 0,
124            k1,
125            b,
126            tokenizer: Tokenizer::default(),
127        }
128    }
129
130    /// Compute IDF(t) = ln((N - df + 0.5) / (df + 0.5) + 1).
131    fn compute_idf(doc_count: usize, df: usize) -> f32 {
132        let n = doc_count as f32;
133        let df_f = df as f32;
134        ((n - df_f + 0.5) / (df_f + 0.5) + 1.0).ln()
135    }
136
137    /// Recompute IDF cache for every term. Called after any structural change.
138    fn recompute_idf(&mut self) {
139        let doc_count = self.total_docs;
140        let postings = &self.postings;
141        self.idf = postings
142            .iter()
143            .map(|(term, list)| (term.clone(), Self::compute_idf(doc_count, list.len())))
144            .collect();
145    }
146
147    fn recompute_avg_doc_length(&mut self) {
148        if self.total_docs == 0 {
149            self.avg_doc_length = 0.0;
150        } else {
151            // Performance: O(1) — total_tokens is maintained as a running sum.
152            // Before: O(n) values().sum() over all doc_lengths on every insert/remove
153            // After:  direct division; no heap scan regardless of collection size
154            self.avg_doc_length = self.total_tokens as f32 / self.total_docs as f32;
155        }
156    }
157
158    /// Add or update a document. If the document already exists it is first removed.
159    pub fn index_document(&mut self, id: &VectorId, text: &str) -> Result<()> {
160        // Upsert: remove old entry if updating an existing doc.
161        if self.doc_terms.contains_key(id) {
162            self.remove_document(id)?;
163        }
164
165        let tokens = self.tokenizer.tokenize(text);
166        if tokens.is_empty() {
167            return Ok(());
168        }
169
170        let doc_length = tokens.len();
171        let term_freqs = Tokenizer::compute_term_frequencies(&tokens);
172        let terms: Vec<String> = term_freqs.keys().cloned().collect();
173
174        for (term, &count) in &term_freqs {
175            let tf_normalized = count as f32 / doc_length as f32;
176            self.postings
177                .entry(term.clone())
178                .or_default()
179                .insert(id.clone(), tf_normalized, count);
180        }
181
182        self.doc_lengths.insert(id.clone(), doc_length);
183        self.doc_terms.insert(id.clone(), terms);
184        self.total_docs += 1;
185        self.total_tokens += doc_length;
186
187        self.recompute_avg_doc_length();
188        self.recompute_idf();
189        Ok(())
190    }
191
192    /// Remove a document from the index.
193    pub fn remove_document(&mut self, id: &VectorId) -> Result<()> {
194        let terms = self
195            .doc_terms
196            .remove(id)
197            .ok_or_else(|| VecDbError::NotFound { id: id.clone() })?;
198
199        // Capture doc length before removing it so total_tokens can be decremented.
200        let removed_len = self.doc_lengths.get(id).copied().unwrap_or(0);
201
202        for term in &terms {
203            if let Some(list) = self.postings.get_mut(term) {
204                list.remove(id);
205                if list.is_empty() {
206                    self.postings.remove(term);
207                }
208            }
209            self.idf.remove(term);
210        }
211
212        self.doc_lengths.remove(id);
213        if self.total_docs > 0 {
214            self.total_docs -= 1;
215        }
216        self.total_tokens = self.total_tokens.saturating_sub(removed_len);
217
218        self.recompute_avg_doc_length();
219        self.recompute_idf();
220        Ok(())
221    }
222
223    /// Score a pre-tokenized list of query terms against a set of candidate documents.
224    ///
225    /// If `candidate_ids` is empty all documents in the posting lists are scored.
226    /// Results are sorted descending by BM25 score.
227    pub fn score(
228        &self,
229        query_terms: &[String],
230        candidate_ids: &[VectorId],
231    ) -> Result<Vec<(VectorId, f32)>> {
232        let filter: Option<std::collections::HashSet<&VectorId>> = if candidate_ids.is_empty() {
233            None
234        } else {
235            Some(candidate_ids.iter().collect())
236        };
237
238        let mut scores: HashMap<VectorId, f32> = HashMap::new();
239        let avg_dl = self.avg_doc_length.max(1.0);
240        let k1 = self.k1;
241        let b = self.b;
242
243        for term in query_terms {
244            let Some(list) = self.postings.get(term) else {
245                continue;
246            };
247            let idf = self.idf.get(term).copied().unwrap_or(0.0);
248
249            for entry in &list.entries {
250                if let Some(ref set) = filter {
251                    if !set.contains(&entry.doc_id) {
252                        continue;
253                    }
254                }
255                let doc_len = self.doc_lengths.get(&entry.doc_id).copied().unwrap_or(1) as f32;
256                let tf = entry.raw_count as f32;
257                let numerator = tf * (k1 + 1.0);
258                let denominator = tf + k1 * (1.0 - b + b * (doc_len / avg_dl));
259                let term_score = idf * numerator / denominator;
260                *scores.entry(entry.doc_id.clone()).or_insert(0.0) += term_score;
261            }
262        }
263
264        let mut results: Vec<(VectorId, f32)> = scores.into_iter().collect();
265        results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
266        Ok(results)
267    }
268
269    /// Tokenize `query` then score all matching documents. Returns top `k`.
270    pub fn search(&self, query: &str, k: usize) -> Result<Vec<(VectorId, f32)>> {
271        let terms = self.tokenizer.tokenize_query(query);
272        let mut results = self.score(&terms, &[])?;
273        results.truncate(k);
274        Ok(results)
275    }
276
277    /// Tokenize `query_text` and score against `candidate_ids`.
278    ///
279    /// If `candidate_ids` is empty, scores all matching documents.
280    /// Results sorted descending by BM25 score.
281    pub fn score_candidates(
282        &self,
283        query_text: &str,
284        candidate_ids: &[VectorId],
285    ) -> Result<Vec<(VectorId, f32)>> {
286        let terms = self.tokenizer.tokenize_query(query_text);
287        self.score(&terms, candidate_ids)
288    }
289
290    /// Serialize to JSON and write to `path`.
291    pub fn save(&self, path: &Path) -> Result<()> {
292        let data =
293            serde_json::to_vec(self).map_err(|e| VecDbError::SerializationError(e.to_string()))?;
294        std::fs::write(path, data)?;
295        Ok(())
296    }
297
298    /// Load from a JSON file written by `save`. Reconstructs the tokenizer.
299    ///
300    /// Migration: if `total_tokens` is 0 (absent in old files) but `doc_lengths` is
301    /// non-empty, the running sum is recomputed once via O(n) scan so that subsequent
302    /// calls to `recompute_avg_doc_length` are O(1).
303    pub fn load(path: &Path) -> Result<Self> {
304        let data = std::fs::read(path)?;
305        let mut index: Self = serde_json::from_slice(&data)
306            .map_err(|e| VecDbError::SerializationError(e.to_string()))?;
307        index.tokenizer = Tokenizer::default();
308        // One-time migration: backfill total_tokens from doc_lengths when loading old files.
309        if index.total_tokens == 0 && !index.doc_lengths.is_empty() {
310            index.total_tokens = index.doc_lengths.values().sum();
311        }
312        Ok(index)
313    }
314
315    pub fn doc_count(&self) -> usize {
316        self.total_docs
317    }
318
319    pub fn avg_doc_length(&self) -> f32 {
320        self.avg_doc_length
321    }
322}
323
324impl Default for InvertedIndex {
325    fn default() -> Self {
326        Self::new(1.5, 0.75)
327    }
328}
329
330// ──────────────────────────────────────────────
331// Tests
332// ──────────────────────────────────────────────
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use tempfile::tempdir;
338
339    fn idx() -> InvertedIndex {
340        InvertedIndex::default()
341    }
342
343    #[test]
344    fn test_index_and_doc_count() {
345        let mut idx = idx();
346        idx.index_document(&"d1".to_string(), "the quick brown fox")
347            .unwrap();
348        idx.index_document(&"d2".to_string(), "lazy dog sleeps")
349            .unwrap();
350        assert_eq!(idx.doc_count(), 2);
351    }
352
353    #[test]
354    fn test_bm25_relevance_order() {
355        let mut idx = idx();
356        // d1 mentions "rust" twice; d2 once.
357        idx.index_document(&"d1".to_string(), "rust programming rust systems")
358            .unwrap();
359        idx.index_document(&"d2".to_string(), "python scripting rust web")
360            .unwrap();
361        idx.index_document(&"d3".to_string(), "java enterprise spring boot")
362            .unwrap();
363
364        let results = idx.search("rust", 10).unwrap();
365        assert!(!results.is_empty());
366        // d3 has no "rust" → must not appear
367        assert!(!results.iter().any(|(id, _)| id == "d3"));
368        // d1 should score higher than d2
369        let d1_score = results.iter().find(|(id, _)| id == "d1").map(|(_, s)| *s);
370        let d2_score = results.iter().find(|(id, _)| id == "d2").map(|(_, s)| *s);
371        assert!(d1_score.unwrap() > d2_score.unwrap());
372    }
373
374    #[test]
375    fn test_remove_document() {
376        let mut idx = idx();
377        idx.index_document(&"d1".to_string(), "hello world rust")
378            .unwrap();
379        idx.index_document(&"d2".to_string(), "hello python")
380            .unwrap();
381        idx.remove_document(&"d1".to_string()).unwrap();
382        assert_eq!(idx.doc_count(), 1);
383        let results = idx.search("rust", 10).unwrap();
384        assert!(results.is_empty());
385    }
386
387    #[test]
388    fn test_remove_nonexistent_returns_err() {
389        let mut idx = idx();
390        let result = idx.remove_document(&"ghost".to_string());
391        assert!(matches!(result, Err(VecDbError::NotFound { .. })));
392    }
393
394    #[test]
395    fn test_upsert_updates_scores() {
396        let mut idx = idx();
397        idx.index_document(&"d1".to_string(), "apple banana")
398            .unwrap();
399        // Re-index with new content.
400        idx.index_document(&"d1".to_string(), "rust programming language")
401            .unwrap();
402        assert_eq!(idx.doc_count(), 1);
403        // Old terms gone.
404        let old = idx.search("banana", 10).unwrap();
405        assert!(old.is_empty());
406        // New terms present.
407        let new = idx.search("rust", 10).unwrap();
408        assert!(!new.is_empty());
409    }
410
411    #[test]
412    fn test_candidate_filter() {
413        let mut idx = idx();
414        idx.index_document(&"d1".to_string(), "machine learning neural")
415            .unwrap();
416        idx.index_document(&"d2".to_string(), "machine learning rust")
417            .unwrap();
418        let terms = vec!["machine".to_string()];
419        // Only score d1.
420        let results = idx.score(&terms, &["d1".to_string()]).unwrap();
421        assert_eq!(results.len(), 1);
422        assert_eq!(results[0].0, "d1");
423    }
424
425    #[test]
426    fn test_save_and_load() {
427        let dir = tempdir().unwrap();
428        let path = dir.path().join("sparse.json");
429        let mut idx = idx();
430        idx.index_document(&"d1".to_string(), "vector database rust storage")
431            .unwrap();
432        idx.index_document(&"d2".to_string(), "full text search bm25")
433            .unwrap();
434        idx.save(&path).unwrap();
435
436        let loaded = InvertedIndex::load(&path).unwrap();
437        assert_eq!(loaded.doc_count(), 2);
438        let results = loaded.search("vector", 5).unwrap();
439        assert!(!results.is_empty());
440        assert_eq!(results[0].0, "d1");
441    }
442
443    #[test]
444    fn test_idf_decreases_with_more_docs() {
445        let mut idx = idx();
446        idx.index_document(&"d1".to_string(), "common term here")
447            .unwrap();
448        let idf1 = *idx.idf.get("common").unwrap_or(&0.0);
449
450        idx.index_document(&"d2".to_string(), "common term also here")
451            .unwrap();
452        let idf2 = *idx.idf.get("common").unwrap_or(&0.0);
453        // IDF should decrease as "common" appears in more docs.
454        assert!(idf2 <= idf1, "idf1={idf1} idf2={idf2}");
455    }
456
457    #[test]
458    fn test_avg_doc_length_updates() {
459        let mut idx = idx();
460        idx.index_document(&"d1".to_string(), "one two three")
461            .unwrap();
462        idx.index_document(&"d2".to_string(), "one two three four five six")
463            .unwrap();
464        // avg_doc_length must be between 2 and 6 (stopwords filtered)
465        assert!(idx.avg_doc_length() > 0.0);
466    }
467
468    #[test]
469    fn test_empty_query_returns_empty() {
470        let mut idx = idx();
471        idx.index_document(&"d1".to_string(), "hello world")
472            .unwrap();
473        let results = idx.search("", 10).unwrap();
474        assert!(results.is_empty());
475    }
476
477    #[test]
478    fn test_posting_list_sorted() {
479        let mut list = PostingList::default();
480        list.insert("z_doc".to_string(), 0.5, 2);
481        list.insert("a_doc".to_string(), 0.8, 4);
482        list.insert("m_doc".to_string(), 0.3, 1);
483        // Verify sorted order.
484        let ids: Vec<&str> = list.entries.iter().map(|e| e.doc_id.as_str()).collect();
485        assert_eq!(ids, ["a_doc", "m_doc", "z_doc"]);
486    }
487
488    #[test]
489    fn test_posting_list_get() {
490        let mut list = PostingList::default();
491        list.insert("alpha".to_string(), 0.5, 2);
492        list.insert("beta".to_string(), 0.3, 1);
493        assert!(list.get(&"alpha".to_string()).is_some());
494        assert!(list.get(&"gamma".to_string()).is_none());
495    }
496}