use std::cmp::Ordering;
use std::collections::HashMap;
use nodedb_types::SparseVector;
#[derive(Debug, Clone, PartialEq)]
pub struct SparseHit {
pub doc_id: String,
pub score: f32,
}
pub struct SparseInvertedIndex {
postings: HashMap<u32, Vec<(u32, f32)>>,
doc_entries: HashMap<u32, Vec<(u32, f32)>>,
doc_id_forward: HashMap<String, u32>,
doc_id_reverse: HashMap<u32, String>,
next_id: u32,
}
impl Default for SparseInvertedIndex {
fn default() -> Self {
Self::new()
}
}
impl SparseInvertedIndex {
pub fn new() -> Self {
Self {
postings: HashMap::new(),
doc_entries: HashMap::new(),
doc_id_forward: HashMap::new(),
doc_id_reverse: HashMap::new(),
next_id: 0,
}
}
pub fn insert(&mut self, doc_id: &str, vector: &SparseVector) {
let internal_id = match self.doc_id_forward.get(doc_id).copied() {
Some(existing) => {
self.detach_postings(existing);
existing
}
None => {
let id = self.next_id;
self.next_id = self.next_id.saturating_add(1);
self.doc_id_forward.insert(doc_id.to_owned(), id);
self.doc_id_reverse.insert(id, doc_id.to_owned());
id
}
};
let entries = vector.entries().to_vec();
for &(dim, weight) in &entries {
self.postings
.entry(dim)
.or_default()
.push((internal_id, weight));
}
self.doc_entries.insert(internal_id, entries);
}
pub fn delete(&mut self, doc_id: &str) -> bool {
let Some(internal_id) = self.doc_id_forward.remove(doc_id) else {
return false;
};
self.detach_postings(internal_id);
self.doc_entries.remove(&internal_id);
self.doc_id_reverse.remove(&internal_id);
true
}
fn detach_postings(&mut self, internal_id: u32) {
let Some(entries) = self.doc_entries.get(&internal_id) else {
return;
};
let dims: Vec<u32> = entries.iter().map(|&(dim, _)| dim).collect();
for dim in dims {
if let Some(list) = self.postings.get_mut(&dim) {
list.retain(|&(id, _)| id != internal_id);
if list.is_empty() {
self.postings.remove(&dim);
}
}
}
}
pub fn search(&self, query: &SparseVector, top_k: usize) -> Vec<SparseHit> {
if top_k == 0 || query.is_empty() {
return Vec::new();
}
let mut accumulator: HashMap<u32, f32> = HashMap::new();
for &(dim, query_weight) in query.entries() {
let Some(list) = self.postings.get(&dim) else {
continue;
};
for &(internal_id, doc_weight) in list {
*accumulator.entry(internal_id).or_insert(0.0) += query_weight * doc_weight;
}
}
let mut hits: Vec<SparseHit> = accumulator
.into_iter()
.filter(|&(_, score)| score != 0.0)
.filter_map(|(internal_id, score)| {
self.doc_id_reverse
.get(&internal_id)
.map(|doc_id| SparseHit {
doc_id: doc_id.clone(),
score,
})
})
.collect();
hits.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(Ordering::Equal)
.then_with(|| a.doc_id.cmp(&b.doc_id))
});
hits.truncate(top_k);
hits
}
pub fn doc_count(&self) -> usize {
self.doc_id_forward.len()
}
pub fn is_empty(&self) -> bool {
self.doc_id_forward.is_empty()
}
pub fn documents(&self) -> Vec<(String, Vec<(u32, f32)>)> {
let mut out: Vec<(String, Vec<(u32, f32)>)> = self
.doc_id_forward
.iter()
.filter_map(|(doc_id, internal_id)| {
self.doc_entries
.get(internal_id)
.map(|entries| (doc_id.clone(), entries.clone()))
})
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
pub fn from_documents(documents: Vec<(String, Vec<(u32, f32)>)>) -> Self {
let mut index = Self::new();
for (doc_id, entries) in documents {
let Ok(vector) = SparseVector::from_entries(entries) else {
tracing::warn!(
doc_id = %doc_id,
"sparse checkpoint: document entries rejected — skipping"
);
continue;
};
index.insert(&doc_id, &vector);
}
index
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sv(entries: &[(u32, f32)]) -> SparseVector {
SparseVector::from_entries(entries.to_vec()).expect("valid sparse vector")
}
#[test]
fn search_ranks_by_dot_product_descending() {
let mut idx = SparseInvertedIndex::new();
idx.insert("low", &sv(&[(1, 0.1)]));
idx.insert("high", &sv(&[(1, 5.0)]));
idx.insert("mid", &sv(&[(1, 1.0)]));
let hits = idx.search(&sv(&[(1, 1.0)]), 10);
let ids: Vec<&str> = hits.iter().map(|h| h.doc_id.as_str()).collect();
assert_eq!(ids, vec!["high", "mid", "low"]);
}
#[test]
fn disjoint_document_is_excluded() {
let mut idx = SparseInvertedIndex::new();
idx.insert("overlap", &sv(&[(1, 1.0)]));
idx.insert("disjoint", &sv(&[(99, 1.0)]));
let hits = idx.search(&sv(&[(1, 1.0)]), 10);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].doc_id, "overlap");
}
#[test]
fn upsert_replaces_postings_instead_of_duplicating() {
let mut idx = SparseInvertedIndex::new();
idx.insert("d", &sv(&[(1, 1.0)]));
idx.insert("d", &sv(&[(1, 2.0)]));
assert_eq!(idx.doc_count(), 1);
let hits = idx.search(&sv(&[(1, 1.0)]), 10);
assert_eq!(hits.len(), 1);
assert!((hits[0].score - 2.0).abs() < 1e-6);
}
#[test]
fn upsert_drops_dimensions_no_longer_present() {
let mut idx = SparseInvertedIndex::new();
idx.insert("d", &sv(&[(1, 1.0), (2, 1.0)]));
idx.insert("d", &sv(&[(1, 1.0)]));
assert!(idx.search(&sv(&[(2, 1.0)]), 10).is_empty());
}
#[test]
fn delete_removes_document() {
let mut idx = SparseInvertedIndex::new();
idx.insert("d", &sv(&[(1, 1.0)]));
assert!(idx.delete("d"));
assert!(!idx.delete("d"));
assert!(idx.search(&sv(&[(1, 1.0)]), 10).is_empty());
assert!(idx.is_empty());
}
#[test]
fn ties_broken_by_doc_id() {
let mut idx = SparseInvertedIndex::new();
idx.insert("b", &sv(&[(1, 1.0)]));
idx.insert("a", &sv(&[(1, 1.0)]));
idx.insert("c", &sv(&[(1, 1.0)]));
let hits = idx.search(&sv(&[(1, 1.0)]), 10);
let ids: Vec<&str> = hits.iter().map(|h| h.doc_id.as_str()).collect();
assert_eq!(ids, vec!["a", "b", "c"]);
}
#[test]
fn top_k_truncates() {
let mut idx = SparseInvertedIndex::new();
for i in 0..10u32 {
idx.insert(&format!("d{i}"), &sv(&[(1, i as f32 + 1.0)]));
}
assert_eq!(idx.search(&sv(&[(1, 1.0)]), 3).len(), 3);
assert!(idx.search(&sv(&[(1, 1.0)]), 0).is_empty());
}
#[test]
fn documents_round_trip_through_from_documents() {
let mut idx = SparseInvertedIndex::new();
idx.insert("a", &sv(&[(1, 0.5), (7, 0.25)]));
idx.insert("b", &sv(&[(7, 1.0)]));
let restored = SparseInvertedIndex::from_documents(idx.documents());
assert_eq!(restored.doc_count(), 2);
assert_eq!(
restored.search(&sv(&[(7, 1.0)]), 10),
idx.search(&sv(&[(7, 1.0)]), 10)
);
}
}