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#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct PostingEntry {
18 pub doc_id: VectorId,
19 pub term_frequency: f32,
21 pub raw_count: usize,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, Default)]
28pub struct PostingList {
29 pub entries: Vec<PostingEntry>,
30}
31
32impl PostingList {
33 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 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 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#[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 doc_terms: HashMap<VectorId, Vec<String>>,
95 avg_doc_length: f32,
96 #[serde(alias = "doc_count")]
101 pub total_docs: usize,
102 #[serde(default)]
106 pub total_tokens: usize,
107 k1: f32,
108 b: f32,
109 #[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 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 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 self.avg_doc_length = self.total_tokens as f32 / self.total_docs as f32;
155 }
156 }
157
158 pub fn index_document(&mut self, id: &VectorId, text: &str) -> Result<()> {
160 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 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 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 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 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 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 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 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 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#[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 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 assert!(!results.iter().any(|(id, _)| id == "d3"));
368 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 idx.index_document(&"d1".to_string(), "rust programming language")
401 .unwrap();
402 assert_eq!(idx.doc_count(), 1);
403 let old = idx.search("banana", 10).unwrap();
405 assert!(old.is_empty());
406 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 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 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 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 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}