Skip to content

Commit 5376d5d

Browse files
committed
feat(vector): wire PQ codec training and two-phase reranking into collection
Add IndexConfig / IndexType to VectorCollection so PQ-configured collections train and store PQ codes when sealing a segment rather than always falling back to SQ8. Split quantizer training helpers into collection/quantize.rs to keep lifecycle.rs under the 500-line file cap. Extend the sealed-segment search path to use a unified quantized_search() function that handles both PQ and SQ8 with proper asymmetric scoring, widened candidate generation, and exact FP32 reranking. Report PQ quantization and index type correctly in collection stats.
1 parent bf67336 commit 5376d5d

5 files changed

Lines changed: 414 additions & 81 deletions

File tree

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;
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
//! Quantizer training helpers for `VectorCollection`.
2+
//!
3+
//! Split from `lifecycle.rs` to keep that file under the 500-line cap.
4+
//! All methods here are `impl VectorCollection` blocks — Rust allows a
5+
//! type's impl to be split across files.
6+
7+
use crate::hnsw::{HnswIndex, HnswParams};
8+
use crate::index_config::{IndexConfig, IndexType};
9+
use crate::quantize::pq::PqCodec;
10+
use crate::quantize::sq8::Sq8Codec;
11+
12+
use super::lifecycle::VectorCollection;
13+
use super::segment::DEFAULT_SEAL_THRESHOLD;
14+
15+
impl VectorCollection {
16+
/// Convenience constructor for PQ-configured collections.
17+
///
18+
/// Equivalent to building a full `IndexConfig` with
19+
/// `index_type = HnswPq` and the given `pq_m`.
20+
pub fn with_pq_config(dim: usize, hnsw: HnswParams, pq_m: usize) -> Self {
21+
let config = IndexConfig {
22+
hnsw,
23+
index_type: IndexType::HnswPq,
24+
pq_m,
25+
..IndexConfig::default()
26+
};
27+
Self::with_index_config(dim, config)
28+
}
29+
30+
/// Convenience constructor for PQ-configured collections with a custom
31+
/// seal threshold.
32+
pub fn with_seal_threshold_and_pq_config(
33+
dim: usize,
34+
hnsw: HnswParams,
35+
pq_m: usize,
36+
seal_threshold: usize,
37+
) -> Self {
38+
let config = IndexConfig {
39+
hnsw,
40+
index_type: IndexType::HnswPq,
41+
pq_m,
42+
..IndexConfig::default()
43+
};
44+
Self::with_seal_threshold_and_config(dim, config, seal_threshold)
45+
}
46+
47+
/// Build SQ8 quantized data for an HNSW index.
48+
///
49+
/// Returns `None` when there are too few live vectors for stable
50+
/// min/max calibration.
51+
pub fn build_sq8_for_index(index: &HnswIndex) -> Option<(Sq8Codec, Vec<u8>)> {
52+
if index.live_count() < 1000 {
53+
return None;
54+
}
55+
let dim = index.dim();
56+
let n = index.len();
57+
58+
let mut refs: Vec<&[f32]> = Vec::with_capacity(n);
59+
for i in 0..n {
60+
if !index.is_deleted(i as u32)
61+
&& let Some(v) = index.get_vector(i as u32)
62+
{
63+
refs.push(v);
64+
}
65+
}
66+
if refs.is_empty() {
67+
return None;
68+
}
69+
70+
let codec = Sq8Codec::calibrate(&refs, dim);
71+
72+
let mut data = Vec::with_capacity(dim * n);
73+
for i in 0..n {
74+
if let Some(v) = index.get_vector(i as u32) {
75+
data.extend(codec.quantize(v));
76+
} else {
77+
data.extend(vec![0u8; dim]);
78+
}
79+
}
80+
81+
Some((codec, data))
82+
}
83+
84+
/// Train a PQ codec from a built HNSW index's live vectors.
85+
pub fn build_pq_for_index(index: &HnswIndex, pq_m: usize) -> Option<(PqCodec, Vec<u8>)> {
86+
let dim = index.dim();
87+
if pq_m == 0 || !dim.is_multiple_of(pq_m) {
88+
return None;
89+
}
90+
let n = index.len();
91+
let mut refs: Vec<Vec<f32>> = Vec::with_capacity(n);
92+
for i in 0..n {
93+
if !index.is_deleted(i as u32)
94+
&& let Some(v) = index.get_vector(i as u32)
95+
{
96+
refs.push(v.to_vec());
97+
}
98+
}
99+
if refs.is_empty() {
100+
return None;
101+
}
102+
let refs_slices: Vec<&[f32]> = refs.iter().map(|v| v.as_slice()).collect();
103+
let k = 256usize.min(refs.len());
104+
let codec = PqCodec::train(&refs_slices, dim, pq_m, k, 20);
105+
let codes = codec.encode_batch(&refs_slices);
106+
Some((codec, codes))
107+
}
108+
}
109+
110+
// Keep the DEFAULT_SEAL_THRESHOLD import live when future refactors move
111+
// additional ctors into this file; explicitly referenced to suppress
112+
// an otherwise-unused warning.
113+
const _: usize = DEFAULT_SEAL_THRESHOLD;

0 commit comments

Comments
 (0)