Skip to main content

nodedb_codec/vector_quant/
opq.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Optimized Product Quantization (OPQ) — Non-Para OPQ via iterative
4//! SVD-Procrustes rotation that minimizes PQ reconstruction error, yielding
5//! 10–20% recall improvement over vanilla PQ at equal memory.
6//!
7//! # Algorithm
8//!
9//! OPQ wraps standard PQ with a learned rotation matrix `R` (dim × dim,
10//! row-major) applied before codebook training and at query time:
11//!
12//! ```text
13//! encode(v) = PQ_encode(R · v)
14//! distance(q, v) = ADC(R · q, PQ_code(v))
15//! ```
16//!
17//! ## Non-Para OPQ (Ge et al., CVPR 2013)
18//!
19//! The rotation is learned by alternating between two steps until convergence:
20//!
21//! 1. **Codebook step** — hold `R` fixed, train PQ codebooks on the rotated
22//!    training set `R · X` via Lloyd's k-means.
23//! 2. **Procrustes step** — hold codebooks fixed, update `R` to minimize the
24//!    Frobenius reconstruction error ‖R·X − reconstruct(quantize(R·X))‖_F
25//!    via closed-form SVD:
26//!    - Let `Y` = dequantized reconstruction of `R·X` (dim × N matrix).
27//!    - Compute cross-correlation `M = X · Yᵀ`  (dim × dim).
28//!    - SVD: `M = U · Σ · Vᵀ`.
29//!    - New rotation: `R = V · Uᵀ`.
30//!
31//! This alternation is repeated for `opq_iters` iterations (default 5).
32//!
33//! ## Storage format
34//!
35//! `QuantMode::Pq` is reused in `UnifiedQuantizedVector` headers — OPQ is
36//! structurally PQ post-rotation and requires no new on-disk discriminant.
37//! The rotation matrix is stored in `OpqCodec` and applied transparently.
38
39use nalgebra::{DMatrix, SVD};
40
41use crate::vector_quant::codec::{AdcLut, VectorCodec};
42use crate::vector_quant::layout::{QuantHeader, QuantMode, UnifiedQuantizedVector};
43use crate::vector_quant::opq_kmeans::l2_sq;
44use crate::vector_quant::opq_kmeans::lloyd;
45
46// ── OpqCodec ──────────────────────────────────────────────────────────────────
47
48/// Optimized Product Quantization codec.
49///
50/// Stores a learned rotation matrix `R` (dim × dim, row-major) and PQ
51/// codebooks trained on the rotated training set via Non-Para OPQ iterations.
52pub struct OpqCodec {
53    pub dim: usize,
54    /// Number of PQ subspaces.
55    pub m: usize,
56    /// Centroids per subspace (256 for u8 codes).
57    pub k: usize,
58    pub sub_dim: usize,
59    /// Learned rotation matrix R (dim × dim, row-major).
60    rotation: Vec<f32>,
61    /// PQ codebooks trained on R·v: \[M\]\[K\]\[sub_dim\].
62    codebooks: Vec<Vec<Vec<f32>>>,
63}
64
65impl OpqCodec {
66    /// Train an OPQ codec using the Non-Para OPQ algorithm.
67    ///
68    /// Alternates between a codebook step (Lloyd's k-means on the rotated
69    /// training set) and a Procrustes step (SVD-based rotation update to
70    /// minimize reconstruction error) for `opq_iters` iterations.
71    ///
72    /// - `opq_iters`: number of alternating Procrustes+codebook iterations.
73    /// - `kmeans_iters`: Lloyd's k-means iterations per subspace per OPQ iter.
74    pub fn train(
75        vectors: &[&[f32]],
76        dim: usize,
77        m: usize,
78        k: usize,
79        opq_iters: usize,
80        kmeans_iters: usize,
81    ) -> Self {
82        assert!(!vectors.is_empty(), "training set must be non-empty");
83        assert!(dim > 0 && m > 0 && k > 0, "dim/m/k must be positive");
84        assert!(
85            dim.is_multiple_of(m),
86            "dim ({dim}) must be divisible by m ({m})"
87        );
88        let sub_dim = dim / m;
89        let seed = dim as u64 ^ ((m as u64) << 16) ^ ((k as u64) << 32);
90
91        let mut rotation = identity(dim);
92        let mut codebooks: Vec<Vec<Vec<f32>>> = Vec::new();
93
94        let iters = opq_iters.max(1);
95
96        for iter in 0..iters {
97            // Codebook step: train PQ on the current rotated training set.
98            let rotated: Vec<Vec<f32>> =
99                vectors.iter().map(|v| matvec(&rotation, v, dim)).collect();
100            codebooks = train_codebooks(&rotated, m, k, sub_dim, kmeans_iters, seed ^ iter as u64);
101
102            // Procrustes step: find R minimising ‖R·X - Y‖_F where Y is
103            // the dequantized reconstruction of R·X.
104            //
105            // Closed-form solution (Ge et al. CVPR 2013, §3.2):
106            //   M = X · Yᵀ   (dim × dim)
107            //   SVD(M) = U Σ Vᵀ
108            //   R_new = V · Uᵀ
109            //
110            // Skip rotation update on the last iteration — codebooks were
111            // already retrained with the current R.
112            if iter + 1 < iters {
113                let n = vectors.len();
114                // Build dim×N matrices X (original) and Y (reconstructed).
115                // DMatrix is column-major; we store column j = vector j.
116                let x_mat = DMatrix::from_fn(dim, n, |row, col| vectors[col][row]);
117                let y_mat = {
118                    let recon: Vec<Vec<f32>> = rotated
119                        .iter()
120                        .map(|rv| {
121                            let codes = pq_encode(rv, &codebooks, m, sub_dim);
122                            dequantize_codes(&codes, &codebooks)
123                        })
124                        .collect();
125                    DMatrix::from_fn(dim, n, |row, col| recon[col][row])
126                };
127
128                // M = X · Yᵀ  (dim × dim)
129                let m_mat = &x_mat * y_mat.transpose();
130
131                // Guard: skip rotation update if M contains NaN (degenerate
132                // training data or all-zero reconstructions on early iters).
133                let has_nan = m_mat.iter().any(|x| x.is_nan());
134                if !has_nan {
135                    let svd = SVD::new(m_mat, true, true);
136                    if let (Some(u), Some(v_t)) = (svd.u, svd.v_t) {
137                        // R = V · Uᵀ  →  in nalgebra: V = v_tᵀ, so R = v_tᵀ · uᵀ
138                        let r_new = v_t.transpose() * u.transpose();
139                        // Convert column-major DMatrix to row-major Vec<f32>.
140                        let mut buf = Vec::with_capacity(dim * dim);
141                        for i in 0..dim {
142                            for j in 0..dim {
143                                buf.push(r_new[(i, j)]);
144                            }
145                        }
146                        rotation = buf;
147                    }
148                }
149            }
150        }
151
152        Self {
153            dim,
154            m,
155            k,
156            sub_dim,
157            rotation,
158            codebooks,
159        }
160    }
161
162    /// Apply the rotation matrix to `v`, returning `R · v`.
163    pub fn apply_rotation(&self, v: &[f32]) -> Vec<f32> {
164        matvec(&self.rotation, v, self.dim)
165    }
166
167    fn encode_inner(&self, v: &[f32]) -> (Vec<u8>, UnifiedQuantizedVector) {
168        let rotated = self.apply_rotation(v);
169        let codes = pq_encode(&rotated, &self.codebooks, self.m, self.sub_dim);
170        let uqv = make_uqv(&codes, self.dim as u16);
171        (codes, uqv)
172    }
173
174    fn dequantize(&self, codes: &[u8]) -> Vec<f32> {
175        dequantize_codes(codes, &self.codebooks)
176    }
177}
178
179// ── Internal helpers ──────────────────────────────────────────────────────────
180
181/// Return a dim×dim row-major identity matrix.
182fn identity(dim: usize) -> Vec<f32> {
183    let mut mat = vec![0.0f32; dim * dim];
184    for i in 0..dim {
185        mat[i * dim + i] = 1.0;
186    }
187    mat
188}
189
190/// Dequantize PQ codes into a reconstructed vector in rotated space.
191fn dequantize_codes(codes: &[u8], codebooks: &[Vec<Vec<f32>>]) -> Vec<f32> {
192    let mut out = Vec::with_capacity(codebooks.len() * codebooks[0][0].len());
193    for (s, &c) in codes.iter().enumerate() {
194        out.extend_from_slice(&codebooks[s][c as usize]);
195    }
196    out
197}
198
199/// Row-major matrix-vector multiply: returns R · v.
200#[inline]
201fn matvec(r: &[f32], v: &[f32], dim: usize) -> Vec<f32> {
202    let mut out = vec![0.0f32; dim];
203    for i in 0..dim {
204        let row = &r[i * dim..(i + 1) * dim];
205        out[i] = row.iter().zip(v.iter()).map(|(a, b)| a * b).sum();
206    }
207    out
208}
209
210fn pq_encode(v: &[f32], codebooks: &[Vec<Vec<f32>>], m: usize, sub_dim: usize) -> Vec<u8> {
211    let mut codes = Vec::with_capacity(m);
212    for (s, codebook) in codebooks.iter().enumerate().take(m) {
213        let offset = s * sub_dim;
214        let sub = &v[offset..offset + sub_dim];
215        let best = codebook
216            .iter()
217            .enumerate()
218            .min_by(|(_, a), (_, b)| {
219                l2_sq(sub, a)
220                    .partial_cmp(&l2_sq(sub, b))
221                    .unwrap_or(std::cmp::Ordering::Equal)
222            })
223            .map(|(i, _)| i)
224            .unwrap_or(0);
225        codes.push(best as u8);
226    }
227    codes
228}
229
230fn train_codebooks(
231    rotated: &[Vec<f32>],
232    m: usize,
233    k: usize,
234    sub_dim: usize,
235    kmeans_iters: usize,
236    seed: u64,
237) -> Vec<Vec<Vec<f32>>> {
238    let mut codebooks = Vec::with_capacity(m);
239    for s in 0..m {
240        let offset = s * sub_dim;
241        let sub_vecs: Vec<Vec<f32>> = rotated
242            .iter()
243            .map(|v| v[offset..offset + sub_dim].to_vec())
244            .collect();
245        let centroids = lloyd(
246            &sub_vecs,
247            sub_dim,
248            k,
249            kmeans_iters,
250            seed ^ (s as u64 * 0x1234567),
251        );
252        codebooks.push(centroids);
253    }
254    codebooks
255}
256
257fn make_uqv(codes: &[u8], dim: u16) -> UnifiedQuantizedVector {
258    let header = QuantHeader {
259        quant_mode: QuantMode::Pq as u16,
260        dim,
261        global_scale: 1.0,
262        residual_norm: 0.0,
263        dot_quantized: 0.0,
264        outlier_bitmask: 0,
265        reserved: [0; 8],
266    };
267    UnifiedQuantizedVector::new(header, codes, &[])
268        .expect("make_uqv: layout construction must not fail for valid inputs")
269}
270
271// ── VectorCodec wrapper types ─────────────────────────────────────────────────
272
273/// Quantized form returned by [`OpqCodec::encode`].
274pub struct OpqQuantized {
275    codes: Vec<u8>,
276    uqv: UnifiedQuantizedVector,
277}
278
279impl AsRef<UnifiedQuantizedVector> for OpqQuantized {
280    fn as_ref(&self) -> &UnifiedQuantizedVector {
281        &self.uqv
282    }
283}
284
285/// Prepared query: rotated vector + flat ADC distance table (M×K, row-major).
286pub struct OpqQuery {
287    pub distance_table: Vec<f32>,
288    #[allow(dead_code)]
289    rotated: Vec<f32>,
290}
291
292// ── VectorCodec impl ──────────────────────────────────────────────────────────
293
294impl VectorCodec for OpqCodec {
295    type Quantized = OpqQuantized;
296    type Query = OpqQuery;
297
298    fn encode(&self, v: &[f32]) -> Self::Quantized {
299        let (codes, uqv) = self.encode_inner(v);
300        OpqQuantized { codes, uqv }
301    }
302
303    /// Rotate the query, then build flat ADC distance table `[M × K]`.
304    fn prepare_query(&self, q: &[f32]) -> Self::Query {
305        let rotated = self.apply_rotation(q);
306        let mut table = vec![0.0f32; self.m * self.k];
307        for s in 0..self.m {
308            let offset = s * self.sub_dim;
309            let sub_q = &rotated[offset..offset + self.sub_dim];
310            for c in 0..self.k {
311                table[s * self.k + c] = l2_sq(sub_q, &self.codebooks[s][c]);
312            }
313        }
314        OpqQuery {
315            distance_table: table,
316            rotated,
317        }
318    }
319
320    fn adc_lut(&self, q: &Self::Query) -> Option<AdcLut> {
321        let mut lut = AdcLut::new(self.m as u16, self.k as u16);
322        lut.table.copy_from_slice(&q.distance_table);
323        Some(lut)
324    }
325
326    /// Symmetric: dequantize both sides in rotated space, compute L2.
327    fn fast_symmetric_distance(&self, q: &Self::Quantized, v: &Self::Quantized) -> f32 {
328        let qv = self.dequantize(&q.codes);
329        let vv = self.dequantize(&v.codes);
330        l2_sq(&qv, &vv)
331    }
332
333    /// Asymmetric: O(M) ADC table lookups — one per subspace.
334    fn exact_asymmetric_distance(&self, q: &Self::Query, v: &Self::Quantized) -> f32 {
335        v.codes
336            .iter()
337            .enumerate()
338            .map(|(s, &code)| q.distance_table[s * self.k + code as usize])
339            .sum()
340    }
341}
342
343// ── Tests ─────────────────────────────────────────────────────────────────────
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    fn tiny_dataset() -> Vec<Vec<f32>> {
350        (0..10)
351            .map(|i| {
352                let base = i as f32 * 2.0;
353                vec![
354                    base,
355                    base + 0.1,
356                    base - 0.1,
357                    base + 0.2,
358                    base * 0.5,
359                    base * 0.5 + 0.1,
360                    base * 0.5 - 0.1,
361                    base * 0.5 + 0.05,
362                ]
363            })
364            .collect()
365    }
366
367    fn train_tiny() -> OpqCodec {
368        let vecs = tiny_dataset();
369        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
370        OpqCodec::train(&refs, 8, 2, 4, 10, 30)
371    }
372
373    #[test]
374    fn encode_produces_m_bytes() {
375        let codec = train_tiny();
376        let vecs = tiny_dataset();
377        for v in &vecs {
378            let q = codec.encode(v);
379            assert_eq!(q.codes.len(), codec.m);
380        }
381    }
382
383    #[test]
384    fn distance_is_non_negative() {
385        let codec = train_tiny();
386        let vecs = tiny_dataset();
387        for v in &vecs {
388            let qv = codec.encode(v);
389            let qq = codec.prepare_query(v);
390            let asym = codec.exact_asymmetric_distance(&qq, &qv);
391            let sym = codec.fast_symmetric_distance(&qv, &qv);
392            assert!(
393                asym >= 0.0,
394                "asymmetric distance must be non-negative, got {asym}"
395            );
396            assert!(
397                sym >= 0.0,
398                "symmetric distance must be non-negative, got {sym}"
399            );
400        }
401    }
402
403    #[test]
404    fn top1_recall_on_training_set() {
405        let vecs = tiny_dataset();
406        let codec = train_tiny();
407        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
408        let encoded: Vec<_> = refs.iter().map(|v| codec.encode(v)).collect();
409
410        let mut correct = 0usize;
411        for (i, v) in refs.iter().enumerate() {
412            let query = codec.prepare_query(v);
413            let best = encoded
414                .iter()
415                .enumerate()
416                .min_by(|(_, a), (_, b)| {
417                    codec
418                        .exact_asymmetric_distance(&query, a)
419                        .partial_cmp(&codec.exact_asymmetric_distance(&query, b))
420                        .unwrap_or(std::cmp::Ordering::Equal)
421                })
422                .map(|(idx, _)| idx)
423                .unwrap_or(usize::MAX);
424            if best == i {
425                correct += 1;
426            }
427        }
428        let recall = correct as f64 / vecs.len() as f64;
429        // SVD-Procrustes converges to ~70% on this minimum-size synthetic set
430        // (n=10, dim=8, m=2, k=4: 4 bits per vector, codespace collisions
431        // inevitable). Empirical measurements on SIFT1M with realistic
432        // (m=8, k=256, dim=128) routinely hit ≥0.95 — see bench harness.
433        assert!(
434            recall >= 0.70,
435            "top-1 recall on training set too low: {correct}/{} = {recall:.2}",
436            vecs.len()
437        );
438    }
439
440    #[test]
441    fn more_iterations_reduce_reconstruction_error() {
442        let vecs = tiny_dataset();
443        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
444
445        let codec_1 = OpqCodec::train(&refs, 8, 2, 4, 1, 10);
446        let codec_5 = OpqCodec::train(&refs, 8, 2, 4, 5, 10);
447
448        let mean_recon_error = |codec: &OpqCodec| -> f32 {
449            refs.iter()
450                .map(|v| {
451                    let rotated = codec.apply_rotation(v);
452                    let codes = pq_encode(&rotated, &codec.codebooks, codec.m, codec.sub_dim);
453                    let recon = dequantize_codes(&codes, &codec.codebooks);
454                    l2_sq(&rotated, &recon)
455                })
456                .sum::<f32>()
457                / refs.len() as f32
458        };
459
460        let err_1 = mean_recon_error(&codec_1);
461        let err_5 = mean_recon_error(&codec_5);
462
463        assert!(
464            err_5 <= err_1 * 1.05,
465            "5-iter OPQ (err={err_5:.4}) should have ≤ reconstruction error than 1-iter (err={err_1:.4})"
466        );
467    }
468}