vectlite 0.1.8 → 0.1.12
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.
- package/README.md +98 -2
- package/index.d.ts +15 -1
- package/index.js +26 -3
- package/native/Cargo.toml +1 -1
- package/native/src/lib.rs +247 -21
- package/native/vectlite-core/Cargo.toml +1 -1
- package/native/vectlite-core/src/lib.rs +842 -27
- package/native/vectlite-core/src/quantization.rs +1087 -0
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/vectlite.node +0 -0
- package/prebuilds/darwin-x64/vectlite.node +0 -0
- package/prebuilds/linux-x64-gnu/vectlite.node +0 -0
- package/prebuilds/win32-x64-msvc/vectlite.node +0 -0
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
pub mod quantization;
|
|
2
|
+
|
|
1
3
|
use std::collections::{BTreeMap, BTreeSet};
|
|
2
4
|
use std::error::Error as StdError;
|
|
3
5
|
use std::fmt;
|
|
@@ -9,6 +11,8 @@ use std::time::Instant;
|
|
|
9
11
|
use fs2::FileExt;
|
|
10
12
|
use hnsw_rs::prelude::*;
|
|
11
13
|
|
|
14
|
+
use quantization::{QuantizationConfig, QuantizedIndex};
|
|
15
|
+
|
|
12
16
|
const MAGIC: &[u8; 4] = b"VDB1";
|
|
13
17
|
const VERSION: u16 = 4;
|
|
14
18
|
const WAL_MAGIC: &[u8; 4] = b"VWL1";
|
|
@@ -81,7 +85,9 @@ impl fmt::Display for VectLiteError {
|
|
|
81
85
|
Self::DimensionMismatch { expected, found } => {
|
|
82
86
|
write!(
|
|
83
87
|
f,
|
|
84
|
-
"vector dimension mismatch: expected {expected}, found {found}
|
|
88
|
+
"vector dimension mismatch: expected {expected}, found {found}. \
|
|
89
|
+
If you changed embedding models, delete the existing .vdb file \
|
|
90
|
+
or use a different path to create a new database with dimension {found}"
|
|
85
91
|
)
|
|
86
92
|
}
|
|
87
93
|
Self::DuplicateId { namespace, id } => {
|
|
@@ -685,6 +691,8 @@ impl Store {
|
|
|
685
691
|
let _ = fs::remove_file(&wal);
|
|
686
692
|
let manifest = ann_manifest_path(&path);
|
|
687
693
|
let _ = fs::remove_file(&manifest);
|
|
694
|
+
let quant = quantization_params_path(&path);
|
|
695
|
+
let _ = fs::remove_file(&quant);
|
|
688
696
|
// Remove any .hnsw.* sidecar files
|
|
689
697
|
if let Some(parent) = path.parent() {
|
|
690
698
|
if let Some(stem) = path.file_name().and_then(|n| n.to_str()) {
|
|
@@ -736,6 +744,12 @@ pub struct Database {
|
|
|
736
744
|
/// Holds the lock file open for the lifetime of the database.
|
|
737
745
|
/// Dropping this releases the advisory lock.
|
|
738
746
|
_lock_file: Option<File>,
|
|
747
|
+
/// Optional quantized index for accelerated search.
|
|
748
|
+
quantized: Option<QuantizedIndex>,
|
|
749
|
+
/// Configuration used to build the quantized index (persisted).
|
|
750
|
+
quantization_config: Option<QuantizationConfig>,
|
|
751
|
+
/// Ordered keys mapping quantized index positions to record keys.
|
|
752
|
+
quantized_keys: Vec<RecordKey>,
|
|
739
753
|
}
|
|
740
754
|
|
|
741
755
|
#[derive(Default)]
|
|
@@ -786,6 +800,9 @@ impl Database {
|
|
|
786
800
|
ann_loaded_from_disk: false,
|
|
787
801
|
read_only: false,
|
|
788
802
|
_lock_file: Some(lock),
|
|
803
|
+
quantized: None,
|
|
804
|
+
quantization_config: None,
|
|
805
|
+
quantized_keys: Vec::new(),
|
|
789
806
|
};
|
|
790
807
|
|
|
791
808
|
database.flush()?;
|
|
@@ -805,6 +822,28 @@ impl Database {
|
|
|
805
822
|
if !database.ann_loaded_from_disk {
|
|
806
823
|
database.rebuild_ann();
|
|
807
824
|
}
|
|
825
|
+
database.try_load_quantization();
|
|
826
|
+
Ok(database)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/// Open an existing database with a lock timeout in seconds.
|
|
830
|
+
/// If the lock cannot be acquired within the timeout, returns
|
|
831
|
+
/// `VectLiteError::LockContention`.
|
|
832
|
+
pub fn open_with_timeout(path: impl AsRef<Path>, timeout_secs: f64) -> Result<Self> {
|
|
833
|
+
let path = path.as_ref().to_path_buf();
|
|
834
|
+
let timeout = timeout_duration(timeout_secs, "lock_timeout")?;
|
|
835
|
+
let lock = acquire_exclusive_lock_with_timeout(&path, Some(timeout))?;
|
|
836
|
+
let mut file = File::open(&path)?;
|
|
837
|
+
let mut database = Self::read_from(&path, &mut file)?;
|
|
838
|
+
database._lock_file = Some(lock);
|
|
839
|
+
database.read_only = false;
|
|
840
|
+
database.replay_wal()?;
|
|
841
|
+
database.rebuild_sparse_index();
|
|
842
|
+
database.ann_loaded_from_disk = database.try_load_ann_from_disk();
|
|
843
|
+
if !database.ann_loaded_from_disk {
|
|
844
|
+
database.rebuild_ann();
|
|
845
|
+
}
|
|
846
|
+
database.try_load_quantization();
|
|
808
847
|
Ok(database)
|
|
809
848
|
}
|
|
810
849
|
|
|
@@ -812,8 +851,20 @@ impl Database {
|
|
|
812
851
|
/// so multiple readers can coexist. All write operations will return
|
|
813
852
|
/// `VectLiteError::ReadOnly`.
|
|
814
853
|
pub fn open_read_only(path: impl AsRef<Path>) -> Result<Self> {
|
|
854
|
+
Self::open_read_only_with_timeout(path, None)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/// Open an existing database in read-only mode, optionally waiting for a
|
|
858
|
+
/// shared lock to become available.
|
|
859
|
+
pub fn open_read_only_with_timeout(
|
|
860
|
+
path: impl AsRef<Path>,
|
|
861
|
+
timeout_secs: Option<f64>,
|
|
862
|
+
) -> Result<Self> {
|
|
815
863
|
let path = path.as_ref().to_path_buf();
|
|
816
|
-
let
|
|
864
|
+
let timeout = timeout_secs
|
|
865
|
+
.map(|seconds| timeout_duration(seconds, "lock_timeout"))
|
|
866
|
+
.transpose()?;
|
|
867
|
+
let lock = acquire_shared_lock_with_timeout(&path, timeout)?;
|
|
817
868
|
let mut file = File::open(&path)?;
|
|
818
869
|
let mut database = Self::read_from(&path, &mut file)?;
|
|
819
870
|
database._lock_file = Some(lock);
|
|
@@ -824,6 +875,7 @@ impl Database {
|
|
|
824
875
|
if !database.ann_loaded_from_disk {
|
|
825
876
|
database.rebuild_ann();
|
|
826
877
|
}
|
|
878
|
+
database.try_load_quantization();
|
|
827
879
|
Ok(database)
|
|
828
880
|
}
|
|
829
881
|
|
|
@@ -831,7 +883,46 @@ impl Database {
|
|
|
831
883
|
self.read_only
|
|
832
884
|
}
|
|
833
885
|
|
|
886
|
+
/// Returns true if the database has been closed.
|
|
887
|
+
pub fn is_closed(&self) -> bool {
|
|
888
|
+
self._lock_file.is_none() && self.records.is_empty() && self.dimension == 0
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/// Close the database: flush WAL (if writable), release the file lock,
|
|
892
|
+
/// and clear all in-memory state. After calling this, any further
|
|
893
|
+
/// operation will return an error.
|
|
894
|
+
pub fn close(&mut self) -> Result<()> {
|
|
895
|
+
if self.is_closed() {
|
|
896
|
+
return Ok(());
|
|
897
|
+
}
|
|
898
|
+
// Flush WAL to main file if writable
|
|
899
|
+
if !self.read_only {
|
|
900
|
+
self.compact_inner()?;
|
|
901
|
+
}
|
|
902
|
+
// Release the lock by dropping the file handle
|
|
903
|
+
self._lock_file = None;
|
|
904
|
+
// Clear in-memory state
|
|
905
|
+
self.records.clear();
|
|
906
|
+
self.ann = AnnCatalog::default();
|
|
907
|
+
self.sparse_index = SparseIndex::default();
|
|
908
|
+
self.quantized = None;
|
|
909
|
+
self.quantization_config = None;
|
|
910
|
+
self.quantized_keys.clear();
|
|
911
|
+
self.dimension = 0;
|
|
912
|
+
Ok(())
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
fn check_open(&self) -> Result<()> {
|
|
916
|
+
if self.is_closed() {
|
|
917
|
+
return Err(VectLiteError::InvalidFormat(
|
|
918
|
+
"database is closed".to_owned(),
|
|
919
|
+
));
|
|
920
|
+
}
|
|
921
|
+
Ok(())
|
|
922
|
+
}
|
|
923
|
+
|
|
834
924
|
fn check_writable(&self) -> Result<()> {
|
|
925
|
+
self.check_open()?;
|
|
835
926
|
if self.read_only {
|
|
836
927
|
return Err(VectLiteError::ReadOnly);
|
|
837
928
|
}
|
|
@@ -869,6 +960,95 @@ impl Database {
|
|
|
869
960
|
self.records.is_empty()
|
|
870
961
|
}
|
|
871
962
|
|
|
963
|
+
/// Count records, optionally filtered by namespace and/or metadata filter.
|
|
964
|
+
pub fn count_filtered(
|
|
965
|
+
&self,
|
|
966
|
+
namespace: Option<&str>,
|
|
967
|
+
filter: Option<&MetadataFilter>,
|
|
968
|
+
) -> usize {
|
|
969
|
+
self.records
|
|
970
|
+
.iter()
|
|
971
|
+
.filter(|((ns, _), record)| {
|
|
972
|
+
if let Some(target_ns) = namespace {
|
|
973
|
+
if ns != target_ns {
|
|
974
|
+
return false;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if let Some(filter) = filter {
|
|
978
|
+
if !filter.matches(&record.metadata) {
|
|
979
|
+
return false;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
true
|
|
983
|
+
})
|
|
984
|
+
.count()
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/// List records by namespace and/or metadata filter without requiring a
|
|
988
|
+
/// vector query. Returns records ordered by (namespace, id).
|
|
989
|
+
pub fn list(
|
|
990
|
+
&self,
|
|
991
|
+
namespace: Option<&str>,
|
|
992
|
+
filter: Option<&MetadataFilter>,
|
|
993
|
+
limit: usize,
|
|
994
|
+
offset: usize,
|
|
995
|
+
) -> Vec<&Record> {
|
|
996
|
+
self.records
|
|
997
|
+
.iter()
|
|
998
|
+
.filter(|((ns, _), record)| {
|
|
999
|
+
if let Some(target_ns) = namespace {
|
|
1000
|
+
if ns != target_ns {
|
|
1001
|
+
return false;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
if let Some(filter) = filter {
|
|
1005
|
+
if !filter.matches(&record.metadata) {
|
|
1006
|
+
return false;
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
true
|
|
1010
|
+
})
|
|
1011
|
+
.skip(offset)
|
|
1012
|
+
.take(if limit == 0 { usize::MAX } else { limit })
|
|
1013
|
+
.map(|(_, record)| record)
|
|
1014
|
+
.collect()
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
/// Delete all records matching a filter, optionally within a namespace.
|
|
1018
|
+
/// Returns the number of records deleted.
|
|
1019
|
+
pub fn delete_by_filter(
|
|
1020
|
+
&mut self,
|
|
1021
|
+
namespace: Option<&str>,
|
|
1022
|
+
filter: &MetadataFilter,
|
|
1023
|
+
) -> Result<usize> {
|
|
1024
|
+
self.check_writable()?;
|
|
1025
|
+
let keys_to_delete: Vec<(String, String)> = self
|
|
1026
|
+
.records
|
|
1027
|
+
.iter()
|
|
1028
|
+
.filter(|((ns, _), record)| {
|
|
1029
|
+
if let Some(target_ns) = namespace {
|
|
1030
|
+
if ns != target_ns {
|
|
1031
|
+
return false;
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
filter.matches(&record.metadata)
|
|
1035
|
+
})
|
|
1036
|
+
.map(|(key, _)| key.clone())
|
|
1037
|
+
.collect();
|
|
1038
|
+
|
|
1039
|
+
let count = keys_to_delete.len();
|
|
1040
|
+
if count == 0 {
|
|
1041
|
+
return Ok(0);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
let ops: Vec<WalOp> = keys_to_delete
|
|
1045
|
+
.into_iter()
|
|
1046
|
+
.map(|(namespace, id)| WalOp::Delete { namespace, id })
|
|
1047
|
+
.collect();
|
|
1048
|
+
self.apply_wal_batch(ops)?;
|
|
1049
|
+
Ok(count)
|
|
1050
|
+
}
|
|
1051
|
+
|
|
872
1052
|
pub fn insert(
|
|
873
1053
|
&mut self,
|
|
874
1054
|
id: impl Into<String>,
|
|
@@ -1026,7 +1206,12 @@ impl Database {
|
|
|
1026
1206
|
return Ok(0);
|
|
1027
1207
|
}
|
|
1028
1208
|
|
|
1029
|
-
self.
|
|
1209
|
+
self.apply_wal_batch_deferred(records.into_iter().map(WalOp::Upsert).collect())?;
|
|
1210
|
+
self.rebuild_sparse_index();
|
|
1211
|
+
self.rebuild_ann();
|
|
1212
|
+
self.ann_loaded_from_disk = false;
|
|
1213
|
+
self.persist_ann_to_disk()?;
|
|
1214
|
+
self.rebuild_quantized_index();
|
|
1030
1215
|
Ok(count)
|
|
1031
1216
|
}
|
|
1032
1217
|
|
|
@@ -1048,7 +1233,12 @@ impl Database {
|
|
|
1048
1233
|
return Ok(0);
|
|
1049
1234
|
}
|
|
1050
1235
|
|
|
1051
|
-
self.
|
|
1236
|
+
self.apply_wal_batch_deferred(records.into_iter().map(WalOp::Upsert).collect())?;
|
|
1237
|
+
self.rebuild_sparse_index();
|
|
1238
|
+
self.rebuild_ann();
|
|
1239
|
+
self.ann_loaded_from_disk = false;
|
|
1240
|
+
self.persist_ann_to_disk()?;
|
|
1241
|
+
self.rebuild_quantized_index();
|
|
1052
1242
|
Ok(count)
|
|
1053
1243
|
}
|
|
1054
1244
|
|
|
@@ -1239,7 +1429,16 @@ impl Database {
|
|
|
1239
1429
|
WriteOperation::Delete { namespace, id } => Ok(WalOp::Delete { namespace, id }),
|
|
1240
1430
|
})
|
|
1241
1431
|
.collect::<Result<Vec<_>>>()?;
|
|
1242
|
-
|
|
1432
|
+
if ops.is_empty() {
|
|
1433
|
+
return Ok(());
|
|
1434
|
+
}
|
|
1435
|
+
self.apply_wal_batch_deferred(ops)?;
|
|
1436
|
+
self.rebuild_sparse_index();
|
|
1437
|
+
self.rebuild_ann();
|
|
1438
|
+
self.ann_loaded_from_disk = false;
|
|
1439
|
+
self.persist_ann_to_disk()?;
|
|
1440
|
+
self.rebuild_quantized_index();
|
|
1441
|
+
Ok(())
|
|
1243
1442
|
}
|
|
1244
1443
|
|
|
1245
1444
|
fn hybrid_search_internal(
|
|
@@ -1249,6 +1448,7 @@ impl Database {
|
|
|
1249
1448
|
options: HybridSearchOptions,
|
|
1250
1449
|
namespace: Option<&str>,
|
|
1251
1450
|
) -> Result<SearchOutcome> {
|
|
1451
|
+
self.check_open()?;
|
|
1252
1452
|
if let Some(query) = dense_query {
|
|
1253
1453
|
self.validate_vector(query)?;
|
|
1254
1454
|
}
|
|
@@ -1288,8 +1488,22 @@ impl Database {
|
|
|
1288
1488
|
let vector_name = options.vector_name.as_deref();
|
|
1289
1489
|
|
|
1290
1490
|
let dense_start = Instant::now();
|
|
1291
|
-
|
|
1292
|
-
|
|
1491
|
+
// Use quantized index for candidate selection if available (2-stage pipeline).
|
|
1492
|
+
// The quantized index operates on the default vector only and globally (not per-namespace).
|
|
1493
|
+
let quantized_candidates =
|
|
1494
|
+
if vector_name.is_none() || vector_name == Some(DEFAULT_VECTOR_NAME) {
|
|
1495
|
+
dense_query.and_then(|query| self.quantized_candidate_keys(query, fetch_k))
|
|
1496
|
+
} else {
|
|
1497
|
+
None
|
|
1498
|
+
};
|
|
1499
|
+
let ann_candidates = if quantized_candidates.is_some() {
|
|
1500
|
+
// Skip HNSW if quantized index provided candidates
|
|
1501
|
+
None
|
|
1502
|
+
} else {
|
|
1503
|
+
dense_query
|
|
1504
|
+
.and_then(|query| self.ann_candidate_keys(namespace, vector_name, query, fetch_k))
|
|
1505
|
+
};
|
|
1506
|
+
let effective_dense_candidates = quantized_candidates.or(ann_candidates);
|
|
1293
1507
|
let dense_us = dense_start.elapsed().as_micros() as u64;
|
|
1294
1508
|
|
|
1295
1509
|
let sparse_start = Instant::now();
|
|
@@ -1298,17 +1512,19 @@ impl Database {
|
|
|
1298
1512
|
.unwrap_or_default();
|
|
1299
1513
|
let sparse_us = sparse_start.elapsed().as_micros() as u64;
|
|
1300
1514
|
|
|
1301
|
-
let candidate_keys = if dense_query.
|
|
1515
|
+
let candidate_keys = if dense_query.is_none() {
|
|
1516
|
+
Some(sparse_candidates.clone())
|
|
1517
|
+
} else if dense_query.is_some() && effective_dense_candidates.is_none() {
|
|
1302
1518
|
None
|
|
1303
1519
|
} else {
|
|
1304
1520
|
merge_candidate_keys(
|
|
1305
|
-
|
|
1521
|
+
effective_dense_candidates.as_deref(),
|
|
1306
1522
|
Some(sparse_candidates.as_slice()),
|
|
1307
1523
|
)
|
|
1308
1524
|
};
|
|
1309
1525
|
let mut stats = SearchStats {
|
|
1310
|
-
used_ann:
|
|
1311
|
-
ann_candidate_count:
|
|
1526
|
+
used_ann: effective_dense_candidates.is_some(),
|
|
1527
|
+
ann_candidate_count: effective_dense_candidates.as_ref().map_or(0, Vec::len),
|
|
1312
1528
|
fetch_k,
|
|
1313
1529
|
sparse_candidate_count: sparse_candidates.len(),
|
|
1314
1530
|
ann_loaded_from_disk: self.ann_loaded_from_disk,
|
|
@@ -1326,7 +1542,7 @@ impl Database {
|
|
|
1326
1542
|
);
|
|
1327
1543
|
stats.considered_count = results.len();
|
|
1328
1544
|
|
|
1329
|
-
if
|
|
1545
|
+
if effective_dense_candidates.is_some() && results.len() < fetch_k {
|
|
1330
1546
|
stats.exact_fallback = true;
|
|
1331
1547
|
results = self.collect_results(dense_query, sparse_query, &options, namespace, None);
|
|
1332
1548
|
stats.considered_count = results.len();
|
|
@@ -1418,6 +1634,7 @@ impl Database {
|
|
|
1418
1634
|
self.rebuild_ann();
|
|
1419
1635
|
self.ann_loaded_from_disk = false;
|
|
1420
1636
|
self.persist_ann_to_disk()?;
|
|
1637
|
+
self.rebuild_quantized_index();
|
|
1421
1638
|
}
|
|
1422
1639
|
|
|
1423
1640
|
Ok(total)
|
|
@@ -1428,6 +1645,142 @@ impl Database {
|
|
|
1428
1645
|
self.compact_inner()
|
|
1429
1646
|
}
|
|
1430
1647
|
|
|
1648
|
+
// -----------------------------------------------------------------------
|
|
1649
|
+
// Quantization API
|
|
1650
|
+
// -----------------------------------------------------------------------
|
|
1651
|
+
|
|
1652
|
+
/// Enable quantization on this database. Trains the quantizer on all current
|
|
1653
|
+
/// vectors and persists the configuration. Subsequent searches will use the
|
|
1654
|
+
/// quantized index for fast candidate selection followed by exact rescoring.
|
|
1655
|
+
pub fn enable_quantization(&mut self, config: QuantizationConfig) -> Result<()> {
|
|
1656
|
+
self.check_writable()?;
|
|
1657
|
+
if self.records.is_empty() {
|
|
1658
|
+
return Err(VectLiteError::InvalidFormat(
|
|
1659
|
+
"cannot enable quantization on an empty database".to_owned(),
|
|
1660
|
+
));
|
|
1661
|
+
}
|
|
1662
|
+
self.quantization_config = Some(config);
|
|
1663
|
+
self.rebuild_quantized_index();
|
|
1664
|
+
self.persist_quantization_params()?;
|
|
1665
|
+
Ok(())
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
/// Disable quantization and remove persisted parameters.
|
|
1669
|
+
pub fn disable_quantization(&mut self) -> Result<()> {
|
|
1670
|
+
self.check_writable()?;
|
|
1671
|
+
self.quantized = None;
|
|
1672
|
+
self.quantization_config = None;
|
|
1673
|
+
self.quantized_keys.clear();
|
|
1674
|
+
// Remove the sidecar file
|
|
1675
|
+
let params_path = quantization_params_path(&self.path);
|
|
1676
|
+
if params_path.exists() {
|
|
1677
|
+
fs::remove_file(¶ms_path)?;
|
|
1678
|
+
}
|
|
1679
|
+
Ok(())
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/// Returns true if quantization is enabled.
|
|
1683
|
+
pub fn is_quantized(&self) -> bool {
|
|
1684
|
+
self.quantized.is_some()
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/// Returns the quantization configuration if enabled.
|
|
1688
|
+
pub fn quantization_config(&self) -> Option<&QuantizationConfig> {
|
|
1689
|
+
self.quantization_config.as_ref()
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
/// Rebuild the quantized index from current records.
|
|
1693
|
+
fn rebuild_quantized_index(&mut self) {
|
|
1694
|
+
let config = match &self.quantization_config {
|
|
1695
|
+
Some(config) => config.clone(),
|
|
1696
|
+
None => return,
|
|
1697
|
+
};
|
|
1698
|
+
|
|
1699
|
+
if self.records.is_empty() {
|
|
1700
|
+
self.quantized = None;
|
|
1701
|
+
self.quantized_keys.clear();
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
let mut keys = Vec::with_capacity(self.records.len());
|
|
1706
|
+
let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(self.records.len());
|
|
1707
|
+
|
|
1708
|
+
for (key, record) in &self.records {
|
|
1709
|
+
keys.push(key.clone());
|
|
1710
|
+
vectors.push(record.vector.clone());
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
let refs: Vec<&[f32]> = vectors.iter().map(Vec::as_slice).collect();
|
|
1714
|
+
let index = QuantizedIndex::build(&refs, self.dimension, &config);
|
|
1715
|
+
|
|
1716
|
+
self.quantized = Some(index);
|
|
1717
|
+
self.quantized_keys = keys;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
/// Persist quantization parameters to a sidecar file.
|
|
1721
|
+
fn persist_quantization_params(&self) -> Result<()> {
|
|
1722
|
+
let params_path = quantization_params_path(&self.path);
|
|
1723
|
+
if let Some(index) = &self.quantized {
|
|
1724
|
+
let mut file = File::create(¶ms_path)?;
|
|
1725
|
+
index.write_params(&mut file).map_err(VectLiteError::Io)?;
|
|
1726
|
+
file.sync_all()?;
|
|
1727
|
+
} else {
|
|
1728
|
+
if params_path.exists() {
|
|
1729
|
+
fs::remove_file(¶ms_path)?;
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
Ok(())
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
/// Try to load quantization parameters from disk and rebuild codes.
|
|
1736
|
+
fn try_load_quantization(&mut self) -> bool {
|
|
1737
|
+
let params_path = quantization_params_path(&self.path);
|
|
1738
|
+
if !params_path.exists() {
|
|
1739
|
+
return false;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
let file = match File::open(¶ms_path) {
|
|
1743
|
+
Ok(f) => f,
|
|
1744
|
+
Err(_) => return false,
|
|
1745
|
+
};
|
|
1746
|
+
let mut reader = BufReader::new(file);
|
|
1747
|
+
let mut index = match QuantizedIndex::read_params(&mut reader) {
|
|
1748
|
+
Ok(idx) => idx,
|
|
1749
|
+
Err(_) => return false,
|
|
1750
|
+
};
|
|
1751
|
+
|
|
1752
|
+
// Rebuild codes from current records
|
|
1753
|
+
let mut keys = Vec::with_capacity(self.records.len());
|
|
1754
|
+
let mut vectors: Vec<Vec<f32>> = Vec::with_capacity(self.records.len());
|
|
1755
|
+
for (key, record) in &self.records {
|
|
1756
|
+
keys.push(key.clone());
|
|
1757
|
+
vectors.push(record.vector.clone());
|
|
1758
|
+
}
|
|
1759
|
+
let refs: Vec<&[f32]> = vectors.iter().map(Vec::as_slice).collect();
|
|
1760
|
+
index.rebuild_codes(&refs);
|
|
1761
|
+
|
|
1762
|
+
self.quantization_config = Some(index.config());
|
|
1763
|
+
self.quantized = Some(index);
|
|
1764
|
+
self.quantized_keys = keys;
|
|
1765
|
+
true
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
/// Use the quantized index to get candidate record keys for rescoring.
|
|
1769
|
+
fn quantized_candidate_keys(&self, query: &[f32], top_k: usize) -> Option<Vec<RecordKey>> {
|
|
1770
|
+
let index = self.quantized.as_ref()?;
|
|
1771
|
+
if index.count() == 0 {
|
|
1772
|
+
return None;
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
let candidate_indices = index.search_candidates(query, top_k);
|
|
1776
|
+
Some(
|
|
1777
|
+
candidate_indices
|
|
1778
|
+
.into_iter()
|
|
1779
|
+
.filter_map(|idx| self.quantized_keys.get(idx).cloned())
|
|
1780
|
+
.collect(),
|
|
1781
|
+
)
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1431
1784
|
fn compact_inner(&mut self) -> Result<()> {
|
|
1432
1785
|
if let Some(parent) = self.path.parent() {
|
|
1433
1786
|
if !parent.as_os_str().is_empty() {
|
|
@@ -1459,6 +1812,7 @@ impl Database {
|
|
|
1459
1812
|
/// self-contained `.vdb` file (WAL is folded in). The current database is
|
|
1460
1813
|
/// not modified. Works in both read-only and read-write mode.
|
|
1461
1814
|
pub fn snapshot(&self, dest: impl AsRef<Path>) -> Result<()> {
|
|
1815
|
+
self.check_open()?;
|
|
1462
1816
|
let dest = dest.as_ref();
|
|
1463
1817
|
if let Some(parent) = dest.parent() {
|
|
1464
1818
|
if !parent.as_os_str().is_empty() {
|
|
@@ -1479,6 +1833,7 @@ impl Database {
|
|
|
1479
1833
|
/// including the `.vdb` file and ANN sidecar files. The backup is
|
|
1480
1834
|
/// compacted (WAL folded in). Works in both read-only and read-write mode.
|
|
1481
1835
|
pub fn backup(&self, dest: impl AsRef<Path>) -> Result<()> {
|
|
1836
|
+
self.check_open()?;
|
|
1482
1837
|
let dest = dest.as_ref();
|
|
1483
1838
|
fs::create_dir_all(dest)?;
|
|
1484
1839
|
|
|
@@ -1562,12 +1917,43 @@ impl Database {
|
|
|
1562
1917
|
return Ok(());
|
|
1563
1918
|
}
|
|
1564
1919
|
|
|
1920
|
+
let has_sparse = ops.iter().any(|op| match op {
|
|
1921
|
+
WalOp::Upsert(record) => {
|
|
1922
|
+
!record.sparse.is_empty()
|
|
1923
|
+
|| self
|
|
1924
|
+
.records
|
|
1925
|
+
.get(&(record.namespace.clone(), record.id.clone()))
|
|
1926
|
+
.map_or(false, |r| !r.sparse.is_empty())
|
|
1927
|
+
}
|
|
1928
|
+
WalOp::Delete { namespace, id } => self
|
|
1929
|
+
.records
|
|
1930
|
+
.get(&(namespace.clone(), id.clone()))
|
|
1931
|
+
.map_or(false, |r| !r.sparse.is_empty()),
|
|
1932
|
+
});
|
|
1933
|
+
|
|
1565
1934
|
self.append_wal_batch(&ops)?;
|
|
1566
1935
|
self.apply_ops_in_memory(ops);
|
|
1567
|
-
|
|
1936
|
+
|
|
1937
|
+
if has_sparse {
|
|
1938
|
+
self.rebuild_sparse_index();
|
|
1939
|
+
}
|
|
1568
1940
|
self.rebuild_ann();
|
|
1569
1941
|
self.ann_loaded_from_disk = false;
|
|
1570
1942
|
self.persist_ann_to_disk()?;
|
|
1943
|
+
self.rebuild_quantized_index();
|
|
1944
|
+
Ok(())
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/// Write ops to WAL and apply in memory, but defer index rebuilds.
|
|
1948
|
+
/// The caller is responsible for calling `rebuild_sparse_index()`,
|
|
1949
|
+
/// `rebuild_ann()`, and `persist_ann_to_disk()` after all batches are done.
|
|
1950
|
+
fn apply_wal_batch_deferred(&mut self, ops: Vec<WalOp>) -> Result<()> {
|
|
1951
|
+
if ops.is_empty() {
|
|
1952
|
+
return Ok(());
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
self.append_wal_batch(&ops)?;
|
|
1956
|
+
self.apply_ops_in_memory(ops);
|
|
1571
1957
|
Ok(())
|
|
1572
1958
|
}
|
|
1573
1959
|
|
|
@@ -1754,6 +2140,9 @@ impl Database {
|
|
|
1754
2140
|
ann_loaded_from_disk: false,
|
|
1755
2141
|
read_only: false,
|
|
1756
2142
|
_lock_file: None,
|
|
2143
|
+
quantized: None,
|
|
2144
|
+
quantization_config: None,
|
|
2145
|
+
quantized_keys: Vec::new(),
|
|
1757
2146
|
})
|
|
1758
2147
|
}
|
|
1759
2148
|
|
|
@@ -2409,6 +2798,15 @@ fn candidate_count(top_k: usize, total: usize) -> usize {
|
|
|
2409
2798
|
.min(total)
|
|
2410
2799
|
}
|
|
2411
2800
|
|
|
2801
|
+
fn timeout_duration(timeout_secs: f64, label: &str) -> Result<std::time::Duration> {
|
|
2802
|
+
if !timeout_secs.is_finite() || timeout_secs < 0.0 {
|
|
2803
|
+
return Err(VectLiteError::InvalidFormat(format!(
|
|
2804
|
+
"{label} must be a finite, non-negative number of seconds"
|
|
2805
|
+
)));
|
|
2806
|
+
}
|
|
2807
|
+
Ok(std::time::Duration::from_secs_f64(timeout_secs))
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2412
2810
|
fn wal_path(path: &Path) -> PathBuf {
|
|
2413
2811
|
let mut wal = path.as_os_str().to_os_string();
|
|
2414
2812
|
wal.push(".wal");
|
|
@@ -2421,7 +2819,20 @@ fn lock_path(path: &Path) -> PathBuf {
|
|
|
2421
2819
|
PathBuf::from(lock)
|
|
2422
2820
|
}
|
|
2423
2821
|
|
|
2822
|
+
fn quantization_params_path(path: &Path) -> PathBuf {
|
|
2823
|
+
let mut p = path.as_os_str().to_os_string();
|
|
2824
|
+
p.push(".quant");
|
|
2825
|
+
PathBuf::from(p)
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2424
2828
|
fn acquire_exclusive_lock(path: &Path) -> Result<File> {
|
|
2829
|
+
acquire_exclusive_lock_with_timeout(path, None)
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
fn acquire_exclusive_lock_with_timeout(
|
|
2833
|
+
path: &Path,
|
|
2834
|
+
timeout: Option<std::time::Duration>,
|
|
2835
|
+
) -> Result<File> {
|
|
2425
2836
|
if let Some(parent) = path.parent() {
|
|
2426
2837
|
if !parent.as_os_str().is_empty() && !parent.exists() {
|
|
2427
2838
|
fs::create_dir_all(parent)?;
|
|
@@ -2433,16 +2844,43 @@ fn acquire_exclusive_lock(path: &Path) -> Result<File> {
|
|
|
2433
2844
|
.read(true)
|
|
2434
2845
|
.write(true)
|
|
2435
2846
|
.open(lock_path(path))?;
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2847
|
+
|
|
2848
|
+
match timeout {
|
|
2849
|
+
None => {
|
|
2850
|
+
file.try_lock_exclusive().map_err(|err| {
|
|
2851
|
+
VectLiteError::LockContention(format!(
|
|
2852
|
+
"could not acquire exclusive lock on '{}': {err}",
|
|
2853
|
+
path.display()
|
|
2854
|
+
))
|
|
2855
|
+
})?;
|
|
2856
|
+
}
|
|
2857
|
+
Some(duration) => {
|
|
2858
|
+
let start = Instant::now();
|
|
2859
|
+
let interval = std::time::Duration::from_millis(50);
|
|
2860
|
+
loop {
|
|
2861
|
+
match file.try_lock_exclusive() {
|
|
2862
|
+
Ok(()) => break,
|
|
2863
|
+
Err(err) => {
|
|
2864
|
+
if start.elapsed() >= duration {
|
|
2865
|
+
return Err(VectLiteError::LockContention(format!(
|
|
2866
|
+
"could not acquire exclusive lock on '{}' after {:.1}s: {err}",
|
|
2867
|
+
path.display(),
|
|
2868
|
+
duration.as_secs_f64()
|
|
2869
|
+
)));
|
|
2870
|
+
}
|
|
2871
|
+
std::thread::sleep(interval);
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
}
|
|
2876
|
+
}
|
|
2442
2877
|
Ok(file)
|
|
2443
2878
|
}
|
|
2444
2879
|
|
|
2445
|
-
fn
|
|
2880
|
+
fn acquire_shared_lock_with_timeout(
|
|
2881
|
+
path: &Path,
|
|
2882
|
+
timeout: Option<std::time::Duration>,
|
|
2883
|
+
) -> Result<File> {
|
|
2446
2884
|
let lock_file = lock_path(path);
|
|
2447
2885
|
if !lock_file.exists() {
|
|
2448
2886
|
// Lock file may not exist yet for read-only opens on existing dbs
|
|
@@ -2458,12 +2896,36 @@ fn acquire_shared_lock(path: &Path) -> Result<File> {
|
|
|
2458
2896
|
.read(true)
|
|
2459
2897
|
.write(true)
|
|
2460
2898
|
.open(&lock_file)?;
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2899
|
+
|
|
2900
|
+
match timeout {
|
|
2901
|
+
None => {
|
|
2902
|
+
file.try_lock_shared().map_err(|err| {
|
|
2903
|
+
VectLiteError::LockContention(format!(
|
|
2904
|
+
"could not acquire shared lock on '{}': {err}",
|
|
2905
|
+
path.display()
|
|
2906
|
+
))
|
|
2907
|
+
})?;
|
|
2908
|
+
}
|
|
2909
|
+
Some(duration) => {
|
|
2910
|
+
let start = Instant::now();
|
|
2911
|
+
let interval = std::time::Duration::from_millis(50);
|
|
2912
|
+
loop {
|
|
2913
|
+
match file.try_lock_shared() {
|
|
2914
|
+
Ok(()) => break,
|
|
2915
|
+
Err(err) => {
|
|
2916
|
+
if start.elapsed() >= duration {
|
|
2917
|
+
return Err(VectLiteError::LockContention(format!(
|
|
2918
|
+
"could not acquire shared lock on '{}' after {:.1}s: {err}",
|
|
2919
|
+
path.display(),
|
|
2920
|
+
duration.as_secs_f64()
|
|
2921
|
+
)));
|
|
2922
|
+
}
|
|
2923
|
+
std::thread::sleep(interval);
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2467
2929
|
Ok(file)
|
|
2468
2930
|
}
|
|
2469
2931
|
|
|
@@ -3088,7 +3550,7 @@ fn usize_from_u64(value: u64) -> Result<usize> {
|
|
|
3088
3550
|
mod tests {
|
|
3089
3551
|
use super::{
|
|
3090
3552
|
Database, HybridSearchOptions, Metadata, MetadataFilter, MetadataValue, NamedVectors,
|
|
3091
|
-
Record, SearchOptions, SparseVector,
|
|
3553
|
+
Record, SearchOptions, SparseVector, VectLiteError,
|
|
3092
3554
|
};
|
|
3093
3555
|
use std::path::{Path, PathBuf};
|
|
3094
3556
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
@@ -3343,6 +3805,67 @@ mod tests {
|
|
|
3343
3805
|
cleanup(&path);
|
|
3344
3806
|
}
|
|
3345
3807
|
|
|
3808
|
+
#[test]
|
|
3809
|
+
fn upsert_without_sparse_rebuilds_sparse_index() {
|
|
3810
|
+
let path = temp_file("sparse-upsert-clear");
|
|
3811
|
+
let mut database = Database::create(&path, 2).expect("create database");
|
|
3812
|
+
|
|
3813
|
+
let mut sparse_auth = SparseVector::new();
|
|
3814
|
+
sparse_auth.insert("auth".to_owned(), 1.0);
|
|
3815
|
+
|
|
3816
|
+
database
|
|
3817
|
+
.upsert_with_sparse_in_namespace(
|
|
3818
|
+
"docs",
|
|
3819
|
+
"doc1",
|
|
3820
|
+
vec![1.0, 0.0],
|
|
3821
|
+
sparse_auth,
|
|
3822
|
+
Metadata::new(),
|
|
3823
|
+
)
|
|
3824
|
+
.expect("insert sparse doc");
|
|
3825
|
+
|
|
3826
|
+
let mut query_sparse = SparseVector::new();
|
|
3827
|
+
query_sparse.insert("auth".to_owned(), 1.0);
|
|
3828
|
+
|
|
3829
|
+
let initial_outcome = database
|
|
3830
|
+
.hybrid_search_in_namespace_with_stats(
|
|
3831
|
+
"docs",
|
|
3832
|
+
None,
|
|
3833
|
+
Some(&query_sparse),
|
|
3834
|
+
HybridSearchOptions {
|
|
3835
|
+
top_k: 10,
|
|
3836
|
+
filter: None,
|
|
3837
|
+
dense_weight: 0.0,
|
|
3838
|
+
sparse_weight: 1.0,
|
|
3839
|
+
..HybridSearchOptions::default()
|
|
3840
|
+
},
|
|
3841
|
+
)
|
|
3842
|
+
.expect("initial sparse search");
|
|
3843
|
+
assert_eq!(initial_outcome.stats.sparse_candidate_count, 1);
|
|
3844
|
+
|
|
3845
|
+
database
|
|
3846
|
+
.upsert_in_namespace("docs", "doc1", vec![1.0, 0.0], Metadata::new())
|
|
3847
|
+
.expect("replace doc without sparse terms");
|
|
3848
|
+
|
|
3849
|
+
let updated_outcome = database
|
|
3850
|
+
.hybrid_search_in_namespace_with_stats(
|
|
3851
|
+
"docs",
|
|
3852
|
+
None,
|
|
3853
|
+
Some(&query_sparse),
|
|
3854
|
+
HybridSearchOptions {
|
|
3855
|
+
top_k: 10,
|
|
3856
|
+
filter: None,
|
|
3857
|
+
dense_weight: 0.0,
|
|
3858
|
+
sparse_weight: 1.0,
|
|
3859
|
+
..HybridSearchOptions::default()
|
|
3860
|
+
},
|
|
3861
|
+
)
|
|
3862
|
+
.expect("sparse search after clearing sparse terms");
|
|
3863
|
+
assert_eq!(updated_outcome.stats.sparse_candidate_count, 0);
|
|
3864
|
+
assert!(updated_outcome.results.is_empty());
|
|
3865
|
+
|
|
3866
|
+
cleanup(&path);
|
|
3867
|
+
}
|
|
3868
|
+
|
|
3346
3869
|
#[test]
|
|
3347
3870
|
fn named_vectors_roundtrip_and_search() {
|
|
3348
3871
|
let path = temp_file("named-vectors");
|
|
@@ -3528,6 +4051,69 @@ mod tests {
|
|
|
3528
4051
|
cleanup(&path);
|
|
3529
4052
|
}
|
|
3530
4053
|
|
|
4054
|
+
#[test]
|
|
4055
|
+
fn closed_database_rejects_result_based_operations() {
|
|
4056
|
+
let path = temp_file("closed-db");
|
|
4057
|
+
let snapshot = temp_file("closed-db-snapshot");
|
|
4058
|
+
let mut database = Database::create(&path, 2).expect("create database");
|
|
4059
|
+
database
|
|
4060
|
+
.insert("doc1", vec![1.0, 0.0], Metadata::new())
|
|
4061
|
+
.expect("insert doc1");
|
|
4062
|
+
database.close().expect("close database");
|
|
4063
|
+
|
|
4064
|
+
let search_err = database
|
|
4065
|
+
.search(
|
|
4066
|
+
&[1.0, 0.0],
|
|
4067
|
+
SearchOptions {
|
|
4068
|
+
top_k: 1,
|
|
4069
|
+
filter: None,
|
|
4070
|
+
},
|
|
4071
|
+
)
|
|
4072
|
+
.expect_err("search on closed database should fail");
|
|
4073
|
+
assert!(matches!(
|
|
4074
|
+
search_err,
|
|
4075
|
+
VectLiteError::InvalidFormat(message) if message.contains("database is closed")
|
|
4076
|
+
));
|
|
4077
|
+
|
|
4078
|
+
let snapshot_err = database
|
|
4079
|
+
.snapshot(&snapshot)
|
|
4080
|
+
.expect_err("snapshot on closed database should fail");
|
|
4081
|
+
assert!(matches!(
|
|
4082
|
+
snapshot_err,
|
|
4083
|
+
VectLiteError::InvalidFormat(message) if message.contains("database is closed")
|
|
4084
|
+
));
|
|
4085
|
+
|
|
4086
|
+
cleanup(&path);
|
|
4087
|
+
cleanup(&snapshot);
|
|
4088
|
+
}
|
|
4089
|
+
|
|
4090
|
+
#[test]
|
|
4091
|
+
fn lock_timeout_must_be_non_negative_and_finite() {
|
|
4092
|
+
let path = temp_file("timeout-validation");
|
|
4093
|
+
let database = Database::create(&path, 2).expect("create database");
|
|
4094
|
+
|
|
4095
|
+
let negative_err = match Database::open_with_timeout(&path, -1.0) {
|
|
4096
|
+
Ok(_) => panic!("negative lock timeout should fail"),
|
|
4097
|
+
Err(err) => err,
|
|
4098
|
+
};
|
|
4099
|
+
assert!(matches!(
|
|
4100
|
+
negative_err,
|
|
4101
|
+
VectLiteError::InvalidFormat(message) if message.contains("lock_timeout")
|
|
4102
|
+
));
|
|
4103
|
+
|
|
4104
|
+
let nan_err = match Database::open_with_timeout(&path, f64::NAN) {
|
|
4105
|
+
Ok(_) => panic!("NaN lock timeout should fail"),
|
|
4106
|
+
Err(err) => err,
|
|
4107
|
+
};
|
|
4108
|
+
assert!(matches!(
|
|
4109
|
+
nan_err,
|
|
4110
|
+
VectLiteError::InvalidFormat(message) if message.contains("lock_timeout")
|
|
4111
|
+
));
|
|
4112
|
+
|
|
4113
|
+
drop(database);
|
|
4114
|
+
cleanup(&path);
|
|
4115
|
+
}
|
|
4116
|
+
|
|
3531
4117
|
fn temp_file(name: &str) -> PathBuf {
|
|
3532
4118
|
let nanos = SystemTime::now()
|
|
3533
4119
|
.duration_since(UNIX_EPOCH)
|
|
@@ -3541,5 +4127,234 @@ mod tests {
|
|
|
3541
4127
|
|
|
3542
4128
|
fn cleanup(path: &Path) {
|
|
3543
4129
|
let _ = std::fs::remove_file(path);
|
|
4130
|
+
// Also clean up sidecar files
|
|
4131
|
+
let mut quant = path.as_os_str().to_os_string();
|
|
4132
|
+
quant.push(".quant");
|
|
4133
|
+
let _ = std::fs::remove_file(PathBuf::from(&quant));
|
|
4134
|
+
let mut wal = path.as_os_str().to_os_string();
|
|
4135
|
+
wal.push(".wal");
|
|
4136
|
+
let _ = std::fs::remove_file(PathBuf::from(&wal));
|
|
4137
|
+
let mut lock = path.as_os_str().to_os_string();
|
|
4138
|
+
lock.push(".lock");
|
|
4139
|
+
let _ = std::fs::remove_file(PathBuf::from(&lock));
|
|
4140
|
+
}
|
|
4141
|
+
|
|
4142
|
+
// -----------------------------------------------------------------------
|
|
4143
|
+
// Quantization integration tests
|
|
4144
|
+
// -----------------------------------------------------------------------
|
|
4145
|
+
|
|
4146
|
+
#[test]
|
|
4147
|
+
fn scalar_quantization_enables_search_and_persists() {
|
|
4148
|
+
use super::quantization::{QuantizationConfig, ScalarQuantizationConfig};
|
|
4149
|
+
|
|
4150
|
+
let path = temp_file("quant-scalar");
|
|
4151
|
+
let dim = 32;
|
|
4152
|
+
|
|
4153
|
+
{
|
|
4154
|
+
let mut db = Database::create(&path, dim).expect("create");
|
|
4155
|
+
// Insert enough records for meaningful search
|
|
4156
|
+
for i in 0..50 {
|
|
4157
|
+
let mut v = vec![0.0_f32; dim];
|
|
4158
|
+
v[i % dim] = 1.0;
|
|
4159
|
+
v[(i + 1) % dim] = 0.5;
|
|
4160
|
+
db.upsert(format!("doc{i}"), v, Metadata::new())
|
|
4161
|
+
.expect("upsert");
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
// Enable scalar quantization
|
|
4165
|
+
db.enable_quantization(QuantizationConfig::Scalar(ScalarQuantizationConfig {
|
|
4166
|
+
rescore_multiplier: 5,
|
|
4167
|
+
}))
|
|
4168
|
+
.expect("enable quant");
|
|
4169
|
+
|
|
4170
|
+
assert!(db.is_quantized());
|
|
4171
|
+
|
|
4172
|
+
// Search should work with quantization
|
|
4173
|
+
let query = {
|
|
4174
|
+
let mut q = vec![0.0_f32; dim];
|
|
4175
|
+
q[0] = 1.0;
|
|
4176
|
+
q
|
|
4177
|
+
};
|
|
4178
|
+
let results = db
|
|
4179
|
+
.search(
|
|
4180
|
+
&query,
|
|
4181
|
+
SearchOptions {
|
|
4182
|
+
top_k: 5,
|
|
4183
|
+
filter: None,
|
|
4184
|
+
},
|
|
4185
|
+
)
|
|
4186
|
+
.expect("search");
|
|
4187
|
+
assert!(!results.is_empty());
|
|
4188
|
+
// The most similar vector (doc0 has [1,0.5,0,...]) should be first
|
|
4189
|
+
assert_eq!(results[0].id, "doc0");
|
|
4190
|
+
}
|
|
4191
|
+
|
|
4192
|
+
// Reopen and verify quantization persists
|
|
4193
|
+
{
|
|
4194
|
+
let db = Database::open(&path).expect("reopen");
|
|
4195
|
+
assert!(db.is_quantized());
|
|
4196
|
+
assert!(matches!(
|
|
4197
|
+
db.quantization_config(),
|
|
4198
|
+
Some(QuantizationConfig::Scalar(_))
|
|
4199
|
+
));
|
|
4200
|
+
|
|
4201
|
+
let query = {
|
|
4202
|
+
let mut q = vec![0.0_f32; dim];
|
|
4203
|
+
q[0] = 1.0;
|
|
4204
|
+
q
|
|
4205
|
+
};
|
|
4206
|
+
let results = db
|
|
4207
|
+
.search(
|
|
4208
|
+
&query,
|
|
4209
|
+
SearchOptions {
|
|
4210
|
+
top_k: 5,
|
|
4211
|
+
filter: None,
|
|
4212
|
+
},
|
|
4213
|
+
)
|
|
4214
|
+
.expect("search after reopen");
|
|
4215
|
+
assert!(!results.is_empty());
|
|
4216
|
+
assert_eq!(results[0].id, "doc0");
|
|
4217
|
+
}
|
|
4218
|
+
|
|
4219
|
+
cleanup(&path);
|
|
4220
|
+
}
|
|
4221
|
+
|
|
4222
|
+
#[test]
|
|
4223
|
+
fn binary_quantization_enables_search() {
|
|
4224
|
+
use super::quantization::{BinaryQuantizationConfig, QuantizationConfig};
|
|
4225
|
+
|
|
4226
|
+
let path = temp_file("quant-binary");
|
|
4227
|
+
let dim = 64;
|
|
4228
|
+
|
|
4229
|
+
let mut db = Database::create(&path, dim).expect("create");
|
|
4230
|
+
for i in 0..100 {
|
|
4231
|
+
let mut v = vec![0.0_f32; dim];
|
|
4232
|
+
// Set some positive dimensions for the binary representation
|
|
4233
|
+
for j in 0..dim {
|
|
4234
|
+
v[j] = if (i + j) % 3 == 0 { 1.0 } else { -1.0 };
|
|
4235
|
+
}
|
|
4236
|
+
db.upsert(format!("doc{i}"), v, Metadata::new())
|
|
4237
|
+
.expect("upsert");
|
|
4238
|
+
}
|
|
4239
|
+
|
|
4240
|
+
db.enable_quantization(QuantizationConfig::Binary(BinaryQuantizationConfig {
|
|
4241
|
+
rescore_multiplier: 10,
|
|
4242
|
+
}))
|
|
4243
|
+
.expect("enable quant");
|
|
4244
|
+
|
|
4245
|
+
assert!(db.is_quantized());
|
|
4246
|
+
|
|
4247
|
+
// Search: query matches doc0's pattern
|
|
4248
|
+
let query: Vec<f32> = (0..dim)
|
|
4249
|
+
.map(|j| if j % 3 == 0 { 1.0 } else { -1.0 })
|
|
4250
|
+
.collect();
|
|
4251
|
+
let results = db
|
|
4252
|
+
.search(
|
|
4253
|
+
&query,
|
|
4254
|
+
SearchOptions {
|
|
4255
|
+
top_k: 5,
|
|
4256
|
+
filter: None,
|
|
4257
|
+
},
|
|
4258
|
+
)
|
|
4259
|
+
.expect("search");
|
|
4260
|
+
assert!(!results.is_empty());
|
|
4261
|
+
// doc0 should be the best match (identical pattern)
|
|
4262
|
+
assert_eq!(results[0].id, "doc0");
|
|
4263
|
+
|
|
4264
|
+
cleanup(&path);
|
|
4265
|
+
}
|
|
4266
|
+
|
|
4267
|
+
#[test]
|
|
4268
|
+
fn product_quantization_enables_search() {
|
|
4269
|
+
use super::quantization::{ProductQuantizationConfig, QuantizationConfig};
|
|
4270
|
+
|
|
4271
|
+
let path = temp_file("quant-pq");
|
|
4272
|
+
let dim = 32;
|
|
4273
|
+
|
|
4274
|
+
let mut db = Database::create(&path, dim).expect("create");
|
|
4275
|
+
for i in 0..100 {
|
|
4276
|
+
let v: Vec<f32> = (0..dim)
|
|
4277
|
+
.map(|j| ((i * 7 + j * 13) % 100) as f32 / 100.0)
|
|
4278
|
+
.collect();
|
|
4279
|
+
db.upsert(format!("doc{i}"), v, Metadata::new())
|
|
4280
|
+
.expect("upsert");
|
|
4281
|
+
}
|
|
4282
|
+
|
|
4283
|
+
db.enable_quantization(QuantizationConfig::Product(ProductQuantizationConfig {
|
|
4284
|
+
num_sub_vectors: 4,
|
|
4285
|
+
num_centroids: 16,
|
|
4286
|
+
training_iterations: 5,
|
|
4287
|
+
rescore_multiplier: 10,
|
|
4288
|
+
}))
|
|
4289
|
+
.expect("enable quant");
|
|
4290
|
+
|
|
4291
|
+
assert!(db.is_quantized());
|
|
4292
|
+
|
|
4293
|
+
// Search with the same vector as doc0
|
|
4294
|
+
let query: Vec<f32> = (0..dim).map(|j| (j * 13 % 100) as f32 / 100.0).collect();
|
|
4295
|
+
let results = db
|
|
4296
|
+
.search(
|
|
4297
|
+
&query,
|
|
4298
|
+
SearchOptions {
|
|
4299
|
+
top_k: 5,
|
|
4300
|
+
filter: None,
|
|
4301
|
+
},
|
|
4302
|
+
)
|
|
4303
|
+
.expect("search");
|
|
4304
|
+
assert!(!results.is_empty());
|
|
4305
|
+
assert_eq!(results[0].id, "doc0");
|
|
4306
|
+
|
|
4307
|
+
cleanup(&path);
|
|
4308
|
+
}
|
|
4309
|
+
|
|
4310
|
+
#[test]
|
|
4311
|
+
fn disable_quantization_removes_sidecar() {
|
|
4312
|
+
use super::quantization::{QuantizationConfig, ScalarQuantizationConfig};
|
|
4313
|
+
|
|
4314
|
+
let path = temp_file("quant-disable");
|
|
4315
|
+
let dim = 8;
|
|
4316
|
+
|
|
4317
|
+
let mut db = Database::create(&path, dim).expect("create");
|
|
4318
|
+
for i in 0..10 {
|
|
4319
|
+
let v: Vec<f32> = (0..dim).map(|j| (i + j) as f32).collect();
|
|
4320
|
+
db.upsert(format!("doc{i}"), v, Metadata::new())
|
|
4321
|
+
.expect("upsert");
|
|
4322
|
+
}
|
|
4323
|
+
|
|
4324
|
+
db.enable_quantization(QuantizationConfig::Scalar(
|
|
4325
|
+
ScalarQuantizationConfig::default(),
|
|
4326
|
+
))
|
|
4327
|
+
.expect("enable");
|
|
4328
|
+
assert!(db.is_quantized());
|
|
4329
|
+
|
|
4330
|
+
// Verify sidecar exists
|
|
4331
|
+
let quant_path = {
|
|
4332
|
+
let mut p = path.as_os_str().to_os_string();
|
|
4333
|
+
p.push(".quant");
|
|
4334
|
+
PathBuf::from(p)
|
|
4335
|
+
};
|
|
4336
|
+
assert!(quant_path.exists());
|
|
4337
|
+
|
|
4338
|
+
db.disable_quantization().expect("disable");
|
|
4339
|
+
assert!(!db.is_quantized());
|
|
4340
|
+
assert!(!quant_path.exists());
|
|
4341
|
+
|
|
4342
|
+
cleanup(&path);
|
|
4343
|
+
}
|
|
4344
|
+
|
|
4345
|
+
#[test]
|
|
4346
|
+
fn quantization_empty_database_returns_error() {
|
|
4347
|
+
use super::quantization::{QuantizationConfig, ScalarQuantizationConfig};
|
|
4348
|
+
|
|
4349
|
+
let path = temp_file("quant-empty");
|
|
4350
|
+
let mut db = Database::create(&path, 4).expect("create");
|
|
4351
|
+
|
|
4352
|
+
let result = db.enable_quantization(QuantizationConfig::Scalar(
|
|
4353
|
+
ScalarQuantizationConfig::default(),
|
|
4354
|
+
));
|
|
4355
|
+
assert!(result.is_err());
|
|
4356
|
+
assert!(!db.is_quantized());
|
|
4357
|
+
|
|
4358
|
+
cleanup(&path);
|
|
3544
4359
|
}
|
|
3545
4360
|
}
|