use ipfrs_core::{Error, Result};
use nalgebra::{DMatrix, DVector};
use serde::{Deserialize, Serialize};
use super::scalar::{QuantizedVector, ScalarQuantizer};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProductQuantizerConfig {
pub dimension: usize,
pub num_subquantizers: usize,
pub bits_per_subquantizer: u8,
pub codebooks: Vec<Vec<Vec<f32>>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProductQuantizer {
pub(crate) config: ProductQuantizerConfig,
subdimension: usize,
num_centroids: usize,
trained: bool,
}
impl ProductQuantizer {
pub fn new(dimension: usize, num_subquantizers: usize, bits: u8) -> Result<Self> {
if !dimension.is_multiple_of(num_subquantizers) {
return Err(Error::InvalidInput(format!(
"Dimension {} must be divisible by num_subquantizers {}",
dimension, num_subquantizers
)));
}
if bits > 16 {
return Err(Error::InvalidInput(
"Bits per subquantizer must be <= 16".to_string(),
));
}
let subdimension = dimension / num_subquantizers;
let num_centroids = 1 << bits;
Ok(Self {
config: ProductQuantizerConfig {
dimension,
num_subquantizers,
bits_per_subquantizer: bits,
codebooks: Vec::new(),
},
subdimension,
num_centroids,
trained: false,
})
}
pub fn standard(dimension: usize) -> Result<Self> {
Self::new(dimension, 8, 8)
}
pub fn train(&mut self, vectors: &[Vec<f32>], max_iterations: usize) -> Result<()> {
if vectors.is_empty() {
return Err(Error::InvalidInput(
"Cannot train on empty vector set".to_string(),
));
}
for (i, vec) in vectors.iter().enumerate() {
if vec.len() != self.config.dimension {
return Err(Error::InvalidInput(format!(
"Vector {} has dimension {}, expected {}",
i,
vec.len(),
self.config.dimension
)));
}
}
self.config.codebooks = Vec::with_capacity(self.config.num_subquantizers);
for sq in 0..self.config.num_subquantizers {
let start = sq * self.subdimension;
let end = start + self.subdimension;
let subvectors: Vec<Vec<f32>> =
vectors.iter().map(|v| v[start..end].to_vec()).collect();
let centroids = self.kmeans(&subvectors, self.num_centroids, max_iterations)?;
self.config.codebooks.push(centroids);
}
self.trained = true;
Ok(())
}
fn kmeans(&self, data: &[Vec<f32>], k: usize, max_iterations: usize) -> Result<Vec<Vec<f32>>> {
if data.is_empty() {
return Err(Error::InvalidInput("Empty data for k-means".to_string()));
}
let dim = data[0].len();
let n = data.len();
let actual_k = k.min(n);
let mut centroids = Vec::with_capacity(actual_k);
centroids.push(data[0].clone());
for _ in 1..actual_k {
let mut best_idx = 0;
let mut best_dist = 0.0f32;
for (i, vec) in data.iter().enumerate() {
let min_dist = centroids
.iter()
.map(|c| self.l2_distance(vec, c))
.fold(f32::MAX, |a, b| a.min(b));
if min_dist > best_dist {
best_dist = min_dist;
best_idx = i;
}
}
centroids.push(data[best_idx].clone());
}
let mut assignments = vec![0usize; n];
for _iter in 0..max_iterations {
let mut changed = false;
for (i, vec) in data.iter().enumerate() {
let nearest = centroids
.iter()
.enumerate()
.map(|(j, c)| (j, self.l2_distance(vec, c)))
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(j, _)| j)
.unwrap_or(0);
if assignments[i] != nearest {
assignments[i] = nearest;
changed = true;
}
}
if !changed {
break;
}
let mut new_centroids = vec![vec![0.0f32; dim]; actual_k];
let mut counts = vec![0usize; actual_k];
for (i, vec) in data.iter().enumerate() {
let cluster = assignments[i];
counts[cluster] += 1;
for (j, &val) in vec.iter().enumerate() {
new_centroids[cluster][j] += val;
}
}
for (i, centroid) in new_centroids.iter_mut().enumerate() {
if counts[i] > 0 {
for val in centroid.iter_mut() {
*val /= counts[i] as f32;
}
} else {
*centroid = centroids[i].clone();
}
}
centroids = new_centroids;
}
while centroids.len() < k {
centroids.push(centroids[centroids.len() - 1].clone());
}
Ok(centroids)
}
fn l2_distance(&self, a: &[f32], b: &[f32]) -> f32 {
a.iter()
.zip(b.iter())
.map(|(x, y)| (x - y) * (x - y))
.sum::<f32>()
.sqrt()
}
pub fn quantize(&self, vector: &[f32]) -> Result<PQCode> {
if !self.trained {
return Err(Error::InvalidInput(
"Product quantizer must be trained before use".to_string(),
));
}
if vector.len() != self.config.dimension {
return Err(Error::InvalidInput(format!(
"Vector has dimension {}, expected {}",
vector.len(),
self.config.dimension
)));
}
let mut codes = Vec::with_capacity(self.config.num_subquantizers);
for sq in 0..self.config.num_subquantizers {
let start = sq * self.subdimension;
let end = start + self.subdimension;
let subvector = &vector[start..end];
let codebook = &self.config.codebooks[sq];
let (best_idx, _) = codebook
.iter()
.enumerate()
.map(|(i, c)| (i, self.l2_distance(subvector, c)))
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.unwrap_or((0, 0.0));
codes.push(best_idx as u8);
}
Ok(PQCode { codes })
}
pub fn dequantize(&self, code: &PQCode) -> Result<Vec<f32>> {
if !self.trained {
return Err(Error::InvalidInput(
"Product quantizer must be trained before use".to_string(),
));
}
if code.codes.len() != self.config.num_subquantizers {
return Err(Error::InvalidInput(format!(
"PQ code has {} elements, expected {}",
code.codes.len(),
self.config.num_subquantizers
)));
}
let mut result = Vec::with_capacity(self.config.dimension);
for (sq, &idx) in code.codes.iter().enumerate() {
let centroid = &self.config.codebooks[sq][idx as usize];
result.extend_from_slice(centroid);
}
Ok(result)
}
pub fn asymmetric_distance(&self, query: &[f32], code: &PQCode) -> Result<f32> {
if !self.trained {
return Err(Error::InvalidInput(
"Product quantizer must be trained".to_string(),
));
}
let mut total_dist_sq = 0.0f32;
for sq in 0..self.config.num_subquantizers {
let start = sq * self.subdimension;
let end = start + self.subdimension;
let subquery = &query[start..end];
let centroid = &self.config.codebooks[sq][code.codes[sq] as usize];
for (q, c) in subquery.iter().zip(centroid.iter()) {
let diff = q - c;
total_dist_sq += diff * diff;
}
}
Ok(total_dist_sq.sqrt())
}
pub fn compute_distance_table(&self, query: &[f32]) -> Result<Vec<Vec<f32>>> {
if !self.trained {
return Err(Error::InvalidInput(
"Product quantizer must be trained".to_string(),
));
}
let mut table = Vec::with_capacity(self.config.num_subquantizers);
for sq in 0..self.config.num_subquantizers {
let start = sq * self.subdimension;
let end = start + self.subdimension;
let subquery = &query[start..end];
let distances: Vec<f32> = self.config.codebooks[sq]
.iter()
.map(|c| {
subquery
.iter()
.zip(c.iter())
.map(|(q, c)| (q - c) * (q - c))
.sum::<f32>()
})
.collect();
table.push(distances);
}
Ok(table)
}
pub fn distance_from_table(&self, table: &[Vec<f32>], code: &PQCode) -> f32 {
let mut total = 0.0f32;
for (sq, &idx) in code.codes.iter().enumerate() {
total += table[sq][idx as usize];
}
total.sqrt()
}
pub fn compression_ratio(&self) -> f32 {
(self.config.dimension * 4) as f32 / self.config.num_subquantizers as f32
}
pub fn is_trained(&self) -> bool {
self.trained
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PQCode {
pub codes: Vec<u8>,
}
impl PQCode {
pub fn size_bytes(&self) -> usize {
self.codes.len()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OptimizedProductQuantizer {
pq: ProductQuantizer,
#[serde(with = "rotation_matrix_serde")]
rotation: Option<DMatrix<f32>>,
rotation_trained: bool,
}
mod rotation_matrix_serde {
use super::DMatrix;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Serialize, Deserialize)]
struct MatrixData {
nrows: usize,
ncols: usize,
data: Vec<f32>,
}
pub fn serialize<S>(
matrix: &Option<DMatrix<f32>>,
serializer: S,
) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
let opt_data = matrix.as_ref().map(|m| MatrixData {
nrows: m.nrows(),
ncols: m.ncols(),
data: m.as_slice().to_vec(),
});
opt_data.serialize(serializer)
}
pub fn deserialize<'de, D>(
deserializer: D,
) -> std::result::Result<Option<DMatrix<f32>>, D::Error>
where
D: Deserializer<'de>,
{
let opt: Option<MatrixData> = Option::deserialize(deserializer)?;
Ok(opt.map(|data| DMatrix::from_vec(data.nrows, data.ncols, data.data)))
}
}
impl OptimizedProductQuantizer {
pub fn new(dimension: usize, num_subquantizers: usize, bits: u8) -> Result<Self> {
let pq = ProductQuantizer::new(dimension, num_subquantizers, bits)?;
Ok(Self {
pq,
rotation: None,
rotation_trained: false,
})
}
pub fn standard(dimension: usize) -> Result<Self> {
Self::new(dimension, 8, 8)
}
#[allow(clippy::too_many_arguments)]
pub fn train(
&mut self,
vectors: &[Vec<f32>],
max_iterations: usize,
rotation_iterations: usize,
) -> Result<()> {
if vectors.is_empty() {
return Err(Error::InvalidInput(
"Cannot train on empty vector set".to_string(),
));
}
let dim = self.pq.config.dimension;
for (i, vec) in vectors.iter().enumerate() {
if vec.len() != dim {
return Err(Error::InvalidInput(format!(
"Vector {} has dimension {}, expected {}",
i,
vec.len(),
dim
)));
}
}
let mut rotation = DMatrix::<f32>::identity(dim, dim);
for iteration in 0..rotation_iterations {
let rotated = self.apply_rotation_batch(vectors, &rotation);
self.pq.train(&rotated, max_iterations)?;
if iteration < rotation_iterations - 1 {
rotation = self.learn_rotation(vectors, &self.pq)?;
}
}
self.rotation = Some(rotation);
self.rotation_trained = true;
Ok(())
}
#[allow(dead_code)]
fn learn_rotation(&self, vectors: &[Vec<f32>], pq: &ProductQuantizer) -> Result<DMatrix<f32>> {
let dim = pq.config.dimension;
let n = vectors.len();
let mut cov = DMatrix::<f32>::zeros(dim, dim);
for vec in vectors {
let code = pq.quantize(vec)?;
let reconstructed = pq.dequantize(&code)?;
let v = DVector::from_vec(vec.clone());
let r = DVector::from_vec(reconstructed);
cov += v * r.transpose();
}
cov /= n as f32;
let svd = cov.svd(true, true);
match (svd.u, svd.v_t) {
(Some(u), Some(vt)) => Ok(u * vt),
_ => {
Ok(DMatrix::identity(dim, dim))
}
}
}
fn apply_rotation_batch(&self, vectors: &[Vec<f32>], rotation: &DMatrix<f32>) -> Vec<Vec<f32>> {
vectors
.iter()
.map(|v| self.apply_rotation(v, rotation))
.collect()
}
fn apply_rotation(&self, vector: &[f32], rotation: &DMatrix<f32>) -> Vec<f32> {
let v = DVector::from_vec(vector.to_vec());
let rotated = rotation * v;
rotated.as_slice().to_vec()
}
pub fn quantize(&self, vector: &[f32]) -> Result<PQCode> {
if !self.is_trained() {
return Err(Error::InvalidInput("OPQ must be trained".to_string()));
}
let rotated = match &self.rotation {
Some(r) => self.apply_rotation(vector, r),
None => vector.to_vec(),
};
self.pq.quantize(&rotated)
}
pub fn dequantize(&self, code: &PQCode) -> Result<Vec<f32>> {
if !self.is_trained() {
return Err(Error::InvalidInput("OPQ must be trained".to_string()));
}
let rotated = self.pq.dequantize(code)?;
match &self.rotation {
Some(r) => {
let r_inv = r.transpose();
Ok(self.apply_rotation(&rotated, &r_inv))
}
None => Ok(rotated),
}
}
pub fn asymmetric_distance(&self, query: &[f32], code: &PQCode) -> Result<f32> {
if !self.is_trained() {
return Err(Error::InvalidInput("OPQ must be trained".to_string()));
}
let rotated_query = match &self.rotation {
Some(r) => self.apply_rotation(query, r),
None => query.to_vec(),
};
self.pq.asymmetric_distance(&rotated_query, code)
}
pub fn compute_distance_table(&self, query: &[f32]) -> Result<Vec<Vec<f32>>> {
if !self.is_trained() {
return Err(Error::InvalidInput("OPQ must be trained".to_string()));
}
let rotated_query = match &self.rotation {
Some(r) => self.apply_rotation(query, r),
None => query.to_vec(),
};
self.pq.compute_distance_table(&rotated_query)
}
pub fn distance_from_table(&self, table: &[Vec<f32>], code: &PQCode) -> f32 {
self.pq.distance_from_table(table, code)
}
pub fn compression_ratio(&self) -> f32 {
self.pq.compression_ratio()
}
pub fn is_trained(&self) -> bool {
self.pq.is_trained() && self.rotation_trained
}
#[allow(dead_code)]
pub fn inner_pq(&self) -> &ProductQuantizer {
&self.pq
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuantizationBenchmark {
pub recall_at_k: Vec<(usize, f32)>,
pub compression_ratio: f32,
pub avg_quantization_error: f32,
pub max_quantization_error: f32,
pub memory_savings: usize,
pub speed_factor: f32,
}
impl QuantizationBenchmark {
pub fn summary(&self) -> String {
let recall_str: Vec<String> = self
.recall_at_k
.iter()
.map(|(k, r)| format!("R@{}: {:.2}%", k, r * 100.0))
.collect();
format!(
"Compression: {:.1}x, Avg Error: {:.4}, {}, Memory Saved: {} bytes",
self.compression_ratio,
self.avg_quantization_error,
recall_str.join(", "),
self.memory_savings
)
}
}
pub struct QuantizationBenchmarker;
impl QuantizationBenchmarker {
pub fn benchmark_scalar(
quantizer: &ScalarQuantizer,
vectors: &[Vec<f32>],
queries: &[Vec<f32>],
ground_truth: &[Vec<usize>],
k_values: &[usize],
) -> Result<QuantizationBenchmark> {
if !quantizer.is_trained() {
return Err(Error::InvalidInput("Quantizer must be trained".to_string()));
}
let quantized: Vec<QuantizedVector> = vectors
.iter()
.map(|v| quantizer.quantize(v))
.collect::<Result<Vec<_>>>()?;
let mut total_error = 0.0f32;
let mut max_error = 0.0f32;
for (i, qv) in quantized.iter().enumerate() {
let restored = quantizer.dequantize(qv)?;
let error: f32 = vectors[i]
.iter()
.zip(restored.iter())
.map(|(a, b)| (a - b).powi(2))
.sum::<f32>()
.sqrt();
total_error += error;
max_error = max_error.max(error);
}
let avg_error = total_error / vectors.len() as f32;
let mut recall_at_k = Vec::new();
for &k in k_values {
let mut total_recall = 0.0f32;
for (qi, query) in queries.iter().enumerate() {
let query_quantized = quantizer.quantize(query)?;
let mut distances: Vec<(usize, f32)> = quantized
.iter()
.enumerate()
.map(|(i, qv)| {
let dist = quantizer
.distance_l2_quantized(&query_quantized, qv)
.unwrap_or(f32::MAX);
(i, dist)
})
.collect();
distances
.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let found: std::collections::HashSet<usize> =
distances.iter().take(k).map(|(i, _)| *i).collect();
let gt: std::collections::HashSet<usize> =
ground_truth[qi].iter().take(k).copied().collect();
let intersection = found.intersection(>).count();
total_recall += intersection as f32 / k.min(gt.len()) as f32;
}
let recall = total_recall / queries.len() as f32;
recall_at_k.push((k, recall));
}
let original_size = vectors.len() * vectors[0].len() * 4; let quantized_size = vectors.len() * vectors[0].len(); let memory_savings = original_size - quantized_size;
Ok(QuantizationBenchmark {
recall_at_k,
compression_ratio: quantizer.compression_ratio(),
avg_quantization_error: avg_error,
max_quantization_error: max_error,
memory_savings,
speed_factor: 2.0, })
}
pub fn benchmark_pq(
pq: &ProductQuantizer,
vectors: &[Vec<f32>],
queries: &[Vec<f32>],
ground_truth: &[Vec<usize>],
k_values: &[usize],
) -> Result<QuantizationBenchmark> {
if !pq.is_trained() {
return Err(Error::InvalidInput("PQ must be trained".to_string()));
}
let codes: Vec<PQCode> = vectors
.iter()
.map(|v| pq.quantize(v))
.collect::<Result<Vec<_>>>()?;
let mut total_error = 0.0f32;
let mut max_error = 0.0f32;
for (i, code) in codes.iter().enumerate() {
let restored = pq.dequantize(code)?;
let error: f32 = vectors[i]
.iter()
.zip(restored.iter())
.map(|(a, b)| (a - b).powi(2))
.sum::<f32>()
.sqrt();
total_error += error;
max_error = max_error.max(error);
}
let avg_error = total_error / vectors.len() as f32;
let mut recall_at_k = Vec::new();
for &k in k_values {
let mut total_recall = 0.0f32;
for (qi, query) in queries.iter().enumerate() {
let table = pq.compute_distance_table(query)?;
let mut distances: Vec<(usize, f32)> = codes
.iter()
.enumerate()
.map(|(i, code)| (i, pq.distance_from_table(&table, code)))
.collect();
distances
.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let found: std::collections::HashSet<usize> =
distances.iter().take(k).map(|(i, _)| *i).collect();
let gt: std::collections::HashSet<usize> =
ground_truth[qi].iter().take(k).copied().collect();
let intersection = found.intersection(>).count();
total_recall += intersection as f32 / k.min(gt.len()) as f32;
}
let recall = total_recall / queries.len() as f32;
recall_at_k.push((k, recall));
}
let original_size = vectors.len() * vectors[0].len() * 4;
let quantized_size = vectors.len() * codes[0].size_bytes();
let memory_savings = original_size.saturating_sub(quantized_size);
Ok(QuantizationBenchmark {
recall_at_k,
compression_ratio: pq.compression_ratio(),
avg_quantization_error: avg_error,
max_quantization_error: max_error,
memory_savings,
speed_factor: 4.0, })
}
pub fn compute_ground_truth(
vectors: &[Vec<f32>],
queries: &[Vec<f32>],
k: usize,
) -> Vec<Vec<usize>> {
queries
.iter()
.map(|query| {
let mut distances: Vec<(usize, f32)> = vectors
.iter()
.enumerate()
.map(|(i, v)| {
let dist: f32 = query
.iter()
.zip(v.iter())
.map(|(a, b)| (a - b).powi(2))
.sum();
(i, dist)
})
.collect();
distances
.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
distances.iter().take(k).map(|(i, _)| *i).collect()
})
.collect()
}
pub fn compare_methods(
vectors: &[Vec<f32>],
queries: &[Vec<f32>],
k_values: &[usize],
) -> Result<QuantizationComparison> {
let max_k = *k_values.iter().max().unwrap_or(&10);
let ground_truth = Self::compute_ground_truth(vectors, queries, max_k);
let mut sq = ScalarQuantizer::uint8(vectors[0].len());
sq.train(vectors)?;
let scalar_results =
Self::benchmark_scalar(&sq, vectors, queries, &ground_truth, k_values)?;
let dim = vectors[0].len();
let pq_results = if dim >= 8 && dim.is_multiple_of(8) {
let mut pq = ProductQuantizer::new(dim, 8, 8)?;
pq.train(vectors, 20)?;
Some(Self::benchmark_pq(
&pq,
vectors,
queries,
&ground_truth,
k_values,
)?)
} else {
None
};
Ok(QuantizationComparison {
scalar: scalar_results,
product: pq_results,
dataset_size: vectors.len(),
dimension: dim,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QuantizationComparison {
pub scalar: QuantizationBenchmark,
pub product: Option<QuantizationBenchmark>,
pub dataset_size: usize,
pub dimension: usize,
}
impl QuantizationComparison {
pub fn summary(&self) -> String {
let mut result = format!(
"Dataset: {} vectors, {} dimensions\n\nScalar Quantization:\n {}\n",
self.dataset_size,
self.dimension,
self.scalar.summary()
);
if let Some(ref pq) = self.product {
result.push_str(&format!("\nProduct Quantization:\n {}\n", pq.summary()));
}
result
}
pub fn best_method_for_k(&self, k: usize) -> (&str, f32) {
let scalar_recall = self
.scalar
.recall_at_k
.iter()
.find(|(kv, _)| *kv == k)
.map(|(_, r)| *r)
.unwrap_or(0.0);
if let Some(ref pq) = self.product {
let pq_recall = pq
.recall_at_k
.iter()
.find(|(kv, _)| *kv == k)
.map(|(_, r)| *r)
.unwrap_or(0.0);
if pq_recall > scalar_recall {
return ("ProductQuantization", pq_recall);
}
}
("ScalarQuantization", scalar_recall)
}
}