velesdb_core/quantization/
pq.rs1use crate::error::Error;
9use serde::{Deserialize, Serialize};
10use std::borrow::Cow;
11
12use super::pq_kmeans::{kmeans_train, l2_squared, nearest_centroid};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct PQCodebook {
17 pub centroids: Vec<Vec<Vec<f32>>>,
19 pub dimension: usize,
21 pub num_subspaces: usize,
23 pub num_centroids: usize,
25 pub subspace_dim: usize,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct PQVector {
32 pub codes: Vec<u16>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct ProductQuantizer {
39 pub codebook: PQCodebook,
41 pub rotation: Option<Vec<f32>>,
43}
44
45pub(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
73fn 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
102fn 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 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 #[cfg(debug_assertions)]
158 check_degenerate_centroids(¢roids);
159
160 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 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 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 #[allow(clippy::cast_possible_truncation)]
209 codes.push(code as u16);
210 }
211
212 Ok(PQVector { codes })
213 }
214
215 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
248fn 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
271fn 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#[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 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 fn validate_dimensions(cb: &PQCodebook) -> Result<(), Error> {
370 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 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 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 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 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 #[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 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#[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 let lut = quantizer.precompute_lut(query_vector);
520 distance_pq_l2_with_lut(pq_vector, &lut, quantizer.codebook.num_centroids)
521}
522
523#[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#[cfg_attr(not(feature = "persistence"), allow(dead_code))]
548const ADC_SIMD_BATCH_THRESHOLD: usize = 8;
549
550#[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 for pq_vec in pq_vectors {
584 quantizer.validate_codes(pq_vec)?;
585 }
586
587 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 let lut = quantizer.precompute_lut(query);
599
600 let code_slices: Vec<&[u16]> = pq_vectors
602 .iter()
603 .map(|pq_vec| pq_vec.codes.as_slice())
604 .collect();
605
606 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;