nodedb_lite/engine/sparse_vector/
checkpoint.rs1use std::collections::HashMap;
27
28use nodedb_types::Namespace;
29use nodedb_types::error::{NodeDbError, NodeDbResult};
30
31use crate::storage::engine::{StorageEngine, WriteOp};
32
33use super::index::SparseInvertedIndex;
34
35const INDEX_LIST_KEY: &[u8] = b"sparse:_indices";
37
38type SerDocument = (String, Vec<(u32, f32)>);
40
41fn documents_key(index_key: &str) -> String {
43 format!("sparse:{index_key}:docs")
44}
45
46pub(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
83pub(crate) async fn write_serialized_sparse<S>(storage: &S, ops: Vec<WriteOp>) -> NodeDbResult<()>
85where
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
98pub(crate) async fn restore_sparse<S>(
106 storage: &S,
107) -> NodeDbResult<Option<HashMap<String, SparseInvertedIndex>>>
108where
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)]
151mod 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}