Skip to main content

docbert_plaid/
search.rs

1//! Query-time search over a built [`Index`].
2//!
3//! The flow mirrors PLAID's reference implementation:
4//!
5//! 1. For every query token, find the `n_probe` top dot-product coarse
6//!    centroids (matching `S_c,q = C · Q^T`). This is paper Stage 1.
7//! 2. *Optional* centroid pruning: drop probed centroids whose best
8//!    score across all query tokens sits below
9//!    `centroid_score_threshold`. This is used both here (to shrink the
10//!    reachable centroid set before postings are gathered) and below,
11//!    at token level, when scoring `D̃`.
12//! 3. Union the doc-level IVF postings for the surviving centroids to
13//!    get a set of candidate documents.
14//! 4. *Optional* centroid interaction. When `n_candidate_docs` is set,
15//!    the cascade runs in two refinement passes:
16//!    - Stage 2 (paper §4.3): approximate MaxSim with the pruned mask
17//!      applied at token level; keep top `n_candidate_docs` (paper's
18//!      `ndocs`).
19//!    - Stage 3 (paper §4.2): approximate MaxSim without pruning on
20//!      the Stage 2 survivors; keep top `n_candidate_docs / 4` (paper's
21//!      `ndocs/4` empirical heuristic), clamped to at least `top_k`.
22//! 5. Decode the survivors' stored residual codes into approximate
23//!    embeddings and compute exact MaxSim against the query.
24//! 6. Sort by exact MaxSim score and return the top-`top_k`.
25//!
26//! The pruning and interaction stages share a precomputed
27//! `[n_query_tokens, n_centroids]` query-centroid score matrix that
28//! step 1 would conceptually compute anyway, and together they
29//! drastically shrink the set of candidates reaching the expensive
30//! decode in step 5. Callers that want the legacy behaviour (every
31//! probed candidate decodes, no pruning) can pass
32//! `n_candidate_docs = None` and `centroid_score_threshold = None`.
33
34use candle_core::Tensor;
35
36use crate::{
37    Result,
38    codec::DecodeTable,
39    device::default_device,
40    distance::dot,
41    index::Index,
42};
43
44/// Tunable knobs for a single search call.
45#[derive(Debug, Clone, Copy)]
46pub struct SearchParams {
47    /// Number of top-scoring documents to return.
48    pub top_k: usize,
49    /// Number of top dot-product centroids each query token probes.
50    pub n_probe: usize,
51    /// Maximum number of candidates surviving PLAID's
52    /// centroid-interaction stage that proceed to full decode +
53    /// exact MaxSim. `None` disables centroid interaction and sends
54    /// every probed candidate straight to decode — correct but
55    /// slower for large corpora.
56    pub n_candidate_docs: Option<usize>,
57    /// Minimum per-centroid score (max dot product against any query
58    /// token) required for a centroid to contribute to candidate
59    /// generation and centroid interaction. Centroids below this
60    /// threshold are treated as unreachable for this query. `None`
61    /// disables pruning, matching the legacy probe behaviour.
62    pub centroid_score_threshold: Option<f32>,
63}
64
65/// One entry in a ranked search result list.
66#[derive(Debug, Clone, Copy, PartialEq)]
67pub struct SearchResult {
68    pub doc_id: u64,
69    pub score: f32,
70}
71
72/// Run a PLAID-style search over `index` and return the top-`top_k`
73/// documents ranked by ColBERT MaxSim against `query_tokens`.
74///
75/// `query_tokens` is a flat row-major `n_query_tokens × dim` buffer.
76/// Results are sorted by score, highest first. Ties are broken by
77/// `doc_id` ascending to keep the output deterministic.
78///
79/// # Errors
80///
81/// Returns [`PlaidError::Tensor`] if the batched MaxSim matmul fails
82/// (e.g. CUDA OOM on the final decode tensor).
83///
84/// # Panics
85///
86/// Panics if `query_tokens.len() % index.params.dim != 0`, if
87/// `params.top_k == 0`, or if `params.n_probe == 0`.
88///
89/// [`PlaidError::Tensor`]: crate::PlaidError::Tensor
90pub fn search(
91    index: &Index,
92    query_tokens: &[f32],
93    params: SearchParams,
94) -> Result<Vec<SearchResult>> {
95    let dim = index.params.dim;
96    assert!(params.top_k > 0, "search: top_k must be positive");
97    assert!(params.n_probe > 0, "search: n_probe must be positive");
98    assert!(
99        query_tokens.len().is_multiple_of(dim),
100        "search: query length {} is not a multiple of dim {}",
101        query_tokens.len(),
102        dim,
103    );
104
105    if query_tokens.is_empty() || index.num_documents() == 0 {
106        return Ok(Vec::new());
107    }
108
109    let n_centroids = index.codec.num_centroids();
110    let n_probe = params.n_probe.min(n_centroids);
111
112    // Precompute the per-centroid max dot product against any query
113    // token when either pruning or centroid interaction is active.
114    // Pruning uses it to build a pruned-centroid bitmask for use in
115    // both candidate generation and centroid interaction; centroid
116    // interaction uses the full qc_scores matrix for approximate
117    // per-doc scoring.
118    let qc_scores: Option<Vec<f32>> = (params
119        .centroid_score_threshold
120        .is_some()
121        || params.n_candidate_docs.is_some())
122    .then(|| {
123        query_centroid_score_matrix(query_tokens, &index.codec.centroids, dim)
124    });
125    let pruned_mask: Option<Vec<bool>> =
126        match (qc_scores.as_ref(), params.centroid_score_threshold) {
127            (Some(scores), Some(threshold)) => {
128                let per_cent = per_centroid_max_scores(
129                    scores,
130                    query_tokens.len() / dim,
131                    n_centroids,
132                );
133                Some(per_cent.iter().map(|&s| s < threshold).collect())
134            }
135            _ => None,
136        };
137
138    // 1-2. Gather the union of candidate doc indices reachable via the
139    //      probed centroids. IVF postings are already deduplicated per
140    //      doc, so each centroid contributes at most one write per doc.
141    //      Centroid pruning drops probed centroids whose best per-query
142    //      score sits below the caller's threshold — skipping them
143    //      both shrinks the candidate set and saves work later in
144    //      centroid interaction.
145    let mut candidate_docs: Vec<bool> = vec![false; index.num_documents()];
146    for query_token in query_tokens.chunks_exact(dim) {
147        for centroid_id in
148            top_n_centroids(query_token, &index.codec.centroids, dim, n_probe)
149        {
150            if let Some(mask) = pruned_mask.as_ref()
151                && mask[centroid_id]
152            {
153                continue;
154            }
155            for &doc_idx in index.ivf.docs_for_centroid(centroid_id) {
156                candidate_docs[doc_idx as usize] = true;
157            }
158        }
159    }
160
161    let mut candidate_idxs: Vec<usize> = candidate_docs
162        .iter()
163        .enumerate()
164        .filter_map(|(idx, &is_cand)| {
165            (is_cand && !index.doc_tokens[idx].is_empty()).then_some(idx)
166        })
167        .collect();
168
169    // 3. Centroid interaction: when the caller set `n_candidate_docs`,
170    //    cheaply rank candidates via the precomputed query-centroid
171    //    score matrix and keep only the top survivors before paying
172    //    for full decode + exact MaxSim.
173    //
174    //    The `if let` pattern-matches both fields at once so the
175    //    compiler can prove qc_scores is Some under this arm — no
176    //    `expect` gymnastics needed.
177    if let (Some(n_stage2), Some(qc_scores)) =
178        (params.n_candidate_docs, qc_scores.as_ref())
179    {
180        let n_q = query_tokens.len() / dim;
181        let n_c = index.codec.num_centroids();
182
183        // Stage 2 — cheap pruned centroid interaction. Keep top
184        // `n_stage2` candidates (the paper's `ndocs`).
185        candidate_idxs = shortlist_by_approx_score(
186            candidate_idxs,
187            index,
188            qc_scores,
189            n_q,
190            n_c,
191            pruned_mask.as_deref(),
192            n_stage2,
193        );
194
195        // Stage 3 — unpruned centroid interaction. Refines the Stage 2
196        // survivors down to `ndocs/4`, matching the paper's empirical
197        // heuristic. Callers that want top-`k` results should set
198        // `n_candidate_docs >= 4 * top_k` so Stage 3 doesn't undershoot
199        // their requested result count. docbert-core's default already
200        // does this (8 * top_k).
201        let n_stage3 = n_stage2.div_ceil(4).max(1);
202        candidate_idxs = shortlist_by_approx_score(
203            candidate_idxs,
204            index,
205            qc_scores,
206            n_q,
207            n_c,
208            None,
209            n_stage3,
210        );
211    }
212
213    // 5. Score every surviving candidate with one batched MaxSim matmul
214    //    on decoded tokens.
215    let mut scored: Vec<SearchResult> = if candidate_idxs.is_empty() {
216        Vec::new()
217    } else {
218        batch_maxsim(query_tokens, &candidate_idxs, index, dim)?
219            .into_iter()
220            .map(|(doc_idx, score)| SearchResult {
221                doc_id: index.doc_ids[doc_idx],
222                score,
223            })
224            .collect()
225    };
226
227    // 6. Rank by score (desc), tie-break by doc_id (asc) for determinism.
228    scored.sort_by(|a, b| {
229        b.score
230            .partial_cmp(&a.score)
231            .unwrap_or(std::cmp::Ordering::Equal)
232            .then_with(|| a.doc_id.cmp(&b.doc_id))
233    });
234    scored.truncate(params.top_k);
235    Ok(scored)
236}
237
238/// Indices of the `n` centroids with the highest dot-product against
239/// `point`, most relevant first.
240///
241/// This is the multi-centroid counterpart to
242/// [`crate::kmeans::nearest_centroid`]. It implements PLAID's
243/// query-centroid scoring `S_c,q = C · Q^T` (one row per query token),
244/// which is the ranking the paper uses for candidate generation.
245///
246/// Dot product is used here instead of squared L2 because the paper and
247/// ColBERT's downstream MaxSim both operate on dot-product similarity.
248/// For centroids with varying magnitudes the two metrics disagree, and
249/// dot product is what keeps probe ordering consistent with the scorer.
250/// Ties are broken toward the earlier centroid index for determinism.
251///
252/// The output vector has length `min(n, num_centroids)`.
253///
254/// # Panics
255///
256/// Panics on any shape mismatch between `point`, `centroids`, and `dim`.
257pub fn top_n_centroids(
258    point: &[f32],
259    centroids: &[f32],
260    dim: usize,
261    n: usize,
262) -> Vec<usize> {
263    assert_eq!(
264        point.len(),
265        dim,
266        "top_n_centroids: point length {} does not match dim {}",
267        point.len(),
268        dim,
269    );
270    assert!(dim > 0, "top_n_centroids: dim must be positive");
271    assert!(
272        !centroids.is_empty() && centroids.len().is_multiple_of(dim),
273        "top_n_centroids: centroids length {} is not a positive multiple of dim {}",
274        centroids.len(),
275        dim,
276    );
277
278    let k = centroids.len() / dim;
279    let mut scored: Vec<(usize, f32)> = centroids
280        .chunks_exact(dim)
281        .enumerate()
282        .map(|(i, c)| (i, dot(point, c)))
283        .collect();
284    scored.sort_by(|a, b| {
285        b.1.partial_cmp(&a.1)
286            .unwrap_or(std::cmp::Ordering::Equal)
287            .then_with(|| a.0.cmp(&b.0))
288    });
289    scored.into_iter().take(n.min(k)).map(|(i, _)| i).collect()
290}
291
292/// Compute the row-major `[n_q, n_centroids]` matrix of query-to-centroid
293/// dot products used by PLAID's centroid-interaction stage.
294///
295/// Materialising the whole matrix once amortises the per-centroid work
296/// across every candidate document — the same query-centroid entries
297/// get hit by every doc that touches each centroid.
298fn query_centroid_score_matrix(
299    query_tokens: &[f32],
300    centroids: &[f32],
301    dim: usize,
302) -> Vec<f32> {
303    let n_q = query_tokens.len() / dim;
304    let n_c = centroids.len() / dim;
305    let mut out = vec![0.0f32; n_q * n_c];
306    for (qi, q) in query_tokens.chunks_exact(dim).enumerate() {
307        for (ci, c) in centroids.chunks_exact(dim).enumerate() {
308            out[qi * n_c + ci] = dot(q, c);
309        }
310    }
311    out
312}
313
314/// Rank `candidate_idxs` by approximate centroid-interaction score
315/// and keep the top `limit` survivors.
316///
317/// A no-op when the input is already smaller than `limit`. Used by
318/// both Stage 2 (pruned) and Stage 3 (unpruned) of the paper's
319/// centroid-interaction cascade; the only difference between the two
320/// callers is the `mask` argument.
321fn shortlist_by_approx_score(
322    candidate_idxs: Vec<usize>,
323    index: &Index,
324    qc_scores: &[f32],
325    n_q: usize,
326    n_centroids: usize,
327    mask: Option<&[bool]>,
328    limit: usize,
329) -> Vec<usize> {
330    if candidate_idxs.len() <= limit {
331        return candidate_idxs;
332    }
333    let mut approx: Vec<(usize, f32)> = candidate_idxs
334        .into_iter()
335        .map(|doc_idx| {
336            let score = approx_centroid_interaction_score(
337                &index.doc_tokens[doc_idx],
338                qc_scores,
339                n_q,
340                n_centroids,
341                mask,
342            );
343            (doc_idx, score)
344        })
345        .collect();
346    approx.sort_by(|a, b| {
347        b.1.partial_cmp(&a.1)
348            .unwrap_or(std::cmp::Ordering::Equal)
349            .then_with(|| a.0.cmp(&b.0))
350    });
351    approx.truncate(limit);
352    approx.into_iter().map(|(idx, _)| idx).collect()
353}
354
355/// For every centroid, return the max dot-product score achieved by
356/// any query token against that centroid.
357///
358/// PLAID's centroid pruning uses this "best any-query score" to cheaply
359/// rank centroids by query relevance: a centroid whose best score
360/// across all query tokens is still small cannot usefully participate
361/// in MaxSim, so it can be dropped without hurting top-K.
362fn per_centroid_max_scores(
363    qc_scores: &[f32],
364    n_q: usize,
365    n_centroids: usize,
366) -> Vec<f32> {
367    let mut out = vec![f32::NEG_INFINITY; n_centroids];
368    for q in 0..n_q {
369        let row = &qc_scores[q * n_centroids..(q + 1) * n_centroids];
370        for (c, &s) in row.iter().enumerate() {
371            if s > out[c] {
372                out[c] = s;
373            }
374        }
375    }
376    out
377}
378
379/// Approximate MaxSim for one candidate doc using only the centroids
380/// the doc touches. This is PLAID's centroid-interaction score:
381///
382/// `Σ_q max_{c ∈ unique_centroids(doc)} qc_scores[q, c]`
383///
384/// Duplicate centroid ids within the doc are filtered implicitly: a
385/// centroid that appears twice can't beat itself in the max, so the
386/// running-max shortcut is correct without an explicit dedup pass.
387///
388/// When `pruned_mask` is `Some`, tokens whose centroid is marked
389/// pruned are skipped before the max, matching PLAID §4.3's token-level
390/// pruning of `D̃`. If every token in the doc is pruned the result is
391/// `f32::NEG_INFINITY`, which sorts below every non-pruned doc in the
392/// centroid-interaction shortlist.
393fn approx_centroid_interaction_score(
394    doc: &[crate::codec::EncodedVector],
395    qc_scores: &[f32],
396    n_q: usize,
397    n_centroids: usize,
398    pruned_mask: Option<&[bool]>,
399) -> f32 {
400    if doc.is_empty() {
401        return 0.0;
402    }
403    // If every token's centroid is pruned, the doc has no D̃ rows left;
404    // report −∞ so the caller's ranking drops it instead of a
405    // deceptive 0.0.
406    if let Some(mask) = pruned_mask
407        && doc.iter().all(|ev| mask[ev.centroid_id as usize])
408    {
409        return f32::NEG_INFINITY;
410    }
411    let mut total = 0.0f32;
412    for q in 0..n_q {
413        let row = &qc_scores[q * n_centroids..(q + 1) * n_centroids];
414        let mut best = f32::NEG_INFINITY;
415        for ev in doc {
416            if let Some(mask) = pruned_mask
417                && mask[ev.centroid_id as usize]
418            {
419                continue;
420            }
421            let s = row[ev.centroid_id as usize];
422            if s > best {
423                best = s;
424            }
425        }
426        if best.is_finite() {
427            total += best;
428        }
429    }
430    total
431}
432
433/// Score a batch of candidate docs against `query_tokens` with a
434/// padding-free packed MaxSim, matching PLAID §4.5.
435///
436/// All candidate tokens are decoded into a single `[total_tokens, dim]`
437/// buffer concatenated along the doc axis — no padding between docs,
438/// no mask. A single GEMM produces `[total_tokens, n_q]` scores, and
439/// per-doc MaxSim is a scalar max-then-sum over the slice of rows
440/// belonging to each candidate. On uniform-length corpora this costs
441/// roughly the same as the old padded path, but on ragged corpora
442/// (short + long docs mixed) it avoids the `max_len - doc_len` wasted
443/// rows and the `[n_c, max_len, n_q]` masking broadcast the old path
444/// had to materialise.
445///
446/// Final scoring still matches the ColBERT/PLAID reference definition
447/// `S = Σ_i max_j q_i · d_j`. Decoding uses the `DecodeTable` lookup
448/// so each packed byte lands in the output buffer as a single table
449/// read plus the centroid add.
450fn batch_maxsim(
451    query_tokens: &[f32],
452    candidate_idxs: &[usize],
453    index: &Index,
454    dim: usize,
455) -> Result<Vec<(usize, f32)>> {
456    let n_q = query_tokens.len() / dim;
457
458    let decode_table = DecodeTable::new(&index.codec);
459    let total_tokens_bound: usize = candidate_idxs
460        .iter()
461        .map(|&i| index.doc_tokens[i].len())
462        .sum();
463    // Packed (concatenated) decoded tokens and per-doc row offsets.
464    let mut packed: Vec<f32> = Vec::with_capacity(total_tokens_bound * dim);
465    let mut offsets: Vec<usize> = Vec::with_capacity(candidate_idxs.len() + 1);
466    offsets.push(0);
467    for &doc_idx in candidate_idxs {
468        for ev in &index.doc_tokens[doc_idx] {
469            packed.extend(
470                index.codec.decode_vector_with_table(ev, &decode_table)?,
471            );
472        }
473        offsets.push(packed.len() / dim);
474    }
475    let total_tokens = packed.len() / dim;
476    if total_tokens == 0 || n_q == 0 {
477        return Ok(candidate_idxs.iter().map(|&i| (i, 0.0)).collect());
478    }
479
480    // Single `[total_tokens, dim] × [dim, n_q]` GEMM. No padding, no
481    // mask — the packed layout means every row is a real token.
482    let device = default_device();
483    let docs_t = Tensor::from_vec(packed, (total_tokens, dim), device)?;
484    let q_t = Tensor::from_slice(query_tokens, (n_q, dim), device)?;
485    let q_transposed = q_t.t()?.contiguous()?;
486    let scores_flat: Vec<f32> = docs_t
487        .matmul(&q_transposed)?
488        .to_vec2::<f32>()?
489        .into_iter()
490        .flatten()
491        .collect();
492
493    // Per-doc MaxSim reduction over the doc's slice of score rows.
494    let mut out = Vec::with_capacity(candidate_idxs.len());
495    for (i, &doc_idx) in candidate_idxs.iter().enumerate() {
496        let start = offsets[i];
497        let end = offsets[i + 1];
498        if start == end {
499            out.push((doc_idx, 0.0));
500            continue;
501        }
502        let mut total = 0.0f32;
503        for q in 0..n_q {
504            let mut best = f32::NEG_INFINITY;
505            for t in start..end {
506                let s = scores_flat[t * n_q + q];
507                if s > best {
508                    best = s;
509                }
510            }
511            if best.is_finite() {
512                total += best;
513            }
514        }
515        out.push((doc_idx, total));
516    }
517    Ok(out)
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use crate::{
524        distance::dot,
525        index::{DocumentTokens, IndexParams, build_index},
526    };
527
528    fn params() -> IndexParams {
529        IndexParams {
530            dim: 2,
531            nbits: 2,
532            k_centroids: 2,
533            max_kmeans_iters: 50,
534        }
535    }
536
537    /// Two well-separated clusters of **unit-norm** 2-D tokens in three
538    /// documents. This mirrors the real-world ColBERT invariant that
539    /// token embeddings live on the unit sphere, which is what makes
540    /// the dot-product MaxSim meaningful. Doc 1 points east, doc 2
541    /// points north, doc 3 has one token in each cluster.
542    fn corpus() -> Vec<DocumentTokens> {
543        // Unit vectors by construction.
544        let east_a = [1.0f32, 0.0];
545        let east_b = normalize([0.98, 0.2]);
546        let east_c = normalize([0.97, -0.24]);
547        let north_a = [0.0f32, 1.0];
548        let north_b = normalize([0.2, 0.98]);
549        let north_c = normalize([-0.24, 0.97]);
550
551        let mut doc1 = Vec::new();
552        doc1.extend_from_slice(&east_a);
553        doc1.extend_from_slice(&east_b);
554        doc1.extend_from_slice(&east_c);
555
556        let mut doc2 = Vec::new();
557        doc2.extend_from_slice(&north_a);
558        doc2.extend_from_slice(&north_b);
559        doc2.extend_from_slice(&north_c);
560
561        let mut doc3 = Vec::new();
562        doc3.extend_from_slice(&east_a);
563        doc3.extend_from_slice(&north_a);
564
565        vec![
566            DocumentTokens {
567                doc_id: 1,
568                tokens: doc1,
569                n_tokens: 3,
570            },
571            DocumentTokens {
572                doc_id: 2,
573                tokens: doc2,
574                n_tokens: 3,
575            },
576            DocumentTokens {
577                doc_id: 3,
578                tokens: doc3,
579                n_tokens: 2,
580            },
581        ]
582    }
583
584    fn normalize(v: [f32; 2]) -> [f32; 2] {
585        let norm = (v[0] * v[0] + v[1] * v[1]).sqrt();
586        [v[0] / norm, v[1] / norm]
587    }
588
589    #[test]
590    fn top_n_centroids_ranks_by_descending_dot_product() {
591        // Three centroids on the +x axis at magnitudes 0, 5, and 12.
592        // Query at (4, 0). Dot products are 0, 20, 48; PLAID ranks by
593        // descending relevance (`S_c,q = C · Q^T`), so the biggest
594        // dot product wins.
595        let centroids = [0.0, 0.0, 5.0, 0.0, 12.0, 0.0];
596        let out = top_n_centroids(&[4.0, 0.0], &centroids, 2, 3);
597        assert_eq!(out, vec![2, 1, 0]);
598    }
599
600    #[test]
601    fn top_n_centroids_breaks_ties_toward_earlier_index() {
602        // Zero query makes every dot product 0. The deterministic
603        // tie-break is "earliest index wins".
604        let centroids = [1.0, 0.0, 0.0, 1.0, -1.0, 0.0];
605        let out = top_n_centroids(&[0.0, 0.0], &centroids, 2, 3);
606        assert_eq!(out, vec![0, 1, 2]);
607    }
608
609    #[test]
610    fn top_n_centroids_on_unit_norm_centroids_picks_most_aligned() {
611        // All centroids and the query are unit-norm. Dot product then
612        // reduces to cosine similarity, so the best-aligned centroid
613        // wins. Here (0.6, 0.8) is the closest direction to (1, 0).
614        let centroids = [
615            0.6, 0.8, //
616            0.0, 1.0, //
617            -1.0, 0.0, //
618            1.0, 0.0,
619        ];
620        let out = top_n_centroids(&[1.0, 0.0], &centroids, 2, 2);
621        assert_eq!(out, vec![3, 0]);
622    }
623
624    #[test]
625    fn top_n_centroids_caps_at_num_centroids() {
626        let centroids = [0.0, 0.0, 5.0, 0.0];
627        let out = top_n_centroids(&[0.0, 0.0], &centroids, 2, 10);
628        assert_eq!(out, vec![0, 1]);
629    }
630
631    #[test]
632    fn search_returns_empty_for_empty_query() {
633        let index = build_index(&corpus(), params()).unwrap();
634        let out = search(
635            &index,
636            &[],
637            SearchParams {
638                top_k: 3,
639                n_probe: 2,
640                n_candidate_docs: None,
641                centroid_score_threshold: None,
642            },
643        )
644        .unwrap();
645        assert!(out.is_empty());
646    }
647
648    #[test]
649    fn search_ranks_matching_cluster_highest() {
650        // Unit-norm query pointing east. Doc 1 has three east tokens,
651        // doc 3 has one east and one north token, doc 2 is entirely
652        // north. MaxSim should prefer doc 1.
653        let index = build_index(&corpus(), params()).unwrap();
654        let out = search(
655            &index,
656            &[1.0, 0.0],
657            SearchParams {
658                top_k: 3,
659                n_probe: 2,
660                n_candidate_docs: None,
661                centroid_score_threshold: None,
662            },
663        )
664        .unwrap();
665        assert!(!out.is_empty(), "should surface at least one doc");
666        assert_eq!(out[0].doc_id, 1, "closest doc should rank first");
667    }
668
669    #[test]
670    fn search_respects_top_k() {
671        let index = build_index(&corpus(), params()).unwrap();
672        let out = search(
673            &index,
674            &[1.0, 0.0],
675            SearchParams {
676                top_k: 1,
677                n_probe: 2,
678                n_candidate_docs: None,
679                centroid_score_threshold: None,
680            },
681        )
682        .unwrap();
683        assert_eq!(out.len(), 1);
684    }
685
686    #[test]
687    fn search_scores_are_non_increasing() {
688        let index = build_index(&corpus(), params()).unwrap();
689        // Two query tokens, one east, one north.
690        let out = search(
691            &index,
692            &[1.0, 0.0, 0.0, 1.0],
693            SearchParams {
694                top_k: 3,
695                n_probe: 2,
696                n_candidate_docs: None,
697                centroid_score_threshold: None,
698            },
699        )
700        .unwrap();
701        for pair in out.windows(2) {
702            assert!(
703                pair[0].score >= pair[1].score,
704                "scores must be descending: {pair:?}"
705            );
706        }
707    }
708
709    #[test]
710    fn search_with_single_probe_still_finds_the_right_cluster() {
711        // With n_probe=1, only one centroid is probed per query token.
712        // A query firmly inside one cluster should still surface its
713        // corresponding document first.
714        let index = build_index(&corpus(), params()).unwrap();
715        let out = search(
716            &index,
717            &[0.0, 1.0],
718            SearchParams {
719                top_k: 1,
720                n_probe: 1,
721                n_candidate_docs: None,
722                centroid_score_threshold: None,
723            },
724        )
725        .unwrap();
726        assert_eq!(out[0].doc_id, 2);
727    }
728
729    #[test]
730    fn search_skips_documents_with_no_tokens() {
731        let mut docs = corpus();
732        docs.push(DocumentTokens {
733            doc_id: 42,
734            tokens: vec![],
735            n_tokens: 0,
736        });
737        let index = build_index(&docs, params()).unwrap();
738        let out = search(
739            &index,
740            &[1.0, 0.0],
741            SearchParams {
742                top_k: 10,
743                n_probe: 2,
744                n_candidate_docs: None,
745                centroid_score_threshold: None,
746            },
747        )
748        .unwrap();
749        assert!(
750            out.iter().all(|r| r.doc_id != 42),
751            "empty doc should not appear in results"
752        );
753    }
754
755    #[test]
756    fn search_score_equals_sum_of_maxsim_over_decoded_tokens() {
757        // Sanity: the score we return should match a ground-truth
758        // MaxSim computed with the same decoded tokens, confirming our
759        // per-token similarity is dot product (not -squared_l2).
760        let index = build_index(&corpus(), params()).unwrap();
761        let query = [1.0f32, 0.0];
762
763        let out = search(
764            &index,
765            &query,
766            SearchParams {
767                top_k: 1,
768                n_probe: index.codec.num_centroids(),
769                n_candidate_docs: None,
770                centroid_score_threshold: None,
771            },
772        )
773        .unwrap();
774        assert!(!out.is_empty());
775
776        let top = out[0];
777        let doc_idx = index.position_of(top.doc_id).expect("doc present");
778        let decoded: Vec<Vec<f32>> = index.doc_tokens[doc_idx]
779            .iter()
780            .map(|ev| index.codec.decode_vector(ev).unwrap())
781            .collect();
782
783        // Standard MaxSim: Σ max_j q_i · d_j.
784        let mut expected = 0.0f32;
785        for q in query.chunks_exact(2) {
786            let best = decoded
787                .iter()
788                .map(|d| dot(q, d))
789                .fold(f32::NEG_INFINITY, f32::max);
790            expected += best;
791        }
792        assert!(
793            (expected - top.score).abs() < 1e-5,
794            "score {} differs from ground-truth MaxSim {}",
795            top.score,
796            expected,
797        );
798    }
799
800    #[test]
801    fn search_works_with_four_bit_residuals() {
802        // Build an index with 4-bit residual quantization (16 buckets)
803        // and confirm the MaxSim-ranked top result is still the
804        // matching-cluster doc.
805        let params_4bit = IndexParams {
806            dim: 2,
807            nbits: 4,
808            k_centroids: 2,
809            max_kmeans_iters: 50,
810        };
811        let index = build_index(&corpus(), params_4bit).unwrap();
812        let out = search(
813            &index,
814            &[1.0, 0.0],
815            SearchParams {
816                top_k: 1,
817                n_probe: 2,
818                n_candidate_docs: None,
819                centroid_score_threshold: None,
820            },
821        )
822        .unwrap();
823        assert_eq!(out[0].doc_id, 1);
824    }
825
826    #[test]
827    fn search_survives_large_synthetic_corpus_on_both_clusters() {
828        // Bigger synthetic corpus with two distinct unit-norm clusters.
829        // An east query must top-rank an east document; same for north.
830        let mut docs = Vec::new();
831        for i in 0..20 {
832            let jitter = i as f32 * 0.003;
833            let east = normalize([1.0 - jitter, 0.05 + jitter]);
834            docs.push(DocumentTokens {
835                doc_id: 100 + i,
836                tokens: east.to_vec(),
837                n_tokens: 1,
838            });
839            let north = normalize([0.05 + jitter, 1.0 - jitter]);
840            docs.push(DocumentTokens {
841                doc_id: 200 + i,
842                tokens: north.to_vec(),
843                n_tokens: 1,
844            });
845        }
846        let index = build_index(&docs, params()).unwrap();
847
848        let east = search(
849            &index,
850            &[1.0, 0.0],
851            SearchParams {
852                top_k: 5,
853                n_probe: 2,
854                n_candidate_docs: None,
855                centroid_score_threshold: None,
856            },
857        )
858        .unwrap();
859        for r in &east {
860            assert!(
861                (100..120).contains(&r.doc_id),
862                "east query should only surface east docs: got {}",
863                r.doc_id,
864            );
865        }
866
867        let north = search(
868            &index,
869            &[0.0, 1.0],
870            SearchParams {
871                top_k: 5,
872                n_probe: 2,
873                n_candidate_docs: None,
874                centroid_score_threshold: None,
875            },
876        )
877        .unwrap();
878        for r in &north {
879            assert!(
880                (200..220).contains(&r.doc_id),
881                "north query should only surface north docs: got {}",
882                r.doc_id,
883            );
884        }
885    }
886
887    #[test]
888    #[should_panic(expected = "top_k must be positive")]
889    fn search_panics_on_zero_top_k() {
890        let index = build_index(&corpus(), params()).unwrap();
891        let _ = search(
892            &index,
893            &[1.0, 0.0],
894            SearchParams {
895                top_k: 0,
896                n_probe: 1,
897                n_candidate_docs: None,
898                centroid_score_threshold: None,
899            },
900        )
901        .unwrap();
902    }
903
904    #[test]
905    #[should_panic(expected = "n_probe must be positive")]
906    fn search_panics_on_zero_n_probe() {
907        let index = build_index(&corpus(), params()).unwrap();
908        let _ = search(
909            &index,
910            &[1.0, 0.0],
911            SearchParams {
912                top_k: 1,
913                n_probe: 0,
914                n_candidate_docs: None,
915                centroid_score_threshold: None,
916            },
917        )
918        .unwrap();
919    }
920
921    #[test]
922    #[should_panic(expected = "query length")]
923    fn search_panics_on_ragged_query() {
924        let index = build_index(&corpus(), params()).unwrap();
925        let _ = search(
926            &index,
927            &[1.0, 0.0, 0.5],
928            SearchParams {
929                top_k: 1,
930                n_probe: 1,
931                n_candidate_docs: None,
932                centroid_score_threshold: None,
933            },
934        )
935        .unwrap();
936    }
937
938    #[test]
939    fn centroid_interaction_shortlist_caps_candidates_surviving_to_decode() {
940        // With only one candidate allowed past centroid interaction we
941        // should get exactly one doc in the results, and it must be the
942        // best-scoring one under the approximate stage.
943        let index = build_index(&corpus(), params()).unwrap();
944        let out = search(
945            &index,
946            &[1.0, 0.0],
947            SearchParams {
948                top_k: 5,
949                n_probe: index.codec.num_centroids(),
950                n_candidate_docs: Some(1),
951                centroid_score_threshold: None,
952            },
953        )
954        .unwrap();
955        assert_eq!(out.len(), 1, "shortlist of 1 must cap output at 1");
956        assert_eq!(
957            out[0].doc_id, 1,
958            "east query should surface the east doc as the survivor",
959        );
960    }
961
962    #[test]
963    fn centroid_interaction_with_large_shortlist_matches_no_shortlist() {
964        // A shortlist size at least equal to the number of candidates
965        // is effectively no shortlisting — the centroid-interaction
966        // stage picks everything through, so results must agree with
967        // the legacy `None` path byte-for-byte.
968        let index = build_index(&corpus(), params()).unwrap();
969        let query = [1.0, 0.0, 0.0, 1.0];
970
971        let legacy = search(
972            &index,
973            &query,
974            SearchParams {
975                top_k: 3,
976                n_probe: index.codec.num_centroids(),
977                n_candidate_docs: None,
978                centroid_score_threshold: None,
979            },
980        )
981        .unwrap();
982        let shortlisted = search(
983            &index,
984            &query,
985            SearchParams {
986                top_k: 3,
987                n_probe: index.codec.num_centroids(),
988                n_candidate_docs: Some(1_000),
989                centroid_score_threshold: None,
990            },
991        )
992        .unwrap();
993        assert_eq!(legacy, shortlisted);
994    }
995
996    #[test]
997    fn centroid_pruning_with_low_threshold_matches_no_pruning() {
998        // A threshold below every attainable centroid score is a
999        // no-op. For unit-norm centroids the dot product ceiling is
1000        // ~1, so using −1.0 guarantees every centroid survives.
1001        let index = build_index(&corpus(), params()).unwrap();
1002        let query = [1.0, 0.0, 0.0, 1.0];
1003        let unpruned = search(
1004            &index,
1005            &query,
1006            SearchParams {
1007                top_k: 3,
1008                n_probe: index.codec.num_centroids(),
1009                n_candidate_docs: None,
1010                centroid_score_threshold: None,
1011            },
1012        )
1013        .unwrap();
1014        let pruned = search(
1015            &index,
1016            &query,
1017            SearchParams {
1018                top_k: 3,
1019                n_probe: index.codec.num_centroids(),
1020                n_candidate_docs: None,
1021                centroid_score_threshold: Some(-1.0),
1022            },
1023        )
1024        .unwrap();
1025        assert_eq!(unpruned, pruned);
1026    }
1027
1028    #[test]
1029    fn two_stage_interaction_caps_survivors_to_ndocs_div_four() {
1030        // Paper §4.2/§4.3: Stage 2 shortlists to `ndocs`, Stage 3
1031        // refines to `ndocs/4`. With 20 synthetic east/north docs, a
1032        // Stage 2 shortlist of 16, and `top_k` well above the Stage 3
1033        // cap, we should see at most `16/4 = 4` docs in the final
1034        // result — the Stage 3 bound, not `top_k` nor Stage 2's 16.
1035        let mut docs = Vec::new();
1036        for i in 0..20 {
1037            let jitter = i as f32 * 0.003;
1038            let east = normalize([1.0 - jitter, 0.05 + jitter]);
1039            docs.push(DocumentTokens {
1040                doc_id: 100 + i,
1041                tokens: east.to_vec(),
1042                n_tokens: 1,
1043            });
1044        }
1045        let index = build_index(&docs, params()).unwrap();
1046        let out = search(
1047            &index,
1048            &[1.0, 0.0],
1049            SearchParams {
1050                top_k: 20,
1051                n_probe: index.codec.num_centroids(),
1052                n_candidate_docs: Some(16),
1053                centroid_score_threshold: None,
1054            },
1055        )
1056        .unwrap();
1057        assert!(
1058            out.len() <= 4,
1059            "Stage 3 must cap the decoded set at ndocs/4 = 4; got {}",
1060            out.len(),
1061        );
1062        assert!(
1063            !out.is_empty(),
1064            "Stage 3 should still return at least one result"
1065        );
1066    }
1067
1068    #[test]
1069    fn approx_score_skips_tokens_whose_centroid_is_pruned() {
1070        // Paper §4.3: D̃ must only be comprised of tokens whose centroid
1071        // passes the t_cs threshold. This directly exercises that
1072        // behaviour on a controlled qc_scores matrix.
1073        //
1074        // 3 centroids, 2 query tokens. Centroid 0 is strong for q0 only,
1075        // centroid 1 is strong for q1 only, centroid 2 is mediocre for
1076        // both. A doc with tokens in centroids [0, 2] would normally
1077        // pull qc_scores[2] into the q1 max (because centroid 0 is weak
1078        // for q1 and centroid 2 at least has 0.4). Pruning centroid 2
1079        // drops that contribution and only centroid 0's qc values
1080        // remain.
1081        use crate::codec::EncodedVector;
1082        let n_q = 2;
1083        let n_c = 3;
1084        // qc_scores layout is row-major [n_q, n_c].
1085        // q0 row: centroid 0 = 0.9, centroid 1 = 0.0, centroid 2 = 0.4.
1086        // q1 row: centroid 0 = 0.0, centroid 1 = 0.9, centroid 2 = 0.4.
1087        let qc = [0.9, 0.0, 0.4, 0.0, 0.9, 0.4];
1088        let doc = [
1089            EncodedVector {
1090                centroid_id: 0,
1091                codes: vec![0, 0],
1092            },
1093            EncodedVector {
1094                centroid_id: 2,
1095                codes: vec![0, 0],
1096            },
1097        ];
1098
1099        let unpruned =
1100            approx_centroid_interaction_score(&doc, &qc, n_q, n_c, None);
1101        // q0: max(0.9, 0.4) = 0.9; q1: max(0.0, 0.4) = 0.4. Total 1.3.
1102        assert!((unpruned - 1.3).abs() < 1e-5, "unpruned was {unpruned}");
1103
1104        // Prune centroid 2 (per-centroid max 0.4 < t_cs=0.5).
1105        let pruned_mask = [false, false, true];
1106        let pruned = approx_centroid_interaction_score(
1107            &doc,
1108            &qc,
1109            n_q,
1110            n_c,
1111            Some(&pruned_mask),
1112        );
1113        // q0: max over {centroid 0} = 0.9; q1: max over {centroid 0} =
1114        // 0.0. Total 0.9.
1115        assert!((pruned - 0.9).abs() < 1e-5, "pruned was {pruned}");
1116    }
1117
1118    #[test]
1119    fn approx_score_on_all_pruned_doc_is_negative_infinity() {
1120        // Every centroid in the doc is masked. The scorer must return a
1121        // score low enough that the doc loses to any non-empty doc in
1122        // the centroid-interaction sort.
1123        use crate::codec::EncodedVector;
1124        let qc = [0.5, 0.5];
1125        let doc = [EncodedVector {
1126            centroid_id: 0,
1127            codes: vec![0, 0],
1128        }];
1129        let mask = [true];
1130        let score =
1131            approx_centroid_interaction_score(&doc, &qc, 1, 1, Some(&mask));
1132        assert!(
1133            score == f32::NEG_INFINITY,
1134            "all-pruned doc should score -inf, got {score}",
1135        );
1136    }
1137
1138    #[test]
1139    fn centroid_pruning_excludes_docs_whose_only_centroid_is_below_threshold() {
1140        // Build an index with two clusters. An east-pointing query
1141        // scores the east centroid near 1.0 and the north centroid
1142        // near 0.0. A threshold of 0.5 prunes the north centroid,
1143        // so doc 2 (pure-north) must be filtered out — but doc 3
1144        // (mixed east + north) still has an east token and survives.
1145        let index = build_index(&corpus(), params()).unwrap();
1146        let out = search(
1147            &index,
1148            &[1.0f32, 0.0],
1149            SearchParams {
1150                top_k: 5,
1151                n_probe: index.codec.num_centroids(),
1152                n_candidate_docs: None,
1153                centroid_score_threshold: Some(0.5),
1154            },
1155        )
1156        .unwrap();
1157        let ids: Vec<u64> = out.iter().map(|r| r.doc_id).collect();
1158        assert!(
1159            !ids.contains(&2),
1160            "pure-north doc must be pruned when only the east centroid survives the threshold: got {ids:?}",
1161        );
1162        assert!(
1163            ids.contains(&1),
1164            "east-cluster doc must still rank when its centroid survives: got {ids:?}",
1165        );
1166    }
1167
1168    #[test]
1169    fn centroid_pruning_with_unreachable_threshold_drops_every_candidate() {
1170        // No centroid can score higher than ~1.0 against a unit-norm
1171        // query on unit-norm centroids. A threshold of 10.0 prunes
1172        // everything, so no docs survive.
1173        let index = build_index(&corpus(), params()).unwrap();
1174        let out = search(
1175            &index,
1176            &[1.0, 0.0],
1177            SearchParams {
1178                top_k: 3,
1179                n_probe: index.codec.num_centroids(),
1180                n_candidate_docs: None,
1181                centroid_score_threshold: Some(10.0),
1182            },
1183        )
1184        .unwrap();
1185        assert!(
1186            out.is_empty(),
1187            "every centroid below threshold should yield empty results",
1188        );
1189    }
1190
1191    #[test]
1192    fn centroid_interaction_preserves_exact_maxsim_on_surviving_candidates() {
1193        // The final ranking stage still runs exact decoded MaxSim.
1194        // With a shortlist that keeps everyone, the top-1 score must
1195        // equal the MaxSim over decoded tokens for that doc.
1196        let index = build_index(&corpus(), params()).unwrap();
1197        let query = [1.0f32, 0.0];
1198        let out = search(
1199            &index,
1200            &query,
1201            SearchParams {
1202                top_k: 1,
1203                n_probe: index.codec.num_centroids(),
1204                n_candidate_docs: Some(1_000),
1205                centroid_score_threshold: None,
1206            },
1207        )
1208        .unwrap();
1209        let top = out[0];
1210        let doc_idx = index.position_of(top.doc_id).unwrap();
1211        let decoded: Vec<Vec<f32>> = index.doc_tokens[doc_idx]
1212            .iter()
1213            .map(|ev| index.codec.decode_vector(ev).unwrap())
1214            .collect();
1215        let expected: f32 = query
1216            .chunks_exact(2)
1217            .map(|q| {
1218                decoded
1219                    .iter()
1220                    .map(|d| dot(q, d))
1221                    .fold(f32::NEG_INFINITY, f32::max)
1222            })
1223            .sum();
1224        assert!((expected - top.score).abs() < 1e-5);
1225    }
1226}