Skip to main content

edgehdf5_memory/
ivf.rs

1//! Inverted File Index (IVF) for approximate nearest neighbor search.
2//!
3//! Partitions the vector space into clusters using k-means, then searches
4//! only the `nprobe` nearest clusters for a query. Combined with PQ for
5//! maximum throughput on large collections.
6
7use crate::pq::ProductQuantizer;
8use crate::cosine_similarity_prenorm;
9
10/// An inverted file index that partitions vectors into clusters.
11pub struct IVFIndex {
12    /// Cluster centroids: `[num_clusters][dim]` stored flat.
13    pub centroids: Vec<f32>,
14    /// Number of clusters.
15    pub num_clusters: usize,
16    /// Vector dimension.
17    pub dim: usize,
18    /// Inverted lists: for each cluster, the indices of vectors assigned to it.
19    pub inverted_lists: Vec<Vec<usize>>,
20}
21
22impl IVFIndex {
23    /// Train an IVF index using k-means clustering.
24    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        // Initialize centroids from evenly-spaced vectors
29        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            // Assignment step
42            let mut changed = false;
43            for (i, vec) in vectors.iter().enumerate() {
44                let best = nearest_centroid(vec, &centroids, 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            // Update centroids
55            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        // Build inverted lists
77        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    /// Assign a vector to its nearest cluster.
91    pub fn assign(&self, vector: &[f32]) -> usize {
92        nearest_centroid(vector, &self.centroids, self.num_clusters, self.dim)
93    }
94
95    /// Search using IVF: probe the `nprobe` nearest clusters and return
96    /// top-k results by cosine similarity.
97    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    /// Find the `nprobe` nearest cluster centroids to the query.
133    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    /// Check if clusters are reasonably balanced (no cluster has more than
147    /// 3x the average size).
148    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    /// Serialize for HDF5 storage.
159    /// Returns (centroids, inverted_list_offsets, inverted_list_data, metadata).
160    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    /// Reconstruct from HDF5 data.
182    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
208/// Combined IVF-PQ search: IVF narrows candidates, PQ makes distance fast.
209pub struct IVFPQIndex {
210    pub ivf: IVFIndex,
211    pub pq: ProductQuantizer,
212    /// PQ codes for all vectors: `[n_vectors * pq.num_subvectors]`.
213    pub codes: Vec<u8>,
214}
215
216impl IVFPQIndex {
217    /// Build a combined IVF-PQ index.
218    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    /// Search using IVF to narrow clusters, then PQ for fast approximate
232    /// distance, then re-rank top candidates with exact cosine.
233    #[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        // Collect candidate indices from probed clusters
248        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        // Sort by PQ distance (ascending = closest first)
263        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        // Re-rank with exact cosine
267        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/// Select the best search strategy based on collection size.
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291pub enum SearchStrategy {
292    /// Brute-force SIMD (< 10K vectors).
293    BruteForce,
294    /// Brute-force SIMD with pre-computed norms (10K-100K).
295    BruteForceNorms,
296    /// IVF-PQ for very large collections (> 100K).
297    IVFPQ,
298}
299
300/// Auto-select search strategy based on collection size.
301pub 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 = &centroids[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// ---------------------------------------------------------------------------
326// Tests
327// ---------------------------------------------------------------------------
328
329#[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        // All vectors should be assigned
350        let total: usize = ivf.inverted_lists.iter().map(|l| l.len()).sum();
351        assert_eq!(total, 200);
352        // No cluster should be completely empty (with enough vectors)
353        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        // With random data, clusters should be roughly balanced
363        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        // Search all clusters (nprobe = num_clusters)
377        let ivf_results = ivf.search(&query, &vectors, &norms, &tombstones, 5, 10);
378
379        // Brute force
380        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        // Should get same top-10
395        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        // Exact top-10
426        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        // IVF-PQ recall@10 should be > 80%
443        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        // Assigning a training vector should return its cluster
482        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}