Skip to content

Commit 4d18c03

Browse files
authored
Merge pull request #65 from NodeDB-Lab/bug-to-coverage/issue-50-vector
fix(vector): resolve 7 correctness, memory-safety, and quantization bugs (#50)
2 parents aa5f1ee + 400f645 commit 4d18c03

28 files changed

Lines changed: 1801 additions & 641 deletions

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/lifecycle.rs

Lines changed: 98 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
use crate::flat::FlatIndex;
44
use crate::hnsw::{HnswIndex, HnswParams};
5-
use crate::quantize::sq8::Sq8Codec;
5+
use crate::index_config::{IndexConfig, IndexType};
66

77
use super::segment::{BuildRequest, BuildingSegment, DEFAULT_SEAL_THRESHOLD, SealedSegment};
88

@@ -40,6 +40,8 @@ pub struct VectorCollection {
4040
pub multi_doc_map: std::collections::HashMap<String, Vec<u32>>,
4141
/// Number of vectors in the growing segment before sealing.
4242
pub(crate) seal_threshold: usize,
43+
/// Full index configuration (index type, PQ params, IVF params).
44+
pub(crate) index_config: IndexConfig,
4345
}
4446

4547
impl VectorCollection {
@@ -50,6 +52,25 @@ impl VectorCollection {
5052

5153
/// Create an empty collection with an explicit seal threshold.
5254
pub fn with_seal_threshold(dim: usize, params: HnswParams, seal_threshold: usize) -> Self {
55+
let index_config = IndexConfig {
56+
hnsw: params.clone(),
57+
..IndexConfig::default()
58+
};
59+
Self::with_seal_threshold_and_config(dim, index_config, seal_threshold)
60+
}
61+
62+
/// Create an empty collection with a full index configuration.
63+
pub fn with_index_config(dim: usize, config: IndexConfig) -> Self {
64+
Self::with_seal_threshold_and_config(dim, config, DEFAULT_SEAL_THRESHOLD)
65+
}
66+
67+
/// Create an empty collection with a full index config and custom seal threshold.
68+
pub fn with_seal_threshold_and_config(
69+
dim: usize,
70+
config: IndexConfig,
71+
seal_threshold: usize,
72+
) -> Self {
73+
let params = config.hnsw.clone();
5374
Self {
5475
growing: FlatIndex::new(dim, params.metric),
5576
growing_base_id: 0,
@@ -66,6 +87,7 @@ impl VectorCollection {
6687
doc_id_map: std::collections::HashMap::new(),
6788
multi_doc_map: std::collections::HashMap::new(),
6889
seal_threshold,
90+
index_config: config,
6991
}
7092
}
7193

@@ -213,53 +235,28 @@ impl VectorCollection {
213235
.position(|b| b.segment_id == segment_id)
214236
{
215237
let building = self.building.remove(pos);
216-
let sq8 = Self::build_sq8_for_index(&index);
238+
let use_pq = self.index_config.index_type == IndexType::HnswPq;
239+
let (sq8, pq) = if use_pq {
240+
(
241+
None,
242+
Self::build_pq_for_index(&index, self.index_config.pq_m),
243+
)
244+
} else {
245+
(Self::build_sq8_for_index(&index), None)
246+
};
217247
let (tier, mmap_vectors) = self.resolve_tier_for_build(segment_id, &index);
218248

219249
self.sealed.push(SealedSegment {
220250
index,
221251
base_id: building.base_id,
222252
sq8,
253+
pq,
223254
tier,
224255
mmap_vectors,
225256
});
226257
}
227258
}
228259

229-
/// Build SQ8 quantized data for an HNSW index.
230-
pub fn build_sq8_for_index(index: &HnswIndex) -> Option<(Sq8Codec, Vec<u8>)> {
231-
if index.live_count() < 1000 {
232-
return None;
233-
}
234-
let dim = index.dim();
235-
let n = index.len();
236-
237-
let mut refs: Vec<&[f32]> = Vec::with_capacity(n);
238-
for i in 0..n {
239-
if !index.is_deleted(i as u32)
240-
&& let Some(v) = index.get_vector(i as u32)
241-
{
242-
refs.push(v);
243-
}
244-
}
245-
if refs.is_empty() {
246-
return None;
247-
}
248-
249-
let codec = Sq8Codec::calibrate(&refs, dim);
250-
251-
let mut data = Vec::with_capacity(dim * n);
252-
for i in 0..n {
253-
if let Some(v) = index.get_vector(i as u32) {
254-
data.extend(codec.quantize(v));
255-
} else {
256-
data.extend(vec![0u8; dim]);
257-
}
258-
}
259-
260-
Some((codec, data))
261-
}
262-
263260
/// Access sealed segments (read-only).
264261
pub fn sealed_segments(&self) -> &[SealedSegment] {
265262
&self.sealed
@@ -276,10 +273,64 @@ impl VectorCollection {
276273
}
277274

278275
/// Compact sealed segments by removing tombstoned nodes.
276+
///
277+
/// Rewrites `doc_id_map` and `multi_doc_map` for every sealed segment
278+
/// so that global ids continue to resolve to the correct document
279+
/// strings after local-id renumbering.
279280
pub fn compact(&mut self) -> usize {
280281
let mut total_removed = 0;
281282
for seg in &mut self.sealed {
282-
total_removed += seg.index.compact();
283+
let base_id = seg.base_id;
284+
let (removed, id_map) = seg.index.compact_with_map();
285+
total_removed += removed;
286+
if removed == 0 {
287+
continue;
288+
}
289+
290+
// Rebuild doc_id_map for entries in [base_id, base_id + id_map.len()).
291+
let segment_end = base_id as u64 + id_map.len() as u64;
292+
let doc_keys: Vec<u32> = self
293+
.doc_id_map
294+
.keys()
295+
.copied()
296+
.filter(|&k| (k as u64) >= base_id as u64 && (k as u64) < segment_end)
297+
.collect();
298+
// Two-phase: remove all old entries first, then insert new ones so
299+
// we don't clobber a freshly-remapped entry with a later tombstone
300+
// removal.
301+
let mut new_entries: Vec<(u32, String)> = Vec::with_capacity(doc_keys.len());
302+
for old_global in &doc_keys {
303+
let doc = self.doc_id_map.remove(old_global);
304+
let old_local = (old_global - base_id) as usize;
305+
let new_local = id_map[old_local];
306+
if new_local != u32::MAX
307+
&& let Some(doc) = doc
308+
{
309+
new_entries.push((base_id + new_local, doc));
310+
}
311+
}
312+
for (k, v) in new_entries {
313+
self.doc_id_map.insert(k, v);
314+
}
315+
316+
// Rewrite multi_doc_map entries for this segment.
317+
for ids in self.multi_doc_map.values_mut() {
318+
ids.retain_mut(|vid| {
319+
let v = *vid;
320+
if (v as u64) >= base_id as u64 && (v as u64) < segment_end {
321+
let old_local = (v - base_id) as usize;
322+
let new_local = id_map[old_local];
323+
if new_local == u32::MAX {
324+
false
325+
} else {
326+
*vid = base_id + new_local;
327+
true
328+
}
329+
} else {
330+
true
331+
}
332+
});
333+
}
283334
}
284335
total_removed
285336
}
@@ -382,12 +433,20 @@ impl VectorCollection {
382433
0.0
383434
};
384435

385-
let quantization = if self.sealed.iter().any(|s| s.sq8.is_some()) {
436+
let quantization = if self.sealed.iter().any(|s| s.pq.is_some()) {
437+
nodedb_types::VectorIndexQuantization::Pq
438+
} else if self.sealed.iter().any(|s| s.sq8.is_some()) {
386439
nodedb_types::VectorIndexQuantization::Sq8
387440
} else {
388441
nodedb_types::VectorIndexQuantization::None
389442
};
390443

444+
let index_type = match self.index_config.index_type {
445+
IndexType::HnswPq => nodedb_types::VectorIndexType::HnswPq,
446+
IndexType::IvfPq => nodedb_types::VectorIndexType::IvfPq,
447+
IndexType::Hnsw => nodedb_types::VectorIndexType::Hnsw,
448+
};
449+
391450
let hnsw_mem: usize = self
392451
.sealed
393452
.iter()
@@ -422,7 +481,7 @@ impl VectorCollection {
422481
memory_bytes,
423482
disk_bytes,
424483
build_in_progress: !self.building.is_empty(),
425-
index_type: nodedb_types::VectorIndexType::Hnsw,
484+
index_type,
426485
hnsw_m: self.params.m,
427486
hnsw_m0: self.params.m0,
428487
hnsw_ef_construction: self.params.ef_construction,

nodedb-vector/src/collection/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
pub mod budget;
22
pub mod checkpoint;
33
pub mod lifecycle;
4+
pub mod quantize;
45
pub mod search;
56
pub mod segment;
67
pub mod tier;

0 commit comments

Comments
 (0)