Skip to main content

hermes_core/structures/vector/quantization/
pq.rs

1//! Product Quantization with an Optimized Product Quantization rotation.
2//!
3//! Implementation inspired by Google's ScaNN (Scalable Nearest Neighbors):
4//! - **OPQ rotation**: learns optimal rotation matrix before quantization
5//! - **Product quantization** with learned codebooks
6//! - **SIMD-accelerated** asymmetric distance computation
7
8use serde::{Deserialize, Serialize};
9
10#[cfg(target_arch = "aarch64")]
11#[allow(unused_imports)]
12use std::arch::aarch64::*;
13
14#[cfg(all(target_arch = "x86_64", feature = "native"))]
15#[allow(unused_imports)]
16use std::arch::x86_64::*;
17
18/// Default number of centroids per subspace (K) - must be 256 for u8 codes
19pub const DEFAULT_NUM_CENTROIDS: usize = 256;
20
21/// Configuration for residual Product Quantization with OPQ.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PQConfig {
24    /// Vector dimension
25    pub dim: usize,
26    /// Number of subspaces (M) - computed from dim / dims_per_block
27    pub num_subspaces: usize,
28    /// Dimensions per subspace block.
29    pub dims_per_block: usize,
30    /// Number of centroids per subspace (K) - typically 256 for u8 codes
31    pub num_centroids: usize,
32    /// Random seed for reproducibility
33    pub seed: u64,
34    /// Use OPQ rotation matrix (learned via SVD)
35    pub use_opq: bool,
36    /// Number of OPQ iterations
37    pub opq_iters: usize,
38}
39
40impl PQConfig {
41    /// Production profile: at most one byte per eight dimensions (96 bytes for
42    /// 768D), with OPQ training. Raw vectors remain available for the bounded
43    /// exact rerank stage.
44    pub fn new(dim: usize) -> Self {
45        assert!(dim > 0, "PQ dimension must be non-zero");
46        let dims_per_block = (1..=8)
47            .rev()
48            .find(|candidate| dim.is_multiple_of(*candidate))
49            .unwrap_or(1);
50        Self {
51            dim,
52            num_subspaces: dim / dims_per_block,
53            dims_per_block,
54            num_centroids: DEFAULT_NUM_CENTROIDS,
55            seed: 42,
56            use_opq: true,
57            opq_iters: 4,
58        }
59    }
60
61    #[cfg(test)]
62    pub fn with_opq(mut self, enabled: bool, iters: usize) -> Self {
63        self.use_opq = enabled;
64        self.opq_iters = iters;
65        self
66    }
67
68    /// Dimension of each subspace
69    pub fn subspace_dim(&self) -> usize {
70        self.dims_per_block
71    }
72}
73
74/// Learned codebook for Product Quantization with OPQ rotation
75///
76/// Trained once, shared across all segments (like CoarseCentroids).
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct PQCodebook {
79    /// Configuration
80    pub config: PQConfig,
81    /// OPQ rotation matrix (dim × dim), stored row-major
82    pub rotation_matrix: Option<Vec<f32>>,
83    /// Centroids: M subspaces × K centroids × subspace_dim
84    pub centroids: Vec<f32>,
85    /// Version for merge compatibility checking
86    pub version: u64,
87}
88
89impl PQCodebook {
90    pub(crate) fn validate(&self) -> Result<(), String> {
91        let config = &self.config;
92        if config.dim == 0
93            || config.num_subspaces == 0
94            || config.dims_per_block == 0
95            || config.num_centroids == 0
96            || config.num_centroids > 256
97        {
98            return Err("PQ codebook has invalid zero/unbounded dimensions".to_string());
99        }
100        let covered_dim = config
101            .num_subspaces
102            .checked_mul(config.dims_per_block)
103            .ok_or_else(|| "PQ subspace dimension overflow".to_string())?;
104        if covered_dim != config.dim {
105            return Err(format!(
106                "PQ subspaces cover {covered_dim} dimensions, expected {}",
107                config.dim
108            ));
109        }
110        let expected_centroids = config
111            .num_subspaces
112            .checked_mul(config.num_centroids)
113            .and_then(|count| count.checked_mul(config.dims_per_block))
114            .ok_or_else(|| "PQ centroid size overflow".to_string())?;
115        if self.centroids.len() != expected_centroids
116            || self.centroids.iter().any(|value| !value.is_finite())
117        {
118            return Err(format!(
119                "PQ centroid table is invalid: got {}, expected {expected_centroids}",
120                self.centroids.len()
121            ));
122        }
123        if let Some(rotation) = &self.rotation_matrix {
124            let expected = config
125                .dim
126                .checked_mul(config.dim)
127                .ok_or_else(|| "PQ rotation size overflow".to_string())?;
128            if rotation.len() != expected || rotation.iter().any(|value| !value.is_finite()) {
129                return Err(format!(
130                    "PQ rotation matrix is invalid: got {}, expected {expected}",
131                    rotation.len()
132                ));
133            }
134        }
135        if config.use_opq != self.rotation_matrix.is_some() {
136            return Err("PQ OPQ configuration does not match its rotation matrix".to_string());
137        }
138        Ok(())
139    }
140
141    /// Train the global residual codebook and OPQ rotation.
142    pub fn train(config: PQConfig, vectors: &[Vec<f32>], max_iters: usize) -> Self {
143        #[cfg(not(feature = "native"))]
144        let config = PQConfig {
145            use_opq: false,
146            opq_iters: 0,
147            ..config
148        };
149
150        assert!(!vectors.is_empty(), "Cannot train on empty vector set");
151        assert!(
152            vectors
153                .iter()
154                .all(|vector| vector.len() == config.dim
155                    && vector.iter().all(|value| value.is_finite())),
156            "Vector dimension mismatch or non-finite training value"
157        );
158
159        let m = config.num_subspaces;
160        let k = config.num_centroids;
161        let sub_dim = config.subspace_dim();
162        let n = vectors.len();
163
164        // Step 1: Learn OPQ rotation matrix if enabled
165        #[cfg(feature = "native")]
166        let rotation_matrix = (config.use_opq && config.opq_iters > 0)
167            .then(|| Self::learn_opq_rotation(&config, vectors, max_iters));
168        #[cfg(not(feature = "native"))]
169        let rotation_matrix: Option<Vec<f32>> = None;
170
171        // Step 2: Apply rotation to vectors
172        let rotated_vectors: Vec<Vec<f32>> = if let Some(ref r) = rotation_matrix {
173            vectors
174                .iter()
175                .map(|v| Self::apply_rotation(r, v, config.dim))
176                .collect()
177        } else {
178            vectors.to_vec()
179        };
180
181        // Step 3: Train k-means for each subspace
182        let mut centroids = Vec::with_capacity(m * k * sub_dim);
183
184        for subspace_idx in 0..m {
185            let offset = subspace_idx * sub_dim;
186
187            let subdata: Vec<f32> = rotated_vectors
188                .iter()
189                .flat_map(|v| v[offset..offset + sub_dim].iter().copied())
190                .collect();
191
192            let actual_k = k.min(n);
193
194            let trained = crate::structures::vector::kmeans::train_euclidean_kmeans(
195                &subdata,
196                n,
197                sub_dim,
198                actual_k,
199                max_iters,
200                config.seed ^ (subspace_idx as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15),
201            );
202            centroids.extend(trained.centroids);
203
204            // Pad if needed
205            while centroids.len() < (subspace_idx + 1) * k * sub_dim {
206                let last_start = centroids.len() - sub_dim;
207                let last: Vec<f32> = centroids[last_start..].to_vec();
208                centroids.extend(last);
209            }
210        }
211
212        let version = std::time::SystemTime::now()
213            .duration_since(std::time::UNIX_EPOCH)
214            .unwrap_or_default()
215            .as_nanos() as u64;
216
217        Self {
218            config,
219            rotation_matrix,
220            centroids,
221            version,
222        }
223    }
224
225    /// Learn OPQ rotation matrix using SVD
226    #[cfg(feature = "native")]
227    fn learn_opq_rotation(config: &PQConfig, vectors: &[Vec<f32>], max_iters: usize) -> Vec<f32> {
228        use nalgebra::DMatrix;
229
230        let dim = config.dim;
231        let n = vectors.len();
232
233        let mut rotation = DMatrix::<f32>::identity(dim, dim);
234        let data: Vec<f32> = vectors.iter().flat_map(|v| v.iter().copied()).collect();
235        let x = DMatrix::from_row_slice(n, dim, &data);
236
237        for _iter in 0..config.opq_iters.min(max_iters) {
238            // Vectors are rows here while the runtime applies `R * x` to a
239            // column vector, hence the transpose.
240            let rotated = &x * rotation.transpose();
241            let reconstructed = Self::reconstruct_pq(
242                config,
243                &rotated,
244                5,
245                config.seed ^ (_iter as u64).wrapping_mul(0x517c_c1b7_2722_0a95),
246            );
247
248            let xtx_hat = x.transpose() * &reconstructed;
249            let svd = xtx_hat.svd(true, true);
250            if let (Some(u), Some(vt)) = (svd.u, svd.v_t) {
251                // Orthogonal Procrustes gives Q=U*V^T for X*Q ~= Y.
252                // Runtime stores R=Q^T.
253                rotation = vt.transpose() * u.transpose();
254            }
255        }
256
257        let mut row_major = Vec::with_capacity(dim * dim);
258        for row in 0..dim {
259            for column in 0..dim {
260                row_major.push(rotation[(row, column)]);
261            }
262        }
263        row_major
264    }
265
266    #[cfg(feature = "native")]
267    fn reconstruct_pq(
268        config: &PQConfig,
269        rotated: &nalgebra::DMatrix<f32>,
270        iterations: usize,
271        seed: u64,
272    ) -> nalgebra::DMatrix<f32> {
273        let m = config.num_subspaces;
274        let k = config.num_centroids.min(rotated.nrows());
275        let sub_dim = config.subspace_dim();
276        let n = rotated.nrows();
277        let mut reconstructed = nalgebra::DMatrix::<f32>::zeros(n, config.dim);
278
279        for subspace_idx in 0..m {
280            let mut subdata: Vec<f32> = Vec::with_capacity(n * sub_dim);
281            for row in 0..n {
282                for col in 0..sub_dim {
283                    subdata.push(rotated[(row, subspace_idx * sub_dim + col)]);
284                }
285            }
286
287            let trained = crate::structures::vector::kmeans::train_euclidean_kmeans(
288                &subdata,
289                n,
290                sub_dim,
291                k,
292                iterations,
293                seed ^ (subspace_idx as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15),
294            );
295            for (row, &assignment) in trained.assignments.iter().enumerate() {
296                let centroid = &trained.centroids[assignment * sub_dim..(assignment + 1) * sub_dim];
297                for (column, &value) in centroid.iter().enumerate() {
298                    reconstructed[(row, subspace_idx * sub_dim + column)] = value;
299                }
300            }
301        }
302        reconstructed
303    }
304
305    /// Apply rotation matrix to vector (SIMD-accelerated dot product per row)
306    fn apply_rotation(rotation: &[f32], vector: &[f32], dim: usize) -> Vec<f32> {
307        let mut result = vec![0.0f32; dim];
308        for i in 0..dim {
309            result[i] = crate::structures::simd::dot_product_f32(
310                &rotation[i * dim..(i + 1) * dim],
311                vector,
312                dim,
313            );
314        }
315        result
316    }
317
318    /// Find nearest centroid index
319    fn find_nearest(centroids: &[f32], vector: &[f32], sub_dim: usize) -> usize {
320        let num_centroids = centroids.len() / sub_dim;
321        let mut best_idx = 0;
322        let mut best_dist = f32::MAX;
323
324        for c in 0..num_centroids {
325            let offset = c * sub_dim;
326            let dist: f32 = vector
327                .iter()
328                .zip(&centroids[offset..offset + sub_dim])
329                .map(|(&a, &b)| (a - b) * (a - b))
330                .sum();
331
332            if dist < best_dist {
333                best_dist = dist;
334                best_idx = c;
335            }
336        }
337
338        best_idx
339    }
340
341    /// Append one vector's PQ codes to a contiguous cluster column while
342    /// reusing caller-owned rotation scratch across the entire segment build.
343    pub(crate) fn encode_into(
344        &self,
345        vector: &[f32],
346        centroid: Option<&[f32]>,
347        codes: &mut Vec<u8>,
348        residual: &mut Vec<f32>,
349        rotated: &mut Vec<f32>,
350    ) {
351        let m = self.config.num_subspaces;
352        let k = self.config.num_centroids;
353        let sub_dim = self.config.subspace_dim();
354
355        residual.clear();
356        residual.reserve(self.config.dim);
357        if let Some(centroid) = centroid {
358            residual.extend(
359                vector
360                    .iter()
361                    .zip(centroid)
362                    .map(|(&value, &center)| value - center),
363            );
364        } else {
365            residual.extend_from_slice(vector);
366        }
367
368        let vec_to_encode = if let Some(ref r) = self.rotation_matrix {
369            rotated.clear();
370            rotated.resize(self.config.dim, 0.0);
371            for (row, output) in rotated.iter_mut().enumerate() {
372                *output = crate::structures::simd::dot_product_f32(
373                    &r[row * self.config.dim..(row + 1) * self.config.dim],
374                    residual,
375                    self.config.dim,
376                );
377            }
378            rotated.as_slice()
379        } else {
380            residual.as_slice()
381        };
382
383        codes.reserve(m);
384
385        for subspace_idx in 0..m {
386            let vec_offset = subspace_idx * sub_dim;
387            let subvec = &vec_to_encode[vec_offset..vec_offset + sub_dim];
388
389            let centroid_base = subspace_idx * k * sub_dim;
390            let centroids_slice = &self.centroids[centroid_base..centroid_base + k * sub_dim];
391
392            let nearest = Self::find_nearest(centroids_slice, subvec, sub_dim);
393            codes.push(nearest as u8);
394        }
395    }
396
397    /// Encode one vector into an owned code for diagnostics and external use.
398    /// Segment payload construction uses [`Self::encode_into`] to avoid one
399    /// allocation per indexed vector.
400    pub fn encode(&self, vector: &[f32], centroid: Option<&[f32]>) -> Vec<u8> {
401        let mut codes = Vec::with_capacity(self.config.num_subspaces);
402        self.encode_into(
403            vector,
404            centroid,
405            &mut codes,
406            &mut Vec::new(),
407            &mut Vec::new(),
408        );
409        codes
410    }
411
412    /// Decode PQ codes back to approximate vector
413    pub fn decode(&self, codes: &[u8]) -> Vec<f32> {
414        let m = self.config.num_subspaces;
415        let k = self.config.num_centroids;
416        let sub_dim = self.config.subspace_dim();
417
418        let mut rotated_vector = Vec::with_capacity(self.config.dim);
419
420        for (subspace_idx, &code) in codes.iter().enumerate().take(m) {
421            let centroid_base = subspace_idx * k * sub_dim;
422            let centroid_offset = centroid_base + (code as usize) * sub_dim;
423            rotated_vector
424                .extend_from_slice(&self.centroids[centroid_offset..centroid_offset + sub_dim]);
425        }
426
427        // Apply inverse rotation if present
428        if let Some(ref r) = self.rotation_matrix {
429            Self::apply_rotation_transpose(r, &rotated_vector, self.config.dim)
430        } else {
431            rotated_vector
432        }
433    }
434
435    /// Apply transpose of rotation matrix
436    fn apply_rotation_transpose(rotation: &[f32], vector: &[f32], dim: usize) -> Vec<f32> {
437        let mut result = vec![0.0f32; dim];
438        for i in 0..dim {
439            for j in 0..dim {
440                result[i] += rotation[j * dim + i] * vector[j];
441            }
442        }
443        result
444    }
445
446    /// Get centroid for a specific subspace and code
447    #[inline]
448    pub fn get_centroid(&self, subspace_idx: usize, code: u8) -> &[f32] {
449        let k = self.config.num_centroids;
450        let sub_dim = self.config.subspace_dim();
451        let offset = subspace_idx * k * sub_dim + (code as usize) * sub_dim;
452        &self.centroids[offset..offset + sub_dim]
453    }
454
455    /// Rotate a query vector
456    pub fn rotate_query(&self, query: &[f32]) -> Vec<f32> {
457        if let Some(ref r) = self.rotation_matrix {
458            Self::apply_rotation(r, query, self.config.dim)
459        } else {
460            query.to_vec()
461        }
462    }
463
464    /// Memory usage in bytes
465    pub fn size_bytes(&self) -> usize {
466        let centroids_size = self.centroids.len() * 4;
467        let rotation_size = self
468            .rotation_matrix
469            .as_ref()
470            .map(|r| r.len() * 4)
471            .unwrap_or(0);
472        centroids_size + rotation_size + 64
473    }
474}
475
476/// Precomputed distance table for fast asymmetric distance computation
477#[derive(Debug, Clone)]
478pub struct DistanceTable {
479    /// M × K table of squared distances
480    pub distances: Vec<f32>,
481    /// Number of subspaces
482    pub num_subspaces: usize,
483    /// Number of centroids per subspace
484    pub num_centroids: usize,
485}
486
487impl DistanceTable {
488    /// Build distance table for a query vector
489    pub fn build(codebook: &PQCodebook, query: &[f32], centroid: Option<&[f32]>) -> Self {
490        let m = codebook.config.num_subspaces;
491        let k = codebook.config.num_centroids;
492        let sub_dim = codebook.config.subspace_dim();
493
494        // Compute residual if centroid provided
495        let residual: Vec<f32> = if let Some(c) = centroid {
496            query.iter().zip(c).map(|(&v, &c)| v - c).collect()
497        } else {
498            query.to_vec()
499        };
500
501        // Apply rotation if present
502        let rotated_query = codebook.rotate_query(&residual);
503
504        let mut distances = Vec::with_capacity(m * k);
505
506        for subspace_idx in 0..m {
507            let query_offset = subspace_idx * sub_dim;
508            let query_sub = &rotated_query[query_offset..query_offset + sub_dim];
509
510            let centroid_base = subspace_idx * k * sub_dim;
511
512            for centroid_idx in 0..k {
513                let centroid_offset = centroid_base + centroid_idx * sub_dim;
514                let centroid = &codebook.centroids[centroid_offset..centroid_offset + sub_dim];
515
516                let dist: f32 = query_sub
517                    .iter()
518                    .zip(centroid.iter())
519                    .map(|(&a, &b)| (a - b) * (a - b))
520                    .sum();
521
522                distances.push(dist);
523            }
524        }
525
526        Self {
527            distances,
528            num_subspaces: m,
529            num_centroids: k,
530        }
531    }
532
533    /// Compute approximate distance using PQ codes
534    #[inline]
535    pub fn compute_distance(&self, codes: &[u8]) -> f32 {
536        let k = self.num_centroids;
537        let mut total = 0.0f32;
538
539        for (subspace_idx, &code) in codes.iter().enumerate() {
540            let table_offset = subspace_idx * k + code as usize;
541            total += self.distances[table_offset];
542        }
543
544        total
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551    use rand::prelude::*;
552
553    #[test]
554    fn test_pq_config() {
555        let config = PQConfig::new(128);
556        assert_eq!(config.dim, 128);
557        assert_eq!(config.dims_per_block, 8);
558        assert_eq!(config.num_subspaces, 16);
559    }
560
561    #[test]
562    fn test_pq_encode_decode() {
563        let dim = 32;
564        let config = PQConfig::new(dim).with_opq(false, 0);
565
566        let mut rng = rand::rngs::StdRng::seed_from_u64(42);
567        let vectors: Vec<Vec<f32>> = (0..100)
568            .map(|_| (0..dim).map(|_| rng.random::<f32>() - 0.5).collect())
569            .collect();
570
571        let codebook = PQCodebook::train(config, &vectors, 10);
572
573        let test_vec: Vec<f32> = (0..dim).map(|i| i as f32 / dim as f32).collect();
574        let code = codebook.encode(&test_vec, None);
575
576        assert_eq!(code.len(), 4); // 32 dims / 8 dims_per_block
577    }
578
579    #[test]
580    fn test_distance_table() {
581        let dim = 16;
582        let config = PQConfig::new(dim).with_opq(false, 0);
583
584        let mut rng = rand::rngs::StdRng::seed_from_u64(123);
585        let vectors: Vec<Vec<f32>> = (0..50)
586            .map(|_| (0..dim).map(|_| rng.random::<f32>()).collect())
587            .collect();
588
589        let codebook = PQCodebook::train(config, &vectors, 5);
590
591        let query: Vec<f32> = (0..dim).map(|_| rng.random::<f32>()).collect();
592        let table = DistanceTable::build(&codebook, &query, None);
593
594        let code = codebook.encode(&vectors[0], None);
595        let dist = table.compute_distance(&code);
596
597        assert!(dist >= 0.0);
598    }
599}