vectlite 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,16 @@
1
1
  //! Vector quantization module for memory-efficient similarity search.
2
2
  //!
3
3
  //! Supports three quantization strategies:
4
- //! - **Scalar (int8)**: 4x memory reduction with minimal recall loss
5
- //! - **Binary**: 32x memory reduction, uses Hamming distance for fast filtering
4
+ //! - **Scalar (int8)**: compact in-memory candidate index with minimal recall loss
5
+ //! - **Binary**: smallest in-memory candidate index, uses Hamming distance for fast filtering
6
6
  //! - **Product Quantization (PQ)**: Configurable compression for very large datasets
7
7
  //!
8
8
  //! All strategies support a 2-stage pipeline: fast quantized search followed by
9
9
  //! exact float32 rescoring of top candidates.
10
10
 
11
- use std::io::{Read, Write};
11
+ use std::io::{Error, ErrorKind, Read, Write};
12
+
13
+ use crate::{DistanceMetric, Result, VectLiteError};
12
14
 
13
15
  // ---------------------------------------------------------------------------
14
16
  // Public types
@@ -18,10 +20,10 @@ use std::io::{Read, Write};
18
20
  #[derive(Clone, Debug, PartialEq)]
19
21
  pub enum QuantizationConfig {
20
22
  /// Scalar quantization: maps each f32 dimension to int8 using per-dimension
21
- /// min/max calibration. 4x memory reduction.
23
+ /// min/max calibration for a compact in-memory candidate index.
22
24
  Scalar(ScalarQuantizationConfig),
23
25
  /// Binary quantization: maps each f32 dimension to a single bit.
24
- /// 32x memory reduction. Best for high-dimensional normalized embeddings.
26
+ /// Smallest in-memory candidate index. Best for high-dimensional normalized embeddings.
25
27
  Binary(BinaryQuantizationConfig),
26
28
  /// Product quantization: splits vector into sub-vectors and quantizes each
27
29
  /// to a centroid index. Highest compression for large datasets.
@@ -31,14 +33,14 @@ pub enum QuantizationConfig {
31
33
  #[derive(Clone, Debug, PartialEq)]
32
34
  pub struct ScalarQuantizationConfig {
33
35
  /// Number of top candidates from quantized search to rescore with float32.
34
- /// Default: 5x top_k (minimum 100).
36
+ /// Default: 10x top_k.
35
37
  pub rescore_multiplier: usize,
36
38
  }
37
39
 
38
40
  impl Default for ScalarQuantizationConfig {
39
41
  fn default() -> Self {
40
42
  Self {
41
- rescore_multiplier: 5,
43
+ rescore_multiplier: 10,
42
44
  }
43
45
  }
44
46
  }
@@ -46,7 +48,7 @@ impl Default for ScalarQuantizationConfig {
46
48
  #[derive(Clone, Debug, PartialEq)]
47
49
  pub struct BinaryQuantizationConfig {
48
50
  /// Number of top candidates from Hamming search to rescore with float32.
49
- /// Default: 10x top_k (minimum 100).
51
+ /// Default: 10x top_k.
50
52
  pub rescore_multiplier: usize,
51
53
  }
52
54
 
@@ -69,6 +71,7 @@ pub struct ProductQuantizationConfig {
69
71
  /// Number of k-means training iterations.
70
72
  pub training_iterations: usize,
71
73
  /// Number of top candidates from PQ search to rescore with float32.
74
+ /// Default: 10x top_k.
72
75
  pub rescore_multiplier: usize,
73
76
  }
74
77
 
@@ -83,6 +86,53 @@ impl Default for ProductQuantizationConfig {
83
86
  }
84
87
  }
85
88
 
89
+ /// Choose a valid default PQ sub-vector count for a database dimension.
90
+ ///
91
+ /// Prefer the historical default of 16 when possible, then fall back to smaller
92
+ /// common divisors so dimensions such as 100, 146, and 200 do not require an
93
+ /// explicit `num_sub_vectors`.
94
+ pub fn default_product_num_sub_vectors(dimension: usize) -> usize {
95
+ [16, 12, 10, 8, 6, 4, 3, 2, 1]
96
+ .into_iter()
97
+ .find(|candidate| dimension % candidate == 0)
98
+ .unwrap_or(1)
99
+ }
100
+
101
+ /// List every valid PQ sub-vector count for a database dimension.
102
+ pub fn valid_product_num_sub_vectors(dimension: usize) -> Vec<usize> {
103
+ if dimension == 0 {
104
+ return Vec::new();
105
+ }
106
+
107
+ (1..=dimension)
108
+ .filter(|candidate| dimension % candidate == 0)
109
+ .collect()
110
+ }
111
+
112
+ /// Validate quantization settings before an index build can panic.
113
+ pub fn validate_quantization_config(config: &QuantizationConfig, dimension: usize) -> Result<()> {
114
+ if let QuantizationConfig::Product(cfg) = config {
115
+ if cfg.num_sub_vectors == 0 {
116
+ return Err(VectLiteError::InvalidFormat(
117
+ "num_sub_vectors must be greater than 0".to_owned(),
118
+ ));
119
+ }
120
+ if dimension % cfg.num_sub_vectors != 0 {
121
+ return Err(VectLiteError::InvalidFormat(format!(
122
+ "dimension ({dimension}) must be divisible by num_sub_vectors ({})",
123
+ cfg.num_sub_vectors
124
+ )));
125
+ }
126
+ if cfg.num_centroids == 0 || cfg.num_centroids > 256 {
127
+ return Err(VectLiteError::InvalidFormat(
128
+ "num_centroids must be between 1 and 256".to_owned(),
129
+ ));
130
+ }
131
+ }
132
+
133
+ Ok(())
134
+ }
135
+
86
136
  // ---------------------------------------------------------------------------
87
137
  // Scalar Quantization
88
138
  // ---------------------------------------------------------------------------
@@ -173,18 +223,27 @@ impl ScalarQuantizer {
173
223
  .collect()
174
224
  }
175
225
 
176
- /// Compute approximate cosine distance between a quantized query and all stored vectors.
226
+ /// Compute approximate cosine similarity between the query and all stored vectors.
177
227
  /// Returns indices sorted by approximate similarity (best first).
178
228
  pub fn search(&self, query: &[f32], top_k: usize) -> Vec<(usize, f32)> {
179
- let rescore_count = (top_k * self.config.rescore_multiplier)
180
- .max(100)
181
- .min(self.count);
182
- let query_quantized = self.quantize_query(query);
229
+ self.search_with_metric(query, top_k, DistanceMetric::Cosine)
230
+ }
231
+
232
+ /// Compute approximate metric scores between the query and all stored vectors.
233
+ /// Returns indices sorted by approximate score (best first).
234
+ pub fn search_with_metric(
235
+ &self,
236
+ query: &[f32],
237
+ top_k: usize,
238
+ metric: DistanceMetric,
239
+ ) -> Vec<(usize, f32)> {
240
+ assert_eq!(query.len(), self.dimension);
241
+ let rescore_count = rescore_count(top_k, self.config.rescore_multiplier, self.count);
183
242
  let mut scores: Vec<(usize, f32)> = (0..self.count)
184
243
  .map(|idx| {
185
244
  let offset = idx * self.dimension;
186
245
  let code_slice = &self.codes[offset..offset + self.dimension];
187
- let sim = scalar_quantized_dot(&query_quantized, code_slice);
246
+ let sim = self.approximate_score(query, code_slice, metric);
188
247
  (idx, sim)
189
248
  })
190
249
  .collect();
@@ -195,6 +254,71 @@ impl ScalarQuantizer {
195
254
  scores
196
255
  }
197
256
 
257
+ fn approximate_score(&self, query: &[f32], code_slice: &[u8], metric: DistanceMetric) -> f32 {
258
+ match metric {
259
+ DistanceMetric::Cosine => {
260
+ let mut dot = 0.0_f32;
261
+ let mut query_norm = 0.0_f32;
262
+ let mut vector_norm = 0.0_f32;
263
+
264
+ for (((&query_value, &code), &min), &scale) in query
265
+ .iter()
266
+ .zip(code_slice.iter())
267
+ .zip(self.mins.iter())
268
+ .zip(self.scales.iter())
269
+ {
270
+ let value = dequantize_scalar(code, min, scale);
271
+ dot += query_value * value;
272
+ query_norm += query_value * query_value;
273
+ vector_norm += value * value;
274
+ }
275
+
276
+ if query_norm == 0.0 || vector_norm == 0.0 {
277
+ 0.0
278
+ } else {
279
+ dot / (query_norm.sqrt() * vector_norm.sqrt())
280
+ }
281
+ }
282
+ DistanceMetric::Euclidean => {
283
+ let mut sum = 0.0_f32;
284
+ for (((&query_value, &code), &min), &scale) in query
285
+ .iter()
286
+ .zip(code_slice.iter())
287
+ .zip(self.mins.iter())
288
+ .zip(self.scales.iter())
289
+ {
290
+ let delta = query_value - dequantize_scalar(code, min, scale);
291
+ sum += delta * delta;
292
+ }
293
+ -sum.sqrt()
294
+ }
295
+ DistanceMetric::DotProduct => {
296
+ let mut dot = 0.0_f32;
297
+ for (((&query_value, &code), &min), &scale) in query
298
+ .iter()
299
+ .zip(code_slice.iter())
300
+ .zip(self.mins.iter())
301
+ .zip(self.scales.iter())
302
+ {
303
+ dot += query_value * dequantize_scalar(code, min, scale);
304
+ }
305
+ dot
306
+ }
307
+ DistanceMetric::Manhattan => {
308
+ let mut sum = 0.0_f32;
309
+ for (((&query_value, &code), &min), &scale) in query
310
+ .iter()
311
+ .zip(code_slice.iter())
312
+ .zip(self.mins.iter())
313
+ .zip(self.scales.iter())
314
+ {
315
+ sum += (query_value - dequantize_scalar(code, min, scale)).abs();
316
+ }
317
+ -sum
318
+ }
319
+ }
320
+ }
321
+
198
322
  /// Rebuild codes from training vectors (used after deserialization with new vectors).
199
323
  pub fn rebuild_codes(&mut self, vectors: &[&[f32]]) {
200
324
  self.codes.clear();
@@ -311,9 +435,7 @@ impl BinaryQuantizer {
311
435
  /// Search using Hamming distance. Returns candidate indices sorted by
312
436
  /// Hamming similarity (fewest differing bits first).
313
437
  pub fn search(&self, query: &[f32], top_k: usize) -> Vec<(usize, u32)> {
314
- let rescore_count = (top_k * self.config.rescore_multiplier)
315
- .max(100)
316
- .min(self.count);
438
+ let rescore_count = rescore_count(top_k, self.config.rescore_multiplier, self.count);
317
439
  let query_binary = self.binarize_query(query);
318
440
  let mut distances: Vec<(usize, u32)> = (0..self.count)
319
441
  .map(|idx| {
@@ -476,9 +598,7 @@ impl ProductQuantizer {
476
598
  /// Search using asymmetric distance computation (ADC).
477
599
  /// Returns candidate indices sorted by approximate L2 distance.
478
600
  pub fn search(&self, query: &[f32], top_k: usize) -> Vec<(usize, f32)> {
479
- let rescore_count = (top_k * self.config.rescore_multiplier)
480
- .max(100)
481
- .min(self.count);
601
+ let rescore_count = rescore_count(top_k, self.config.rescore_multiplier, self.count);
482
602
  let distance_table = self.compute_distance_table(query);
483
603
 
484
604
  let mut distances: Vec<(usize, f32)> = (0..self.count)
@@ -542,6 +662,20 @@ impl ProductQuantizer {
542
662
  let num_centroids = read_usize(reader)?;
543
663
  let training_iterations = read_usize(reader)?;
544
664
  let rescore_multiplier = read_usize(reader)?;
665
+ if num_sub_vectors == 0 || dimension % num_sub_vectors != 0 {
666
+ return Err(Error::new(
667
+ ErrorKind::InvalidData,
668
+ format!(
669
+ "dimension ({dimension}) must be divisible by num_sub_vectors ({num_sub_vectors})"
670
+ ),
671
+ ));
672
+ }
673
+ if num_centroids == 0 || num_centroids > 256 {
674
+ return Err(Error::new(
675
+ ErrorKind::InvalidData,
676
+ "num_centroids must be between 1 and 256",
677
+ ));
678
+ }
545
679
  let sub_dimension = dimension / num_sub_vectors;
546
680
 
547
681
  // Read codebooks
@@ -586,7 +720,7 @@ impl ProductQuantizer {
586
720
  #[derive(Clone, Debug, PartialEq)]
587
721
  pub struct TwoBitQuantizationConfig {
588
722
  /// Number of top candidate docs from quantized search to rescore with
589
- /// exact float32 MaxSim. Default: 4x top_k (minimum 50).
723
+ /// exact float32 MaxSim. Default: 4x top_k.
590
724
  pub rescore_multiplier: usize,
591
725
  }
592
726
 
@@ -619,11 +753,7 @@ pub struct TwoBitQuantizer {
619
753
 
620
754
  impl TwoBitQuantizer {
621
755
  /// Train a 2-bit quantizer by computing per-dimension quartiles.
622
- pub fn train(
623
- vectors: &[&[f32]],
624
- dimension: usize,
625
- config: TwoBitQuantizationConfig,
626
- ) -> Self {
756
+ pub fn train(vectors: &[&[f32]], dimension: usize, config: TwoBitQuantizationConfig) -> Self {
627
757
  assert!(!vectors.is_empty(), "need at least one vector to train");
628
758
 
629
759
  // Collect values per dimension and compute quartile boundaries
@@ -672,9 +802,7 @@ impl TwoBitQuantizer {
672
802
  /// Search for top-k candidates using approximate 2-bit dot products.
673
803
  /// Returns (index, approx_score) pairs sorted best-first.
674
804
  pub fn search(&self, query: &[f32], top_k: usize) -> Vec<(usize, i32)> {
675
- let rescore_count = (top_k * self.config.rescore_multiplier)
676
- .max(50)
677
- .min(self.count);
805
+ let rescore_count = rescore_count(top_k, self.config.rescore_multiplier, self.count);
678
806
  let query_codes = self.quantize_vector(query);
679
807
 
680
808
  let mut scores: Vec<(usize, i32)> = (0..self.count)
@@ -691,8 +819,11 @@ impl TwoBitQuantizer {
691
819
  self.codes.clear();
692
820
  self.codes.reserve(vectors.len() * self.bytes_per_vector);
693
821
  for vector in vectors {
694
- self.codes
695
- .extend_from_slice(&quantize_two_bit(vector, &self.boundaries, self.bytes_per_vector));
822
+ self.codes.extend_from_slice(&quantize_two_bit(
823
+ vector,
824
+ &self.boundaries,
825
+ self.bytes_per_vector,
826
+ ));
696
827
  }
697
828
  self.count = vectors.len();
698
829
  }
@@ -824,9 +955,11 @@ impl MultiVectorQuantizedIndex {
824
955
 
825
956
  /// Search: returns candidate document indices sorted by approximate MaxSim.
826
957
  pub fn search(&self, query_tokens: &[&[f32]], top_k: usize) -> Vec<usize> {
827
- let rescore_count = (top_k * self.quantizer.config.rescore_multiplier)
828
- .max(50)
829
- .min(self.doc_ranges.len());
958
+ let rescore_count = rescore_count(
959
+ top_k,
960
+ self.quantizer.config.rescore_multiplier,
961
+ self.doc_ranges.len(),
962
+ );
830
963
  if query_tokens.is_empty() || self.doc_ranges.is_empty() {
831
964
  return Vec::new();
832
965
  }
@@ -846,10 +979,7 @@ impl MultiVectorQuantizedIndex {
846
979
  }
847
980
 
848
981
  /// Rebuild from document token vectors (after loading parameters from disk).
849
- pub fn rebuild(
850
- &mut self,
851
- doc_token_vectors: &[&[Vec<f32>]],
852
- ) {
982
+ pub fn rebuild(&mut self, doc_token_vectors: &[&[Vec<f32>]]) {
853
983
  let all_tokens: Vec<&[f32]> = doc_token_vectors
854
984
  .iter()
855
985
  .flat_map(|tokens| tokens.iter().map(|v| v.as_slice()))
@@ -936,10 +1066,23 @@ impl QuantizedIndex {
936
1066
  /// Search the quantized index. Returns candidate indices sorted by
937
1067
  /// approximate similarity (best first), to be rescored with exact vectors.
938
1068
  pub fn search_candidates(&self, query: &[f32], top_k: usize) -> Vec<usize> {
1069
+ self.search_candidates_with_metric(query, top_k, DistanceMetric::Cosine)
1070
+ }
1071
+
1072
+ /// Search the quantized index with the database metric.
1073
+ /// Returns candidate indices sorted by approximate score (best first).
1074
+ pub fn search_candidates_with_metric(
1075
+ &self,
1076
+ query: &[f32],
1077
+ top_k: usize,
1078
+ metric: DistanceMetric,
1079
+ ) -> Vec<usize> {
939
1080
  match self {
940
- QuantizedIndex::Scalar(q) => {
941
- q.search(query, top_k).into_iter().map(|(i, _)| i).collect()
942
- }
1081
+ QuantizedIndex::Scalar(q) => q
1082
+ .search_with_metric(query, top_k, metric)
1083
+ .into_iter()
1084
+ .map(|(i, _)| i)
1085
+ .collect(),
943
1086
  QuantizedIndex::Binary(q) => {
944
1087
  q.search(query, top_k).into_iter().map(|(i, _)| i).collect()
945
1088
  }
@@ -1020,6 +1163,14 @@ impl QuantizedIndex {
1020
1163
  // Internal helper functions
1021
1164
  // ---------------------------------------------------------------------------
1022
1165
 
1166
+ #[inline]
1167
+ fn rescore_count(top_k: usize, rescore_multiplier: usize, count: usize) -> usize {
1168
+ top_k
1169
+ .max(1)
1170
+ .saturating_mul(rescore_multiplier.max(1))
1171
+ .min(count)
1172
+ }
1173
+
1023
1174
  /// Quantize a single f32 value to u8 using the given min and scale.
1024
1175
  #[inline]
1025
1176
  fn quantize_scalar(val: f32, min: f32, scale: f32) -> u8 {
@@ -1030,15 +1181,13 @@ fn quantize_scalar(val: f32, min: f32, scale: f32) -> u8 {
1030
1181
  }
1031
1182
  }
1032
1183
 
1033
- /// Approximate dot product between two u8-quantized vectors.
1034
- /// Higher value = more similar (analogous to cosine similarity for normalized vectors).
1035
1184
  #[inline]
1036
- fn scalar_quantized_dot(a: &[u8], b: &[u8]) -> f32 {
1037
- let mut sum = 0i32;
1038
- for (&ai, &bi) in a.iter().zip(b.iter()) {
1039
- sum += (ai as i32) * (bi as i32);
1185
+ fn dequantize_scalar(code: u8, min: f32, scale: f32) -> f32 {
1186
+ if scale == 0.0 {
1187
+ min
1188
+ } else {
1189
+ min + (code as f32 / scale)
1040
1190
  }
1041
- sum as f32
1042
1191
  }
1043
1192
 
1044
1193
  /// Convert a float vector to a binary representation (1 bit per dimension).
@@ -1258,6 +1407,39 @@ mod tests {
1258
1407
  assert_eq!(results[0].1, 0); // Hamming distance 0
1259
1408
  }
1260
1409
 
1410
+ #[test]
1411
+ fn rescore_multiplier_controls_candidate_count_without_hidden_floor() {
1412
+ let vectors = random_vectors(200, 64, 7);
1413
+ let refs: Vec<&[f32]> = vectors.iter().map(Vec::as_slice).collect();
1414
+
1415
+ let scalar = ScalarQuantizer::train(
1416
+ &refs,
1417
+ 64,
1418
+ ScalarQuantizationConfig {
1419
+ rescore_multiplier: 1,
1420
+ },
1421
+ );
1422
+ assert_eq!(scalar.search(&vectors[0], 10).len(), 10);
1423
+
1424
+ let scalar = ScalarQuantizer::train(
1425
+ &refs,
1426
+ 64,
1427
+ ScalarQuantizationConfig {
1428
+ rescore_multiplier: 4,
1429
+ },
1430
+ );
1431
+ assert_eq!(scalar.search(&vectors[0], 10).len(), 40);
1432
+
1433
+ let mut binary = BinaryQuantizer::new(
1434
+ 64,
1435
+ BinaryQuantizationConfig {
1436
+ rescore_multiplier: 2,
1437
+ },
1438
+ );
1439
+ binary.add_vectors(&refs);
1440
+ assert_eq!(binary.search(&vectors[0], 10).len(), 20);
1441
+ }
1442
+
1261
1443
  #[test]
1262
1444
  fn product_quantization_basic() {
1263
1445
  let vectors = random_vectors(200, 128, 42);
@@ -1520,7 +1702,10 @@ mod tests {
1520
1702
  for (a, b) in original.boundaries.iter().zip(restored.boundaries.iter()) {
1521
1703
  assert!((a - b).abs() < 1e-6);
1522
1704
  }
1523
- assert_eq!(original.config.rescore_multiplier, restored.config.rescore_multiplier);
1705
+ assert_eq!(
1706
+ original.config.rescore_multiplier,
1707
+ restored.config.rescore_multiplier
1708
+ );
1524
1709
  }
1525
1710
 
1526
1711
  #[test]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vectlite",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Embedded vector store for local-first AI applications.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
Binary file