Skip to content

Commit bf67336

Browse files
committed
fix(vector): preserve tombstones through checkpoint and compact doc_id_map
Checkpoint serialization previously skipped deleted vectors entirely, so on restore those local ids were missing and all subsequent ids shifted by one — corrupting the HNSW graph's neighbor adjacency. Fix by capturing the deleted flag for every vector (growing and building segments) and replaying tombstones via insert_tombstoned / index.delete() on restore. Separately, compact() previously discarded doc_id_map and multi_doc_map entries for the segment being compacted. Use compact_with_map() to obtain the old→new local id remapping and rewrite both maps so that global ids continue to resolve to the correct document strings after compaction.
1 parent bf238c7 commit bf67336

4 files changed

Lines changed: 281 additions & 9 deletions

File tree

nodedb-vector/src/collection/checkpoint.rs

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use crate::collection::tier::StorageTier;
77
use crate::distance::DistanceMetric;
88
use crate::flat::FlatIndex;
99
use crate::hnsw::{HnswIndex, HnswParams};
10+
use crate::quantize::pq::PqCodec;
1011

1112
use super::lifecycle::VectorCollection;
1213

@@ -33,12 +34,18 @@ pub(crate) struct CollectionSnapshot {
3334
pub(crate) struct SealedSnapshot {
3435
pub base_id: u32,
3536
pub hnsw_bytes: Vec<u8>,
37+
#[serde(default)]
38+
pub pq_bytes: Option<Vec<u8>>,
39+
#[serde(default)]
40+
pub pq_codes: Option<Vec<u8>>,
3641
}
3742

3843
#[derive(Serialize, Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack)]
3944
pub(crate) struct BuildingSnapshot {
4045
pub base_id: u32,
4146
pub vectors: Vec<Vec<f32>>,
47+
#[serde(default)]
48+
pub deleted: Vec<bool>,
4249
}
4350

4451
impl VectorCollection {
@@ -53,17 +60,27 @@ impl VectorCollection {
5360
next_id: self.next_id,
5461
growing_base_id: self.growing_base_id,
5562
growing_vectors: (0..self.growing.len() as u32)
56-
.filter_map(|i| self.growing.get_vector(i).map(|v| v.to_vec()))
63+
.filter_map(|i| self.growing.get_vector_raw(i).map(|v| v.to_vec()))
5764
.collect(),
5865
growing_deleted: (0..self.growing.len() as u32)
59-
.map(|i| self.growing.get_vector(i).is_none())
66+
.map(|i| self.growing.is_deleted(i))
6067
.collect(),
6168
sealed_segments: self
6269
.sealed
6370
.iter()
64-
.map(|s| SealedSnapshot {
65-
base_id: s.base_id,
66-
hnsw_bytes: s.index.checkpoint_to_bytes(),
71+
.map(|s| {
72+
let (pq_bytes, pq_codes) = match &s.pq {
73+
Some((codec, codes)) => {
74+
(zerompk::to_msgpack_vec(codec).ok(), Some(codes.clone()))
75+
}
76+
None => (None, None),
77+
};
78+
SealedSnapshot {
79+
base_id: s.base_id,
80+
hnsw_bytes: s.index.checkpoint_to_bytes(),
81+
pq_bytes,
82+
pq_codes,
83+
}
6784
})
6885
.collect(),
6986
building_segments: self
@@ -72,7 +89,10 @@ impl VectorCollection {
7289
.map(|b| BuildingSnapshot {
7390
base_id: b.base_id,
7491
vectors: (0..b.flat.len() as u32)
75-
.filter_map(|i| b.flat.get_vector(i).map(|v| v.to_vec()))
92+
.filter_map(|i| b.flat.get_vector_raw(i).map(|v| v.to_vec()))
93+
.collect(),
94+
deleted: (0..b.flat.len() as u32)
95+
.map(|i| b.flat.is_deleted(i))
7696
.collect(),
7797
})
7898
.collect(),
@@ -118,18 +138,35 @@ impl VectorCollection {
118138
};
119139

120140
let mut growing = FlatIndex::new(snap.dim, metric);
121-
for v in &snap.growing_vectors {
122-
growing.insert(v.clone());
141+
for (i, v) in snap.growing_vectors.iter().enumerate() {
142+
let deleted = snap.growing_deleted.get(i).copied().unwrap_or(false);
143+
if deleted {
144+
growing.insert_tombstoned(v.clone());
145+
} else {
146+
growing.insert(v.clone());
147+
}
123148
}
124149

125150
let mut sealed = Vec::with_capacity(snap.sealed_segments.len());
126151
for ss in &snap.sealed_segments {
127152
if let Some(index) = HnswIndex::from_checkpoint(&ss.hnsw_bytes) {
128-
let sq8 = VectorCollection::build_sq8_for_index(&index);
153+
let pq = match (&ss.pq_bytes, &ss.pq_codes) {
154+
(Some(bytes), Some(codes)) => zerompk::from_msgpack::<PqCodec>(bytes)
155+
.ok()
156+
.map(|codec| (codec, codes.clone())),
157+
_ => None,
158+
};
159+
// Only train SQ8 when PQ isn't present — a segment never carries both.
160+
let sq8 = if pq.is_some() {
161+
None
162+
} else {
163+
VectorCollection::build_sq8_for_index(&index)
164+
};
129165
sealed.push(SealedSegment {
130166
index,
131167
base_id: ss.base_id,
132168
sq8,
169+
pq,
133170
tier: StorageTier::L0Ram,
134171
mmap_vectors: None,
135172
});
@@ -143,18 +180,29 @@ impl VectorCollection {
143180
.insert(v.clone())
144181
.expect("dimension guaranteed by checkpoint");
145182
}
183+
// Replay building-segment tombstones onto the HNSW index.
184+
for (i, &dead) in bs.deleted.iter().enumerate() {
185+
if dead {
186+
index.delete(i as u32);
187+
}
188+
}
146189
let sq8 = VectorCollection::build_sq8_for_index(&index);
147190
sealed.push(SealedSegment {
148191
index,
149192
base_id: bs.base_id,
150193
sq8,
194+
pq: None,
151195
tier: StorageTier::L0Ram,
152196
mmap_vectors: None,
153197
});
154198
}
155199

156200
let next_segment_id = (sealed.len() + 1) as u32;
157201

202+
let index_config = crate::index_config::IndexConfig {
203+
hnsw: params.clone(),
204+
..crate::index_config::IndexConfig::default()
205+
};
158206
Some(Self {
159207
growing,
160208
growing_base_id: snap.growing_base_id,
@@ -171,6 +219,7 @@ impl VectorCollection {
171219
doc_id_map: snap.doc_id_map.into_iter().collect(),
172220
multi_doc_map: snap.multi_doc_map.into_iter().collect(),
173221
seal_threshold: DEFAULT_SEAL_THRESHOLD,
222+
index_config,
174223
})
175224
}
176225
}

nodedb-vector/src/collection/segment.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use crate::collection::tier::StorageTier;
44
use crate::flat::FlatIndex;
55
use crate::hnsw::{HnswIndex, HnswParams};
66
use crate::mmap_segment::MmapVectorSegment;
7+
use crate::quantize::pq::PqCodec;
78
use crate::quantize::sq8::Sq8Codec;
89

910
/// Default threshold for sealing the growing segment.
@@ -44,6 +45,8 @@ pub struct SealedSegment {
4445
pub base_id: u32,
4546
/// Optional SQ8 quantized vectors for accelerated traversal.
4647
pub sq8: Option<(Sq8Codec, Vec<u8>)>,
48+
/// Optional PQ-compressed codes (for HnswPq-configured indexes).
49+
pub pq: Option<(PqCodec, Vec<u8>)>,
4750
/// Storage tier: L0Ram = FP32 in HNSW nodes, L1Nvme = FP32 in mmap file.
4851
pub tier: StorageTier,
4952
/// mmap-backed vector segment for L1 NVMe tier.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
//! Soft-deletes in growing / building segments must survive checkpoint restore.
2+
//!
3+
//! Spec: `delete(id)` on a vector in the growing segment (or a building
4+
//! segment awaiting HNSW completion) tombstones the vector. Checkpoints
5+
//! MUST serialize that tombstone, and `from_checkpoint` MUST apply it so
6+
//! the restored collection reports the same `live_count()` and excludes
7+
//! the deleted vector from `search()` results.
8+
//!
9+
//! Today:
10+
//! - `FlatIndex::get_vector` returns `Some(..)` even for tombstoned
11+
//! slots, so `growing_deleted` is serialized as all-false.
12+
//! - `from_checkpoint` ignores the `growing_deleted` field entirely and
13+
//! re-inserts every vector as live.
14+
//!
15+
//! Result: crash recovery silently resurrects soft-deleted rows — a
16+
//! correctness regression for any workflow using `valid_until` deletes.
17+
18+
#![cfg(feature = "collection")]
19+
20+
use nodedb_vector::DistanceMetric;
21+
use nodedb_vector::collection::VectorCollection;
22+
use nodedb_vector::hnsw::HnswParams;
23+
24+
fn params() -> HnswParams {
25+
HnswParams {
26+
metric: DistanceMetric::L2,
27+
..HnswParams::default()
28+
}
29+
}
30+
31+
#[test]
32+
fn growing_segment_tombstones_survive_checkpoint_roundtrip() {
33+
let mut coll = VectorCollection::new(2, params());
34+
for i in 0..10u32 {
35+
coll.insert(vec![i as f32, 0.0]);
36+
}
37+
assert!(coll.delete(3), "delete on live growing vector must succeed");
38+
assert!(coll.delete(7), "delete on live growing vector must succeed");
39+
let live_before = coll.live_count();
40+
assert_eq!(live_before, 8);
41+
42+
let bytes = coll.checkpoint_to_bytes();
43+
let restored = VectorCollection::from_checkpoint(&bytes).expect("checkpoint deserializes");
44+
45+
assert_eq!(
46+
restored.live_count(),
47+
live_before,
48+
"tombstoned growing-segment vectors resurrected on restore"
49+
);
50+
51+
let results = restored.search(&[3.0, 0.0], 10, 64);
52+
let ids: std::collections::HashSet<u32> = results.iter().map(|r| r.id).collect();
53+
assert!(
54+
!ids.contains(&3),
55+
"soft-deleted id=3 reappeared in search after restore"
56+
);
57+
assert!(
58+
!ids.contains(&7),
59+
"soft-deleted id=7 reappeared in search after restore"
60+
);
61+
}
62+
63+
#[test]
64+
fn building_segment_tombstones_survive_checkpoint_roundtrip() {
65+
// Force a seal so the deleted rows live in a building segment at
66+
// snapshot time, exercising the `building_segments` encode path.
67+
let mut coll = VectorCollection::with_seal_threshold(2, params(), 20);
68+
for i in 0..20u32 {
69+
coll.insert(vec![i as f32, 0.0]);
70+
}
71+
let _req = coll.seal("k").expect("seal produced request");
72+
// Intentionally do NOT complete the build — vectors now sit in the
73+
// building segment as a FlatIndex.
74+
assert!(coll.delete(5), "delete on building vector must succeed");
75+
assert!(coll.delete(15), "delete on building vector must succeed");
76+
let live_before = coll.live_count();
77+
assert_eq!(live_before, 18);
78+
79+
let bytes = coll.checkpoint_to_bytes();
80+
let restored = VectorCollection::from_checkpoint(&bytes).expect("checkpoint deserializes");
81+
82+
assert_eq!(
83+
restored.live_count(),
84+
live_before,
85+
"tombstoned building-segment vectors resurrected on restore"
86+
);
87+
88+
let results = restored.search(&[5.0, 0.0], 20, 64);
89+
let ids: std::collections::HashSet<u32> = results.iter().map(|r| r.id).collect();
90+
assert!(
91+
!ids.contains(&5),
92+
"soft-deleted id=5 reappeared after restore"
93+
);
94+
assert!(
95+
!ids.contains(&15),
96+
"soft-deleted id=15 reappeared after restore"
97+
);
98+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
//! `compact()` must keep `doc_id_map` / `multi_doc_map` consistent with the
2+
//! renumbered HNSW local IDs.
3+
//!
4+
//! Spec: `HnswIndex::compact()` removes tombstoned nodes and renumbers
5+
//! surviving local node ids. The collection stores `doc_id_map` and
6+
//! `multi_doc_map` keyed on GLOBAL ids (`seg.base_id + local`). After
7+
//! compaction those globals shift too — the collection MUST walk both
8+
//! maps and rewrite every entry for the compacted segment to the new
9+
//! `(seg.base_id + new_local)` globals. Without the rewrite,
10+
//! `get_doc_id(vid)` and `delete_multi_vector(doc)` point at stale or
11+
//! wrong vectors.
12+
13+
#![cfg(feature = "collection")]
14+
15+
use nodedb_vector::DistanceMetric;
16+
use nodedb_vector::collection::VectorCollection;
17+
use nodedb_vector::hnsw::{HnswIndex, HnswParams};
18+
19+
fn params() -> HnswParams {
20+
HnswParams {
21+
metric: DistanceMetric::L2,
22+
..HnswParams::default()
23+
}
24+
}
25+
26+
fn build_collection_with_docs() -> VectorCollection {
27+
let mut coll = VectorCollection::with_seal_threshold(2, params(), 6);
28+
// Six docs, one vector each. Global ids 0..6.
29+
for i in 0..6u32 {
30+
coll.insert_with_doc_id(vec![i as f32, 0.0], format!("doc_{i}"));
31+
}
32+
// Seal + complete → sealed segment with base_id=0, local ids 0..6.
33+
let req = coll.seal("k").expect("seal produced request");
34+
let mut idx = HnswIndex::new(req.dim, req.params.clone());
35+
for v in &req.vectors {
36+
idx.insert(v.clone()).unwrap();
37+
}
38+
coll.complete_build(req.segment_id, idx);
39+
coll
40+
}
41+
42+
#[test]
43+
fn doc_id_map_stays_correct_after_compact() {
44+
let mut coll = build_collection_with_docs();
45+
46+
// Tombstone two vectors in the middle of the sealed segment.
47+
assert!(coll.delete(1));
48+
assert!(coll.delete(3));
49+
50+
// Sanity: pre-compact, the surviving doc mapping still resolves.
51+
assert_eq!(coll.get_doc_id(0), Some("doc_0"));
52+
assert_eq!(coll.get_doc_id(5), Some("doc_5"));
53+
54+
let removed = coll.compact();
55+
assert_eq!(removed, 2, "compact should remove 2 tombstoned nodes");
56+
57+
// Spec: the search results (identified by renumbered global ids) still
58+
// resolve to the original doc strings. For the surviving vectors
59+
// {0, 2, 4, 5} post-compact globals become {0, 1, 2, 3}. `get_doc_id`
60+
// MUST map those new globals to "doc_0", "doc_2", "doc_4", "doc_5".
61+
let results = coll.search(&[0.0, 0.0], 4, 64);
62+
let ids: Vec<u32> = results.iter().map(|r| r.id).collect();
63+
assert_eq!(ids.len(), 4, "expected 4 live vectors post-compact");
64+
65+
let observed_docs: std::collections::HashSet<String> = ids
66+
.iter()
67+
.filter_map(|id| coll.get_doc_id(*id).map(|s| s.to_string()))
68+
.collect();
69+
let expected_docs: std::collections::HashSet<String> = ["doc_0", "doc_2", "doc_4", "doc_5"]
70+
.into_iter()
71+
.map(String::from)
72+
.collect();
73+
74+
assert_eq!(
75+
observed_docs, expected_docs,
76+
"doc_id_map was not rewritten after compact — globals shifted but the map did not"
77+
);
78+
}
79+
80+
#[test]
81+
fn multi_doc_map_stays_correct_after_compact() {
82+
let mut coll = VectorCollection::with_seal_threshold(2, params(), 6);
83+
84+
// Two multi-vector docs: doc_a owns globals 0,1,2; doc_b owns 3,4,5.
85+
let a_vecs: Vec<Vec<f32>> = (0..3u32).map(|i| vec![i as f32, 0.0]).collect();
86+
let a_refs: Vec<&[f32]> = a_vecs.iter().map(|v| v.as_slice()).collect();
87+
let a_ids = coll.insert_multi_vector(&a_refs, "doc_a".to_string());
88+
assert_eq!(a_ids, vec![0, 1, 2]);
89+
90+
let b_vecs: Vec<Vec<f32>> = (3..6u32).map(|i| vec![i as f32, 0.0]).collect();
91+
let b_refs: Vec<&[f32]> = b_vecs.iter().map(|v| v.as_slice()).collect();
92+
let b_ids = coll.insert_multi_vector(&b_refs, "doc_b".to_string());
93+
assert_eq!(b_ids, vec![3, 4, 5]);
94+
95+
let req = coll.seal("k").expect("seal produced request");
96+
let mut idx = HnswIndex::new(req.dim, req.params.clone());
97+
for v in &req.vectors {
98+
idx.insert(v.clone()).unwrap();
99+
}
100+
coll.complete_build(req.segment_id, idx);
101+
102+
// Tombstone one vector from each doc (middle of each group).
103+
assert!(coll.delete(1));
104+
assert!(coll.delete(4));
105+
106+
coll.compact();
107+
108+
// Spec: `delete_multi_vector("doc_a")` must reach the two remaining
109+
// vectors that originally belonged to doc_a, regardless of the local
110+
// id renumbering performed by HnswIndex::compact.
111+
let deleted_a = coll.delete_multi_vector("doc_a");
112+
assert_eq!(
113+
deleted_a, 2,
114+
"delete_multi_vector(doc_a) must find its 2 remaining vectors after compact"
115+
);
116+
117+
let live_after = coll.live_count();
118+
assert_eq!(
119+
live_after, 2,
120+
"post-compact + doc_a delete: only doc_b's 2 remaining vectors survive"
121+
);
122+
}

0 commit comments

Comments
 (0)