Skip to main content

nodedb_vector/multivec/
plaid.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! PLAID-style centroid-based candidate pruning for multi-vector search.
4//!
5//! Builds K-means centroids over all document vectors.  Each document is
6//! encoded as a sorted bag of centroid IDs.  At query time the query's
7//! centroid bag is computed and only documents whose centroid bag overlaps
8//! the query bag are returned as candidates.
9//!
10//! Reference: Santhanam et al., "PLAID: An Efficient Engine for Late
11//! Interaction Retrieval", CIKM 2022.
12
13use std::collections::{HashMap, HashSet};
14
15use crate::distance::scalar::scalar_distance;
16use crate::error::{VectorError, check_dim};
17use nodedb_types::vector_distance::DistanceMetric;
18
19use super::storage::MultiVectorStore;
20
21// ---------------------------------------------------------------------------
22// Internal Lloyd's K-means (tiny, self-contained)
23// ---------------------------------------------------------------------------
24
25/// Assign each vector to its nearest centroid index.
26fn assign(vectors: &[Vec<f32>], centroids: &[Vec<f32>]) -> Vec<usize> {
27    vectors
28        .iter()
29        .map(|v| {
30            centroids
31                .iter()
32                .enumerate()
33                .map(|(i, c)| (i, scalar_distance(v, c, DistanceMetric::L2)))
34                .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
35                .map(|(i, _)| i)
36                .unwrap_or(0)
37        })
38        .collect()
39}
40
41/// Deterministic LCG step for reproducible randomness across train calls.
42fn lcg_next(s: &mut u64) -> u64 {
43    *s = s
44        .wrapping_mul(6364136223846793005)
45        .wrapping_add(1442695040888963407);
46    *s
47}
48
49/// Distance from `v` to the nearest centroid in `centroids`.
50fn min_dist_to_centroids(v: &[f32], centroids: &[Vec<f32>]) -> f32 {
51    centroids
52        .iter()
53        .map(|c| scalar_distance(v, c, DistanceMetric::L2))
54        .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
55        .unwrap_or(f32::INFINITY)
56}
57
58/// Recompute centroids as the mean of their assigned vectors. Empty clusters
59/// are re-seeded to the input vector farthest from all live centroids — this
60/// guarantees every centroid covers some part of the input space and prevents
61/// k-means from collapsing into fewer-than-k effective clusters.
62fn recompute(
63    vectors: &[Vec<f32>],
64    assignments: &[usize],
65    num_centroids: usize,
66    dim: usize,
67    prev_centroids: &[Vec<f32>],
68) -> Vec<Vec<f32>> {
69    let mut sums = vec![vec![0.0f32; dim]; num_centroids];
70    let mut counts = vec![0usize; num_centroids];
71
72    for (v, &c) in vectors.iter().zip(assignments.iter()) {
73        for (s, x) in sums[c].iter_mut().zip(v.iter()) {
74            *s += x;
75        }
76        counts[c] += 1;
77    }
78
79    // First pass: average populated clusters in place.
80    for (s, &n) in sums.iter_mut().zip(counts.iter()) {
81        if n > 0 {
82            s.iter_mut().for_each(|x| *x /= n as f32);
83        }
84    }
85
86    // Second pass: re-seed empty clusters from vectors farthest from any live
87    // centroid. Snapshot the live set first so each empty slot is filled
88    // deterministically and subsequent re-seeds in the same call see the
89    // updated pool.
90    for c_idx in 0..num_centroids {
91        if counts[c_idx] != 0 {
92            continue;
93        }
94        let live: Vec<Vec<f32>> = counts
95            .iter()
96            .enumerate()
97            .filter(|(i, cnt)| *i != c_idx && **cnt > 0)
98            .map(|(i, _)| sums[i].clone())
99            .collect();
100        let seed_pool: &[Vec<f32>] = if live.is_empty() {
101            prev_centroids
102        } else {
103            &live
104        };
105        let farthest = vectors
106            .iter()
107            .map(|v| (v, min_dist_to_centroids(v, seed_pool)))
108            .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
109            .map(|(v, _)| v.clone());
110        if let Some(v) = farthest {
111            sums[c_idx] = v;
112            counts[c_idx] = 1; // mark live so later empty slots see it as a seed.
113        } else if c_idx < prev_centroids.len() {
114            sums[c_idx] = prev_centroids[c_idx].clone();
115            counts[c_idx] = 1;
116        }
117    }
118
119    sums
120}
121
122/// k-means++ initialisation: first centroid uniform random, subsequent
123/// centroids drawn with probability proportional to squared distance from the
124/// nearest already-chosen centroid. Deterministic given `seed`.
125fn kmeans_plus_plus_init(vectors: &[Vec<f32>], k: usize, seed: u64) -> Vec<Vec<f32>> {
126    let mut state = seed.wrapping_add(1);
127    let first = (lcg_next(&mut state) as usize) % vectors.len();
128    let mut centroids: Vec<Vec<f32>> = vec![vectors[first].clone()];
129
130    while centroids.len() < k {
131        let dists: Vec<f32> = vectors
132            .iter()
133            .map(|v| {
134                let d = min_dist_to_centroids(v, &centroids);
135                d * d
136            })
137            .collect();
138        let total: f64 = dists.iter().map(|&d| d as f64).sum();
139        if total <= 0.0 {
140            // All remaining vectors coincide with existing centroids; just
141            // pick any unique-by-index vector to fill k.
142            let idx = (lcg_next(&mut state) as usize) % vectors.len();
143            centroids.push(vectors[idx].clone());
144            continue;
145        }
146        // Deterministic weighted draw from the LCG.
147        let r = (lcg_next(&mut state) as f64) / (u64::MAX as f64) * total;
148        let mut acc = 0.0f64;
149        let mut pick = vectors.len() - 1;
150        for (i, &d) in dists.iter().enumerate() {
151            acc += d as f64;
152            if acc >= r {
153                pick = i;
154                break;
155            }
156        }
157        centroids.push(vectors[pick].clone());
158    }
159
160    centroids
161}
162
163/// Run Lloyd's K-means with k-means++ initialisation. Empty clusters are
164/// re-seeded each iteration so the result always has exactly `k` distinct
165/// centroids covering the input space.
166fn kmeans(
167    vectors: &[Vec<f32>],
168    num_centroids: usize,
169    iters: usize,
170    seed: u64,
171    dim: usize,
172) -> Vec<Vec<f32>> {
173    if vectors.is_empty() || num_centroids == 0 {
174        return Vec::new();
175    }
176
177    let k = num_centroids.min(vectors.len());
178    let mut centroids = kmeans_plus_plus_init(vectors, k, seed);
179
180    for _ in 0..iters {
181        let assignments = assign(vectors, &centroids);
182        let new_centroids = recompute(vectors, &assignments, k, dim, &centroids);
183        centroids = new_centroids;
184    }
185
186    centroids
187}
188
189// ---------------------------------------------------------------------------
190// PlaidPruner
191// ---------------------------------------------------------------------------
192
193/// PLAID centroid-based candidate pruner.
194///
195/// After `train`, call `candidates` at query time to get the set of document
196/// IDs whose centroid bag overlaps the query's centroid bag.
197pub struct PlaidPruner {
198    pub centroids: Vec<Vec<f32>>,
199    /// Sorted list of centroid IDs for each document.
200    doc_centroids: HashMap<u32, Vec<u16>>,
201}
202
203impl PlaidPruner {
204    /// Train the pruner from a `MultiVectorStore`.
205    ///
206    /// * `num_centroids` — number of K-means clusters.
207    /// * `kmeans_iters` — Lloyd iterations.
208    /// * `seed` — deterministic seed for centroid initialisation.
209    pub fn train(
210        store: &MultiVectorStore,
211        num_centroids: u16,
212        kmeans_iters: usize,
213        seed: u64,
214    ) -> Self {
215        let dim = store.dim;
216        let nc = num_centroids as usize;
217
218        // Collect all document vectors for K-means training.
219        let all_vectors: Vec<Vec<f32>> = store
220            .iter()
221            .flat_map(|doc| doc.vectors.iter().cloned())
222            .collect();
223
224        let centroids = kmeans(&all_vectors, nc, kmeans_iters, seed, dim);
225
226        // Encode each document as a sorted, deduplicated bag of centroid IDs.
227        let doc_centroids: HashMap<u32, Vec<u16>> = store
228            .iter()
229            .map(|doc| {
230                let mut ids: Vec<u16> = doc
231                    .vectors
232                    .iter()
233                    .map(|v| {
234                        centroids
235                            .iter()
236                            .enumerate()
237                            .map(|(i, c)| (i as u16, scalar_distance(v, c, DistanceMetric::L2)))
238                            .min_by(|a, b| {
239                                a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)
240                            })
241                            .map(|(i, _)| i)
242                            .unwrap_or(0)
243                    })
244                    .collect();
245                ids.sort_unstable();
246                ids.dedup();
247                (doc.doc_id, ids)
248            })
249            .collect();
250
251        Self {
252            centroids,
253            doc_centroids,
254        }
255    }
256
257    /// Return candidate doc IDs whose centroid bag overlaps the query's
258    /// centroid bag.
259    ///
260    /// The query centroid bag is the set of nearest centroids for each query
261    /// vector.
262    ///
263    /// A query vector without the centroid dimension fails with
264    /// [`VectorError::DimensionMismatch`].
265    pub fn candidates(&self, query: &[Vec<f32>]) -> Result<Vec<u32>, VectorError> {
266        let Some(first) = self.centroids.first() else {
267            return Ok(Vec::new());
268        };
269        for v in query {
270            check_dim(first.len(), v.len())?;
271        }
272        if query.is_empty() {
273            return Ok(Vec::new());
274        }
275
276        // Build query centroid bag.
277        let query_bag: HashSet<u16> = query
278            .iter()
279            .filter_map(|v| {
280                self.centroids
281                    .iter()
282                    .enumerate()
283                    .map(|(i, c)| (i as u16, scalar_distance(v, c, DistanceMetric::L2)))
284                    .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
285                    .map(|(id, _)| id)
286            })
287            .collect();
288
289        // Collect docs that share at least one centroid with the query.
290        Ok(self
291            .doc_centroids
292            .iter()
293            .filter(|(_, doc_ids)| doc_ids.iter().any(|id| query_bag.contains(id)))
294            .map(|(&doc_id, _)| doc_id)
295            .collect())
296    }
297}
298
299// ---------------------------------------------------------------------------
300// Tests
301// ---------------------------------------------------------------------------
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use crate::multivec::storage::{MultiVecMode, MultiVectorDoc, MultiVectorStore};
307
308    fn build_store() -> MultiVectorStore {
309        let mut store = MultiVectorStore::new(2, MultiVecMode::PerToken);
310
311        // Three well-separated clusters of documents.
312        // Cluster A: docs 0–2 near (0,0).
313        // Cluster B: docs 3–5 near (10,0).
314        // Cluster C: docs 6–8 near (0,10).
315        for i in 0u32..3 {
316            store
317                .insert(MultiVectorDoc {
318                    doc_id: i,
319                    vectors: vec![vec![i as f32 * 0.1, i as f32 * 0.1]],
320                })
321                .unwrap();
322        }
323        for i in 3u32..6 {
324            store
325                .insert(MultiVectorDoc {
326                    doc_id: i,
327                    vectors: vec![vec![10.0 + i as f32 * 0.1, 0.0]],
328                })
329                .unwrap();
330        }
331        for i in 6u32..9 {
332            store
333                .insert(MultiVectorDoc {
334                    doc_id: i,
335                    vectors: vec![vec![0.0, 10.0 + i as f32 * 0.1]],
336                })
337                .unwrap();
338        }
339        store
340    }
341
342    #[test]
343    fn train_produces_correct_centroid_count() {
344        let store = build_store();
345        let pruner = PlaidPruner::train(&store, 3, 10, 42);
346        assert_eq!(pruner.centroids.len(), 3);
347    }
348
349    #[test]
350    fn centroids_have_correct_dim() {
351        let store = build_store();
352        let pruner = PlaidPruner::train(&store, 3, 10, 42);
353        for c in &pruner.centroids {
354            assert_eq!(c.len(), 2);
355        }
356    }
357
358    #[test]
359    fn candidates_non_empty_for_matching_query() {
360        let store = build_store();
361        let pruner = PlaidPruner::train(&store, 3, 10, 42);
362
363        // A query near cluster A should return at least some candidates.
364        let query = vec![vec![0.0f32, 0.0f32]];
365        let cands = pruner.candidates(&query).unwrap();
366        assert!(!cands.is_empty(), "expected at least one candidate");
367    }
368
369    #[test]
370    fn candidates_empty_when_no_centroids() {
371        // An empty store produces a pruner with no centroids.
372        let store = MultiVectorStore::new(2, MultiVecMode::PerToken);
373        let pruner = PlaidPruner::train(&store, 3, 5, 1);
374        let query = vec![vec![0.0f32, 0.0f32]];
375        assert!(pruner.candidates(&query).unwrap().is_empty());
376    }
377
378    #[test]
379    fn candidates_cover_input_range() {
380        // After training, the set of all-doc candidates (using multiple query
381        // vectors spanning the whole space) should cover all 9 documents.
382        let store = build_store();
383        let pruner = PlaidPruner::train(&store, 3, 15, 7);
384        let query = vec![
385            vec![0.0f32, 0.0f32],
386            vec![10.0f32, 0.0f32],
387            vec![0.0f32, 10.0f32],
388        ];
389        let mut cands = pruner.candidates(&query).unwrap();
390        cands.sort_unstable();
391        cands.dedup();
392        assert_eq!(cands.len(), 9, "all docs should be candidates: {:?}", cands);
393    }
394}