Skip to content

Commit 6b5b675

Browse files
committed
feat(vector): add sparse inverted-index engine
Introduce a per-collection sparse-vector inverted index (insert, search, delete) with checkpoint persistence and rebuild-on-restore, and wire it into document writes/deletes, bulk/transaction ingest, and SQL sparse search lowering.
1 parent 25c16cd commit 6b5b675

20 files changed

Lines changed: 1437 additions & 27 deletions

File tree

nodedb-lite/src/engine/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ pub mod fts;
66
pub mod graph;
77
pub mod htap;
88
pub mod index_integration;
9+
pub mod sparse_vector;
910
pub mod spatial;
1011
pub mod strict;
1112
pub mod timeseries;
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
//! Checkpoint serialization and restoration for [`SparseVectorManager`].
4+
//!
5+
//! Persists every sparse inverted index so a cold open loads them directly —
6+
//! no rebuild pass over source documents is required.
7+
//!
8+
//! ## Key layout under `Namespace::Vector`
9+
//!
10+
//! `nodedb_types::Namespace` has no sparse-specific variant, so the sparse
11+
//! index shares the vector engine's namespace behind a distinct `sparse:`
12+
//! key prefix that cannot collide with the dense HNSW keys.
13+
//!
14+
//! | Key | Value |
15+
//! |----------------------------|-------------------------------------------------------|
16+
//! | `sparse:_indices` | MessagePack `Vec<String>` — index key list |
17+
//! | `sparse:{index_key}:docs` | MessagePack `Vec<(String, Vec<(u32, f32)>)>` |
18+
//!
19+
//! Documents are stored as their own `(dimension, weight)` entries rather than
20+
//! as posting lists: the inverted index is a pure function of the document
21+
//! vectors, so storing the source form keeps the blob minimal and makes the
22+
//! restored index structurally identical to a freshly built one.
23+
//!
24+
//! [`SparseVectorManager`]: super::manager::SparseVectorManager
25+
26+
use std::collections::HashMap;
27+
28+
use nodedb_types::Namespace;
29+
use nodedb_types::error::{NodeDbError, NodeDbResult};
30+
31+
use crate::storage::engine::{StorageEngine, WriteOp};
32+
33+
use super::index::SparseInvertedIndex;
34+
35+
/// Key holding the MessagePack list of live sparse index keys.
36+
const INDEX_LIST_KEY: &[u8] = b"sparse:_indices";
37+
38+
/// A single document's sparse vector as persisted.
39+
type SerDocument = (String, Vec<(u32, f32)>);
40+
41+
/// Per-index documents key: `sparse:{index_key}:docs`.
42+
fn documents_key(index_key: &str) -> String {
43+
format!("sparse:{index_key}:docs")
44+
}
45+
46+
/// Serialize sparse index state into write ops.
47+
///
48+
/// Pure and synchronous, so it is safe to call while holding the manager's
49+
/// mutex guard; the caller performs the I/O after releasing the lock.
50+
///
51+
/// Empty indexes are still listed so that a checkpoint taken after every
52+
/// document was deleted restores as empty rather than resurrecting the
53+
/// previous checkpoint's contents.
54+
pub(crate) fn serialize_sparse(
55+
indices: &HashMap<String, SparseInvertedIndex>,
56+
) -> NodeDbResult<Vec<WriteOp>> {
57+
let mut ops: Vec<WriteOp> = Vec::with_capacity(indices.len() + 1);
58+
59+
let mut index_keys: Vec<String> = indices.keys().cloned().collect();
60+
index_keys.sort();
61+
let keys_bytes = zerompk::to_msgpack_vec(&index_keys)
62+
.map_err(|e| NodeDbError::serialization("msgpack", e))?;
63+
ops.push(WriteOp::Put {
64+
ns: Namespace::Vector,
65+
key: INDEX_LIST_KEY.to_vec(),
66+
value: keys_bytes,
67+
});
68+
69+
for (index_key, index) in indices {
70+
let documents: Vec<SerDocument> = index.documents();
71+
let bytes = zerompk::to_msgpack_vec(&documents)
72+
.map_err(|e| NodeDbError::serialization("msgpack", e))?;
73+
ops.push(WriteOp::Put {
74+
ns: Namespace::Vector,
75+
key: documents_key(index_key).into_bytes(),
76+
value: bytes,
77+
});
78+
}
79+
80+
Ok(ops)
81+
}
82+
83+
/// Write pre-serialized sparse index state to storage.
84+
pub(crate) async fn write_serialized_sparse<S>(storage: &S, ops: Vec<WriteOp>) -> NodeDbResult<()>
85+
where
86+
S: StorageEngine,
87+
{
88+
if ops.is_empty() {
89+
return Ok(());
90+
}
91+
storage
92+
.batch_write(&ops)
93+
.await
94+
.map_err(|e| NodeDbError::storage(format!("sparse checkpoint batch_write: {e}")))?;
95+
Ok(())
96+
}
97+
98+
/// Restore sparse index state from storage on cold open.
99+
///
100+
/// Returns `None` when no checkpoint has ever been written, which the caller
101+
/// distinguishes from a checkpoint that legitimately holds no documents — only
102+
/// the former warrants a rebuild from source documents. A corrupt blob for one
103+
/// index is logged and that index restored empty rather than failing the whole
104+
/// open; the remaining indexes stay usable.
105+
pub(crate) async fn restore_sparse<S>(
106+
storage: &S,
107+
) -> NodeDbResult<Option<HashMap<String, SparseInvertedIndex>>>
108+
where
109+
S: StorageEngine,
110+
{
111+
let Some(keys_bytes) = storage.get(Namespace::Vector, INDEX_LIST_KEY).await? else {
112+
return Ok(None);
113+
};
114+
let Ok(index_keys) = zerompk::from_msgpack::<Vec<String>>(&keys_bytes) else {
115+
tracing::warn!("sparse checkpoint: index list undecodable — starting fresh");
116+
return Ok(None);
117+
};
118+
119+
let mut indices: HashMap<String, SparseInvertedIndex> =
120+
HashMap::with_capacity(index_keys.len());
121+
122+
for index_key in &index_keys {
123+
let key = documents_key(index_key);
124+
let Some(bytes) = storage.get(Namespace::Vector, key.as_bytes()).await? else {
125+
indices.insert(index_key.clone(), SparseInvertedIndex::new());
126+
continue;
127+
};
128+
match zerompk::from_msgpack::<Vec<SerDocument>>(&bytes) {
129+
Ok(documents) => {
130+
indices.insert(
131+
index_key.clone(),
132+
SparseInvertedIndex::from_documents(documents),
133+
);
134+
}
135+
Err(e) => {
136+
tracing::warn!(
137+
index_key,
138+
error = %e,
139+
"sparse checkpoint: document blob undecodable — index restored empty"
140+
);
141+
indices.insert(index_key.clone(), SparseInvertedIndex::new());
142+
}
143+
}
144+
}
145+
146+
tracing::debug!(index_count = indices.len(), "sparse checkpoint restored");
147+
Ok(Some(indices))
148+
}
149+
150+
#[cfg(test)]
151+
mod tests {
152+
use nodedb_types::SparseVector;
153+
154+
use super::*;
155+
use crate::PagedbStorageMem;
156+
157+
fn sv(entries: &[(u32, f32)]) -> SparseVector {
158+
SparseVector::from_entries(entries.to_vec()).expect("valid sparse vector")
159+
}
160+
161+
#[tokio::test]
162+
async fn round_trip_through_storage() {
163+
let storage = PagedbStorageMem::open_in_memory()
164+
.await
165+
.expect("in-memory pagedb");
166+
167+
let mut index = SparseInvertedIndex::new();
168+
index.insert("d1", &sv(&[(1, 0.5), (9, 0.25)]));
169+
index.insert("d2", &sv(&[(9, 1.0)]));
170+
171+
let mut indices = HashMap::new();
172+
indices.insert("docs:emb".to_string(), index);
173+
174+
let ops = serialize_sparse(&indices).expect("serialize");
175+
write_serialized_sparse(&storage, ops).await.expect("write");
176+
177+
let restored = restore_sparse(&storage)
178+
.await
179+
.expect("restore")
180+
.expect("checkpoint present");
181+
let restored_index = restored.get("docs:emb").expect("index restored");
182+
assert_eq!(restored_index.doc_count(), 2);
183+
184+
let hits = restored_index.search(&sv(&[(9, 1.0)]), 10);
185+
assert_eq!(hits.len(), 2);
186+
assert_eq!(hits[0].doc_id, "d2");
187+
}
188+
189+
#[tokio::test]
190+
async fn absent_checkpoint_reports_none() {
191+
let storage = PagedbStorageMem::open_in_memory()
192+
.await
193+
.expect("in-memory pagedb");
194+
let restored = restore_sparse(&storage).await.expect("restore");
195+
assert!(restored.is_none());
196+
}
197+
}

0 commit comments

Comments
 (0)