Skip to main content

velesdb_core/quantization/
pq.rs

1//! Product Quantization (PQ) for aggressive lossy vector compression.
2//!
3//! PQ splits vectors into multiple subspaces and quantizes each subspace
4//! independently with its own codebook (k-means centroids).
5//!
6//! K-means training is in [`super::pq_kmeans`], OPQ rotation in [`super::pq_opq`].
7
8use crate::error::Error;
9use serde::{Deserialize, Serialize};
10use std::borrow::Cow;
11
12use super::pq_kmeans::{kmeans_train, l2_squared, nearest_centroid};
13
14/// Per-subspace centroid tables learned with k-means.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct PQCodebook {
17    /// Flattened centroids, indexed as `[subspace][centroid][subspace_dim]`.
18    pub centroids: Vec<Vec<Vec<f32>>>,
19    /// Full vector dimension.
20    pub dimension: usize,
21    /// Number of subspaces `m`.
22    pub num_subspaces: usize,
23    /// Number of centroids `k` per subspace.
24    pub num_centroids: usize,
25    /// Dimension of each subspace.
26    pub subspace_dim: usize,
27}
28
29/// Compressed representation of a vector: one centroid id per subspace.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct PQVector {
32    /// Selected centroid ids for each subspace.
33    pub codes: Vec<u16>,
34}
35
36/// Product quantizer model and helpers for train/encode/decode.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct ProductQuantizer {
39    /// Trained codebook.
40    pub codebook: PQCodebook,
41    /// OPQ rotation matrix (flattened row-major D x D). None if OPQ disabled.
42    pub rotation: Option<Vec<f32>>,
43}
44
45/// Validate common training parameters shared by [`ProductQuantizer::train`] and
46/// [`super::pq_opq::train_opq`].
47///
48/// Returns `(dimension, subspace_dim)` on success.
49///
50/// # Errors
51///
52/// Returns `Error::InvalidQuantizerConfig` if:
53/// - `vectors` is empty
54/// - `num_subspaces` is 0
55/// - `num_centroids` is 0 or exceeds `u16::MAX`
56/// - vector dimension is zero or not uniform across all vectors
57/// - vector dimension is not divisible by `num_subspaces`
58/// - `num_centroids` exceeds `vectors.len()`
59pub(super) fn validate_train_params(
60    vectors: &[Vec<f32>],
61    num_subspaces: usize,
62    num_centroids: usize,
63) -> Result<(usize, usize), Error> {
64    validate_basic_params(vectors, num_subspaces, num_centroids)?;
65
66    let dimension = vectors[0].len();
67    validate_dimension(vectors, dimension, num_subspaces, num_centroids)?;
68
69    let subspace_dim = dimension / num_subspaces;
70    Ok((dimension, subspace_dim))
71}
72
73/// Validates non-empty dataset and non-zero subspace/centroid counts.
74fn validate_basic_params(
75    vectors: &[Vec<f32>],
76    num_subspaces: usize,
77    num_centroids: usize,
78) -> Result<(), Error> {
79    if vectors.is_empty() {
80        return Err(Error::InvalidQuantizerConfig(
81            "cannot train PQ with empty dataset".into(),
82        ));
83    }
84    if num_subspaces == 0 {
85        return Err(Error::InvalidQuantizerConfig(
86            "num_subspaces must be > 0".into(),
87        ));
88    }
89    if num_centroids == 0 {
90        return Err(Error::InvalidQuantizerConfig(
91            "num_centroids must be > 0".into(),
92        ));
93    }
94    if u16::try_from(num_centroids).is_err() {
95        return Err(Error::InvalidQuantizerConfig(
96            "num_centroids must fit in u16 (max 65535)".into(),
97        ));
98    }
99    Ok(())
100}
101
102/// Validates dimension uniformity, divisibility, and centroid count bounds.
103fn validate_dimension(
104    vectors: &[Vec<f32>],
105    dimension: usize,
106    num_subspaces: usize,
107    num_centroids: usize,
108) -> Result<(), Error> {
109    if dimension == 0 {
110        return Err(Error::InvalidQuantizerConfig(
111            "vectors must have non-zero dimension".into(),
112        ));
113    }
114    if !vectors.iter().all(|v| v.len() == dimension) {
115        return Err(Error::InvalidQuantizerConfig(
116            "all vectors must share the same dimension".into(),
117        ));
118    }
119    if !dimension.is_multiple_of(num_subspaces) {
120        return Err(Error::InvalidQuantizerConfig(
121            "dimension must be divisible by num_subspaces".into(),
122        ));
123    }
124    if num_centroids > vectors.len() {
125        return Err(Error::InvalidQuantizerConfig(format!(
126            "num_centroids ({num_centroids}) exceeds number of training vectors ({})",
127            vectors.len()
128        )));
129    }
130    Ok(())
131}
132
133impl ProductQuantizer {
134    /// Train a PQ codebook using simplified k-means for each subspace.
135    ///
136    /// # Errors
137    ///
138    /// Returns `Error::InvalidQuantizerConfig` if:
139    /// - `vectors` is empty
140    /// - `num_subspaces` is 0
141    /// - `num_centroids` is 0 or exceeds `u16::MAX`
142    /// - vector dimension is not divisible by `num_subspaces`
143    /// - `num_centroids` exceeds `vectors.len()`
144    pub fn train(
145        vectors: &[Vec<f32>],
146        num_subspaces: usize,
147        num_centroids: usize,
148    ) -> Result<Self, Error> {
149        let (dimension, subspace_dim) =
150            validate_train_params(vectors, num_subspaces, num_centroids)?;
151
152        let centroids =
153            train_subspace_centroids(vectors, num_subspaces, subspace_dim, num_centroids);
154
155        // Post-training: degenerate centroid detection.
156        // This O(k^2) check is only run in debug builds.
157        #[cfg(debug_assertions)]
158        check_degenerate_centroids(&centroids);
159
160        // LUT size validation
161        let lut_size = num_subspaces * num_centroids * 4;
162        if lut_size > 8192 {
163            tracing::warn!("PQ LUT size {lut_size} bytes exceeds L1-friendly 8KB threshold");
164        }
165
166        Ok(Self {
167            codebook: PQCodebook {
168                centroids,
169                dimension,
170                num_subspaces,
171                num_centroids,
172                subspace_dim,
173            },
174            rotation: None,
175        })
176    }
177
178    /// Quantize a full-precision vector into PQ codes.
179    ///
180    /// Applies OPQ rotation (if present) before encoding, so that codebook
181    /// centroids — which were trained on rotated vectors — remain consistent
182    /// with the encoded representation.
183    ///
184    /// # Errors
185    ///
186    /// Returns `Error::InvalidQuantizerConfig` if `vector.len()` does not match
187    /// the codebook dimension.
188    pub fn quantize(&self, vector: &[f32]) -> Result<PQVector, Error> {
189        if vector.len() != self.codebook.dimension {
190            return Err(Error::InvalidQuantizerConfig(format!(
191                "vector dimension mismatch: expected {}, got {}",
192                self.codebook.dimension,
193                vector.len()
194            )));
195        }
196
197        // Apply rotation so codes are computed in the same space as the codebook.
198        let rotated = self.apply_rotation(vector);
199        let effective: &[f32] = &rotated;
200
201        let mut codes = Vec::with_capacity(self.codebook.num_subspaces);
202        for subspace in 0..self.codebook.num_subspaces {
203            let start = subspace * self.codebook.subspace_dim;
204            let end = start + self.codebook.subspace_dim;
205            let code = nearest_centroid(&effective[start..end], &self.codebook.centroids[subspace]);
206            // Reason: `num_centroids` is validated to fit in u16 during `train()`.
207            // `nearest_centroid` returns an index < num_centroids, so it always fits.
208            #[allow(clippy::cast_possible_truncation)]
209            codes.push(code as u16);
210        }
211
212        Ok(PQVector { codes })
213    }
214
215    /// Reconstruct an approximate vector from PQ codes.
216    ///
217    /// # Errors
218    ///
219    /// Returns `Error::InvalidQuantizerConfig` if the number of codes does not
220    /// match the number of subspaces, or if a code index is out of range.
221    pub fn reconstruct(&self, pq_vector: &PQVector) -> Result<Vec<f32>, Error> {
222        if pq_vector.codes.len() != self.codebook.num_subspaces {
223            return Err(Error::InvalidQuantizerConfig(format!(
224                "code count mismatch: expected {}, got {}",
225                self.codebook.num_subspaces,
226                pq_vector.codes.len()
227            )));
228        }
229
230        let mut reconstructed = Vec::with_capacity(self.codebook.dimension);
231        for (subspace, &code) in pq_vector.codes.iter().enumerate() {
232            let code_idx = usize::from(code);
233            if code_idx >= self.codebook.centroids[subspace].len() {
234                return Err(Error::InvalidQuantizerConfig(format!(
235                    "code index {code_idx} out of range for subspace {subspace} \
236                     (max {})",
237                    self.codebook.centroids[subspace].len() - 1
238                )));
239            }
240            let centroid = &self.codebook.centroids[subspace][code_idx];
241            reconstructed.extend_from_slice(centroid);
242        }
243
244        Ok(reconstructed)
245    }
246}
247
248/// Train centroids for a single subspace via k-means.
249fn train_single_subspace(
250    vectors: &[Vec<f32>],
251    subspace: usize,
252    subspace_dim: usize,
253    num_centroids: usize,
254    #[cfg(feature = "gpu")] gpu_ctx: Option<&crate::gpu::PqGpuContext>,
255) -> Vec<Vec<f32>> {
256    let start = subspace * subspace_dim;
257    let end = start + subspace_dim;
258    let sub_vectors: Vec<Vec<f32>> = vectors.iter().map(|v| v[start..end].to_vec()).collect();
259    #[allow(clippy::cast_possible_truncation)]
260    let seed = 42u64.wrapping_add(subspace as u64);
261    kmeans_train(
262        &sub_vectors,
263        num_centroids,
264        50,
265        seed,
266        #[cfg(feature = "gpu")]
267        gpu_ctx,
268    )
269}
270
271/// Train centroids for all subspaces, using rayon when persistence is enabled.
272fn train_subspace_centroids(
273    vectors: &[Vec<f32>],
274    num_subspaces: usize,
275    subspace_dim: usize,
276    num_centroids: usize,
277) -> Vec<Vec<Vec<f32>>> {
278    #[cfg(feature = "gpu")]
279    let gpu_ctx = crate::gpu::PqGpuContext::new();
280
281    #[cfg(feature = "persistence")]
282    {
283        use rayon::prelude::*;
284        (0..num_subspaces)
285            .into_par_iter()
286            .map(|s| {
287                train_single_subspace(
288                    vectors,
289                    s,
290                    subspace_dim,
291                    num_centroids,
292                    #[cfg(feature = "gpu")]
293                    gpu_ctx.as_ref(),
294                )
295            })
296            .collect()
297    }
298    #[cfg(not(feature = "persistence"))]
299    {
300        (0..num_subspaces)
301            .map(|s| {
302                train_single_subspace(
303                    vectors,
304                    s,
305                    subspace_dim,
306                    num_centroids,
307                    #[cfg(feature = "gpu")]
308                    gpu_ctx.as_ref(),
309                )
310            })
311            .collect()
312    }
313}
314
315/// Debug-only check for degenerate (nearly duplicate) centroids after training.
316#[cfg(debug_assertions)]
317fn check_degenerate_centroids(centroids: &[Vec<Vec<f32>>]) {
318    for (subspace, sub_centroids) in centroids.iter().enumerate() {
319        for i in 0..sub_centroids.len() {
320            for j in (i + 1)..sub_centroids.len() {
321                let dist = l2_squared(&sub_centroids[i], &sub_centroids[j]);
322                if dist < 1e-6 {
323                    tracing::warn!(
324                        "degenerate centroids detected in subspace {subspace}: \
325                         centroids {i} and {j} distance {dist}"
326                    );
327                }
328            }
329        }
330    }
331}
332
333impl ProductQuantizer {
334    /// Validate a deserialized quantizer's structural invariants.
335    ///
336    /// Must be called once on every quantizer loaded from untrusted bytes
337    /// (see [`Self::load_codebook`]) before it is used for search. After this
338    /// returns `Ok`, the codebook layout matches its declared dimensions and
339    /// the rotation matrix (if present) is `dimension * dimension`, so the
340    /// unchecked indexing in `apply_rotation` operates
341    /// only on in-bounds offsets.
342    ///
343    /// # Errors
344    ///
345    /// Returns `Error::IndexCorrupted` if the centroid table shape, subspace
346    /// dimension, or rotation length is inconsistent with the declared
347    /// `dimension` / `num_subspaces` / `num_centroids`.
348    pub fn validate_loaded(&self) -> Result<(), Error> {
349        let cb = &self.codebook;
350        if cb.num_subspaces == 0 || cb.num_centroids == 0 || cb.subspace_dim == 0 {
351            return Err(Error::IndexCorrupted(
352                "PQ codebook has zero subspaces, centroids, or subspace_dim".into(),
353            ));
354        }
355        Self::validate_dimensions(cb)?;
356        if cb.centroids.len() != cb.num_subspaces {
357            return Err(Error::IndexCorrupted(format!(
358                "PQ codebook has {} centroid tables, expected num_subspaces {}",
359                cb.centroids.len(),
360                cb.num_subspaces
361            )));
362        }
363        Self::validate_centroid_tables(cb)?;
364        self.validate_rotation()
365    }
366
367    /// Validate the `num_centroids` ceiling and the `subspace_dim * num_subspaces`
368    /// shape invariant against attacker-controlled, post-deserialize fields.
369    fn validate_dimensions(cb: &PQCodebook) -> Result<(), Error> {
370        // `train()` enforces `num_centroids <= u16::MAX` so codes (u16) can index
371        // every centroid. Without this, `validate_codes` (`code < num_centroids`)
372        // is trivially true for any `num_centroids > 65535`, letting out-of-table
373        // codes slip past. Reject here, consistent with `train`.
374        if u16::try_from(cb.num_centroids).is_err() {
375            return Err(Error::IndexCorrupted(format!(
376                "PQ codebook num_centroids {} exceeds u16::MAX (65535)",
377                cb.num_centroids
378            )));
379        }
380        // `checked_mul`: both operands are attacker-controlled post-deserialize; a
381        // wrapping multiply (esp. on 32-bit targets) could false-pass this check.
382        let Some(product) = cb.subspace_dim.checked_mul(cb.num_subspaces) else {
383            return Err(Error::IndexCorrupted(format!(
384                "PQ codebook subspace_dim {} * num_subspaces {} overflows usize",
385                cb.subspace_dim, cb.num_subspaces
386            )));
387        };
388        if product != cb.dimension {
389            return Err(Error::IndexCorrupted(format!(
390                "PQ codebook dimension {} != num_subspaces {} * subspace_dim {}",
391                cb.dimension, cb.num_subspaces, cb.subspace_dim
392            )));
393        }
394        Ok(())
395    }
396
397    /// Validate that every subspace has `num_centroids` centroids of `subspace_dim`.
398    fn validate_centroid_tables(cb: &PQCodebook) -> Result<(), Error> {
399        for (subspace, table) in cb.centroids.iter().enumerate() {
400            if table.len() != cb.num_centroids {
401                return Err(Error::IndexCorrupted(format!(
402                    "PQ subspace {subspace} has {} centroids, expected {}",
403                    table.len(),
404                    cb.num_centroids
405                )));
406            }
407            if let Some(bad) = table.iter().position(|c| c.len() != cb.subspace_dim) {
408                return Err(Error::IndexCorrupted(format!(
409                    "PQ subspace {subspace} centroid {bad} has wrong length, expected {}",
410                    cb.subspace_dim
411                )));
412            }
413        }
414        Ok(())
415    }
416
417    /// Validate the OPQ rotation matrix length against the codebook dimension.
418    fn validate_rotation(&self) -> Result<(), Error> {
419        if let Some(matrix) = &self.rotation {
420            super::validate_rotation_len(matrix.len(), self.codebook.dimension, "OPQ")?;
421        }
422        Ok(())
423    }
424
425    /// Verify every code in `pq_vector` is a valid centroid index `< num_centroids`
426    /// and that the code count matches `num_subspaces`.
427    ///
428    /// This is the precondition the unsafe SIMD ADC kernels rely on: once it
429    /// returns `Ok`, `code[i] < num_centroids` for all `i`, so every gather
430    /// index `subspace * num_centroids + code` stays within the LUT bounds.
431    ///
432    /// # Errors
433    ///
434    /// Returns `Error::IndexCorrupted` if a code is out of range or the code
435    /// count is wrong.
436    pub fn validate_codes(&self, pq_vector: &PQVector) -> Result<(), Error> {
437        if pq_vector.codes.len() != self.codebook.num_subspaces {
438            return Err(Error::IndexCorrupted(format!(
439                "PQ vector has {} codes, expected num_subspaces {}",
440                pq_vector.codes.len(),
441                self.codebook.num_subspaces
442            )));
443        }
444        if let Some(bad) = pq_vector
445            .codes
446            .iter()
447            .position(|&c| usize::from(c) >= self.codebook.num_centroids)
448        {
449            return Err(Error::IndexCorrupted(format!(
450                "PQ code {} at subspace {bad} >= num_centroids {}",
451                pq_vector.codes[bad], self.codebook.num_centroids
452            )));
453        }
454        Ok(())
455    }
456
457    /// Precompute ADC lookup table for a query vector.
458    ///
459    /// Returns flat `[m * k]` table indexed as `lut[subspace * k + centroid_id]`.
460    /// Applies OPQ rotation if present.
461    #[must_use]
462    pub fn precompute_lut(&self, query: &[f32]) -> Vec<f32> {
463        let query = self.apply_rotation(query);
464        let m = self.codebook.num_subspaces;
465        let k = self.codebook.num_centroids;
466        let sd = self.codebook.subspace_dim;
467        let mut lut = Vec::with_capacity(m * k);
468        for subspace in 0..m {
469            let q_sub = &query[subspace * sd..(subspace + 1) * sd];
470            for centroid in &self.codebook.centroids[subspace] {
471                lut.push(l2_squared(q_sub, centroid));
472            }
473        }
474        lut
475    }
476
477    /// Apply OPQ rotation matrix to a vector.
478    ///
479    /// Returns a [`Cow::Borrowed`] slice pointing to the original vector when no
480    /// rotation is present, avoiding an allocation on the common no-rotation path.
481    /// Returns a [`Cow::Owned`] `Vec<f32>` with the rotated result otherwise.
482    pub(crate) fn apply_rotation<'a>(&self, vector: &'a [f32]) -> Cow<'a, [f32]> {
483        match &self.rotation {
484            None => Cow::Borrowed(vector),
485            Some(matrix) => {
486                let d = vector.len();
487                let mut rotated = vec![0.0_f32; d];
488                for i in 0..d {
489                    for j in 0..d {
490                        rotated[i] += matrix[i * d + j] * vector[j];
491                    }
492                }
493                Cow::Owned(rotated)
494            }
495        }
496    }
497}
498
499/// Asymmetric distance computation (ADC): query is f32, candidate is PQ-coded.
500///
501/// Applies OPQ rotation to the query when the quantizer has a rotation matrix,
502/// matching the space in which the codebook centroids were trained.
503///
504/// This is a crate-internal function. Inputs are expected to be valid by
505/// construction: `query_vector.len() == quantizer.codebook.dimension` and
506/// `pq_vector.codes.len() == quantizer.codebook.num_subspaces`. These invariants
507/// are enforced at insert/train time and asserted only in debug builds.
508#[must_use]
509#[cfg_attr(not(feature = "persistence"), allow(dead_code))]
510pub(crate) fn distance_pq_l2(
511    query_vector: &[f32],
512    pq_vector: &PQVector,
513    quantizer: &ProductQuantizer,
514) -> f32 {
515    debug_assert_eq!(query_vector.len(), quantizer.codebook.dimension);
516    debug_assert_eq!(pq_vector.codes.len(), quantizer.codebook.num_subspaces);
517
518    // RF-2: Reuse precompute_lut to avoid duplicating the rotation + LUT build loop.
519    let lut = quantizer.precompute_lut(query_vector);
520    distance_pq_l2_with_lut(pq_vector, &lut, quantizer.codebook.num_centroids)
521}
522
523/// Computes ADC distance from a precomputed lookup table.
524///
525/// The LUT is indexed as `lut[subspace * k + centroid_id]`.
526/// This is the hot inner loop for batch ADC scoring.
527#[must_use]
528#[cfg_attr(not(feature = "persistence"), allow(dead_code))]
529pub(crate) fn distance_pq_l2_with_lut(
530    pq_vector: &PQVector,
531    lut: &[f32],
532    num_centroids: usize,
533) -> f32 {
534    pq_vector
535        .codes
536        .iter()
537        .enumerate()
538        .map(|(subspace, &code)| lut[subspace * num_centroids + usize::from(code)])
539        .sum::<f32>()
540        .sqrt()
541}
542
543/// Minimum batch size for SIMD ADC path.
544///
545/// Below this threshold the overhead of building code slices and dispatching
546/// through `adc_distances_batch` exceeds the scalar per-item path.
547#[cfg_attr(not(feature = "persistence"), allow(dead_code))]
548const ADC_SIMD_BATCH_THRESHOLD: usize = 8;
549
550/// Batch ADC rescoring using SIMD-accelerated distance computation.
551///
552/// Builds a single LUT from the query vector (applying OPQ rotation if
553/// present), then dispatches to [`crate::simd_native::adc::adc_distances_batch`]
554/// for vectorized distance computation across all candidates.
555///
556/// Returns `(index, sqrt_distance)` pairs preserving the input order.
557///
558/// Falls back to scalar per-item scoring when the batch is smaller than
559/// [`ADC_SIMD_BATCH_THRESHOLD`] or when the SIMD path returns an error.
560///
561/// # Errors
562///
563/// Returns `Err` if a candidate's PQ codes are out of range for the codebook
564/// (`Error::IndexCorrupted`, e.g. from a tampered/corrupt persisted vector) or
565/// if LUT construction parameters are inconsistent (zero subspaces).
566#[cfg_attr(not(feature = "persistence"), allow(dead_code))]
567pub(crate) fn pq_adc_batch_rescore(
568    quantizer: &ProductQuantizer,
569    query: &[f32],
570    pq_vectors: &[&PQVector],
571) -> crate::error::Result<Vec<f32>> {
572    if pq_vectors.is_empty() {
573        return Ok(Vec::new());
574    }
575
576    let m = quantizer.codebook.num_subspaces;
577
578    // Validate every code once, before any indexing into the LUT. This upholds
579    // the unsafe SIMD kernel precondition `code[i] < num_centroids` (== k) for
580    // the whole batch — so the gather indices stay within the LUT bounds — and
581    // keeps the scalar fallback panic-free. A single linear scan here keeps the
582    // check out of both the scalar and SIMD hot loops.
583    for pq_vec in pq_vectors {
584        quantizer.validate_codes(pq_vec)?;
585    }
586
587    // Small batches: scalar path avoids slice-building overhead.
588    if pq_vectors.len() < ADC_SIMD_BATCH_THRESHOLD {
589        let lut = quantizer.precompute_lut(query);
590        let k = quantizer.codebook.num_centroids;
591        return Ok(pq_vectors
592            .iter()
593            .map(|pq_vec| distance_pq_l2_with_lut(pq_vec, &lut, k))
594            .collect());
595    }
596
597    // Build LUT once (includes OPQ rotation).
598    let lut = quantizer.precompute_lut(query);
599
600    // Collect code slices for the SIMD kernel.
601    let code_slices: Vec<&[u16]> = pq_vectors
602        .iter()
603        .map(|pq_vec| pq_vec.codes.as_slice())
604        .collect();
605
606    // SIMD-accelerated ADC returns squared L2 sums; apply sqrt for L2 distance.
607    let squared_dists = crate::simd_native::adc::adc_distances_batch(&lut, &code_slices, m)?;
608
609    Ok(squared_dists.iter().map(|&d| d.sqrt()).collect())
610}
611
612#[cfg(test)]
613#[path = "pq_tests.rs"]
614mod tests;