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.
@@ -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 lock = acquire_shared_lock(&path)?;
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.apply_wal_batch(records.into_iter().map(WalOp::Upsert).collect())?;
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.apply_wal_batch(records.into_iter().map(WalOp::Upsert).collect())?;
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
- self.apply_wal_batch(ops)
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
- let ann_candidates = dense_query
1292
- .and_then(|query| self.ann_candidate_keys(namespace, vector_name, query, fetch_k));
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.is_some() && ann_candidates.is_none() {
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
- ann_candidates.as_deref(),
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: ann_candidates.is_some(),
1311
- ann_candidate_count: ann_candidates.as_ref().map_or(0, Vec::len),
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 ann_candidates.is_some() && results.len() < fetch_k {
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(&params_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(&params_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(&params_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(&params_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
- self.rebuild_sparse_index();
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
- file.try_lock_exclusive().map_err(|err| {
2437
- VectLiteError::LockContention(format!(
2438
- "could not acquire exclusive lock on '{}': {err}",
2439
- path.display()
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 acquire_shared_lock(path: &Path) -> Result<File> {
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
- file.try_lock_shared().map_err(|err| {
2462
- VectLiteError::LockContention(format!(
2463
- "could not acquire shared lock on '{}': {err}",
2464
- path.display()
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
  }