Skip to main content

nodedb_vector/rerank/codecs/
pq.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! `RerankCodec` wrapper for Product Quantization (PQ).
4//!
5//! PQ is a training-based codec: `train()` runs k-means over a sample of
6//! vectors to learn per-subspace codebooks. Until training is complete,
7//! `encode` and `prepare_query` return `RerankError::NotTrained`.
8//!
9//! Distance uses the ADC (Asymmetric Distance Computation) model: the query
10//! is kept in FP32 and a per-subspace lookup table is precomputed once via
11//! `prepare_query`; each candidate lookup is then O(M) table additions.
12//! The prepared form maps directly to `PreparedQuery::Lut` — the existing
13//! variant already holds `Vec<Vec<f32>>` which is exactly the PQ distance
14//! table (`lut[sub][centroid]`).
15
16use nodedb_codec::vector_quant::layout::UnifiedQuantizedVectorRef;
17use nodedb_mem::ScopedMemory;
18
19use crate::{
20    quantize::pq::PqCodec,
21    rerank::codec::{CodecName, PreparedQuery, RerankCodec},
22    rerank::types::RerankError,
23};
24
25// ── packed_bits_len helper ────────────────────────────────────────────────────
26
27/// PQ stores one centroid-index byte per subspace: packed_bits_len == m.
28#[inline]
29fn pq_packed_bits_len(m: usize) -> usize {
30    m
31}
32
33// ── PqRerank ──────────────────────────────────────────────────────────────────
34
35/// Object-safe `RerankCodec` wrapper around `PqCodec`.
36///
37/// The codec starts untrained. `encode` and `prepare_query` return
38/// `RerankError::NotTrained` until `train()` has been called with a
39/// representative sample of vectors.
40///
41/// `from_codec` accepts a pre-trained `PqCodec` (used when restoring from
42/// a snapshot).
43pub struct PqRerank {
44    codec: Option<PqCodec>,
45    dim: usize,
46    m: usize,
47    k: usize,
48    max_iter: usize,
49    /// Charges the codebooks a future `train()` call learns against the
50    /// bound database, tenant, and engine budget.
51    memory: ScopedMemory,
52}
53
54impl PqRerank {
55    /// Construct an untrained PQ codec configuration.
56    ///
57    /// `m` is the number of subspaces; `k` is centroids per subspace.
58    /// Defaults used by higher-level callers: `m = 8`, `k = 256`.
59    /// `encode` / `distance_prepared` return `RerankError::NotTrained` until
60    /// `train()` has been called.
61    pub fn new(dim: usize, m: usize, k: usize, memory: ScopedMemory) -> Self {
62        Self {
63            codec: None,
64            dim,
65            m,
66            k,
67            max_iter: 25,
68            memory,
69        }
70    }
71
72    /// Construct from a pre-trained codec (used when restoring from snapshot).
73    pub fn from_codec(codec: PqCodec, memory: ScopedMemory) -> Self {
74        let dim = codec.dim;
75        let m = codec.m;
76        let k = codec.k;
77        Self {
78            codec: Some(codec),
79            dim,
80            m,
81            k,
82            max_iter: 25,
83            memory,
84        }
85    }
86}
87
88impl RerankCodec for PqRerank {
89    /// Encode a full-precision vector to PQ bytes (one centroid index per subspace).
90    ///
91    /// The serialized form is the raw `UnifiedQuantizedVector` buffer
92    /// (`as_bytes()`): 32-byte `QuantHeader` followed by `m` code bytes.
93    fn encode(&self, v: &[f32]) -> Result<Vec<u8>, RerankError> {
94        if v.len() != self.dim {
95            return Err(RerankError::BadInput(format!(
96                "pq encode: vector len {} != codec dim {}",
97                v.len(),
98                self.dim
99            )));
100        }
101        let codec = self.codec.as_ref().ok_or_else(|| {
102            RerankError::NotTrained(
103                "pq: codec must be trained before encoding (call train() with a sample of vectors)"
104                    .to_string(),
105            )
106        })?;
107        use nodedb_codec::vector_quant::codec::VectorCodec;
108        let quantized = <PqCodec as VectorCodec>::encode(codec, v);
109        Ok(quantized.as_ref().as_bytes().to_vec())
110    }
111
112    /// Prepare the query by precomputing the M×K asymmetric distance table.
113    ///
114    /// The prepared form is `PreparedQuery::Lut` where `lut[sub][centroid]`
115    /// holds the squared L2 distance from the query's sub-vector to each
116    /// centroid of subspace `sub`. This is the standard ADC lookup table.
117    fn prepare_query(&self, q: &[f32]) -> Result<PreparedQuery, RerankError> {
118        if q.len() != self.dim {
119            return Err(RerankError::BadInput(format!(
120                "pq prepare_query: query len {} != codec dim {}",
121                q.len(),
122                self.dim
123            )));
124        }
125        let codec = self.codec.as_ref().ok_or_else(|| {
126            RerankError::NotTrained(
127                "pq: codec must be trained before prepare_query (call train() with a sample of vectors)"
128                    .to_string(),
129            )
130        })?;
131        use nodedb_codec::vector_quant::codec::VectorCodec;
132        let pq_query = <PqCodec as VectorCodec>::prepare_query(codec, q);
133        Ok(PreparedQuery::Lut(pq_query.distance_table))
134    }
135
136    /// Compute asymmetric ADC distance from a prepared query to a PQ-encoded
137    /// candidate.
138    ///
139    /// Expects `PreparedQuery::Lut` produced by `prepare_query`.
140    fn distance_prepared(
141        &self,
142        prepared: &PreparedQuery,
143        encoded: &[u8],
144    ) -> Result<f32, RerankError> {
145        let lut = match prepared {
146            PreparedQuery::Lut(t) => t,
147            _ => {
148                return Err(RerankError::BadInput(
149                    "pq distance: expected PreparedQuery::Lut".to_string(),
150                ));
151            }
152        };
153
154        let packed_len = pq_packed_bits_len(self.m);
155        let uqv_ref = UnifiedQuantizedVectorRef::from_bytes(encoded, packed_len).map_err(|e| {
156            RerankError::BadInput(format!("pq distance: failed to parse encoded bytes: {e}"))
157        })?;
158
159        let packed = uqv_ref.packed_bits();
160        // ADC: sum lut[sub][code[sub]] for each subspace.
161        let dist = packed
162            .iter()
163            .enumerate()
164            .map(|(sub, &code)| {
165                lut.get(sub)
166                    .and_then(|row| row.get(code as usize).copied())
167                    .unwrap_or(0.0)
168            })
169            .sum();
170        Ok(dist)
171    }
172
173    fn name(&self) -> CodecName {
174        CodecName::Pq
175    }
176
177    fn to_bytes(&self) -> Result<Vec<u8>, RerankError> {
178        let codec = self.codec.as_ref().ok_or_else(|| {
179            RerankError::NotTrained("pq sidecar serialize: codec not trained".to_string())
180        })?;
181        codec
182            .to_bytes()
183            .map_err(|e| RerankError::BadInput(format!("pq to_bytes: {e}")))
184    }
185
186    /// Train PQ codebooks via k-means on a sample of vectors.
187    ///
188    /// Validates that:
189    /// - `samples` is non-empty.
190    /// - Every sample has length `self.dim`.
191    /// - `self.dim % self.m == 0` (PQ requires divisible dimensionality).
192    /// - At least `self.k` samples are provided (k-means needs ≥ k points).
193    ///
194    /// On success, stores the trained codec and subsequent `encode` /
195    /// `distance_prepared` calls will succeed.
196    fn train(&mut self, samples: &[&[f32]]) -> Result<(), RerankError> {
197        if samples.is_empty() {
198            return Err(RerankError::BadInput(
199                "pq train: empty sample set".to_string(),
200            ));
201        }
202        for s in samples {
203            if s.len() != self.dim {
204                return Err(RerankError::BadInput(format!(
205                    "pq train: sample has len {} but codec dim is {}",
206                    s.len(),
207                    self.dim
208                )));
209            }
210        }
211        if !self.dim.is_multiple_of(self.m) {
212            return Err(RerankError::BadInput(format!(
213                "pq train: dim ({}) must be divisible by m ({})",
214                self.dim, self.m
215            )));
216        }
217        if samples.len() < self.k {
218            return Err(RerankError::BadInput(format!(
219                "pq train: need >= k samples for k-means, got {}",
220                samples.len()
221            )));
222        }
223        let codec = PqCodec::train(
224            samples,
225            self.dim,
226            self.m,
227            self.k,
228            self.max_iter,
229            self.memory.clone(),
230        )
231        .map_err(|e| RerankError::BadInput(format!("pq train: {e}")))?;
232        self.codec = Some(codec);
233        Ok(())
234    }
235}
236
237// ── Tests ─────────────────────────────────────────────────────────────────────
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::test_support::test_memory;
243
244    const DIM: usize = 32;
245    const M: usize = 4;
246    const K: usize = 8;
247    const N: usize = 64;
248
249    fn det_vec(i: usize, dim: usize) -> Vec<f32> {
250        (0..dim)
251            .map(|j| ((i * 31 + j) % 100) as f32 / 100.0)
252            .collect()
253    }
254
255    fn trained() -> PqRerank {
256        let vecs: Vec<Vec<f32>> = (0..N).map(|i| det_vec(i, DIM)).collect();
257        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
258        let mut codec = PqRerank::new(DIM, M, K, test_memory());
259        codec.train(&refs).expect("train must succeed");
260        codec
261    }
262
263    #[test]
264    fn train_then_encode_roundtrip() {
265        let codec = trained();
266        let v = det_vec(0, DIM);
267        let enc = codec.encode(&v).expect("encode");
268        let prep = codec.prepare_query(&v).expect("prepare_query");
269        let dist = codec.distance_prepared(&prep, &enc).expect("distance");
270        assert!(dist.is_finite(), "distance must be finite, got {dist}");
271        assert!(dist >= 0.0, "distance must be non-negative, got {dist}");
272        // Self-distance should be small for ADC on identical vector.
273        assert!(dist < 1.0, "self-distance too large: {dist}");
274    }
275
276    #[test]
277    fn encode_before_train_returns_not_trained() {
278        let codec = PqRerank::new(DIM, M, K, test_memory());
279        let v = det_vec(0, DIM);
280        let err = codec.encode(&v).unwrap_err();
281        let msg = format!("{err}");
282        assert!(
283            msg.contains("not trained") || msg.contains("trained"),
284            "expected 'trained' in error, got: {msg}"
285        );
286    }
287
288    #[test]
289    fn train_with_wrong_dim_sample_fails() {
290        let vecs: Vec<Vec<f32>> = (0..N).map(|i| det_vec(i, DIM)).collect();
291        let mut refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
292        let bad = det_vec(0, DIM + 4);
293        refs.push(bad.as_slice());
294        let mut codec = PqRerank::new(DIM, M, K, test_memory());
295        let err = codec.train(&refs).unwrap_err();
296        let msg = format!("{err}");
297        assert!(
298            msg.contains("bad input"),
299            "expected bad input error, got: {msg}"
300        );
301    }
302
303    #[test]
304    fn train_with_indivisible_dim_fails() {
305        // dim=33, m=4: 33 % 4 != 0
306        let vecs: Vec<Vec<f32>> = (0..16).map(|i| det_vec(i, 33)).collect();
307        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
308        let mut codec = PqRerank::new(33, 4, 8, test_memory());
309        let err = codec.train(&refs).unwrap_err();
310        let msg = format!("{err}");
311        assert!(
312            msg.contains("divisible"),
313            "expected divisibility error, got: {msg}"
314        );
315    }
316
317    #[test]
318    fn train_with_too_few_samples_fails() {
319        // k=8 but only 4 samples
320        let vecs: Vec<Vec<f32>> = (0..4).map(|i| det_vec(i, DIM)).collect();
321        let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
322        let mut codec = PqRerank::new(DIM, M, 8, test_memory());
323        let err = codec.train(&refs).unwrap_err();
324        let msg = format!("{err}");
325        assert!(
326            msg.contains("k samples") || msg.contains("bad input"),
327            "expected sample count error, got: {msg}"
328        );
329    }
330
331    #[test]
332    fn name_is_pq() {
333        let codec = PqRerank::new(DIM, M, K, test_memory());
334        assert_eq!(codec.name(), CodecName::Pq);
335    }
336}