Skip to content

Commit b54c4ae

Browse files
committed
fix(vector): correct k-means++ d² sampling in IVF and PQ training
The previous initialization selected the farthest point deterministically rather than sampling proportionally to squared distance. Replace with proper weighted d² sampling using a deterministic xorshift RNG so centroid seeding is stable across runs and converges reliably for skewed distributions. Also derive MessagePack serialization for PqCodec so trained codecs survive checkpointing.
1 parent eecf3f4 commit b54c4ae

5 files changed

Lines changed: 238 additions & 42 deletions

File tree

nodedb-vector/src/hnsw/build.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ impl HnswIndex {
4747
// Phase 1: Greedy descent from top layer to new_layer + 1.
4848
if self.max_layer > new_layer {
4949
for layer in (new_layer + 1..=self.max_layer).rev() {
50-
let results = search_layer(self, &query, current_ep, 1, layer, None);
50+
let results = search_layer(self, &query, current_ep, 1, layer, None, 0);
5151
if let Some(nearest) = results.first() {
5252
current_ep = nearest.id;
5353
}
@@ -58,7 +58,7 @@ impl HnswIndex {
5858
let insert_top = new_layer.min(self.max_layer);
5959
for layer in (0..=insert_top).rev() {
6060
let ef = self.params.ef_construction;
61-
let candidates = search_layer(self, &query, current_ep, ef, layer, None);
61+
let candidates = search_layer(self, &query, current_ep, ef, layer, None, 0);
6262

6363
let m = self.max_neighbors(layer);
6464
let selected = select_neighbors_heuristic(self, &candidates, m);

nodedb-vector/src/hnsw/search.rs

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,14 @@ impl HnswIndex {
3131
// Phase 1: Greedy descent from top layer to layer 1.
3232
let mut current_ep = ep;
3333
for layer in (1..=self.max_layer).rev() {
34-
let results = search_layer(self, query, current_ep, 1, layer, None);
34+
let results = search_layer(self, query, current_ep, 1, layer, None, 0);
3535
if let Some(nearest) = results.first() {
3636
current_ep = nearest.id;
3737
}
3838
}
3939

4040
// Phase 2: Beam search at layer 0.
41-
let results = search_layer(self, query, current_ep, ef, 0, None);
41+
let results = search_layer(self, query, current_ep, ef, 0, None, 0);
4242

4343
results
4444
.into_iter()
@@ -51,16 +51,28 @@ impl HnswIndex {
5151
}
5252

5353
/// Filtered K-NN search with Roaring bitmap pre-filtering.
54-
///
55-
/// Only nodes whose ID is present in `filter` are included in results.
56-
/// All nodes are still used for graph navigation — this prevents accuracy
57-
/// degradation for selective filters.
5854
pub fn search_filtered(
5955
&self,
6056
query: &[f32],
6157
k: usize,
6258
ef: usize,
6359
filter: &RoaringBitmap,
60+
) -> Vec<SearchResult> {
61+
self.search_filtered_offset(query, k, ef, filter, 0)
62+
}
63+
64+
/// Filtered K-NN search where the bitmap is keyed in a shifted ID space.
65+
///
66+
/// `id_offset` is added to local node IDs before testing `filter.contains`.
67+
/// Used by multi-segment collections where the bitmap holds GLOBAL ids
68+
/// and each segment's HNSW nodes are numbered starting at `base_id`.
69+
pub fn search_filtered_offset(
70+
&self,
71+
query: &[f32],
72+
k: usize,
73+
ef: usize,
74+
filter: &RoaringBitmap,
75+
id_offset: u32,
6476
) -> Vec<SearchResult> {
6577
assert_eq!(query.len(), self.dim, "query dimension mismatch");
6678
if self.is_empty() {
@@ -74,13 +86,13 @@ impl HnswIndex {
7486

7587
let mut current_ep = ep;
7688
for layer in (1..=self.max_layer).rev() {
77-
let results = search_layer(self, query, current_ep, 1, layer, None);
89+
let results = search_layer(self, query, current_ep, 1, layer, None, 0);
7890
if let Some(nearest) = results.first() {
7991
current_ep = nearest.id;
8092
}
8193
}
8294

83-
let results = search_layer(self, query, current_ep, ef, 0, Some(filter));
95+
let results = search_layer(self, query, current_ep, ef, 0, Some(filter), id_offset);
8496

8597
results
8698
.into_iter()
@@ -99,9 +111,22 @@ impl HnswIndex {
99111
k: usize,
100112
ef: usize,
101113
bitmap_bytes: &[u8],
114+
) -> Vec<SearchResult> {
115+
self.search_with_bitmap_bytes_offset(query, k, ef, bitmap_bytes, 0)
116+
}
117+
118+
/// Deserialize a Roaring bitmap and search with an ID offset applied
119+
/// before testing membership. See `search_filtered_offset` for rationale.
120+
pub fn search_with_bitmap_bytes_offset(
121+
&self,
122+
query: &[f32],
123+
k: usize,
124+
ef: usize,
125+
bitmap_bytes: &[u8],
126+
id_offset: u32,
102127
) -> Vec<SearchResult> {
103128
match RoaringBitmap::deserialize_from(bitmap_bytes) {
104-
Ok(bitmap) => self.search_filtered(query, k, ef, &bitmap),
129+
Ok(bitmap) => self.search_filtered_offset(query, k, ef, &bitmap, id_offset),
105130
Err(_) => self.search(query, k, ef),
106131
}
107132
}
@@ -119,6 +144,7 @@ pub(crate) fn search_layer(
119144
ef: usize,
120145
layer: usize,
121146
filter: Option<&RoaringBitmap>,
147+
id_offset: u32,
122148
) -> Vec<Candidate> {
123149
let mut visited: HashSet<u32> = HashSet::new();
124150
visited.insert(entry_point);
@@ -139,7 +165,7 @@ pub(crate) fn search_layer(
139165
return false;
140166
}
141167
match filter {
142-
Some(f) => f.contains(id),
168+
Some(f) => f.contains(id + id_offset),
143169
None => true,
144170
}
145171
};

nodedb-vector/src/ivf.rs

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -220,21 +220,40 @@ fn kmeans_centroids(data: &[&[f32]], dim: usize, k: usize, max_iter: usize) -> V
220220
let mut centroids: Vec<Vec<f32>> = vec![data[0].to_vec()];
221221
let mut min_dists = vec![f32::MAX; n];
222222

223+
// Initialize min_dists against the first centroid.
224+
for (i, point) in data.iter().enumerate() {
225+
let d = distance(point, &centroids[0], DistanceMetric::L2);
226+
if d < min_dists[i] {
227+
min_dists[i] = d;
228+
}
229+
}
230+
231+
let mut rng = crate::hnsw::Xorshift64::new(0xC0FF_EEDE_ADBE_EF42);
223232
for _ in 1..k {
224-
let Some(last) = centroids.last() else { break };
233+
let total: f64 = min_dists.iter().map(|&d| d as f64).sum();
234+
let next_idx = if total < f64::EPSILON {
235+
0
236+
} else {
237+
let target = rng.next_f64() * total;
238+
let mut acc = 0.0f64;
239+
let mut chosen = n - 1;
240+
for (i, &d) in min_dists.iter().enumerate() {
241+
acc += d as f64;
242+
if acc >= target {
243+
chosen = i;
244+
break;
245+
}
246+
}
247+
chosen
248+
};
249+
centroids.push(data[next_idx].to_vec());
250+
let last = centroids.last().expect("just pushed");
225251
for (i, point) in data.iter().enumerate() {
226252
let d = distance(point, last, DistanceMetric::L2);
227253
if d < min_dists[i] {
228254
min_dists[i] = d;
229255
}
230256
}
231-
let best = min_dists
232-
.iter()
233-
.enumerate()
234-
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
235-
.map(|(i, _)| i)
236-
.unwrap_or(0);
237-
centroids.push(data[best].to_vec());
238257
}
239258

240259
let mut assignments = vec![0usize; n];

nodedb-vector/src/quantize/pq.rs

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
use serde::{Deserialize, Serialize};
1616

1717
/// PQ codec with trained codebooks.
18-
#[derive(Clone, Serialize, Deserialize)]
18+
#[derive(Clone, Serialize, Deserialize, zerompk::ToMessagePack, zerompk::FromMessagePack)]
1919
pub struct PqCodec {
2020
/// Original vector dimensionality.
2121
pub dim: usize,
@@ -161,43 +161,57 @@ fn l2_sub(a: &[f32], b: &[f32]) -> f32 {
161161

162162
/// Simple k-means clustering for PQ codebook training.
163163
///
164-
/// Uses k-means++ initialization for stable convergence.
164+
/// Uses proper k-means++ initialization (weighted d² sampling) with a
165+
/// deterministic seed so training is reproducible across runs.
165166
fn kmeans(data: &[&[f32]], dim: usize, k: usize, max_iter: usize) -> Vec<Vec<f32>> {
166167
let n = data.len();
167168
if n == 0 || k == 0 {
168169
return Vec::new();
169170
}
170171
let k = k.min(n); // Can't have more centroids than data points.
171172

172-
// K-means++ initialization.
173+
// K-means++ initialization with deterministic xorshift.
174+
let mut rng = crate::hnsw::Xorshift64::new(0xC0FF_EEDE_ADBE_EF42);
175+
173176
let mut centroids: Vec<Vec<f32>> = Vec::with_capacity(k);
174-
// First centroid: pick the first data point (deterministic).
175177
centroids.push(data[0].to_vec());
176178

177179
let mut min_dists = vec![f32::MAX; n];
178-
for c in 1..k {
179-
// Update min distances to nearest centroid.
180+
// Update against the first centroid.
181+
for (i, point) in data.iter().enumerate() {
182+
let d = l2_sub(point, &centroids[0]);
183+
if d < min_dists[i] {
184+
min_dists[i] = d;
185+
}
186+
}
187+
188+
for _ in 1..k {
189+
let total: f64 = min_dists.iter().map(|&d| d as f64).sum();
190+
let next_idx = if total < f64::EPSILON {
191+
// All points coincide with existing centroids.
192+
0
193+
} else {
194+
let target = rng.next_f64() * total;
195+
let mut acc = 0.0f64;
196+
let mut chosen = n - 1;
197+
for (i, &d) in min_dists.iter().enumerate() {
198+
acc += d as f64;
199+
if acc >= target {
200+
chosen = i;
201+
break;
202+
}
203+
}
204+
chosen
205+
};
206+
centroids.push(data[next_idx].to_vec());
207+
// Incrementally update min_dists against the new centroid.
208+
let last = centroids.last().expect("just pushed");
180209
for (i, point) in data.iter().enumerate() {
181-
let d = l2_sub(point, &centroids[c - 1]);
210+
let d = l2_sub(point, last);
182211
if d < min_dists[i] {
183212
min_dists[i] = d;
184213
}
185214
}
186-
// Pick next centroid proportional to d².
187-
let total: f64 = min_dists.iter().map(|&d| d as f64).sum();
188-
if total < f64::EPSILON {
189-
// All points coincide — duplicate the first centroid.
190-
centroids.push(data[0].to_vec());
191-
continue;
192-
}
193-
// Deterministic selection: pick the point with max min_dist.
194-
let best_idx = min_dists
195-
.iter()
196-
.enumerate()
197-
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Equal))
198-
.map(|(i, _)| i)
199-
.unwrap_or(0);
200-
centroids.push(data[best_idx].to_vec());
201215
}
202216

203217
// K-means iterations.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
//! PQ and IVF-PQ codebook training must distribute centroids across the
2+
//! data even when many input vectors are near-duplicates.
3+
//!
4+
//! Spec: k-means initialization selects centroids spread across the data
5+
//! distribution. The current implementation has two compounding bugs:
6+
//!
7+
//! 1. `min_dists[i]` is only updated against `centroids[c - 1]` (the
8+
//! last centroid), not against the full centroid set. Once two
9+
//! centroids coincide, `min_dists` stops reflecting "distance to the
10+
//! nearest centroid," so every subsequent deterministic-argmax pick
11+
//! lands on the same outlier.
12+
//! 2. The comment says "k-means++" but the selection is deterministic
13+
//! farthest-point, so outliers dominate rather than being sampled
14+
//! proportionally to d².
15+
//!
16+
//! Effect: on workloads with repeated prefixes/suffixes (templated chat,
17+
//! shared headers/footers), most of the 256 centroids alias to one or two
18+
//! points and PQ recall collapses.
19+
20+
use nodedb_vector::quantize::pq::PqCodec;
21+
22+
/// Training set of 200 vectors: 190 near-duplicates at the origin plus
23+
/// 10 outliers scattered across a single subspace. A correct k-means++
24+
/// spreads centroids across both clusters; the current farthest-point-
25+
/// with-broken-min-distance-update collapses to ~2 distinct centroids.
26+
fn clustered_with_duplicates() -> Vec<Vec<f32>> {
27+
let mut vecs: Vec<Vec<f32>> = Vec::with_capacity(200);
28+
// Cluster A: 190 near-identical vectors near origin.
29+
for i in 0..190 {
30+
let eps = (i as f32) * 1e-5;
31+
vecs.push(vec![eps, -eps, eps * 0.5, -eps * 0.5]);
32+
}
33+
// Cluster B: 10 outliers at distinct coordinates.
34+
for j in 0..10 {
35+
let x = 100.0 + (j as f32) * 10.0;
36+
vecs.push(vec![x, -x, x * 0.5, -x * 0.5]);
37+
}
38+
vecs
39+
}
40+
41+
fn unique_centroid_count(codec: &PqCodec, vectors: &[Vec<f32>]) -> usize {
42+
let refs: Vec<&[f32]> = vectors.iter().map(|v| v.as_slice()).collect();
43+
let codes = codec.encode_batch(&refs);
44+
let m = codec.m;
45+
// Per-subspace unique centroid indices used across the batch.
46+
let mut min_unique = usize::MAX;
47+
for sub in 0..m {
48+
let mut seen = std::collections::HashSet::new();
49+
for row in 0..vectors.len() {
50+
seen.insert(codes[row * m + sub]);
51+
}
52+
if seen.len() < min_unique {
53+
min_unique = seen.len();
54+
}
55+
}
56+
min_unique
57+
}
58+
59+
#[test]
60+
fn pq_kmeans_produces_diverse_centroids_on_duplicate_heavy_data() {
61+
let vecs = clustered_with_duplicates();
62+
let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
63+
let codec = PqCodec::train(&refs, 4, 2, 16, 20);
64+
65+
let unique = unique_centroid_count(&codec, &vecs);
66+
assert!(
67+
unique >= 4,
68+
"k-means collapsed to {unique} unique centroids per subspace on \
69+
duplicate-heavy input; a correct k-means++ should pick at least \
70+
4 distinct cluster representatives for k=16"
71+
);
72+
}
73+
74+
#[test]
75+
fn pq_distance_table_separates_duplicates_from_outliers() {
76+
// Spec test: after training, the PQ distance from a duplicate-cluster
77+
// query to a duplicate vector must be meaningfully smaller than the
78+
// distance to an outlier vector. Under the collapse bug, most
79+
// codebook entries alias to one point so all distances look similar.
80+
let vecs = clustered_with_duplicates();
81+
let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
82+
let codec = PqCodec::train(&refs, 4, 2, 16, 20);
83+
84+
let query = [0.0f32, 0.0, 0.0, 0.0];
85+
let table = codec.build_distance_table(&query);
86+
87+
let dup_code = codec.encode(&vecs[0]); // duplicate cluster
88+
let outlier_code = codec.encode(&vecs[195]); // outlier cluster
89+
90+
let dup_dist = codec.asymmetric_distance(&table, &dup_code);
91+
let outlier_dist = codec.asymmetric_distance(&table, &outlier_code);
92+
93+
assert!(
94+
outlier_dist > dup_dist * 10.0,
95+
"PQ failed to distinguish duplicate (d={dup_dist}) from outlier \
96+
(d={outlier_dist}) — codebook collapsed and the two codes encode \
97+
to near-identical table entries"
98+
);
99+
}
100+
101+
#[cfg(feature = "ivf")]
102+
#[test]
103+
fn ivf_pq_training_does_not_collapse_on_duplicate_heavy_data() {
104+
use nodedb_vector::DistanceMetric;
105+
use nodedb_vector::{IvfPqIndex, IvfPqParams};
106+
107+
let vecs = clustered_with_duplicates();
108+
let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
109+
let mut idx = IvfPqIndex::new(
110+
4,
111+
IvfPqParams {
112+
n_cells: 8,
113+
pq_m: 2,
114+
pq_k: 16,
115+
nprobe: 4,
116+
metric: DistanceMetric::L2,
117+
},
118+
);
119+
idx.train(&refs);
120+
for v in &vecs {
121+
idx.add(v);
122+
}
123+
124+
// Query at the origin. Correct training assigns near-duplicates to
125+
// one cell and outliers to another; the nearest result must come
126+
// from the duplicate cluster (original indices 0..190).
127+
let results = idx.search(&[0.0, 0.0, 0.0, 0.0], 5);
128+
assert!(!results.is_empty(), "IVF-PQ returned no results");
129+
for r in &results {
130+
assert!(
131+
r.id < 190,
132+
"IVF-PQ k-means collapse: query at origin returned outlier id={} \
133+
instead of a near-duplicate cluster member",
134+
r.id
135+
);
136+
}
137+
}

0 commit comments

Comments
 (0)