1use crate::pq::ProductQuantizer;
8use crate::cosine_similarity_prenorm;
9
10pub struct IVFIndex {
12 pub centroids: Vec<f32>,
14 pub num_clusters: usize,
16 pub dim: usize,
18 pub inverted_lists: Vec<Vec<usize>>,
20}
21
22impl IVFIndex {
23 pub fn train(vectors: &[Vec<f32>], dim: usize, num_clusters: usize) -> Self {
25 let n = vectors.len();
26 let actual_clusters = num_clusters.min(n);
27
28 let mut centroids = vec![0.0f32; actual_clusters * dim];
30 let step = if n > actual_clusters { n / actual_clusters } else { 1 };
31 for c in 0..actual_clusters {
32 let src_idx = (c * step) % n;
33 let dst = &mut centroids[c * dim..(c + 1) * dim];
34 dst.copy_from_slice(&vectors[src_idx]);
35 }
36
37 let mut assignments = vec![0usize; n];
38 let max_iters = 15;
39
40 for _ in 0..max_iters {
41 let mut changed = false;
43 for (i, vec) in vectors.iter().enumerate() {
44 let best = nearest_centroid(vec, ¢roids, actual_clusters, dim);
45 if assignments[i] != best {
46 assignments[i] = best;
47 changed = true;
48 }
49 }
50 if !changed {
51 break;
52 }
53
54 let mut counts = vec![0u32; actual_clusters];
56 centroids.fill(0.0);
57 for (i, vec) in vectors.iter().enumerate() {
58 let c = assignments[i];
59 counts[c] += 1;
60 let offset = c * dim;
61 for d in 0..dim {
62 centroids[offset + d] += vec[d];
63 }
64 }
65 for (c, &count) in counts.iter().enumerate().take(actual_clusters) {
66 if count > 0 {
67 let offset = c * dim;
68 let cnt = count as f32;
69 for d in 0..dim {
70 centroids[offset + d] /= cnt;
71 }
72 }
73 }
74 }
75
76 let mut inverted_lists = vec![Vec::new(); actual_clusters];
78 for (i, &c) in assignments.iter().enumerate() {
79 inverted_lists[c].push(i);
80 }
81
82 Self {
83 centroids,
84 num_clusters: actual_clusters,
85 dim,
86 inverted_lists,
87 }
88 }
89
90 pub fn assign(&self, vector: &[f32]) -> usize {
92 nearest_centroid(vector, &self.centroids, self.num_clusters, self.dim)
93 }
94
95 pub fn search(
98 &self,
99 query: &[f32],
100 vectors: &[Vec<f32>],
101 norms: &[f32],
102 tombstones: &[u8],
103 nprobe: usize,
104 k: usize,
105 ) -> Vec<(usize, f32)> {
106 let probe_clusters = self.nearest_clusters(query, nprobe);
107 let query_norm = rustyhdf5_accel::vector_norm(query);
108
109 let mut results: Vec<(usize, f32)> = Vec::new();
110
111 for cluster_id in probe_clusters {
112 for &idx in &self.inverted_lists[cluster_id] {
113 if idx < tombstones.len() && tombstones[idx] != 0 {
114 continue;
115 }
116 let vec_norm = if idx < norms.len() {
117 norms[idx]
118 } else {
119 rustyhdf5_accel::vector_norm(&vectors[idx])
120 };
121 let score =
122 cosine_similarity_prenorm(query, query_norm, &vectors[idx], vec_norm);
123 results.push((idx, score));
124 }
125 }
126
127 results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
128 results.truncate(k);
129 results
130 }
131
132 fn nearest_clusters(&self, query: &[f32], nprobe: usize) -> Vec<usize> {
134 let mut dists: Vec<(usize, f32)> = (0..self.num_clusters)
135 .map(|c| {
136 let centroid = &self.centroids[c * self.dim..(c + 1) * self.dim];
137 let sim = rustyhdf5_accel::cosine_similarity(query, centroid);
138 (c, sim)
139 })
140 .collect();
141
142 dists.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
143 dists.iter().take(nprobe).map(|&(c, _)| c).collect()
144 }
145
146 pub fn is_balanced(&self) -> bool {
149 if self.inverted_lists.is_empty() {
150 return true;
151 }
152 let total: usize = self.inverted_lists.iter().map(|l| l.len()).sum();
153 let avg = total as f32 / self.inverted_lists.len() as f32;
154 let max_size = self.inverted_lists.iter().map(|l| l.len()).max().unwrap_or(0);
155 max_size as f32 <= avg * 3.0
156 }
157
158 pub fn to_hdf5_data(&self) -> (&[f32], Vec<i64>, Vec<i64>, [i64; 2]) {
161 let mut offsets = Vec::with_capacity(self.num_clusters + 1);
162 let mut data = Vec::new();
163 let mut offset = 0i64;
164 for list in &self.inverted_lists {
165 offsets.push(offset);
166 for &idx in list {
167 data.push(idx as i64);
168 }
169 offset += list.len() as i64;
170 }
171 offsets.push(offset);
172
173 (
174 &self.centroids,
175 offsets,
176 data,
177 [self.num_clusters as i64, self.dim as i64],
178 )
179 }
180
181 pub fn from_hdf5_data(
183 centroids: Vec<f32>,
184 offsets: &[i64],
185 data: &[i64],
186 metadata: [i64; 2],
187 ) -> Self {
188 let num_clusters = metadata[0] as usize;
189 let dim = metadata[1] as usize;
190 let mut inverted_lists = Vec::with_capacity(num_clusters);
191
192 for c in 0..num_clusters {
193 let start = offsets[c] as usize;
194 let end = offsets[c + 1] as usize;
195 let list: Vec<usize> = data[start..end].iter().map(|&v| v as usize).collect();
196 inverted_lists.push(list);
197 }
198
199 Self {
200 centroids,
201 num_clusters,
202 dim,
203 inverted_lists,
204 }
205 }
206}
207
208pub struct IVFPQIndex {
210 pub ivf: IVFIndex,
211 pub pq: ProductQuantizer,
212 pub codes: Vec<u8>,
214}
215
216impl IVFPQIndex {
217 pub fn build(
219 vectors: &[Vec<f32>],
220 dim: usize,
221 num_clusters: usize,
222 num_subvectors: usize,
223 num_centroids: usize,
224 ) -> Self {
225 let ivf = IVFIndex::train(vectors, dim, num_clusters);
226 let pq = ProductQuantizer::train(vectors, dim, num_subvectors, num_centroids);
227 let codes = pq.encode_all(vectors);
228 Self { ivf, pq, codes }
229 }
230
231 #[allow(clippy::too_many_arguments)]
234 pub fn search(
235 &self,
236 query: &[f32],
237 vectors: &[Vec<f32>],
238 norms: &[f32],
239 tombstones: &[u8],
240 nprobe: usize,
241 candidates: usize,
242 k: usize,
243 ) -> Vec<(usize, f32)> {
244 let probe_clusters = self.ivf.nearest_clusters(query, nprobe);
245 let table = self.pq.precompute_distance_table(query);
246
247 let mut pq_results: Vec<(usize, f32)> = Vec::new();
249 for cluster_id in probe_clusters {
250 for &idx in &self.ivf.inverted_lists[cluster_id] {
251 if idx < tombstones.len() && tombstones[idx] != 0 {
252 continue;
253 }
254 let code_start = idx * self.pq.num_subvectors;
255 let code_end = code_start + self.pq.num_subvectors;
256 let codes = &self.codes[code_start..code_end];
257 let dist = self.pq.asymmetric_distance_with_table(&table, codes);
258 pq_results.push((idx, dist));
259 }
260 }
261
262 pq_results.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
264 pq_results.truncate(candidates);
265
266 let query_norm = rustyhdf5_accel::vector_norm(query);
268 let mut reranked: Vec<(usize, f32)> = pq_results
269 .iter()
270 .map(|&(idx, _)| {
271 let vec_norm = if idx < norms.len() {
272 norms[idx]
273 } else {
274 rustyhdf5_accel::vector_norm(&vectors[idx])
275 };
276 (
277 idx,
278 cosine_similarity_prenorm(query, query_norm, &vectors[idx], vec_norm),
279 )
280 })
281 .collect();
282
283 reranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
284 reranked.truncate(k);
285 reranked
286 }
287}
288
289#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291pub enum SearchStrategy {
292 BruteForce,
294 BruteForceNorms,
296 IVFPQ,
298}
299
300pub fn auto_strategy(num_vectors: usize) -> SearchStrategy {
302 if num_vectors < 10_000 {
303 SearchStrategy::BruteForce
304 } else if num_vectors <= 100_000 {
305 SearchStrategy::BruteForceNorms
306 } else {
307 SearchStrategy::IVFPQ
308 }
309}
310
311fn nearest_centroid(vector: &[f32], centroids: &[f32], num_clusters: usize, dim: usize) -> usize {
312 let mut best = 0;
313 let mut best_sim = f32::NEG_INFINITY;
314 for c in 0..num_clusters {
315 let centroid = ¢roids[c * dim..(c + 1) * dim];
316 let sim = rustyhdf5_accel::cosine_similarity(vector, centroid);
317 if sim > best_sim {
318 best_sim = sim;
319 best = c;
320 }
321 }
322 best
323}
324
325#[cfg(test)]
330mod tests {
331 use super::*;
332
333 fn make_vectors(n: usize, dim: usize, seed: u32) -> Vec<Vec<f32>> {
334 let mut s = seed;
335 let mut next = || -> f32 {
336 s = s.wrapping_mul(1103515245).wrapping_add(12345);
337 ((s >> 16) as f32) / 65536.0 - 0.5
338 };
339 (0..n).map(|_| (0..dim).map(|_| next()).collect()).collect()
340 }
341
342 #[test]
343 fn ivf_clustering_produces_clusters() {
344 let dim = 32;
345 let vectors = make_vectors(200, dim, 42);
346 let ivf = IVFIndex::train(&vectors, dim, 10);
347
348 assert_eq!(ivf.num_clusters, 10);
349 let total: usize = ivf.inverted_lists.iter().map(|l| l.len()).sum();
351 assert_eq!(total, 200);
352 let non_empty = ivf.inverted_lists.iter().filter(|l| !l.is_empty()).count();
354 assert!(non_empty > 0);
355 }
356
357 #[test]
358 fn ivf_balanced_clusters() {
359 let dim = 32;
360 let vectors = make_vectors(1000, dim, 42);
361 let ivf = IVFIndex::train(&vectors, dim, 10);
362 assert!(ivf.is_balanced(), "clusters should be reasonably balanced");
364 }
365
366 #[test]
367 fn ivf_search_nprobe_all_matches_brute_force() {
368 let dim = 32;
369 let vectors = make_vectors(100, dim, 42);
370 let norms: Vec<f32> = vectors.iter().map(|v| rustyhdf5_accel::vector_norm(v)).collect();
371 let tombstones = vec![0u8; 100];
372 let query = vectors[0].clone();
373
374 let ivf = IVFIndex::train(&vectors, dim, 5);
375
376 let ivf_results = ivf.search(&query, &vectors, &norms, &tombstones, 5, 10);
378
379 let query_norm = rustyhdf5_accel::vector_norm(&query);
381 let mut brute: Vec<(usize, f32)> = vectors
382 .iter()
383 .enumerate()
384 .map(|(i, v)| {
385 (
386 i,
387 cosine_similarity_prenorm(&query, query_norm, v, norms[i]),
388 )
389 })
390 .collect();
391 brute.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
392 brute.truncate(10);
393
394 let ivf_ids: Vec<usize> = ivf_results.iter().map(|r| r.0).collect();
396 let brute_ids: Vec<usize> = brute.iter().map(|r| r.0).collect();
397 assert_eq!(ivf_ids, brute_ids, "nprobe=all should match brute force");
398 }
399
400 #[test]
401 fn ivf_search_nprobe_1_returns_results() {
402 let dim = 32;
403 let vectors = make_vectors(200, dim, 42);
404 let norms: Vec<f32> = vectors.iter().map(|v| rustyhdf5_accel::vector_norm(v)).collect();
405 let tombstones = vec![0u8; 200];
406 let query = vectors[0].clone();
407
408 let ivf = IVFIndex::train(&vectors, dim, 10);
409 let results = ivf.search(&query, &vectors, &norms, &tombstones, 1, 10);
410 assert!(!results.is_empty(), "nprobe=1 should still find results");
411 }
412
413 #[test]
414 fn ivf_pq_combined_search_recall() {
415 let dim = 64;
416 let n = 500;
417 let vectors = make_vectors(n, dim, 42);
418 let norms: Vec<f32> = vectors.iter().map(|v| rustyhdf5_accel::vector_norm(v)).collect();
419 let tombstones = vec![0u8; n];
420 let query = vectors[0].clone();
421
422 let index = IVFPQIndex::build(&vectors, dim, 10, 8, 64);
423 let results = index.search(&query, &vectors, &norms, &tombstones, 5, 100, 10);
424
425 let query_norm = rustyhdf5_accel::vector_norm(&query);
427 let mut exact: Vec<(usize, f32)> = vectors
428 .iter()
429 .enumerate()
430 .map(|(i, v)| {
431 (
432 i,
433 cosine_similarity_prenorm(&query, query_norm, v, norms[i]),
434 )
435 })
436 .collect();
437 exact.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
438 let exact_top10: Vec<usize> = exact.iter().take(10).map(|r| r.0).collect();
439
440 let ivfpq_ids: Vec<usize> = results.iter().map(|r| r.0).collect();
441 let overlap = exact_top10.iter().filter(|i| ivfpq_ids.contains(i)).count();
442 assert!(
444 overlap >= 8,
445 "IVF-PQ recall too low: {overlap}/10 overlap"
446 );
447 }
448
449 #[test]
450 fn auto_strategy_selection() {
451 assert_eq!(auto_strategy(100), SearchStrategy::BruteForce);
452 assert_eq!(auto_strategy(9_999), SearchStrategy::BruteForce);
453 assert_eq!(auto_strategy(10_000), SearchStrategy::BruteForceNorms);
454 assert_eq!(auto_strategy(50_000), SearchStrategy::BruteForceNorms);
455 assert_eq!(auto_strategy(100_000), SearchStrategy::BruteForceNorms);
456 assert_eq!(auto_strategy(100_001), SearchStrategy::IVFPQ);
457 }
458
459 #[test]
460 fn ivf_hdf5_roundtrip() {
461 let dim = 16;
462 let vectors = make_vectors(50, dim, 42);
463 let ivf = IVFIndex::train(&vectors, dim, 5);
464
465 let (centroids, offsets, data, meta) = ivf.to_hdf5_data();
466 let ivf2 = IVFIndex::from_hdf5_data(centroids.to_vec(), &offsets, &data, meta);
467
468 assert_eq!(ivf.num_clusters, ivf2.num_clusters);
469 assert_eq!(ivf.dim, ivf2.dim);
470 for c in 0..ivf.num_clusters {
471 assert_eq!(ivf.inverted_lists[c], ivf2.inverted_lists[c]);
472 }
473 }
474
475 #[test]
476 fn ivf_assign_consistent() {
477 let dim = 16;
478 let vectors = make_vectors(100, dim, 42);
479 let ivf = IVFIndex::train(&vectors, dim, 5);
480
481 for (i, v) in vectors.iter().enumerate() {
483 let cluster = ivf.assign(v);
484 assert!(
485 ivf.inverted_lists[cluster].contains(&i),
486 "vector {i} should be in cluster {cluster}"
487 );
488 }
489 }
490
491 #[test]
492 fn ivf_respects_tombstones() {
493 let dim = 16;
494 let vectors = make_vectors(50, dim, 42);
495 let norms: Vec<f32> = vectors.iter().map(|v| rustyhdf5_accel::vector_norm(v)).collect();
496 let mut tombstones = vec![0u8; 50];
497 tombstones[0] = 1;
498 tombstones[1] = 1;
499
500 let ivf = IVFIndex::train(&vectors, dim, 5);
501 let results = ivf.search(&vectors[2], &vectors, &norms, &tombstones, 5, 50);
502 assert!(results.iter().all(|r| r.0 != 0 && r.0 != 1));
503 }
504}