vectlite 0.1.12 → 0.9.0

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,20 +1,23 @@
1
1
  pub mod quantization;
2
2
 
3
- use std::collections::{BTreeMap, BTreeSet};
3
+ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
4
4
  use std::error::Error as StdError;
5
5
  use std::fmt;
6
6
  use std::fs::{self, File, OpenOptions};
7
7
  use std::io::{self, BufReader, BufWriter, ErrorKind, Read, Write};
8
8
  use std::path::{Path, PathBuf};
9
- use std::time::Instant;
9
+ use std::time::{Instant, SystemTime, UNIX_EPOCH};
10
10
 
11
11
  use fs2::FileExt;
12
12
  use hnsw_rs::prelude::*;
13
+ use simsimd::SpatialSimilarity;
13
14
 
14
- use quantization::{QuantizationConfig, QuantizedIndex};
15
+ use quantization::{
16
+ MultiVectorQuantizationConfig, MultiVectorQuantizedIndex, QuantizationConfig, QuantizedIndex,
17
+ };
15
18
 
16
19
  const MAGIC: &[u8; 4] = b"VDB1";
17
- const VERSION: u16 = 4;
20
+ const VERSION: u16 = 7;
18
21
  const WAL_MAGIC: &[u8; 4] = b"VWL1";
19
22
  const TYPE_STRING: u8 = 1;
20
23
  const TYPE_INTEGER: u8 = 2;
@@ -38,12 +41,209 @@ pub type Result<T> = std::result::Result<T, VectLiteError>;
38
41
  pub type Metadata = BTreeMap<String, MetadataValue>;
39
42
  pub type SparseVector = BTreeMap<String, f32>;
40
43
  pub type NamedVectors = BTreeMap<String, Vec<f32>>;
44
+ /// Multi-vectors: a named space maps to N token-level vectors (e.g. ColBERT embeddings).
45
+ pub type MultiVectors = BTreeMap<String, Vec<Vec<f32>>>;
41
46
  type RecordKey = (String, String);
42
47
 
48
+ /// Distance metric used for vector similarity computation.
49
+ ///
50
+ /// Each metric defines how vectors are compared and scored.
51
+ /// The metric is persisted in the database file and cannot be changed
52
+ /// after creation.
53
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
54
+ pub enum DistanceMetric {
55
+ /// Cosine similarity: `dot(a,b) / (|a| * |b|)`.
56
+ /// Returns a similarity in \[-1, 1\] (higher is more similar).
57
+ /// This is the default metric and the most common choice for text embeddings.
58
+ Cosine,
59
+ /// Euclidean (L2) distance: `sqrt(sum((a_i - b_i)^2))`.
60
+ /// Returns a distance >= 0 (lower is more similar).
61
+ Euclidean,
62
+ /// Dot product: `sum(a_i * b_i)`.
63
+ /// Returns raw inner product (higher is more similar for normalized vectors).
64
+ /// Use this for pre-normalized embeddings (e.g. OpenAI v3 with `dimensions` param).
65
+ DotProduct,
66
+ /// Manhattan (L1) distance: `sum(|a_i - b_i|)`.
67
+ /// Returns a distance >= 0 (lower is more similar).
68
+ Manhattan,
69
+ }
70
+
71
+ impl DistanceMetric {
72
+ /// Serialization tag for the binary format.
73
+ fn to_tag(self) -> u8 {
74
+ match self {
75
+ DistanceMetric::Cosine => 0,
76
+ DistanceMetric::Euclidean => 1,
77
+ DistanceMetric::DotProduct => 2,
78
+ DistanceMetric::Manhattan => 3,
79
+ }
80
+ }
81
+
82
+ /// Deserialize from tag byte.
83
+ fn from_tag(tag: u8) -> Result<Self> {
84
+ match tag {
85
+ 0 => Ok(DistanceMetric::Cosine),
86
+ 1 => Ok(DistanceMetric::Euclidean),
87
+ 2 => Ok(DistanceMetric::DotProduct),
88
+ 3 => Ok(DistanceMetric::Manhattan),
89
+ _ => Err(VectLiteError::InvalidFormat(format!(
90
+ "unknown distance metric tag {tag}"
91
+ ))),
92
+ }
93
+ }
94
+
95
+ /// Compute similarity between two vectors using SIMD-accelerated routines.
96
+ ///
97
+ /// For all metrics, the returned score is oriented so that **higher is better**
98
+ /// (more similar / closer). Distance metrics (Euclidean, Manhattan) are negated.
99
+ pub fn score(self, left: &[f32], right: &[f32]) -> f32 {
100
+ match self {
101
+ DistanceMetric::Cosine => simd_cosine_similarity(left, right),
102
+ DistanceMetric::Euclidean => {
103
+ // Negate so higher = more similar
104
+ -simd_euclidean_distance(left, right)
105
+ }
106
+ DistanceMetric::DotProduct => simd_dot_product(left, right),
107
+ DistanceMetric::Manhattan => {
108
+ // Negate so higher = more similar
109
+ -simd_manhattan_distance(left, right)
110
+ }
111
+ }
112
+ }
113
+
114
+ /// String name suitable for user-facing display and serialization.
115
+ pub fn name(self) -> &'static str {
116
+ match self {
117
+ DistanceMetric::Cosine => "cosine",
118
+ DistanceMetric::Euclidean => "euclidean",
119
+ DistanceMetric::DotProduct => "dotproduct",
120
+ DistanceMetric::Manhattan => "manhattan",
121
+ }
122
+ }
123
+
124
+ /// Parse a metric name (case-insensitive).
125
+ pub fn from_name(name: &str) -> Result<Self> {
126
+ match name.to_ascii_lowercase().as_str() {
127
+ "cosine" => Ok(DistanceMetric::Cosine),
128
+ "euclidean" | "l2" => Ok(DistanceMetric::Euclidean),
129
+ "dotproduct" | "dot" | "dot_product" | "ip" | "inner_product" => {
130
+ Ok(DistanceMetric::DotProduct)
131
+ }
132
+ "manhattan" | "l1" => Ok(DistanceMetric::Manhattan),
133
+ _ => Err(VectLiteError::InvalidFormat(format!(
134
+ "unknown distance metric '{name}'; valid options: cosine, euclidean, dotproduct, manhattan"
135
+ ))),
136
+ }
137
+ }
138
+
139
+ /// Whether this metric behaves as a similarity (higher = better)
140
+ /// or a distance (lower = better) in its raw form before negation.
141
+ pub fn is_similarity(self) -> bool {
142
+ matches!(self, DistanceMetric::Cosine | DistanceMetric::DotProduct)
143
+ }
144
+ }
145
+
146
+ impl Default for DistanceMetric {
147
+ fn default() -> Self {
148
+ DistanceMetric::Cosine
149
+ }
150
+ }
151
+
152
+ impl fmt::Display for DistanceMetric {
153
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
154
+ f.write_str(self.name())
155
+ }
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // SIMD-accelerated distance functions (simsimd with scalar fallback)
160
+ // ---------------------------------------------------------------------------
161
+
162
+ /// Cosine similarity using SIMD, returns value in [-1, 1].
163
+ fn simd_cosine_similarity(left: &[f32], right: &[f32]) -> f32 {
164
+ // simsimd returns cosine *distance* (1 - cos_sim), so we convert.
165
+ match f32::cosine(left, right) {
166
+ Some(dist) => 1.0 - dist as f32,
167
+ None => scalar_cosine_similarity(left, right),
168
+ }
169
+ }
170
+
171
+ /// Euclidean (L2) distance using SIMD, returns value >= 0.
172
+ fn simd_euclidean_distance(left: &[f32], right: &[f32]) -> f32 {
173
+ match f32::sqeuclidean(left, right) {
174
+ Some(sq) => (sq as f32).sqrt(),
175
+ None => scalar_euclidean_distance(left, right),
176
+ }
177
+ }
178
+
179
+ /// Dot product using SIMD.
180
+ fn simd_dot_product(left: &[f32], right: &[f32]) -> f32 {
181
+ // simsimd::SpatialSimilarity::dot returns the raw inner product.
182
+ match f32::dot(left, right) {
183
+ Some(d) => d as f32,
184
+ None => scalar_dot_product(left, right),
185
+ }
186
+ }
187
+
188
+ /// Manhattan (L1) distance using SIMD, returns value >= 0.
189
+ fn simd_manhattan_distance(left: &[f32], right: &[f32]) -> f32 {
190
+ // simsimd does not provide L1; use scalar.
191
+ scalar_manhattan_distance(left, right)
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Scalar fallback implementations
196
+ // ---------------------------------------------------------------------------
197
+
198
+ fn scalar_cosine_similarity(left: &[f32], right: &[f32]) -> f32 {
199
+ let mut dot = 0.0_f32;
200
+ let mut left_norm = 0.0_f32;
201
+ let mut right_norm = 0.0_f32;
202
+ for (l, r) in left.iter().zip(right.iter()) {
203
+ dot += l * r;
204
+ left_norm += l * l;
205
+ right_norm += r * r;
206
+ }
207
+ if left_norm == 0.0 || right_norm == 0.0 {
208
+ 0.0
209
+ } else {
210
+ dot / (left_norm.sqrt() * right_norm.sqrt())
211
+ }
212
+ }
213
+
214
+ fn scalar_euclidean_distance(left: &[f32], right: &[f32]) -> f32 {
215
+ left.iter()
216
+ .zip(right.iter())
217
+ .map(|(l, r)| (l - r) * (l - r))
218
+ .sum::<f32>()
219
+ .sqrt()
220
+ }
221
+
222
+ fn scalar_dot_product(left: &[f32], right: &[f32]) -> f32 {
223
+ left.iter().zip(right.iter()).map(|(l, r)| l * r).sum()
224
+ }
225
+
226
+ fn scalar_manhattan_distance(left: &[f32], right: &[f32]) -> f32 {
227
+ left.iter()
228
+ .zip(right.iter())
229
+ .map(|(l, r)| (l - r).abs())
230
+ .sum()
231
+ }
232
+
43
233
  #[derive(Clone, Debug)]
44
234
  enum WalOp {
45
235
  Upsert(Record),
46
236
  Delete { namespace: String, id: String },
237
+ UpdateMetadata {
238
+ namespace: String,
239
+ id: String,
240
+ metadata: Metadata,
241
+ },
242
+ SetTtl {
243
+ namespace: String,
244
+ id: String,
245
+ expires_at: Option<f64>,
246
+ },
47
247
  }
48
248
 
49
249
  #[derive(Clone, Debug)]
@@ -240,6 +440,12 @@ pub struct Record {
240
440
  pub vectors: NamedVectors,
241
441
  pub sparse: SparseVector,
242
442
  pub metadata: Metadata,
443
+ /// Multi-vectors for late interaction scoring (e.g. ColBERT token embeddings).
444
+ /// Each key is a named vector space, and the value is a list of token-level vectors.
445
+ pub multi_vectors: MultiVectors,
446
+ /// Optional Unix-epoch timestamp (seconds, f64) at which this record expires.
447
+ /// `None` means the record never expires.
448
+ pub expires_at: Option<f64>,
243
449
  }
244
450
 
245
451
  impl Record {
@@ -259,6 +465,20 @@ impl Record {
259
465
  .map(|(name, vector)| (name.as_str(), vector)),
260
466
  )
261
467
  }
468
+
469
+ /// Returns `true` if the record has an `expires_at` timestamp that is in
470
+ /// the past relative to the given `now` epoch (seconds since UNIX epoch).
471
+ fn is_expired_at(&self, now: f64) -> bool {
472
+ self.expires_at.map_or(false, |ts| ts <= now)
473
+ }
474
+ }
475
+
476
+ /// Returns the current time as seconds since the UNIX epoch.
477
+ fn now_epoch_secs() -> f64 {
478
+ SystemTime::now()
479
+ .duration_since(UNIX_EPOCH)
480
+ .unwrap_or_default()
481
+ .as_secs_f64()
262
482
  }
263
483
 
264
484
  #[derive(Clone, Debug, PartialEq)]
@@ -521,6 +741,13 @@ fn resolve_dot_path<'a>(metadata: &'a Metadata, key: &str) -> Option<&'a Metadat
521
741
  pub struct SearchOptions {
522
742
  pub top_k: usize,
523
743
  pub filter: Option<MetadataFilter>,
744
+ /// Optional prefix dimension for Matryoshka embeddings.
745
+ ///
746
+ /// When set, dense scoring uses only the first `truncate_dim` dimensions
747
+ /// of the stored vectors and query. ANN/quantized candidate selection is
748
+ /// bypassed because those indexes are built over the full database
749
+ /// dimension.
750
+ pub truncate_dim: Option<usize>,
524
751
  }
525
752
 
526
753
  impl Default for SearchOptions {
@@ -528,6 +755,7 @@ impl Default for SearchOptions {
528
755
  Self {
529
756
  top_k: 10,
530
757
  filter: None,
758
+ truncate_dim: None,
531
759
  }
532
760
  }
533
761
  }
@@ -542,6 +770,8 @@ pub struct HybridSearchOptions {
542
770
  pub mmr_lambda: Option<f32>,
543
771
  pub vector_name: Option<String>,
544
772
  pub fusion: FusionStrategy,
773
+ /// Optional prefix dimension for Matryoshka embeddings.
774
+ pub truncate_dim: Option<usize>,
545
775
  /// Multi-vector search: provide per-vector-name queries and their weights.
546
776
  /// When non-empty, the dense score is the weighted sum of cosine
547
777
  /// similarities over the given vector spaces, and `vector_name` is ignored.
@@ -559,6 +789,7 @@ impl Default for HybridSearchOptions {
559
789
  mmr_lambda: None,
560
790
  vector_name: None,
561
791
  fusion: FusionStrategy::Linear,
792
+ truncate_dim: None,
562
793
  multi_vector_queries: BTreeMap::new(),
563
794
  }
564
795
  }
@@ -598,6 +829,10 @@ pub struct SearchStats {
598
829
  pub ann_loaded_from_disk: bool,
599
830
  pub wal_entries_replayed: usize,
600
831
  pub fusion: String,
832
+ /// Effective dense dimensions used for scoring. This can be lower than the
833
+ /// stored database dimension for Matryoshka/prefix searches.
834
+ pub effective_dimension: usize,
835
+ pub matryoshka_truncated: bool,
601
836
  /// Timing breakdown in microseconds.
602
837
  pub timings: SearchTimings,
603
838
  }
@@ -620,6 +855,36 @@ pub struct SearchOutcome {
620
855
  pub stats: SearchStats,
621
856
  }
622
857
 
858
+ /// Options for multi-vector (late interaction / ColBERT-style) search.
859
+ #[derive(Clone, Debug)]
860
+ pub struct MultiVectorSearchOptions {
861
+ /// Number of results to return.
862
+ pub top_k: usize,
863
+ /// Optional metadata filter.
864
+ pub filter: Option<MetadataFilter>,
865
+ /// Optional namespace.
866
+ pub namespace: Option<String>,
867
+ }
868
+
869
+ impl Default for MultiVectorSearchOptions {
870
+ fn default() -> Self {
871
+ Self {
872
+ top_k: 10,
873
+ filter: None,
874
+ namespace: None,
875
+ }
876
+ }
877
+ }
878
+
879
+ /// A result from multi-vector (MaxSim) search.
880
+ #[derive(Clone, Debug)]
881
+ pub struct MultiVectorSearchResult {
882
+ pub namespace: String,
883
+ pub id: String,
884
+ pub score: f32,
885
+ pub metadata: Metadata,
886
+ }
887
+
623
888
  /// A store manages a directory of independent physical collections, each
624
889
  /// backed by its own `.vdb` file with its own dimension, WAL, and ANN index.
625
890
  pub struct Store {
@@ -731,10 +996,208 @@ impl Store {
731
996
  }
732
997
  }
733
998
 
999
+ // ---------------------------------------------------------------------------
1000
+ // Payload indexes (keyword + numeric)
1001
+ // ---------------------------------------------------------------------------
1002
+
1003
+ /// The type of payload index on a metadata field.
1004
+ #[derive(Clone, Copy, Debug, PartialEq, Eq)]
1005
+ pub enum PayloadIndexType {
1006
+ /// Inverted index for exact string equality, `$in`, `$nin` lookups.
1007
+ Keyword,
1008
+ /// Ordered B-tree index for numeric range queries (`$gt`, `$gte`, `$lt`, `$lte`).
1009
+ Numeric,
1010
+ }
1011
+
1012
+ impl PayloadIndexType {
1013
+ fn tag(&self) -> u8 {
1014
+ match self {
1015
+ Self::Keyword => 1,
1016
+ Self::Numeric => 2,
1017
+ }
1018
+ }
1019
+
1020
+ fn from_tag(tag: u8) -> Result<Self> {
1021
+ match tag {
1022
+ 1 => Ok(Self::Keyword),
1023
+ 2 => Ok(Self::Numeric),
1024
+ _ => Err(VectLiteError::InvalidFormat(format!(
1025
+ "unknown payload index type tag: {tag}"
1026
+ ))),
1027
+ }
1028
+ }
1029
+
1030
+ pub fn name(&self) -> &'static str {
1031
+ match self {
1032
+ Self::Keyword => "keyword",
1033
+ Self::Numeric => "numeric",
1034
+ }
1035
+ }
1036
+
1037
+ pub fn from_name(name: &str) -> Result<Self> {
1038
+ match name.to_ascii_lowercase().as_str() {
1039
+ "keyword" | "string" | "text" => Ok(Self::Keyword),
1040
+ "numeric" | "number" | "int" | "float" | "integer" => Ok(Self::Numeric),
1041
+ _ => Err(VectLiteError::InvalidFormat(format!(
1042
+ "unknown payload index type: {name:?}"
1043
+ ))),
1044
+ }
1045
+ }
1046
+ }
1047
+
1048
+ /// A keyword (inverted) index: value → set of record keys.
1049
+ #[derive(Clone, Debug, Default)]
1050
+ struct KeywordIndex {
1051
+ postings: HashMap<String, HashSet<RecordKey>>,
1052
+ }
1053
+
1054
+ impl KeywordIndex {
1055
+ fn insert(&mut self, value: &str, key: RecordKey) {
1056
+ self.postings
1057
+ .entry(value.to_owned())
1058
+ .or_default()
1059
+ .insert(key);
1060
+ }
1061
+
1062
+ fn remove(&mut self, value: &str, key: &RecordKey) {
1063
+ if let Some(set) = self.postings.get_mut(value) {
1064
+ set.remove(key);
1065
+ if set.is_empty() {
1066
+ self.postings.remove(value);
1067
+ }
1068
+ }
1069
+ }
1070
+
1071
+ /// Return keys that match `value` exactly (for `$eq`).
1072
+ fn lookup_eq(&self, value: &str) -> Option<&HashSet<RecordKey>> {
1073
+ self.postings.get(value)
1074
+ }
1075
+
1076
+ /// Return keys matching any of `values` (for `$in`).
1077
+ fn lookup_in(&self, values: &[&str]) -> HashSet<RecordKey> {
1078
+ let mut result = HashSet::new();
1079
+ for value in values {
1080
+ if let Some(set) = self.postings.get(*value) {
1081
+ result.extend(set.iter().cloned());
1082
+ }
1083
+ }
1084
+ result
1085
+ }
1086
+
1087
+ /// Return all indexed keys (universe for negation).
1088
+ #[allow(dead_code)]
1089
+ fn all_keys(&self) -> HashSet<RecordKey> {
1090
+ let mut result = HashSet::new();
1091
+ for set in self.postings.values() {
1092
+ result.extend(set.iter().cloned());
1093
+ }
1094
+ result
1095
+ }
1096
+ }
1097
+
1098
+ /// An ordered-float wrapper for BTreeMap keys.
1099
+ #[derive(Clone, Copy, Debug)]
1100
+ struct OrdF64(f64);
1101
+
1102
+ impl PartialEq for OrdF64 {
1103
+ fn eq(&self, other: &Self) -> bool {
1104
+ self.0.total_cmp(&other.0) == std::cmp::Ordering::Equal
1105
+ }
1106
+ }
1107
+ impl Eq for OrdF64 {}
1108
+
1109
+ impl PartialOrd for OrdF64 {
1110
+ fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
1111
+ Some(self.cmp(other))
1112
+ }
1113
+ }
1114
+
1115
+ impl Ord for OrdF64 {
1116
+ fn cmp(&self, other: &Self) -> std::cmp::Ordering {
1117
+ self.0.total_cmp(&other.0)
1118
+ }
1119
+ }
1120
+
1121
+ impl std::hash::Hash for OrdF64 {
1122
+ fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
1123
+ self.0.to_bits().hash(state);
1124
+ }
1125
+ }
1126
+
1127
+ /// A numeric (sorted B-tree) index: ordered value → set of record keys.
1128
+ #[derive(Clone, Debug, Default)]
1129
+ struct NumericIndex {
1130
+ tree: BTreeMap<OrdF64, HashSet<RecordKey>>,
1131
+ }
1132
+
1133
+ impl NumericIndex {
1134
+ fn insert(&mut self, value: f64, key: RecordKey) {
1135
+ self.tree.entry(OrdF64(value)).or_default().insert(key);
1136
+ }
1137
+
1138
+ fn remove(&mut self, value: f64, key: &RecordKey) {
1139
+ if let Some(set) = self.tree.get_mut(&OrdF64(value)) {
1140
+ set.remove(key);
1141
+ if set.is_empty() {
1142
+ self.tree.remove(&OrdF64(value));
1143
+ }
1144
+ }
1145
+ }
1146
+
1147
+ /// Return keys where value > threshold.
1148
+ fn range_gt(&self, threshold: f64) -> HashSet<RecordKey> {
1149
+ let mut result = HashSet::new();
1150
+ for (_, set) in self.tree.range((std::ops::Bound::Excluded(OrdF64(threshold)), std::ops::Bound::Unbounded)) {
1151
+ result.extend(set.iter().cloned());
1152
+ }
1153
+ result
1154
+ }
1155
+
1156
+ /// Return keys where value >= threshold.
1157
+ fn range_gte(&self, threshold: f64) -> HashSet<RecordKey> {
1158
+ let mut result = HashSet::new();
1159
+ for (_, set) in self.tree.range(OrdF64(threshold)..) {
1160
+ result.extend(set.iter().cloned());
1161
+ }
1162
+ result
1163
+ }
1164
+
1165
+ /// Return keys where value < threshold.
1166
+ fn range_lt(&self, threshold: f64) -> HashSet<RecordKey> {
1167
+ let mut result = HashSet::new();
1168
+ for (_, set) in self.tree.range(..OrdF64(threshold)) {
1169
+ result.extend(set.iter().cloned());
1170
+ }
1171
+ result
1172
+ }
1173
+
1174
+ /// Return keys where value <= threshold.
1175
+ fn range_lte(&self, threshold: f64) -> HashSet<RecordKey> {
1176
+ let mut result = HashSet::new();
1177
+ for (_, set) in self.tree.range(..=OrdF64(threshold)) {
1178
+ result.extend(set.iter().cloned());
1179
+ }
1180
+ result
1181
+ }
1182
+
1183
+ /// Return keys where value == target (exact match).
1184
+ fn lookup_eq(&self, target: f64) -> Option<&HashSet<RecordKey>> {
1185
+ self.tree.get(&OrdF64(target))
1186
+ }
1187
+ }
1188
+
1189
+ /// A live payload index with its definition and populated data.
1190
+ #[derive(Clone, Debug)]
1191
+ enum PayloadIndexData {
1192
+ Keyword(KeywordIndex),
1193
+ Numeric(NumericIndex),
1194
+ }
1195
+
734
1196
  pub struct Database {
735
1197
  path: PathBuf,
736
1198
  wal_path: PathBuf,
737
1199
  dimension: usize,
1200
+ metric: DistanceMetric,
738
1201
  records: BTreeMap<(String, String), Record>,
739
1202
  ann: AnnCatalog,
740
1203
  sparse_index: SparseIndex,
@@ -750,6 +1213,16 @@ pub struct Database {
750
1213
  quantization_config: Option<QuantizationConfig>,
751
1214
  /// Ordered keys mapping quantized index positions to record keys.
752
1215
  quantized_keys: Vec<RecordKey>,
1216
+ /// Optional quantized index for multi-vector (ColBERT) search.
1217
+ multi_vector_quantized: BTreeMap<String, MultiVectorQuantizedIndex>,
1218
+ /// Configuration for multi-vector quantization (per space).
1219
+ multi_vector_quantization_config: BTreeMap<String, MultiVectorQuantizationConfig>,
1220
+ /// Ordered keys mapping multi-vector quantized index doc positions to record keys.
1221
+ multi_vector_quantized_keys: BTreeMap<String, Vec<RecordKey>>,
1222
+ /// Payload index definitions (field → type), persisted in sidecar file.
1223
+ payload_index_defs: BTreeMap<String, PayloadIndexType>,
1224
+ /// Live payload indexes, populated from records.
1225
+ payload_indexes: BTreeMap<String, PayloadIndexData>,
753
1226
  }
754
1227
 
755
1228
  #[derive(Default)]
@@ -758,8 +1231,38 @@ struct AnnCatalog {
758
1231
  namespaces: BTreeMap<String, BTreeMap<String, AnnIndex>>,
759
1232
  }
760
1233
 
1234
+ enum AnnHnsw {
1235
+ Cosine(Hnsw<'static, f32, DistCosine>),
1236
+ Euclidean(Hnsw<'static, f32, DistL2>),
1237
+ DotProduct(Hnsw<'static, f32, DistDot>),
1238
+ Manhattan(Hnsw<'static, f32, DistL1>),
1239
+ }
1240
+
1241
+ impl AnnHnsw {
1242
+ fn search(&self, query: &[f32], knbn: usize, ef_search: usize) -> Vec<Neighbour> {
1243
+ match self {
1244
+ AnnHnsw::Cosine(h) => h.search(query, knbn, ef_search),
1245
+ AnnHnsw::Euclidean(h) => h.search(query, knbn, ef_search),
1246
+ AnnHnsw::DotProduct(h) => h.search(query, knbn, ef_search),
1247
+ AnnHnsw::Manhattan(h) => h.search(query, knbn, ef_search),
1248
+ }
1249
+ }
1250
+
1251
+ fn file_dump(&self, directory: &Path, basename: &str) -> Result<()> {
1252
+ let result = match self {
1253
+ AnnHnsw::Cosine(h) => h.file_dump(directory, basename),
1254
+ AnnHnsw::Euclidean(h) => h.file_dump(directory, basename),
1255
+ AnnHnsw::DotProduct(h) => h.file_dump(directory, basename),
1256
+ AnnHnsw::Manhattan(h) => h.file_dump(directory, basename),
1257
+ };
1258
+ result
1259
+ .map(|_| ())
1260
+ .map_err(|err| VectLiteError::InvalidFormat(format!("failed to persist ANN index: {err}")))
1261
+ }
1262
+ }
1263
+
761
1264
  struct AnnIndex {
762
- hnsw: Hnsw<'static, f32, DistCosine>,
1265
+ hnsw: AnnHnsw,
763
1266
  keys: Vec<RecordKey>,
764
1267
  }
765
1268
 
@@ -786,6 +1289,14 @@ struct ScoredRecord<'a> {
786
1289
 
787
1290
  impl Database {
788
1291
  pub fn create(path: impl AsRef<Path>, dimension: usize) -> Result<Self> {
1292
+ Self::create_with_metric(path, dimension, DistanceMetric::Cosine)
1293
+ }
1294
+
1295
+ pub fn create_with_metric(
1296
+ path: impl AsRef<Path>,
1297
+ dimension: usize,
1298
+ metric: DistanceMetric,
1299
+ ) -> Result<Self> {
789
1300
  ensure_dimension(dimension)?;
790
1301
  let lock = acquire_exclusive_lock(path.as_ref())?;
791
1302
 
@@ -793,6 +1304,7 @@ impl Database {
793
1304
  path: path.as_ref().to_path_buf(),
794
1305
  wal_path: wal_path(path.as_ref()),
795
1306
  dimension,
1307
+ metric,
796
1308
  records: BTreeMap::new(),
797
1309
  ann: AnnCatalog::default(),
798
1310
  sparse_index: SparseIndex::default(),
@@ -803,6 +1315,11 @@ impl Database {
803
1315
  quantized: None,
804
1316
  quantization_config: None,
805
1317
  quantized_keys: Vec::new(),
1318
+ multi_vector_quantized: BTreeMap::new(),
1319
+ multi_vector_quantization_config: BTreeMap::new(),
1320
+ multi_vector_quantized_keys: BTreeMap::new(),
1321
+ payload_index_defs: BTreeMap::new(),
1322
+ payload_indexes: BTreeMap::new(),
806
1323
  };
807
1324
 
808
1325
  database.flush()?;
@@ -823,6 +1340,9 @@ impl Database {
823
1340
  database.rebuild_ann();
824
1341
  }
825
1342
  database.try_load_quantization();
1343
+ database.try_load_multi_vector_quantization();
1344
+ database.try_load_payload_index_defs();
1345
+ database.rebuild_payload_indexes();
826
1346
  Ok(database)
827
1347
  }
828
1348
 
@@ -844,6 +1364,9 @@ impl Database {
844
1364
  database.rebuild_ann();
845
1365
  }
846
1366
  database.try_load_quantization();
1367
+ database.try_load_multi_vector_quantization();
1368
+ database.try_load_payload_index_defs();
1369
+ database.rebuild_payload_indexes();
847
1370
  Ok(database)
848
1371
  }
849
1372
 
@@ -876,6 +1399,9 @@ impl Database {
876
1399
  database.rebuild_ann();
877
1400
  }
878
1401
  database.try_load_quantization();
1402
+ database.try_load_multi_vector_quantization();
1403
+ database.try_load_payload_index_defs();
1404
+ database.rebuild_payload_indexes();
879
1405
  Ok(database)
880
1406
  }
881
1407
 
@@ -930,6 +1456,14 @@ impl Database {
930
1456
  }
931
1457
 
932
1458
  pub fn open_or_create(path: impl AsRef<Path>, dimension: usize) -> Result<Self> {
1459
+ Self::open_or_create_with_metric(path, dimension, DistanceMetric::Cosine)
1460
+ }
1461
+
1462
+ pub fn open_or_create_with_metric(
1463
+ path: impl AsRef<Path>,
1464
+ dimension: usize,
1465
+ metric: DistanceMetric,
1466
+ ) -> Result<Self> {
933
1467
  if path.as_ref().exists() {
934
1468
  let database = Self::open(path)?;
935
1469
  if database.dimension != dimension {
@@ -940,7 +1474,7 @@ impl Database {
940
1474
  }
941
1475
  Ok(database)
942
1476
  } else {
943
- Self::create(path, dimension)
1477
+ Self::create_with_metric(path, dimension, metric)
944
1478
  }
945
1479
  }
946
1480
 
@@ -952,6 +1486,10 @@ impl Database {
952
1486
  self.dimension
953
1487
  }
954
1488
 
1489
+ pub fn metric(&self) -> DistanceMetric {
1490
+ self.metric
1491
+ }
1492
+
955
1493
  pub fn len(&self) -> usize {
956
1494
  self.records.len()
957
1495
  }
@@ -966,22 +1504,42 @@ impl Database {
966
1504
  namespace: Option<&str>,
967
1505
  filter: Option<&MetadataFilter>,
968
1506
  ) -> usize {
969
- self.records
970
- .iter()
971
- .filter(|((ns, _), record)| {
972
- if let Some(target_ns) = namespace {
973
- if ns != target_ns {
1507
+ let now = now_epoch_secs();
1508
+ // Try to use payload indexes to narrow down candidates.
1509
+ let candidates = filter.and_then(|f| self.payload_index_candidates(f, namespace));
1510
+
1511
+ if let Some(ref cand) = candidates {
1512
+ // Iterate only over candidate keys (still verify filter for safety).
1513
+ cand.iter()
1514
+ .filter_map(|key| self.records.get(key))
1515
+ .filter(|record| {
1516
+ !record.is_expired_at(now)
1517
+ && filter
1518
+ .map(|f| f.matches(&record.metadata))
1519
+ .unwrap_or(true)
1520
+ })
1521
+ .count()
1522
+ } else {
1523
+ self.records
1524
+ .iter()
1525
+ .filter(|((ns, _), record)| {
1526
+ if record.is_expired_at(now) {
974
1527
  return false;
975
1528
  }
976
- }
977
- if let Some(filter) = filter {
978
- if !filter.matches(&record.metadata) {
979
- return false;
1529
+ if let Some(target_ns) = namespace {
1530
+ if ns != target_ns {
1531
+ return false;
1532
+ }
980
1533
  }
981
- }
982
- true
983
- })
984
- .count()
1534
+ if let Some(filter) = filter {
1535
+ if !filter.matches(&record.metadata) {
1536
+ return false;
1537
+ }
1538
+ }
1539
+ true
1540
+ })
1541
+ .count()
1542
+ }
985
1543
  }
986
1544
 
987
1545
  /// List records by namespace and/or metadata filter without requiring a
@@ -993,25 +1551,122 @@ impl Database {
993
1551
  limit: usize,
994
1552
  offset: usize,
995
1553
  ) -> Vec<&Record> {
996
- self.records
997
- .iter()
998
- .filter(|((ns, _), record)| {
999
- if let Some(target_ns) = namespace {
1000
- if ns != target_ns {
1554
+ let now = now_epoch_secs();
1555
+ // Try to use payload indexes to narrow down candidates.
1556
+ let candidates = filter.and_then(|f| self.payload_index_candidates(f, namespace));
1557
+
1558
+ if let Some(ref cand) = candidates {
1559
+ // Collect into a sorted vec to maintain (namespace, id) ordering.
1560
+ let mut keys: Vec<&RecordKey> = cand.iter().collect();
1561
+ keys.sort();
1562
+ keys.iter()
1563
+ .filter_map(|key| self.records.get(*key))
1564
+ .filter(|record| {
1565
+ !record.is_expired_at(now)
1566
+ && filter
1567
+ .map(|f| f.matches(&record.metadata))
1568
+ .unwrap_or(true)
1569
+ })
1570
+ .skip(offset)
1571
+ .take(if limit == 0 { usize::MAX } else { limit })
1572
+ .collect()
1573
+ } else {
1574
+ self.records
1575
+ .iter()
1576
+ .filter(|((ns, _), record)| {
1577
+ if record.is_expired_at(now) {
1001
1578
  return false;
1002
1579
  }
1003
- }
1004
- if let Some(filter) = filter {
1005
- if !filter.matches(&record.metadata) {
1006
- return false;
1580
+ if let Some(target_ns) = namespace {
1581
+ if ns != target_ns {
1582
+ return false;
1583
+ }
1584
+ }
1585
+ if let Some(filter) = filter {
1586
+ if !filter.matches(&record.metadata) {
1587
+ return false;
1588
+ }
1007
1589
  }
1590
+ true
1591
+ })
1592
+ .skip(offset)
1593
+ .take(if limit == 0 { usize::MAX } else { limit })
1594
+ .map(|(_, record)| record)
1595
+ .collect()
1596
+ }
1597
+ }
1598
+
1599
+ /// Cursor-based pagination over records. Returns up to `limit` records
1600
+ /// whose key is strictly greater than `after` (if provided), plus an
1601
+ /// optional next-page cursor.
1602
+ ///
1603
+ /// The cursor is an opaque `(namespace, id)` pair serialised as
1604
+ /// `"namespace\0id"`. Callers should treat it as an opaque token.
1605
+ pub fn list_cursor(
1606
+ &self,
1607
+ namespace: Option<&str>,
1608
+ filter: Option<&MetadataFilter>,
1609
+ limit: usize,
1610
+ after: Option<&str>,
1611
+ ) -> (Vec<&Record>, Option<String>) {
1612
+ let now = now_epoch_secs();
1613
+ let limit = if limit == 0 { 100 } else { limit };
1614
+
1615
+ // Decode cursor
1616
+ let after_key: Option<RecordKey> = after.map(|cursor| {
1617
+ let parts: Vec<&str> = cursor.splitn(2, '\0').collect();
1618
+ if parts.len() == 2 {
1619
+ (parts[0].to_owned(), parts[1].to_owned())
1620
+ } else {
1621
+ (String::new(), cursor.to_owned())
1622
+ }
1623
+ });
1624
+
1625
+ let iter = self.records.iter();
1626
+ // If we have a cursor, skip to the first key after it.
1627
+ let iter: Box<dyn Iterator<Item = (&RecordKey, &Record)> + '_> = match &after_key {
1628
+ Some(key) => {
1629
+ // BTreeMap range starting from Excluded(key)
1630
+ use std::ops::Bound;
1631
+ Box::new(
1632
+ self.records
1633
+ .range((Bound::Excluded(key.clone()), Bound::Unbounded)),
1634
+ )
1635
+ }
1636
+ None => Box::new(iter),
1637
+ };
1638
+
1639
+ let mut results = Vec::with_capacity(limit + 1);
1640
+ for ((ns, _id), record) in iter {
1641
+ if record.is_expired_at(now) {
1642
+ continue;
1643
+ }
1644
+ if let Some(target_ns) = namespace {
1645
+ if ns != target_ns {
1646
+ continue;
1008
1647
  }
1009
- true
1010
- })
1011
- .skip(offset)
1012
- .take(if limit == 0 { usize::MAX } else { limit })
1013
- .map(|(_, record)| record)
1014
- .collect()
1648
+ }
1649
+ if let Some(f) = filter {
1650
+ if !f.matches(&record.metadata) {
1651
+ continue;
1652
+ }
1653
+ }
1654
+ results.push(record);
1655
+ if results.len() > limit {
1656
+ break;
1657
+ }
1658
+ }
1659
+
1660
+ let next_cursor = if results.len() > limit {
1661
+ results.pop(); // remove the extra
1662
+ results
1663
+ .last()
1664
+ .map(|r| format!("{}\0{}", r.namespace, r.id))
1665
+ } else {
1666
+ None
1667
+ };
1668
+
1669
+ (results, next_cursor)
1015
1670
  }
1016
1671
 
1017
1672
  /// Delete all records matching a filter, optionally within a namespace.
@@ -1049,15 +1704,186 @@ impl Database {
1049
1704
  Ok(count)
1050
1705
  }
1051
1706
 
1052
- pub fn insert(
1707
+ /// Merge a metadata patch into an existing record without re-writing the
1708
+ /// vector. Keys present in `metadata` overwrite existing keys; keys not
1709
+ /// in the patch are left untouched.
1710
+ ///
1711
+ /// Returns `true` if the record exists and was updated, `false` if the
1712
+ /// record was not found (no error is raised).
1713
+ pub fn update_metadata(
1053
1714
  &mut self,
1054
1715
  id: impl Into<String>,
1055
- vector: impl Into<Vec<f32>>,
1056
1716
  metadata: Metadata,
1057
- ) -> Result<()> {
1058
- self.insert_with_vectors_in_namespace(
1059
- DEFAULT_NAMESPACE,
1060
- id,
1717
+ ) -> Result<bool> {
1718
+ self.update_metadata_in_namespace(DEFAULT_NAMESPACE, id, metadata)
1719
+ }
1720
+
1721
+ /// Merge a metadata patch into an existing record in the given namespace.
1722
+ /// See [`update_metadata`](Self::update_metadata) for details.
1723
+ pub fn update_metadata_in_namespace(
1724
+ &mut self,
1725
+ namespace: impl Into<String>,
1726
+ id: impl Into<String>,
1727
+ metadata: Metadata,
1728
+ ) -> Result<bool> {
1729
+ self.check_writable()?;
1730
+ let namespace = namespace.into();
1731
+ let id = id.into();
1732
+ let key = (namespace.clone(), id.clone());
1733
+ if !self.records.contains_key(&key) {
1734
+ return Ok(false);
1735
+ }
1736
+ self.apply_wal_batch(vec![WalOp::UpdateMetadata {
1737
+ namespace,
1738
+ id,
1739
+ metadata,
1740
+ }])?;
1741
+ Ok(true)
1742
+ }
1743
+
1744
+ // -----------------------------------------------------------------------
1745
+ // TTL / Expiry API
1746
+ // -----------------------------------------------------------------------
1747
+
1748
+ /// Set a time-to-live on a record. The TTL is expressed as seconds from now.
1749
+ /// After `ttl_secs` seconds the record will be excluded from reads and
1750
+ /// garbage-collected on the next `compact()`.
1751
+ ///
1752
+ /// Returns `true` if the record was found, `false` otherwise.
1753
+ pub fn set_ttl(&mut self, id: &str, ttl_secs: f64) -> Result<bool> {
1754
+ self.set_ttl_in_namespace(DEFAULT_NAMESPACE, id, ttl_secs)
1755
+ }
1756
+
1757
+ /// Set a time-to-live on a record in a specific namespace.
1758
+ pub fn set_ttl_in_namespace(
1759
+ &mut self,
1760
+ namespace: &str,
1761
+ id: &str,
1762
+ ttl_secs: f64,
1763
+ ) -> Result<bool> {
1764
+ self.check_writable()?;
1765
+ if ttl_secs < 0.0 || ttl_secs.is_nan() {
1766
+ return Err(VectLiteError::InvalidFormat(
1767
+ "ttl_secs must be a non-negative finite number".to_owned(),
1768
+ ));
1769
+ }
1770
+ let key = (namespace.to_owned(), id.to_owned());
1771
+ if !self.records.contains_key(&key) {
1772
+ return Ok(false);
1773
+ }
1774
+ let expires_at = Some(now_epoch_secs() + ttl_secs);
1775
+ self.apply_wal_batch(vec![WalOp::SetTtl {
1776
+ namespace: namespace.to_owned(),
1777
+ id: id.to_owned(),
1778
+ expires_at,
1779
+ }])?;
1780
+ Ok(true)
1781
+ }
1782
+
1783
+ /// Remove the TTL from a record so it never expires.
1784
+ /// Returns `true` if the record was found, `false` otherwise.
1785
+ pub fn clear_ttl(&mut self, id: &str) -> Result<bool> {
1786
+ self.clear_ttl_in_namespace(DEFAULT_NAMESPACE, id)
1787
+ }
1788
+
1789
+ /// Remove the TTL from a record in a specific namespace.
1790
+ pub fn clear_ttl_in_namespace(&mut self, namespace: &str, id: &str) -> Result<bool> {
1791
+ self.check_writable()?;
1792
+ let key = (namespace.to_owned(), id.to_owned());
1793
+ if !self.records.contains_key(&key) {
1794
+ return Ok(false);
1795
+ }
1796
+ self.apply_wal_batch(vec![WalOp::SetTtl {
1797
+ namespace: namespace.to_owned(),
1798
+ id: id.to_owned(),
1799
+ expires_at: None,
1800
+ }])?;
1801
+ Ok(true)
1802
+ }
1803
+
1804
+ // -----------------------------------------------------------------------
1805
+ // Payload index management API
1806
+ // -----------------------------------------------------------------------
1807
+
1808
+ /// Create a payload index on a metadata field.
1809
+ ///
1810
+ /// - `field` — the top-level metadata key to index (e.g. `"category"`, `"price"`).
1811
+ /// - `index_type` — `PayloadIndexType::Keyword` or `PayloadIndexType::Numeric`.
1812
+ ///
1813
+ /// The index is populated immediately from all existing records. Subsequent
1814
+ /// mutations (upsert, delete, update_metadata) maintain the index incrementally.
1815
+ ///
1816
+ /// Returns `true` if the index was created, `false` if an index already exists
1817
+ /// for this field.
1818
+ pub fn create_index(
1819
+ &mut self,
1820
+ field: impl Into<String>,
1821
+ index_type: PayloadIndexType,
1822
+ ) -> Result<bool> {
1823
+ self.check_writable()?;
1824
+ let field = field.into();
1825
+ if self.payload_index_defs.contains_key(&field) {
1826
+ return Ok(false);
1827
+ }
1828
+ self.payload_index_defs.insert(field.clone(), index_type);
1829
+
1830
+ // Build the index from existing records.
1831
+ let data = match index_type {
1832
+ PayloadIndexType::Keyword => {
1833
+ let mut kw = KeywordIndex::default();
1834
+ for (key, record) in &self.records {
1835
+ if let Some(MetadataValue::String(val)) = record.metadata.get(&field) {
1836
+ kw.insert(val, key.clone());
1837
+ }
1838
+ }
1839
+ PayloadIndexData::Keyword(kw)
1840
+ }
1841
+ PayloadIndexType::Numeric => {
1842
+ let mut num = NumericIndex::default();
1843
+ for (key, record) in &self.records {
1844
+ if let Some(val) = record.metadata.get(&field).and_then(MetadataValue::as_number) {
1845
+ num.insert(val, key.clone());
1846
+ }
1847
+ }
1848
+ PayloadIndexData::Numeric(num)
1849
+ }
1850
+ };
1851
+ self.payload_indexes.insert(field, data);
1852
+ self.persist_payload_index_defs()?;
1853
+ Ok(true)
1854
+ }
1855
+
1856
+ /// Drop a payload index on a metadata field.
1857
+ ///
1858
+ /// Returns `true` if the index existed and was removed, `false` if there was
1859
+ /// no index on this field.
1860
+ pub fn drop_index(&mut self, field: &str) -> Result<bool> {
1861
+ self.check_writable()?;
1862
+ if self.payload_index_defs.remove(field).is_none() {
1863
+ return Ok(false);
1864
+ }
1865
+ self.payload_indexes.remove(field);
1866
+ self.persist_payload_index_defs()?;
1867
+ Ok(true)
1868
+ }
1869
+
1870
+ /// List all payload indexes as `(field, type_name)` pairs.
1871
+ pub fn list_indexes(&self) -> Vec<(String, PayloadIndexType)> {
1872
+ self.payload_index_defs
1873
+ .iter()
1874
+ .map(|(field, index_type)| (field.clone(), *index_type))
1875
+ .collect()
1876
+ }
1877
+
1878
+ pub fn insert(
1879
+ &mut self,
1880
+ id: impl Into<String>,
1881
+ vector: impl Into<Vec<f32>>,
1882
+ metadata: Metadata,
1883
+ ) -> Result<()> {
1884
+ self.insert_with_vectors_in_namespace(
1885
+ DEFAULT_NAMESPACE,
1886
+ id,
1061
1887
  vector,
1062
1888
  NamedVectors::new(),
1063
1889
  SparseVector::new(),
@@ -1212,6 +2038,7 @@ impl Database {
1212
2038
  self.ann_loaded_from_disk = false;
1213
2039
  self.persist_ann_to_disk()?;
1214
2040
  self.rebuild_quantized_index();
2041
+ self.rebuild_all_multi_vector_quantized_indexes();
1215
2042
  Ok(count)
1216
2043
  }
1217
2044
 
@@ -1239,6 +2066,7 @@ impl Database {
1239
2066
  self.ann_loaded_from_disk = false;
1240
2067
  self.persist_ann_to_disk()?;
1241
2068
  self.rebuild_quantized_index();
2069
+ self.rebuild_all_multi_vector_quantized_indexes();
1242
2070
  Ok(count)
1243
2071
  }
1244
2072
 
@@ -1247,7 +2075,10 @@ impl Database {
1247
2075
  }
1248
2076
 
1249
2077
  pub fn get_in_namespace(&self, namespace: &str, id: &str) -> Option<&Record> {
1250
- self.records.get(&(namespace.to_owned(), id.to_owned()))
2078
+ let now = now_epoch_secs();
2079
+ self.records
2080
+ .get(&(namespace.to_owned(), id.to_owned()))
2081
+ .filter(|record| !record.is_expired_at(now))
1251
2082
  }
1252
2083
 
1253
2084
  pub fn delete(&mut self, id: &str) -> Result<bool> {
@@ -1323,6 +2154,7 @@ impl Database {
1323
2154
  HybridSearchOptions {
1324
2155
  top_k: options.top_k,
1325
2156
  filter: options.filter,
2157
+ truncate_dim: options.truncate_dim,
1326
2158
  dense_weight: 1.0,
1327
2159
  sparse_weight: 0.0,
1328
2160
  ..HybridSearchOptions::default()
@@ -1343,6 +2175,7 @@ impl Database {
1343
2175
  HybridSearchOptions {
1344
2176
  top_k: options.top_k,
1345
2177
  filter: options.filter,
2178
+ truncate_dim: options.truncate_dim,
1346
2179
  dense_weight: 1.0,
1347
2180
  sparse_weight: 0.0,
1348
2181
  ..HybridSearchOptions::default()
@@ -1438,6 +2271,7 @@ impl Database {
1438
2271
  self.ann_loaded_from_disk = false;
1439
2272
  self.persist_ann_to_disk()?;
1440
2273
  self.rebuild_quantized_index();
2274
+ self.rebuild_all_multi_vector_quantized_indexes();
1441
2275
  Ok(())
1442
2276
  }
1443
2277
 
@@ -1449,9 +2283,8 @@ impl Database {
1449
2283
  namespace: Option<&str>,
1450
2284
  ) -> Result<SearchOutcome> {
1451
2285
  self.check_open()?;
1452
- if let Some(query) = dense_query {
1453
- self.validate_vector(query)?;
1454
- }
2286
+ let effective_dimension =
2287
+ self.resolve_dense_search_dimension(dense_query, options.truncate_dim)?;
1455
2288
  if dense_query.is_none() && sparse_query.is_none() {
1456
2289
  return Err(VectLiteError::InvalidFormat(
1457
2290
  "search requires a dense query, a sparse query, or both".to_owned(),
@@ -1486,12 +2319,17 @@ impl Database {
1486
2319
  options.mmr_lambda,
1487
2320
  );
1488
2321
  let vector_name = options.vector_name.as_deref();
2322
+ let matryoshka_truncated = effective_dimension
2323
+ .map(|dimension| dimension < self.dimension)
2324
+ .unwrap_or(false);
1489
2325
 
1490
2326
  let dense_start = Instant::now();
1491
2327
  // Use quantized index for candidate selection if available (2-stage pipeline).
1492
2328
  // The quantized index operates on the default vector only and globally (not per-namespace).
1493
2329
  let quantized_candidates =
1494
- if vector_name.is_none() || vector_name == Some(DEFAULT_VECTOR_NAME) {
2330
+ if !matryoshka_truncated
2331
+ && (vector_name.is_none() || vector_name == Some(DEFAULT_VECTOR_NAME))
2332
+ {
1495
2333
  dense_query.and_then(|query| self.quantized_candidate_keys(query, fetch_k))
1496
2334
  } else {
1497
2335
  None
@@ -1501,6 +2339,7 @@ impl Database {
1501
2339
  None
1502
2340
  } else {
1503
2341
  dense_query
2342
+ .filter(|_| !matryoshka_truncated)
1504
2343
  .and_then(|query| self.ann_candidate_keys(namespace, vector_name, query, fetch_k))
1505
2344
  };
1506
2345
  let effective_dense_candidates = quantized_candidates.or(ann_candidates);
@@ -1522,6 +2361,23 @@ impl Database {
1522
2361
  Some(sparse_candidates.as_slice()),
1523
2362
  )
1524
2363
  };
2364
+
2365
+ // Use payload indexes to narrow candidates when doing a full scan.
2366
+ let payload_candidates = options.filter.as_ref().and_then(|f| {
2367
+ self.payload_index_candidates(f, namespace)
2368
+ });
2369
+ let candidate_keys = match (candidate_keys, payload_candidates) {
2370
+ (Some(ck), Some(pc)) => {
2371
+ // Intersect ANN/sparse candidates with payload index candidates.
2372
+ Some(ck.into_iter().filter(|k| pc.contains(k)).collect::<Vec<_>>())
2373
+ }
2374
+ (None, Some(pc)) => {
2375
+ // No ANN candidates but payload index narrowed the set.
2376
+ Some(pc.into_iter().collect::<Vec<_>>())
2377
+ }
2378
+ (ck, None) => ck,
2379
+ };
2380
+
1525
2381
  let mut stats = SearchStats {
1526
2382
  used_ann: effective_dense_candidates.is_some(),
1527
2383
  ann_candidate_count: effective_dense_candidates.as_ref().map_or(0, Vec::len),
@@ -1530,6 +2386,8 @@ impl Database {
1530
2386
  ann_loaded_from_disk: self.ann_loaded_from_disk,
1531
2387
  wal_entries_replayed: self.wal_entries_replayed,
1532
2388
  fusion: options.fusion.label().to_owned(),
2389
+ effective_dimension: effective_dimension.unwrap_or(0),
2390
+ matryoshka_truncated,
1533
2391
  ..SearchStats::default()
1534
2392
  };
1535
2393
 
@@ -1539,12 +2397,14 @@ impl Database {
1539
2397
  &options,
1540
2398
  namespace,
1541
2399
  candidate_keys.as_deref(),
2400
+ effective_dimension,
1542
2401
  );
1543
2402
  stats.considered_count = results.len();
1544
2403
 
1545
2404
  if effective_dense_candidates.is_some() && results.len() < fetch_k {
1546
2405
  stats.exact_fallback = true;
1547
- results = self.collect_results(dense_query, sparse_query, &options, namespace, None);
2406
+ results =
2407
+ self.collect_results(dense_query, sparse_query, &options, namespace, None, effective_dimension);
1548
2408
  stats.considered_count = results.len();
1549
2409
  }
1550
2410
 
@@ -1568,6 +2428,8 @@ impl Database {
1568
2428
  options.dense_weight,
1569
2429
  options.sparse_weight,
1570
2430
  vector_name,
2431
+ self.metric,
2432
+ effective_dimension,
1571
2433
  )
1572
2434
  } else {
1573
2435
  let mut results = results;
@@ -1635,6 +2497,7 @@ impl Database {
1635
2497
  self.ann_loaded_from_disk = false;
1636
2498
  self.persist_ann_to_disk()?;
1637
2499
  self.rebuild_quantized_index();
2500
+ self.rebuild_all_multi_vector_quantized_indexes();
1638
2501
  }
1639
2502
 
1640
2503
  Ok(total)
@@ -1781,110 +2644,672 @@ impl Database {
1781
2644
  )
1782
2645
  }
1783
2646
 
1784
- fn compact_inner(&mut self) -> Result<()> {
1785
- if let Some(parent) = self.path.parent() {
1786
- if !parent.as_os_str().is_empty() {
1787
- fs::create_dir_all(parent)?;
1788
- }
1789
- }
2647
+ // -----------------------------------------------------------------------
2648
+ // Multi-vector (ColBERT / late interaction) API
2649
+ // -----------------------------------------------------------------------
1790
2650
 
1791
- let temp_path = temp_path(&self.path);
1792
- let mut file = File::create(&temp_path)?;
1793
- {
1794
- let mut writer = BufWriter::new(&mut file);
1795
- self.write_to(&mut writer)?;
1796
- writer.flush()?;
2651
+ /// Upsert a record with multi-vector (token-level) embeddings for late interaction.
2652
+ pub fn upsert_multi_vectors(
2653
+ &mut self,
2654
+ id: impl Into<String>,
2655
+ vector: impl Into<Vec<f32>>,
2656
+ metadata: Metadata,
2657
+ multi_vectors: MultiVectors,
2658
+ ) -> Result<()> {
2659
+ self.upsert_multi_vectors_in_namespace(
2660
+ DEFAULT_NAMESPACE, id, vector, metadata, multi_vectors,
2661
+ )
2662
+ }
2663
+
2664
+ /// Upsert a record with multi-vectors in a specific namespace.
2665
+ pub fn upsert_multi_vectors_in_namespace(
2666
+ &mut self,
2667
+ namespace: impl Into<String>,
2668
+ id: impl Into<String>,
2669
+ vector: impl Into<Vec<f32>>,
2670
+ metadata: Metadata,
2671
+ multi_vectors: MultiVectors,
2672
+ ) -> Result<()> {
2673
+ self.check_writable()?;
2674
+ let record = self.record_from_parts_full(
2675
+ namespace,
2676
+ id,
2677
+ vector,
2678
+ NamedVectors::new(),
2679
+ SparseVector::new(),
2680
+ metadata,
2681
+ multi_vectors,
2682
+ )?;
2683
+ self.apply_wal_batch(vec![WalOp::Upsert(record)])?;
2684
+ Ok(())
2685
+ }
2686
+
2687
+ /// Search using multi-vector late interaction (MaxSim) scoring.
2688
+ ///
2689
+ /// `query_tokens` are the token-level embeddings from the query encoder
2690
+ /// (e.g. ColBERT query encoder output).
2691
+ /// `space` identifies which multi-vector space to search in.
2692
+ pub fn search_multi_vector(
2693
+ &self,
2694
+ space: &str,
2695
+ query_tokens: &[Vec<f32>],
2696
+ options: MultiVectorSearchOptions,
2697
+ ) -> Result<Vec<MultiVectorSearchResult>> {
2698
+ self.check_open()?;
2699
+ if space.is_empty() {
2700
+ return Err(VectLiteError::InvalidFormat(
2701
+ "multi-vector space name must not be empty".to_owned(),
2702
+ ));
2703
+ }
2704
+ if query_tokens.is_empty() {
2705
+ return Err(VectLiteError::InvalidFormat(
2706
+ "query_tokens must not be empty".to_owned(),
2707
+ ));
1797
2708
  }
1798
- file.sync_all()?;
1799
2709
 
1800
- if self.path.exists() {
1801
- fs::remove_file(&self.path)?;
2710
+ let top_k = if options.top_k == 0 {
2711
+ self.records.len()
2712
+ } else {
2713
+ options.top_k
2714
+ };
2715
+ let namespace = options.namespace.as_deref();
2716
+
2717
+ // Try quantized multi-vector search first for candidate selection
2718
+ let query_refs: Vec<&[f32]> = query_tokens.iter().map(Vec::as_slice).collect();
2719
+ let candidate_keys: Option<Vec<RecordKey>> = self
2720
+ .multi_vector_quantized
2721
+ .get(space)
2722
+ .and_then(|index| {
2723
+ let keys = self.multi_vector_quantized_keys.get(space)?;
2724
+ let candidate_indices = index.search(&query_refs, top_k);
2725
+ Some(
2726
+ candidate_indices
2727
+ .into_iter()
2728
+ .filter_map(|idx| keys.get(idx).cloned())
2729
+ .collect(),
2730
+ )
2731
+ });
2732
+
2733
+ // Score all candidates with exact MaxSim
2734
+ let now = now_epoch_secs();
2735
+ let record_iter: Box<dyn Iterator<Item = &Record> + '_> = match &candidate_keys {
2736
+ Some(keys) => Box::new(keys.iter().filter_map(|key| self.records.get(key))),
2737
+ None => Box::new(self.records.values()),
2738
+ };
2739
+
2740
+ let mut scored: Vec<(f32, &Record)> = record_iter
2741
+ .filter(|record| {
2742
+ !record.is_expired_at(now)
2743
+ && namespace
2744
+ .map(|ns| record.namespace == ns)
2745
+ .unwrap_or(true)
2746
+ && record.multi_vectors.contains_key(space)
2747
+ && options
2748
+ .filter
2749
+ .as_ref()
2750
+ .map(|f| f.matches(&record.metadata))
2751
+ .unwrap_or(true)
2752
+ })
2753
+ .map(|record| {
2754
+ let doc_tokens = &record.multi_vectors[space];
2755
+ let score = maxsim_score(&query_refs, doc_tokens, self.metric);
2756
+ (score, record)
2757
+ })
2758
+ .collect();
2759
+
2760
+ scored.sort_unstable_by(|a, b| b.0.total_cmp(&a.0));
2761
+ scored.truncate(top_k);
2762
+
2763
+ Ok(scored
2764
+ .into_iter()
2765
+ .map(|(score, record)| MultiVectorSearchResult {
2766
+ namespace: record.namespace.clone(),
2767
+ id: record.id.clone(),
2768
+ score,
2769
+ metadata: record.metadata.clone(),
2770
+ })
2771
+ .collect())
2772
+ }
2773
+
2774
+ /// Enable 2-bit quantization for a multi-vector space to accelerate
2775
+ /// ColBERT-style MaxSim search. Trains the quantizer on all current
2776
+ /// token vectors in the given space.
2777
+ pub fn enable_multi_vector_quantization(
2778
+ &mut self,
2779
+ space: &str,
2780
+ config: MultiVectorQuantizationConfig,
2781
+ ) -> Result<()> {
2782
+ self.check_writable()?;
2783
+ if space.is_empty() {
2784
+ return Err(VectLiteError::InvalidFormat(
2785
+ "multi-vector space name must not be empty".to_owned(),
2786
+ ));
1802
2787
  }
1803
- fs::rename(temp_path, &self.path)?;
1804
- self.clear_wal()?;
1805
- self.wal_entries_replayed = 0;
1806
- self.persist_ann_to_disk()?;
1807
2788
 
2789
+ self.multi_vector_quantization_config
2790
+ .insert(space.to_owned(), config);
2791
+ self.rebuild_multi_vector_quantized_index(space);
2792
+ self.persist_multi_vector_quantization_params(space)?;
1808
2793
  Ok(())
1809
2794
  }
1810
2795
 
1811
- /// Create an atomic snapshot of the database at `dest`. The snapshot is a
1812
- /// self-contained `.vdb` file (WAL is folded in). The current database is
1813
- /// not modified. Works in both read-only and read-write mode.
1814
- pub fn snapshot(&self, dest: impl AsRef<Path>) -> Result<()> {
1815
- self.check_open()?;
1816
- let dest = dest.as_ref();
1817
- if let Some(parent) = dest.parent() {
1818
- if !parent.as_os_str().is_empty() {
1819
- fs::create_dir_all(parent)?;
1820
- }
1821
- }
1822
- let mut file = File::create(dest)?;
1823
- {
1824
- let mut writer = BufWriter::new(&mut file);
1825
- self.write_to(&mut writer)?;
1826
- writer.flush()?;
2796
+ /// Disable multi-vector quantization for a space.
2797
+ pub fn disable_multi_vector_quantization(&mut self, space: &str) -> Result<()> {
2798
+ self.check_writable()?;
2799
+ self.multi_vector_quantized.remove(space);
2800
+ self.multi_vector_quantization_config.remove(space);
2801
+ self.multi_vector_quantized_keys.remove(space);
2802
+ let params_path = multi_vector_quantization_params_path(&self.path, space);
2803
+ if params_path.exists() {
2804
+ fs::remove_file(&params_path)?;
1827
2805
  }
1828
- file.sync_all()?;
1829
2806
  Ok(())
1830
2807
  }
1831
2808
 
1832
- /// Back up the database to `dest` directory. Creates a complete copy
1833
- /// including the `.vdb` file and ANN sidecar files. The backup is
1834
- /// compacted (WAL folded in). Works in both read-only and read-write mode.
1835
- pub fn backup(&self, dest: impl AsRef<Path>) -> Result<()> {
1836
- self.check_open()?;
1837
- let dest = dest.as_ref();
1838
- fs::create_dir_all(dest)?;
2809
+ /// Returns true if multi-vector quantization is enabled for a given space.
2810
+ pub fn is_multi_vector_quantized(&self, space: &str) -> bool {
2811
+ self.multi_vector_quantized.contains_key(space)
2812
+ }
1839
2813
 
1840
- let file_name = self.path.file_name().ok_or_else(|| {
1841
- VectLiteError::InvalidFormat("database path has no file name".to_owned())
1842
- })?;
1843
- let dest_vdb = dest.join(file_name);
1844
- self.snapshot(&dest_vdb)?;
2814
+ fn rebuild_multi_vector_quantized_index(&mut self, space: &str) {
2815
+ let config = match self.multi_vector_quantization_config.get(space) {
2816
+ Some(config) => config.clone(),
2817
+ None => return,
2818
+ };
1845
2819
 
1846
- // Copy ANN sidecar files
1847
- if let Some(parent) = self.path.parent() {
1848
- if let Some(stem) = self.path.file_name().and_then(|n| n.to_str()) {
1849
- if let Ok(entries) = fs::read_dir(parent) {
1850
- for entry in entries.flatten() {
1851
- if let Some(fname) = entry.file_name().to_str() {
1852
- if fname.starts_with(&format!("{stem}.ann.")) {
1853
- let _ = fs::copy(entry.path(), dest.join(fname));
1854
- }
1855
- }
1856
- }
1857
- }
1858
- // Copy ann manifest
1859
- let manifest = ann_manifest_path(&self.path);
1860
- if manifest.exists() {
1861
- if let Some(manifest_name) = manifest.file_name() {
1862
- let _ = fs::copy(&manifest, dest.join(manifest_name));
2820
+ // Collect per-document token vectors for this space
2821
+ let mut keys = Vec::new();
2822
+ let mut doc_token_vectors: Vec<&[Vec<f32>]> = Vec::new();
2823
+ let mut token_dimension = 0_usize;
2824
+
2825
+ for (key, record) in &self.records {
2826
+ if let Some(tokens) = record.multi_vectors.get(space) {
2827
+ if !tokens.is_empty() {
2828
+ if token_dimension == 0 {
2829
+ token_dimension = tokens[0].len();
1863
2830
  }
2831
+ keys.push(key.clone());
2832
+ doc_token_vectors.push(tokens.as_slice());
1864
2833
  }
1865
2834
  }
1866
2835
  }
1867
2836
 
1868
- Ok(())
2837
+ if doc_token_vectors.is_empty() || token_dimension == 0 {
2838
+ self.multi_vector_quantized.remove(space);
2839
+ self.multi_vector_quantized_keys.remove(space);
2840
+ return;
2841
+ }
2842
+
2843
+ let index = MultiVectorQuantizedIndex::build(
2844
+ &doc_token_vectors,
2845
+ token_dimension,
2846
+ &config,
2847
+ );
2848
+
2849
+ self.multi_vector_quantized
2850
+ .insert(space.to_owned(), index);
2851
+ self.multi_vector_quantized_keys
2852
+ .insert(space.to_owned(), keys);
1869
2853
  }
1870
2854
 
1871
- /// Restore a database from a backup directory. Opens the `.vdb` file
1872
- /// found in `source` and returns a new writable Database.
1873
- pub fn restore(source: impl AsRef<Path>, dest: impl AsRef<Path>) -> Result<Self> {
1874
- let source = source.as_ref();
1875
- let dest = dest.as_ref();
2855
+ fn rebuild_all_multi_vector_quantized_indexes(&mut self) {
2856
+ let spaces: Vec<String> = self
2857
+ .multi_vector_quantization_config
2858
+ .keys()
2859
+ .cloned()
2860
+ .collect();
2861
+ for space in spaces {
2862
+ self.rebuild_multi_vector_quantized_index(&space);
2863
+ }
2864
+ }
1876
2865
 
1877
- // Find the .vdb file in the source directory
1878
- let mut vdb_file = None;
1879
- for entry in fs::read_dir(source)? {
1880
- let entry = entry?;
1881
- let path = entry.path();
1882
- if path.extension().and_then(|ext| ext.to_str()) == Some("vdb") {
1883
- vdb_file = Some(path);
1884
- break;
2866
+ fn persist_multi_vector_quantization_params(&self, space: &str) -> Result<()> {
2867
+ let params_path = multi_vector_quantization_params_path(&self.path, space);
2868
+ if let Some(index) = self.multi_vector_quantized.get(space) {
2869
+ let mut file = File::create(&params_path)?;
2870
+ index.write_params(&mut file).map_err(VectLiteError::Io)?;
2871
+ file.sync_all()?;
2872
+ } else {
2873
+ if params_path.exists() {
2874
+ fs::remove_file(&params_path)?;
1885
2875
  }
1886
2876
  }
1887
- let source_vdb = vdb_file.ok_or_else(|| {
2877
+ Ok(())
2878
+ }
2879
+
2880
+ fn try_load_multi_vector_quantization(&mut self) {
2881
+ // Look for .mvquant.<space> sidecar files
2882
+ let Some(parent) = self.path.parent() else {
2883
+ return;
2884
+ };
2885
+ let Some(stem) = self.path.file_name().and_then(|n| n.to_str()) else {
2886
+ return;
2887
+ };
2888
+ let prefix = format!("{stem}.mvquant.");
2889
+
2890
+ let entries = match fs::read_dir(parent) {
2891
+ Ok(entries) => entries,
2892
+ Err(_) => return,
2893
+ };
2894
+
2895
+ for entry in entries.flatten() {
2896
+ let Some(fname) = entry.file_name().to_str().map(String::from) else {
2897
+ continue;
2898
+ };
2899
+ if !fname.starts_with(&prefix) {
2900
+ continue;
2901
+ }
2902
+ let space = &fname[prefix.len()..];
2903
+ if space.is_empty() {
2904
+ continue;
2905
+ }
2906
+
2907
+ let file = match File::open(entry.path()) {
2908
+ Ok(f) => f,
2909
+ Err(_) => continue,
2910
+ };
2911
+ let mut reader = BufReader::new(file);
2912
+ let mut index = match MultiVectorQuantizedIndex::read_params(&mut reader) {
2913
+ Ok(idx) => idx,
2914
+ Err(_) => continue,
2915
+ };
2916
+
2917
+ // Rebuild codes from current records
2918
+ let mut keys = Vec::new();
2919
+ let mut doc_token_vectors: Vec<&[Vec<f32>]> = Vec::new();
2920
+ for (key, record) in &self.records {
2921
+ if let Some(tokens) = record.multi_vectors.get(space) {
2922
+ if !tokens.is_empty() {
2923
+ keys.push(key.clone());
2924
+ doc_token_vectors.push(tokens.as_slice());
2925
+ }
2926
+ }
2927
+ }
2928
+
2929
+ if !doc_token_vectors.is_empty() {
2930
+ index.rebuild(&doc_token_vectors);
2931
+ let MultiVectorQuantizationConfig::TwoBit(ref cfg) = {
2932
+ MultiVectorQuantizationConfig::TwoBit(index.quantizer.config.clone())
2933
+ };
2934
+ self.multi_vector_quantization_config
2935
+ .insert(space.to_owned(), MultiVectorQuantizationConfig::TwoBit(cfg.clone()));
2936
+ self.multi_vector_quantized_keys
2937
+ .insert(space.to_owned(), keys);
2938
+ self.multi_vector_quantized
2939
+ .insert(space.to_owned(), index);
2940
+ }
2941
+ }
2942
+ }
2943
+
2944
+ // -----------------------------------------------------------------------
2945
+ // Payload index helpers
2946
+ // -----------------------------------------------------------------------
2947
+
2948
+ fn payload_index_sidecar_path(&self) -> PathBuf {
2949
+ let mut p = self.path.clone();
2950
+ let name = p.file_name().unwrap_or_default().to_os_string();
2951
+ p.set_file_name(format!("{}.pidx", name.to_string_lossy()));
2952
+ p
2953
+ }
2954
+
2955
+ /// Persist payload index definitions to the sidecar file.
2956
+ fn persist_payload_index_defs(&self) -> Result<()> {
2957
+ let sidecar = self.payload_index_sidecar_path();
2958
+ if self.payload_index_defs.is_empty() {
2959
+ // Remove the sidecar if there are no indexes.
2960
+ let _ = fs::remove_file(&sidecar);
2961
+ return Ok(());
2962
+ }
2963
+ let file = File::create(&sidecar)?;
2964
+ let mut writer = BufWriter::new(file);
2965
+ write_u32(&mut writer, u32_from_usize(self.payload_index_defs.len())?)?;
2966
+ for (field, index_type) in &self.payload_index_defs {
2967
+ write_string(&mut writer, field)?;
2968
+ write_u8(&mut writer, index_type.tag())?;
2969
+ }
2970
+ writer.flush()?;
2971
+ Ok(())
2972
+ }
2973
+
2974
+ /// Load payload index definitions from the sidecar file (if present).
2975
+ fn try_load_payload_index_defs(&mut self) {
2976
+ let sidecar = self.payload_index_sidecar_path();
2977
+ let file = match File::open(&sidecar) {
2978
+ Ok(f) => f,
2979
+ Err(_) => return,
2980
+ };
2981
+ let mut reader = BufReader::new(file);
2982
+ let count = match read_u32(&mut reader) {
2983
+ Ok(n) => usize_from_u32(n).unwrap_or(0),
2984
+ Err(_) => return,
2985
+ };
2986
+ let mut defs = BTreeMap::new();
2987
+ for _ in 0..count {
2988
+ let field = match read_string(&mut reader) {
2989
+ Ok(f) => f,
2990
+ Err(_) => return,
2991
+ };
2992
+ let tag = match read_u8(&mut reader) {
2993
+ Ok(t) => t,
2994
+ Err(_) => return,
2995
+ };
2996
+ let index_type = match PayloadIndexType::from_tag(tag) {
2997
+ Ok(t) => t,
2998
+ Err(_) => return,
2999
+ };
3000
+ defs.insert(field, index_type);
3001
+ }
3002
+ self.payload_index_defs = defs;
3003
+ }
3004
+
3005
+ /// Rebuild all payload indexes from scratch, based on current `payload_index_defs`
3006
+ /// and all records in memory.
3007
+ fn rebuild_payload_indexes(&mut self) {
3008
+ let mut indexes = BTreeMap::new();
3009
+ for (field, index_type) in &self.payload_index_defs {
3010
+ let data = match index_type {
3011
+ PayloadIndexType::Keyword => {
3012
+ let mut kw = KeywordIndex::default();
3013
+ for (key, record) in &self.records {
3014
+ if let Some(MetadataValue::String(val)) = record.metadata.get(field) {
3015
+ kw.insert(val, key.clone());
3016
+ }
3017
+ }
3018
+ PayloadIndexData::Keyword(kw)
3019
+ }
3020
+ PayloadIndexType::Numeric => {
3021
+ let mut num = NumericIndex::default();
3022
+ for (key, record) in &self.records {
3023
+ if let Some(val) = record.metadata.get(field).and_then(MetadataValue::as_number) {
3024
+ num.insert(val, key.clone());
3025
+ }
3026
+ }
3027
+ PayloadIndexData::Numeric(num)
3028
+ }
3029
+ };
3030
+ indexes.insert(field.clone(), data);
3031
+ }
3032
+ self.payload_indexes = indexes;
3033
+ }
3034
+
3035
+ /// Incrementally update payload indexes for an upserted record.
3036
+ /// Call with the old record (if any) first to remove stale entries.
3037
+ fn payload_index_remove(&mut self, key: &RecordKey, metadata: &Metadata) {
3038
+ for (field, data) in &mut self.payload_indexes {
3039
+ match data {
3040
+ PayloadIndexData::Keyword(kw) => {
3041
+ if let Some(MetadataValue::String(val)) = metadata.get(field) {
3042
+ kw.remove(val, key);
3043
+ }
3044
+ }
3045
+ PayloadIndexData::Numeric(num) => {
3046
+ if let Some(val) = metadata.get(field).and_then(MetadataValue::as_number) {
3047
+ num.remove(val, key);
3048
+ }
3049
+ }
3050
+ }
3051
+ }
3052
+ }
3053
+
3054
+ fn payload_index_insert(&mut self, key: &RecordKey, metadata: &Metadata) {
3055
+ for (field, data) in &mut self.payload_indexes {
3056
+ match data {
3057
+ PayloadIndexData::Keyword(kw) => {
3058
+ if let Some(MetadataValue::String(val)) = metadata.get(field) {
3059
+ kw.insert(val, key.clone());
3060
+ }
3061
+ }
3062
+ PayloadIndexData::Numeric(num) => {
3063
+ if let Some(val) = metadata.get(field).and_then(MetadataValue::as_number) {
3064
+ num.insert(val, key.clone());
3065
+ }
3066
+ }
3067
+ }
3068
+ }
3069
+ }
3070
+
3071
+ /// Use payload indexes to narrow down candidate keys for a filter.
3072
+ /// Returns `None` if no indexes can help with this filter (fallback to scan).
3073
+ /// Returns `Some(set)` with the set of record keys that *may* match the filter.
3074
+ fn payload_index_candidates(&self, filter: &MetadataFilter, namespace: Option<&str>) -> Option<HashSet<RecordKey>> {
3075
+ if self.payload_indexes.is_empty() {
3076
+ return None;
3077
+ }
3078
+ self.payload_index_candidates_inner(filter, namespace)
3079
+ }
3080
+
3081
+ fn payload_index_candidates_inner(&self, filter: &MetadataFilter, namespace: Option<&str>) -> Option<HashSet<RecordKey>> {
3082
+ match filter {
3083
+ MetadataFilter::Eq { key, value } => {
3084
+ // Try keyword index for string equality
3085
+ if let Some(PayloadIndexData::Keyword(kw)) = self.payload_indexes.get(key) {
3086
+ if let MetadataValue::String(s) = value {
3087
+ let set = kw.lookup_eq(s).cloned().unwrap_or_default();
3088
+ return Some(self.filter_by_namespace(set, namespace));
3089
+ }
3090
+ }
3091
+ // Try numeric index for numeric equality
3092
+ if let Some(PayloadIndexData::Numeric(num)) = self.payload_indexes.get(key) {
3093
+ if let Some(v) = value.as_number() {
3094
+ let set = num.lookup_eq(v).cloned().unwrap_or_default();
3095
+ return Some(self.filter_by_namespace(set, namespace));
3096
+ }
3097
+ }
3098
+ None
3099
+ }
3100
+ MetadataFilter::In { key, values } => {
3101
+ if let Some(PayloadIndexData::Keyword(kw)) = self.payload_indexes.get(key) {
3102
+ let str_values: Vec<&str> = values
3103
+ .iter()
3104
+ .filter_map(|v| match v {
3105
+ MetadataValue::String(s) => Some(s.as_str()),
3106
+ _ => None,
3107
+ })
3108
+ .collect();
3109
+ if str_values.len() == values.len() {
3110
+ let set = kw.lookup_in(&str_values);
3111
+ return Some(self.filter_by_namespace(set, namespace));
3112
+ }
3113
+ }
3114
+ None
3115
+ }
3116
+ MetadataFilter::GreaterThan { key, value } => {
3117
+ if let Some(PayloadIndexData::Numeric(num)) = self.payload_indexes.get(key) {
3118
+ let set = num.range_gt(*value);
3119
+ return Some(self.filter_by_namespace(set, namespace));
3120
+ }
3121
+ None
3122
+ }
3123
+ MetadataFilter::GreaterThanOrEqual { key, value } => {
3124
+ if let Some(PayloadIndexData::Numeric(num)) = self.payload_indexes.get(key) {
3125
+ let set = num.range_gte(*value);
3126
+ return Some(self.filter_by_namespace(set, namespace));
3127
+ }
3128
+ None
3129
+ }
3130
+ MetadataFilter::LessThan { key, value } => {
3131
+ if let Some(PayloadIndexData::Numeric(num)) = self.payload_indexes.get(key) {
3132
+ let set = num.range_lt(*value);
3133
+ return Some(self.filter_by_namespace(set, namespace));
3134
+ }
3135
+ None
3136
+ }
3137
+ MetadataFilter::LessThanOrEqual { key, value } => {
3138
+ if let Some(PayloadIndexData::Numeric(num)) = self.payload_indexes.get(key) {
3139
+ let set = num.range_lte(*value);
3140
+ return Some(self.filter_by_namespace(set, namespace));
3141
+ }
3142
+ None
3143
+ }
3144
+ MetadataFilter::And(filters) => {
3145
+ // Intersect candidates from all sub-filters that have index support.
3146
+ let mut result: Option<HashSet<RecordKey>> = None;
3147
+ for sub in filters {
3148
+ if let Some(sub_set) = self.payload_index_candidates_inner(sub, namespace) {
3149
+ result = Some(match result {
3150
+ Some(existing) => existing.intersection(&sub_set).cloned().collect(),
3151
+ None => sub_set,
3152
+ });
3153
+ }
3154
+ }
3155
+ result
3156
+ }
3157
+ MetadataFilter::Or(filters) => {
3158
+ // Union candidates, but only if ALL sub-filters have index support.
3159
+ let mut result = HashSet::new();
3160
+ for sub in filters {
3161
+ match self.payload_index_candidates_inner(sub, namespace) {
3162
+ Some(sub_set) => {
3163
+ result.extend(sub_set);
3164
+ }
3165
+ None => return None, // Can't guarantee completeness
3166
+ }
3167
+ }
3168
+ Some(result)
3169
+ }
3170
+ // For other filter types, no index support — fallback to scan.
3171
+ _ => None,
3172
+ }
3173
+ }
3174
+
3175
+ fn filter_by_namespace(&self, keys: HashSet<RecordKey>, namespace: Option<&str>) -> HashSet<RecordKey> {
3176
+ match namespace {
3177
+ Some(ns) => keys.into_iter().filter(|(n, _)| n == ns).collect(),
3178
+ None => keys,
3179
+ }
3180
+ }
3181
+
3182
+ fn compact_inner(&mut self) -> Result<()> {
3183
+ // GC: remove expired records before writing the snapshot.
3184
+ let now = now_epoch_secs();
3185
+ let expired_keys: Vec<RecordKey> = self
3186
+ .records
3187
+ .iter()
3188
+ .filter(|(_, record)| record.is_expired_at(now))
3189
+ .map(|(key, _)| key.clone())
3190
+ .collect();
3191
+ let has_payload_indexes = !self.payload_indexes.is_empty();
3192
+ for key in &expired_keys {
3193
+ if has_payload_indexes {
3194
+ if let Some(record) = self.records.get(key) {
3195
+ let meta = record.metadata.clone();
3196
+ self.payload_index_remove(key, &meta);
3197
+ }
3198
+ }
3199
+ self.records.remove(key);
3200
+ }
3201
+
3202
+ if let Some(parent) = self.path.parent() {
3203
+ if !parent.as_os_str().is_empty() {
3204
+ fs::create_dir_all(parent)?;
3205
+ }
3206
+ }
3207
+
3208
+ let temp_path = temp_path(&self.path);
3209
+ let mut file = File::create(&temp_path)?;
3210
+ {
3211
+ let mut writer = BufWriter::new(&mut file);
3212
+ self.write_to(&mut writer)?;
3213
+ writer.flush()?;
3214
+ }
3215
+ file.sync_all()?;
3216
+
3217
+ if self.path.exists() {
3218
+ fs::remove_file(&self.path)?;
3219
+ }
3220
+ fs::rename(temp_path, &self.path)?;
3221
+ self.clear_wal()?;
3222
+ self.wal_entries_replayed = 0;
3223
+ self.persist_ann_to_disk()?;
3224
+
3225
+ Ok(())
3226
+ }
3227
+
3228
+ /// Create an atomic snapshot of the database at `dest`. The snapshot is a
3229
+ /// self-contained `.vdb` file (WAL is folded in). The current database is
3230
+ /// not modified. Works in both read-only and read-write mode.
3231
+ pub fn snapshot(&self, dest: impl AsRef<Path>) -> Result<()> {
3232
+ self.check_open()?;
3233
+ let dest = dest.as_ref();
3234
+ if let Some(parent) = dest.parent() {
3235
+ if !parent.as_os_str().is_empty() {
3236
+ fs::create_dir_all(parent)?;
3237
+ }
3238
+ }
3239
+ let mut file = File::create(dest)?;
3240
+ {
3241
+ let mut writer = BufWriter::new(&mut file);
3242
+ self.write_to(&mut writer)?;
3243
+ writer.flush()?;
3244
+ }
3245
+ file.sync_all()?;
3246
+ Ok(())
3247
+ }
3248
+
3249
+ /// Back up the database to `dest` directory. Creates a complete copy
3250
+ /// including the `.vdb` file and ANN sidecar files. The backup is
3251
+ /// compacted (WAL folded in). Works in both read-only and read-write mode.
3252
+ pub fn backup(&self, dest: impl AsRef<Path>) -> Result<()> {
3253
+ self.check_open()?;
3254
+ let dest = dest.as_ref();
3255
+ fs::create_dir_all(dest)?;
3256
+
3257
+ let file_name = self.path.file_name().ok_or_else(|| {
3258
+ VectLiteError::InvalidFormat("database path has no file name".to_owned())
3259
+ })?;
3260
+ let dest_vdb = dest.join(file_name);
3261
+ self.snapshot(&dest_vdb)?;
3262
+
3263
+ // Copy ANN sidecar files
3264
+ if let Some(parent) = self.path.parent() {
3265
+ if let Some(stem) = self.path.file_name().and_then(|n| n.to_str()) {
3266
+ if let Ok(entries) = fs::read_dir(parent) {
3267
+ for entry in entries.flatten() {
3268
+ if let Some(fname) = entry.file_name().to_str() {
3269
+ if fname.starts_with(&format!("{stem}.ann.")) {
3270
+ let _ = fs::copy(entry.path(), dest.join(fname));
3271
+ }
3272
+ }
3273
+ }
3274
+ }
3275
+ // Copy ann manifest
3276
+ let manifest = ann_manifest_path(&self.path);
3277
+ if manifest.exists() {
3278
+ if let Some(manifest_name) = manifest.file_name() {
3279
+ let _ = fs::copy(&manifest, dest.join(manifest_name));
3280
+ }
3281
+ }
3282
+
3283
+ // Copy payload index sidecar
3284
+ let pidx = self.payload_index_sidecar_path();
3285
+ if pidx.exists() {
3286
+ if let Some(pidx_name) = pidx.file_name() {
3287
+ let _ = fs::copy(&pidx, dest.join(pidx_name));
3288
+ }
3289
+ }
3290
+ }
3291
+ }
3292
+
3293
+ Ok(())
3294
+ }
3295
+
3296
+ /// Restore a database from a backup directory. Opens the `.vdb` file
3297
+ /// found in `source` and returns a new writable Database.
3298
+ pub fn restore(source: impl AsRef<Path>, dest: impl AsRef<Path>) -> Result<Self> {
3299
+ let source = source.as_ref();
3300
+ let dest = dest.as_ref();
3301
+
3302
+ // Find the .vdb file in the source directory
3303
+ let mut vdb_file = None;
3304
+ for entry in fs::read_dir(source)? {
3305
+ let entry = entry?;
3306
+ let path = entry.path();
3307
+ if path.extension().and_then(|ext| ext.to_str()) == Some("vdb") {
3308
+ vdb_file = Some(path);
3309
+ break;
3310
+ }
3311
+ }
3312
+ let source_vdb = vdb_file.ok_or_else(|| {
1888
3313
  VectLiteError::InvalidFormat("no .vdb file found in backup directory".to_owned())
1889
3314
  })?;
1890
3315
 
@@ -1929,18 +3354,25 @@ impl Database {
1929
3354
  .records
1930
3355
  .get(&(namespace.clone(), id.clone()))
1931
3356
  .map_or(false, |r| !r.sparse.is_empty()),
3357
+ WalOp::UpdateMetadata { .. } | WalOp::SetTtl { .. } => false,
1932
3358
  });
1933
3359
 
3360
+ let metadata_only = ops.iter().all(|op| matches!(op, WalOp::UpdateMetadata { .. } | WalOp::SetTtl { .. }));
3361
+
1934
3362
  self.append_wal_batch(&ops)?;
1935
3363
  self.apply_ops_in_memory(ops);
1936
3364
 
1937
- if has_sparse {
1938
- self.rebuild_sparse_index();
3365
+ // Metadata-only updates don't change vectors, so skip all index rebuilds.
3366
+ if !metadata_only {
3367
+ if has_sparse {
3368
+ self.rebuild_sparse_index();
3369
+ }
3370
+ self.rebuild_ann();
3371
+ self.ann_loaded_from_disk = false;
3372
+ self.persist_ann_to_disk()?;
3373
+ self.rebuild_quantized_index();
3374
+ self.rebuild_all_multi_vector_quantized_indexes();
1939
3375
  }
1940
- self.rebuild_ann();
1941
- self.ann_loaded_from_disk = false;
1942
- self.persist_ann_to_disk()?;
1943
- self.rebuild_quantized_index();
1944
3376
  Ok(())
1945
3377
  }
1946
3378
 
@@ -1958,14 +3390,66 @@ impl Database {
1958
3390
  }
1959
3391
 
1960
3392
  fn apply_ops_in_memory(&mut self, ops: Vec<WalOp>) {
3393
+ let has_payload_indexes = !self.payload_indexes.is_empty();
1961
3394
  for op in ops {
1962
3395
  match op {
1963
3396
  WalOp::Upsert(record) => {
1964
- self.records
1965
- .insert((record.namespace.clone(), record.id.clone()), record);
3397
+ let key = (record.namespace.clone(), record.id.clone());
3398
+ if has_payload_indexes {
3399
+ // Remove old index entries if the record already exists.
3400
+ let old_meta = self.records.get(&key).map(|r| r.metadata.clone());
3401
+ if let Some(ref meta) = old_meta {
3402
+ self.payload_index_remove(&key, meta);
3403
+ }
3404
+ self.payload_index_insert(&key, &record.metadata);
3405
+ }
3406
+ self.records.insert(key, record);
1966
3407
  }
1967
3408
  WalOp::Delete { namespace, id } => {
1968
- self.records.remove(&(namespace, id));
3409
+ let key = (namespace, id);
3410
+ if has_payload_indexes {
3411
+ let old_meta = self.records.get(&key).map(|r| r.metadata.clone());
3412
+ if let Some(ref meta) = old_meta {
3413
+ self.payload_index_remove(&key, meta);
3414
+ }
3415
+ }
3416
+ self.records.remove(&key);
3417
+ }
3418
+ WalOp::UpdateMetadata {
3419
+ namespace,
3420
+ id,
3421
+ metadata,
3422
+ } => {
3423
+ let key = (namespace, id);
3424
+ if has_payload_indexes {
3425
+ if let Some(record) = self.records.get(&key) {
3426
+ let old_meta = record.metadata.clone();
3427
+ self.payload_index_remove(&key, &old_meta);
3428
+ }
3429
+ }
3430
+ if let Some(record) = self.records.get_mut(&key) {
3431
+ for (k, v) in metadata {
3432
+ record.metadata.insert(k, v);
3433
+ }
3434
+ }
3435
+ if has_payload_indexes {
3436
+ if let Some(record) = self.records.get(&key) {
3437
+ let new_meta = record.metadata.clone();
3438
+ self.payload_index_insert(&key, &new_meta);
3439
+ }
3440
+ }
3441
+ // If the record doesn't exist, the update is silently ignored
3442
+ // (same semantics as deleting a non-existent record).
3443
+ }
3444
+ WalOp::SetTtl {
3445
+ namespace,
3446
+ id,
3447
+ expires_at,
3448
+ } => {
3449
+ let key = (namespace, id);
3450
+ if let Some(record) = self.records.get_mut(&key) {
3451
+ record.expires_at = expires_at;
3452
+ }
1969
3453
  }
1970
3454
  }
1971
3455
  }
@@ -2076,7 +3560,13 @@ impl Database {
2076
3560
  let dimension = usize_from_u32(read_u32(reader)?)?;
2077
3561
  ensure_dimension(dimension)?;
2078
3562
 
2079
- let record_count = usize_from_u64(read_u64(reader)?)?;
3563
+ let metric = if version >= 6 {
3564
+ DistanceMetric::from_tag(read_u8(reader)?)?
3565
+ } else {
3566
+ DistanceMetric::Cosine
3567
+ };
3568
+
3569
+ let record_count = usize_from_u64(read_u64(reader)?)?;
2080
3570
  let mut records = BTreeMap::new();
2081
3571
 
2082
3572
  for _ in 0..record_count {
@@ -2118,6 +3608,19 @@ impl Database {
2118
3608
  SparseVector::new()
2119
3609
  };
2120
3610
 
3611
+ let multi_vectors = if version >= 5 {
3612
+ read_multi_vectors(reader)?
3613
+ } else {
3614
+ MultiVectors::new()
3615
+ };
3616
+
3617
+ let expires_at = if version >= 7 {
3618
+ let ts = read_f64(reader)?;
3619
+ if ts == 0.0 { None } else { Some(ts) }
3620
+ } else {
3621
+ None
3622
+ };
3623
+
2121
3624
  let record = Record {
2122
3625
  namespace: namespace.clone(),
2123
3626
  id: id.clone(),
@@ -2125,6 +3628,8 @@ impl Database {
2125
3628
  vectors,
2126
3629
  sparse,
2127
3630
  metadata,
3631
+ multi_vectors,
3632
+ expires_at,
2128
3633
  };
2129
3634
  records.insert((namespace, id), record);
2130
3635
  }
@@ -2133,6 +3638,7 @@ impl Database {
2133
3638
  path: path.to_path_buf(),
2134
3639
  wal_path: wal_path(path),
2135
3640
  dimension,
3641
+ metric,
2136
3642
  records,
2137
3643
  ann: AnnCatalog::default(),
2138
3644
  sparse_index: SparseIndex::default(),
@@ -2143,6 +3649,11 @@ impl Database {
2143
3649
  quantized: None,
2144
3650
  quantization_config: None,
2145
3651
  quantized_keys: Vec::new(),
3652
+ multi_vector_quantized: BTreeMap::new(),
3653
+ multi_vector_quantization_config: BTreeMap::new(),
3654
+ multi_vector_quantized_keys: BTreeMap::new(),
3655
+ payload_index_defs: BTreeMap::new(),
3656
+ payload_indexes: BTreeMap::new(),
2146
3657
  })
2147
3658
  }
2148
3659
 
@@ -2150,6 +3661,7 @@ impl Database {
2150
3661
  writer.write_all(MAGIC)?;
2151
3662
  write_u16(writer, VERSION)?;
2152
3663
  write_u32(writer, u32_from_usize(self.dimension)?)?;
3664
+ write_u8(writer, self.metric.to_tag())?;
2153
3665
  write_u64(writer, u64_from_usize(self.records.len())?)?;
2154
3666
 
2155
3667
  for record in self.records.values() {
@@ -2167,6 +3679,8 @@ impl Database {
2167
3679
  }
2168
3680
  write_named_vectors(writer, &record.vectors)?;
2169
3681
  write_sparse_vector(writer, &record.sparse)?;
3682
+ write_multi_vectors(writer, &record.multi_vectors)?;
3683
+ write_f64(writer, record.expires_at.unwrap_or(0.0))?;
2170
3684
  }
2171
3685
 
2172
3686
  Ok(())
@@ -2183,6 +3697,51 @@ impl Database {
2183
3697
  Ok(())
2184
3698
  }
2185
3699
 
3700
+ fn resolve_dense_search_dimension(
3701
+ &self,
3702
+ dense_query: Option<&[f32]>,
3703
+ truncate_dim: Option<usize>,
3704
+ ) -> Result<Option<usize>> {
3705
+ let Some(query) = dense_query else {
3706
+ return Ok(None);
3707
+ };
3708
+ if query.is_empty() {
3709
+ return Err(VectLiteError::InvalidFormat(
3710
+ "query vector must not be empty".to_owned(),
3711
+ ));
3712
+ }
3713
+ if query.len() > self.dimension {
3714
+ return Err(VectLiteError::DimensionMismatch {
3715
+ expected: self.dimension,
3716
+ found: query.len(),
3717
+ });
3718
+ }
3719
+
3720
+ let effective = match truncate_dim {
3721
+ Some(0) => {
3722
+ return Err(VectLiteError::InvalidFormat(
3723
+ "truncate_dim must be greater than zero".to_owned(),
3724
+ ))
3725
+ }
3726
+ Some(dim) if dim > self.dimension => {
3727
+ return Err(VectLiteError::DimensionMismatch {
3728
+ expected: self.dimension,
3729
+ found: dim,
3730
+ })
3731
+ }
3732
+ Some(dim) if dim > query.len() => {
3733
+ return Err(VectLiteError::InvalidFormat(format!(
3734
+ "truncate_dim ({dim}) cannot exceed query vector length ({})",
3735
+ query.len()
3736
+ )))
3737
+ }
3738
+ Some(dim) => dim,
3739
+ None => query.len(),
3740
+ };
3741
+
3742
+ Ok(Some(effective))
3743
+ }
3744
+
2186
3745
  fn validate_record(&self, record: &Record) -> Result<()> {
2187
3746
  self.validate_vector(&record.vector)?;
2188
3747
 
@@ -2195,6 +3754,30 @@ impl Database {
2195
3754
  self.validate_vector(vector)?;
2196
3755
  }
2197
3756
 
3757
+ for (space_name, token_vectors) in &record.multi_vectors {
3758
+ if space_name.is_empty() {
3759
+ return Err(VectLiteError::InvalidFormat(
3760
+ "multi-vector space names must not be empty".to_owned(),
3761
+ ));
3762
+ }
3763
+ if let Some(first) = token_vectors.first() {
3764
+ if first.is_empty() {
3765
+ return Err(VectLiteError::InvalidFormat(format!(
3766
+ "multi-vector space '{space_name}' contains an empty token vector"
3767
+ )));
3768
+ }
3769
+ let expected_dim = first.len();
3770
+ for token_vec in &token_vectors[1..] {
3771
+ if token_vec.len() != expected_dim {
3772
+ return Err(VectLiteError::InvalidFormat(format!(
3773
+ "multi-vector space '{space_name}' has inconsistent token dimensions: expected {expected_dim}, found {}",
3774
+ token_vec.len(),
3775
+ )));
3776
+ }
3777
+ }
3778
+ }
3779
+ }
3780
+
2198
3781
  Ok(())
2199
3782
  }
2200
3783
 
@@ -2225,7 +3808,7 @@ impl Database {
2225
3808
  if records.len() < ANN_MIN_POINTS {
2226
3809
  None
2227
3810
  } else {
2228
- Some((vector_name, build_ann_index(records)))
3811
+ Some((vector_name, build_ann_index(records, self.metric)))
2229
3812
  }
2230
3813
  })
2231
3814
  .collect();
@@ -2239,7 +3822,7 @@ impl Database {
2239
3822
  if records.len() < ANN_MIN_POINTS {
2240
3823
  None
2241
3824
  } else {
2242
- Some((vector_name, build_ann_index(records)))
3825
+ Some((vector_name, build_ann_index(records, self.metric)))
2243
3826
  }
2244
3827
  })
2245
3828
  .collect::<BTreeMap<_, _>>();
@@ -2292,6 +3875,7 @@ impl Database {
2292
3875
  &expected_entry.vector_name,
2293
3876
  ),
2294
3877
  expected_entry.keys.clone(),
3878
+ self.metric,
2295
3879
  ) else {
2296
3880
  return false;
2297
3881
  };
@@ -2342,9 +3926,7 @@ impl Database {
2342
3926
  None => self.ann.global.get(&entry.vector_name),
2343
3927
  };
2344
3928
  if let Some(index) = index {
2345
- index.hnsw.file_dump(parent, &basename).map_err(|err| {
2346
- VectLiteError::InvalidFormat(format!("failed to persist ANN index: {err}"))
2347
- })?;
3929
+ index.hnsw.file_dump(parent, &basename)?;
2348
3930
  }
2349
3931
  }
2350
3932
 
@@ -2447,7 +4029,9 @@ impl Database {
2447
4029
  options: &HybridSearchOptions,
2448
4030
  namespace: Option<&str>,
2449
4031
  candidate_keys: Option<&[RecordKey]>,
4032
+ effective_dimension: Option<usize>,
2450
4033
  ) -> Vec<ScoredRecord<'_>> {
4034
+ let now = now_epoch_secs();
2451
4035
  let record_iter: Box<dyn Iterator<Item = &Record> + '_> = match candidate_keys {
2452
4036
  Some(keys) => Box::new(keys.iter().filter_map(|key| self.records.get(key))),
2453
4037
  None => Box::new(self.records.values()),
@@ -2455,9 +4039,10 @@ impl Database {
2455
4039
 
2456
4040
  record_iter
2457
4041
  .filter(|record| {
2458
- namespace
2459
- .map(|namespace| record.namespace == namespace)
2460
- .unwrap_or(true)
4042
+ !record.is_expired_at(now)
4043
+ && namespace
4044
+ .map(|namespace| record.namespace == namespace)
4045
+ .unwrap_or(true)
2461
4046
  && (dense_query.is_none()
2462
4047
  || record.vector_for(options.vector_name.as_deref()).is_some())
2463
4048
  && options
@@ -2473,7 +4058,8 @@ impl Database {
2473
4058
  let mut weighted_sum = 0.0_f32;
2474
4059
  for (name, (query, weight)) in &options.multi_vector_queries {
2475
4060
  if let Some(vector) = record.vector_for(Some(name.as_str())) {
2476
- weighted_sum += weight * cosine_similarity(query, vector);
4061
+ weighted_sum +=
4062
+ weight * score_dense_prefix(self.metric, query, vector, effective_dimension);
2477
4063
  }
2478
4064
  }
2479
4065
  (weighted_sum, None)
@@ -2482,7 +4068,14 @@ impl Database {
2482
4068
  .and_then(|query| {
2483
4069
  record
2484
4070
  .vector_for(options.vector_name.as_deref())
2485
- .map(|vector| cosine_similarity(query, vector))
4071
+ .map(|vector| {
4072
+ score_dense_prefix(
4073
+ self.metric,
4074
+ query,
4075
+ vector,
4076
+ effective_dimension,
4077
+ )
4078
+ })
2486
4079
  })
2487
4080
  .unwrap_or(0.0);
2488
4081
  (score, options.vector_name.clone())
@@ -2670,6 +4263,27 @@ impl Database {
2670
4263
  vectors: NamedVectors,
2671
4264
  sparse: SparseVector,
2672
4265
  metadata: Metadata,
4266
+ ) -> Result<Record> {
4267
+ self.record_from_parts_full(
4268
+ namespace,
4269
+ id,
4270
+ vector,
4271
+ vectors,
4272
+ sparse,
4273
+ metadata,
4274
+ MultiVectors::new(),
4275
+ )
4276
+ }
4277
+
4278
+ fn record_from_parts_full(
4279
+ &self,
4280
+ namespace: impl Into<String>,
4281
+ id: impl Into<String>,
4282
+ vector: impl Into<Vec<f32>>,
4283
+ vectors: NamedVectors,
4284
+ sparse: SparseVector,
4285
+ metadata: Metadata,
4286
+ multi_vectors: MultiVectors,
2673
4287
  ) -> Result<Record> {
2674
4288
  let vector = vector.into();
2675
4289
  self.validate_vector(&vector)?;
@@ -2683,6 +4297,31 @@ impl Database {
2683
4297
  self.validate_vector(named_vector)?;
2684
4298
  }
2685
4299
 
4300
+ // Validate multi-vector dimensions
4301
+ for (space_name, token_vectors) in &multi_vectors {
4302
+ if space_name.is_empty() {
4303
+ return Err(VectLiteError::InvalidFormat(
4304
+ "multi-vector space names must not be empty".to_owned(),
4305
+ ));
4306
+ }
4307
+ for token_vec in token_vectors {
4308
+ if token_vec.is_empty() {
4309
+ return Err(VectLiteError::InvalidFormat(format!(
4310
+ "multi-vector space '{space_name}' contains an empty token vector"
4311
+ )));
4312
+ }
4313
+ // Token vectors within a space must all have the same dimension,
4314
+ // but that dimension can differ from the database dimension.
4315
+ if !token_vectors.is_empty() && token_vec.len() != token_vectors[0].len() {
4316
+ return Err(VectLiteError::InvalidFormat(format!(
4317
+ "multi-vector space '{space_name}' has inconsistent token dimensions: expected {}, found {}",
4318
+ token_vectors[0].len(),
4319
+ token_vec.len(),
4320
+ )));
4321
+ }
4322
+ }
4323
+ }
4324
+
2686
4325
  Ok(Record {
2687
4326
  namespace: namespace.into(),
2688
4327
  id: id.into(),
@@ -2690,6 +4329,8 @@ impl Database {
2690
4329
  vectors,
2691
4330
  sparse,
2692
4331
  metadata,
4332
+ multi_vectors,
4333
+ expires_at: None,
2693
4334
  })
2694
4335
  }
2695
4336
  }
@@ -2731,22 +4372,29 @@ fn ensure_dimension(dimension: usize) -> Result<()> {
2731
4372
  Ok(())
2732
4373
  }
2733
4374
 
2734
- fn cosine_similarity(left: &[f32], right: &[f32]) -> f32 {
2735
- let mut dot = 0.0_f32;
2736
- let mut left_norm = 0.0_f32;
2737
- let mut right_norm = 0.0_f32;
2738
-
2739
- for (left_value, right_value) in left.iter().zip(right.iter()) {
2740
- dot += left_value * right_value;
2741
- left_norm += left_value * left_value;
2742
- right_norm += right_value * right_value;
2743
- }
2744
-
2745
- if left_norm == 0.0 || right_norm == 0.0 {
2746
- 0.0
2747
- } else {
2748
- dot / (left_norm.sqrt() * right_norm.sqrt())
4375
+ /// MaxSim scoring (ColBERT-style late interaction).
4376
+ /// For each query token, find the maximum similarity against any document
4377
+ /// token using the given metric, then sum those maxima across all query tokens.
4378
+ fn maxsim_score(
4379
+ query_tokens: &[&[f32]],
4380
+ doc_tokens: &[Vec<f32>],
4381
+ metric: DistanceMetric,
4382
+ ) -> f32 {
4383
+ if query_tokens.is_empty() || doc_tokens.is_empty() {
4384
+ return 0.0;
4385
+ }
4386
+ let mut total = 0.0_f32;
4387
+ for q_token in query_tokens {
4388
+ let mut best = f32::NEG_INFINITY;
4389
+ for d_token in doc_tokens {
4390
+ let sim = metric.score(q_token, d_token);
4391
+ if sim > best {
4392
+ best = sim;
4393
+ }
4394
+ }
4395
+ total += best;
2749
4396
  }
4397
+ total
2750
4398
  }
2751
4399
 
2752
4400
  fn sparse_dot_product(left: &SparseVector, right: &SparseVector) -> f32 {
@@ -2761,24 +4409,46 @@ fn sparse_dot_product(left: &SparseVector, right: &SparseVector) -> f32 {
2761
4409
  })
2762
4410
  }
2763
4411
 
2764
- fn build_ann_index(records: Vec<(RecordKey, &Vec<f32>)>) -> AnnIndex {
2765
- let max_layer = compute_hnsw_layers(records.len());
2766
- let mut hnsw = Hnsw::<f32, DistCosine>::new(
2767
- ANN_M,
2768
- records.len(),
2769
- max_layer,
2770
- ANN_EF_CONSTRUCTION,
2771
- DistCosine {},
2772
- );
4412
+ fn score_dense_prefix(
4413
+ metric: DistanceMetric,
4414
+ left: &[f32],
4415
+ right: &[f32],
4416
+ effective_dimension: Option<usize>,
4417
+ ) -> f32 {
4418
+ let dimension = effective_dimension
4419
+ .unwrap_or_else(|| left.len().min(right.len()))
4420
+ .min(left.len())
4421
+ .min(right.len());
4422
+ metric.score(&left[..dimension], &right[..dimension])
4423
+ }
2773
4424
 
2774
- let mut keys = Vec::with_capacity(records.len());
2775
- for (origin_id, (key, vector)) in records.into_iter().enumerate() {
2776
- hnsw.insert((vector.as_slice(), origin_id));
2777
- keys.push(key);
4425
+ fn build_ann_index(records: Vec<(RecordKey, &Vec<f32>)>, metric: DistanceMetric) -> AnnIndex {
4426
+ let max_layer = compute_hnsw_layers(records.len());
4427
+ let count = records.len();
4428
+
4429
+ macro_rules! build_hnsw {
4430
+ ($dist_type:ty, $dist_val:expr, $variant:ident) => {{
4431
+ let mut hnsw =
4432
+ Hnsw::<f32, $dist_type>::new(ANN_M, count, max_layer, ANN_EF_CONSTRUCTION, $dist_val);
4433
+ let mut keys = Vec::with_capacity(count);
4434
+ for (origin_id, (key, vector)) in records.into_iter().enumerate() {
4435
+ hnsw.insert((vector.as_slice(), origin_id));
4436
+ keys.push(key);
4437
+ }
4438
+ hnsw.set_searching_mode(true);
4439
+ AnnIndex {
4440
+ hnsw: AnnHnsw::$variant(hnsw),
4441
+ keys,
4442
+ }
4443
+ }};
2778
4444
  }
2779
- hnsw.set_searching_mode(true);
2780
4445
 
2781
- AnnIndex { hnsw, keys }
4446
+ match metric {
4447
+ DistanceMetric::Cosine => build_hnsw!(DistCosine, DistCosine {}, Cosine),
4448
+ DistanceMetric::Euclidean => build_hnsw!(DistL2, DistL2 {}, Euclidean),
4449
+ DistanceMetric::DotProduct => build_hnsw!(DistDot, DistDot {}, DotProduct),
4450
+ DistanceMetric::Manhattan => build_hnsw!(DistL1, DistL1 {}, Manhattan),
4451
+ }
2782
4452
  }
2783
4453
 
2784
4454
  fn compute_hnsw_layers(record_count: usize) -> usize {
@@ -2825,6 +4495,12 @@ fn quantization_params_path(path: &Path) -> PathBuf {
2825
4495
  PathBuf::from(p)
2826
4496
  }
2827
4497
 
4498
+ fn multi_vector_quantization_params_path(path: &Path, space: &str) -> PathBuf {
4499
+ let mut p = path.as_os_str().to_os_string();
4500
+ p.push(format!(".mvquant.{space}"));
4501
+ PathBuf::from(p)
4502
+ }
4503
+
2828
4504
  fn acquire_exclusive_lock(path: &Path) -> Result<File> {
2829
4505
  acquire_exclusive_lock_with_timeout(path, None)
2830
4506
  }
@@ -2972,11 +4648,31 @@ fn record_key_signature(keys: &[RecordKey]) -> u64 {
2972
4648
  state
2973
4649
  }
2974
4650
 
2975
- fn load_ann_index(directory: &Path, basename: &str, keys: Vec<RecordKey>) -> Option<AnnIndex> {
4651
+ fn load_ann_index(
4652
+ directory: &Path,
4653
+ basename: &str,
4654
+ keys: Vec<RecordKey>,
4655
+ metric: DistanceMetric,
4656
+ ) -> Option<AnnIndex> {
2976
4657
  let reloader = Box::leak(Box::new(HnswIo::new(directory, basename)));
2977
- let mut hnsw = reloader.load_hnsw_with_dist(DistCosine {}).ok()?;
2978
- hnsw.set_searching_mode(true);
2979
- Some(AnnIndex { hnsw, keys })
4658
+
4659
+ macro_rules! load_with_dist {
4660
+ ($dist_val:expr, $variant:ident) => {{
4661
+ let mut hnsw = reloader.load_hnsw_with_dist($dist_val).ok()?;
4662
+ hnsw.set_searching_mode(true);
4663
+ Some(AnnIndex {
4664
+ hnsw: AnnHnsw::$variant(hnsw),
4665
+ keys,
4666
+ })
4667
+ }};
4668
+ }
4669
+
4670
+ match metric {
4671
+ DistanceMetric::Cosine => load_with_dist!(DistCosine {}, Cosine),
4672
+ DistanceMetric::Euclidean => load_with_dist!(DistL2 {}, Euclidean),
4673
+ DistanceMetric::DotProduct => load_with_dist!(DistDot {}, DotProduct),
4674
+ DistanceMetric::Manhattan => load_with_dist!(DistL1 {}, Manhattan),
4675
+ }
2980
4676
  }
2981
4677
 
2982
4678
  fn write_ann_manifest(path: &Path, entries: &[AnnManifestEntry]) -> Result<()> {
@@ -3157,6 +4853,8 @@ fn apply_mmr<'a>(
3157
4853
  dense_weight: f32,
3158
4854
  sparse_weight: f32,
3159
4855
  vector_name: Option<&str>,
4856
+ metric: DistanceMetric,
4857
+ effective_dimension: Option<usize>,
3160
4858
  ) -> Vec<ScoredRecord<'a>> {
3161
4859
  let limit = top_k.min(candidates.len());
3162
4860
  if limit <= 1 {
@@ -3183,6 +4881,8 @@ fn apply_mmr<'a>(
3183
4881
  dense_weight,
3184
4882
  sparse_weight,
3185
4883
  vector_name,
4884
+ metric,
4885
+ effective_dimension,
3186
4886
  )
3187
4887
  })
3188
4888
  .fold(0.0_f32, f32::max);
@@ -3231,9 +4931,11 @@ fn record_similarity(
3231
4931
  dense_weight: f32,
3232
4932
  sparse_weight: f32,
3233
4933
  vector_name: Option<&str>,
4934
+ metric: DistanceMetric,
4935
+ effective_dimension: Option<usize>,
3234
4936
  ) -> f32 {
3235
4937
  let dense_score = match (left.vector_for(vector_name), right.vector_for(vector_name)) {
3236
- (Some(left), Some(right)) => cosine_similarity(left, right),
4938
+ (Some(left), Some(right)) => score_dense_prefix(metric, left, right, effective_dimension),
3237
4939
  _ => 0.0,
3238
4940
  };
3239
4941
 
@@ -3370,6 +5072,45 @@ fn read_named_vectors(reader: &mut impl Read, dimension: usize) -> Result<NamedV
3370
5072
  Ok(vectors)
3371
5073
  }
3372
5074
 
5075
+ fn write_multi_vectors(writer: &mut impl Write, multi_vectors: &MultiVectors) -> Result<()> {
5076
+ write_u32(writer, u32_from_usize(multi_vectors.len())?)?;
5077
+ for (name, token_vectors) in multi_vectors {
5078
+ write_string(writer, name)?;
5079
+ // Write the token dimension (0 if empty)
5080
+ let token_dim = token_vectors.first().map_or(0, |v| v.len());
5081
+ write_u32(writer, u32_from_usize(token_dim)?)?;
5082
+ write_u32(writer, u32_from_usize(token_vectors.len())?)?;
5083
+ for token_vec in token_vectors {
5084
+ for value in token_vec {
5085
+ write_f32(writer, *value)?;
5086
+ }
5087
+ }
5088
+ }
5089
+ Ok(())
5090
+ }
5091
+
5092
+ fn read_multi_vectors(reader: &mut impl Read) -> Result<MultiVectors> {
5093
+ let space_count = usize_from_u32(read_u32(reader)?)?;
5094
+ let mut multi_vectors = MultiVectors::new();
5095
+
5096
+ for _ in 0..space_count {
5097
+ let name = read_string(reader)?;
5098
+ let token_dim = usize_from_u32(read_u32(reader)?)?;
5099
+ let token_count = usize_from_u32(read_u32(reader)?)?;
5100
+ let mut token_vectors = Vec::with_capacity(token_count);
5101
+ for _ in 0..token_count {
5102
+ let mut vec = Vec::with_capacity(token_dim);
5103
+ for _ in 0..token_dim {
5104
+ vec.push(read_f32(reader)?);
5105
+ }
5106
+ token_vectors.push(vec);
5107
+ }
5108
+ multi_vectors.insert(name, token_vectors);
5109
+ }
5110
+
5111
+ Ok(multi_vectors)
5112
+ }
5113
+
3373
5114
  fn write_wal_op(writer: &mut impl Write, op: &WalOp) -> Result<()> {
3374
5115
  match op {
3375
5116
  WalOp::Upsert(record) => {
@@ -3387,12 +5128,38 @@ fn write_wal_op(writer: &mut impl Write, op: &WalOp) -> Result<()> {
3387
5128
  }
3388
5129
  write_named_vectors(writer, &record.vectors)?;
3389
5130
  write_sparse_vector(writer, &record.sparse)?;
5131
+ write_multi_vectors(writer, &record.multi_vectors)?;
5132
+ write_f64(writer, record.expires_at.unwrap_or(0.0))?;
3390
5133
  }
3391
5134
  WalOp::Delete { namespace, id } => {
3392
5135
  write_u8(writer, 2)?;
3393
5136
  write_string(writer, namespace)?;
3394
5137
  write_string(writer, id)?;
3395
5138
  }
5139
+ WalOp::UpdateMetadata {
5140
+ namespace,
5141
+ id,
5142
+ metadata,
5143
+ } => {
5144
+ write_u8(writer, 3)?;
5145
+ write_string(writer, namespace)?;
5146
+ write_string(writer, id)?;
5147
+ write_u32(writer, u32_from_usize(metadata.len())?)?;
5148
+ for (key, value) in metadata {
5149
+ write_string(writer, key)?;
5150
+ write_metadata_value(writer, value)?;
5151
+ }
5152
+ }
5153
+ WalOp::SetTtl {
5154
+ namespace,
5155
+ id,
5156
+ expires_at,
5157
+ } => {
5158
+ write_u8(writer, 4)?;
5159
+ write_string(writer, namespace)?;
5160
+ write_string(writer, id)?;
5161
+ write_f64(writer, expires_at.unwrap_or(0.0))?;
5162
+ }
3396
5163
  }
3397
5164
  Ok(())
3398
5165
  }
@@ -3421,6 +5188,11 @@ fn read_wal_op(reader: &mut impl Read, dimension: usize) -> Result<WalOp> {
3421
5188
  }
3422
5189
  let vectors = read_named_vectors(reader, dimension)?;
3423
5190
  let sparse = read_sparse_vector(reader)?;
5191
+ let multi_vectors = read_multi_vectors(reader)?;
5192
+ let expires_at = {
5193
+ let ts = read_f64(reader)?;
5194
+ if ts == 0.0 { None } else { Some(ts) }
5195
+ };
3424
5196
  Ok(WalOp::Upsert(Record {
3425
5197
  namespace,
3426
5198
  id,
@@ -3428,12 +5200,41 @@ fn read_wal_op(reader: &mut impl Read, dimension: usize) -> Result<WalOp> {
3428
5200
  vectors,
3429
5201
  sparse,
3430
5202
  metadata,
5203
+ multi_vectors,
5204
+ expires_at,
3431
5205
  }))
3432
5206
  }
3433
5207
  2 => Ok(WalOp::Delete {
3434
5208
  namespace: read_string(reader)?,
3435
5209
  id: read_string(reader)?,
3436
5210
  }),
5211
+ 3 => {
5212
+ let namespace = read_string(reader)?;
5213
+ let id = read_string(reader)?;
5214
+ let metadata_count = usize_from_u32(read_u32(reader)?)?;
5215
+ let mut metadata = Metadata::new();
5216
+ for _ in 0..metadata_count {
5217
+ let key = read_string(reader)?;
5218
+ let value = read_metadata_value(reader)?;
5219
+ metadata.insert(key, value);
5220
+ }
5221
+ Ok(WalOp::UpdateMetadata {
5222
+ namespace,
5223
+ id,
5224
+ metadata,
5225
+ })
5226
+ }
5227
+ 4 => {
5228
+ let namespace = read_string(reader)?;
5229
+ let id = read_string(reader)?;
5230
+ let ts = read_f64(reader)?;
5231
+ let expires_at = if ts == 0.0 { None } else { Some(ts) };
5232
+ Ok(WalOp::SetTtl {
5233
+ namespace,
5234
+ id,
5235
+ expires_at,
5236
+ })
5237
+ }
3437
5238
  other => Err(VectLiteError::InvalidFormat(format!(
3438
5239
  "unknown WAL op tag {other}"
3439
5240
  ))),
@@ -3549,8 +5350,9 @@ fn usize_from_u64(value: u64) -> Result<usize> {
3549
5350
  #[cfg(test)]
3550
5351
  mod tests {
3551
5352
  use super::{
3552
- Database, HybridSearchOptions, Metadata, MetadataFilter, MetadataValue, NamedVectors,
3553
- Record, SearchOptions, SparseVector, VectLiteError,
5353
+ Database, HybridSearchOptions, Metadata, MetadataFilter, MetadataValue, MultiVectors,
5354
+ MultiVectorSearchOptions, NamedVectors, PayloadIndexType, Record, SearchOptions,
5355
+ SparseVector, VectLiteError,
3554
5356
  };
3555
5357
  use std::path::{Path, PathBuf};
3556
5358
  use std::time::{SystemTime, UNIX_EPOCH};
@@ -3614,6 +5416,7 @@ mod tests {
3614
5416
  MetadataFilter::eq("source", "notes"),
3615
5417
  MetadataFilter::contains("title", "auth"),
3616
5418
  ])),
5419
+ truncate_dim: None,
3617
5420
  },
3618
5421
  )
3619
5422
  .expect("search database");
@@ -3658,6 +5461,8 @@ mod tests {
3658
5461
  vectors: NamedVectors::new(),
3659
5462
  sparse: SparseVector::new(),
3660
5463
  metadata: Metadata::new(),
5464
+ multi_vectors: MultiVectors::new(),
5465
+ expires_at: None,
3661
5466
  },
3662
5467
  Record {
3663
5468
  namespace: "".to_owned(),
@@ -3666,6 +5471,8 @@ mod tests {
3666
5471
  vectors: NamedVectors::new(),
3667
5472
  sparse: SparseVector::new(),
3668
5473
  metadata: Metadata::new(),
5474
+ multi_vectors: MultiVectors::new(),
5475
+ expires_at: None,
3669
5476
  },
3670
5477
  ])
3671
5478
  .expect("batch upsert");
@@ -3702,6 +5509,7 @@ mod tests {
3702
5509
  MetadataFilter::gte("priority", 10.0),
3703
5510
  MetadataFilter::lte("priority", 10.0),
3704
5511
  ])),
5512
+ truncate_dim: None,
3705
5513
  },
3706
5514
  )
3707
5515
  .expect("search database");
@@ -4009,6 +5817,7 @@ mod tests {
4009
5817
  SearchOptions {
4010
5818
  top_k: 2,
4011
5819
  filter: None,
5820
+ truncate_dim: None,
4012
5821
  },
4013
5822
  )
4014
5823
  .expect("search database");
@@ -4067,6 +5876,7 @@ mod tests {
4067
5876
  SearchOptions {
4068
5877
  top_k: 1,
4069
5878
  filter: None,
5879
+ truncate_dim: None,
4070
5880
  },
4071
5881
  )
4072
5882
  .expect_err("search on closed database should fail");
@@ -4137,6 +5947,21 @@ mod tests {
4137
5947
  let mut lock = path.as_os_str().to_os_string();
4138
5948
  lock.push(".lock");
4139
5949
  let _ = std::fs::remove_file(PathBuf::from(&lock));
5950
+ // Clean up multi-vector quantization sidecar files (.mvquant.*)
5951
+ if let Some(parent) = path.parent() {
5952
+ if let Some(stem) = path.file_name().and_then(|n| n.to_str()) {
5953
+ let prefix = format!("{stem}.mvquant.");
5954
+ if let Ok(entries) = std::fs::read_dir(parent) {
5955
+ for entry in entries.flatten() {
5956
+ if let Some(fname) = entry.file_name().to_str() {
5957
+ if fname.starts_with(&prefix) {
5958
+ let _ = std::fs::remove_file(entry.path());
5959
+ }
5960
+ }
5961
+ }
5962
+ }
5963
+ }
5964
+ }
4140
5965
  }
4141
5966
 
4142
5967
  // -----------------------------------------------------------------------
@@ -4181,6 +6006,7 @@ mod tests {
4181
6006
  SearchOptions {
4182
6007
  top_k: 5,
4183
6008
  filter: None,
6009
+ truncate_dim: None,
4184
6010
  },
4185
6011
  )
4186
6012
  .expect("search");
@@ -4209,6 +6035,7 @@ mod tests {
4209
6035
  SearchOptions {
4210
6036
  top_k: 5,
4211
6037
  filter: None,
6038
+ truncate_dim: None,
4212
6039
  },
4213
6040
  )
4214
6041
  .expect("search after reopen");
@@ -4254,6 +6081,7 @@ mod tests {
4254
6081
  SearchOptions {
4255
6082
  top_k: 5,
4256
6083
  filter: None,
6084
+ truncate_dim: None,
4257
6085
  },
4258
6086
  )
4259
6087
  .expect("search");
@@ -4298,6 +6126,7 @@ mod tests {
4298
6126
  SearchOptions {
4299
6127
  top_k: 5,
4300
6128
  filter: None,
6129
+ truncate_dim: None,
4301
6130
  },
4302
6131
  )
4303
6132
  .expect("search");
@@ -4357,4 +6186,1863 @@ mod tests {
4357
6186
 
4358
6187
  cleanup(&path);
4359
6188
  }
6189
+
6190
+ // -----------------------------------------------------------------------
6191
+ // Multi-vector / ColBERT-style integration tests
6192
+ // -----------------------------------------------------------------------
6193
+
6194
+ #[test]
6195
+ fn multi_vector_upsert_and_search() {
6196
+ let path = temp_file("mv-upsert-search");
6197
+ let mut db = Database::create(&path, 3).expect("create");
6198
+
6199
+ // Upsert records with ColBERT-style token vectors
6200
+ let mut mv1 = MultiVectors::new();
6201
+ mv1.insert(
6202
+ "colbert".to_owned(),
6203
+ vec![
6204
+ vec![1.0, 0.0, 0.0],
6205
+ vec![0.0, 1.0, 0.0],
6206
+ ],
6207
+ );
6208
+ db.upsert_multi_vectors("doc1", vec![1.0, 0.0, 0.0], Metadata::new(), mv1)
6209
+ .expect("upsert doc1");
6210
+
6211
+ let mut mv2 = MultiVectors::new();
6212
+ mv2.insert(
6213
+ "colbert".to_owned(),
6214
+ vec![
6215
+ vec![0.0, 0.0, 1.0],
6216
+ vec![0.0, 1.0, 0.0],
6217
+ ],
6218
+ );
6219
+ db.upsert_multi_vectors("doc2", vec![0.0, 0.0, 1.0], Metadata::new(), mv2)
6220
+ .expect("upsert doc2");
6221
+
6222
+ assert_eq!(db.len(), 2);
6223
+
6224
+ // Search with query tokens that strongly match doc1
6225
+ let query_tokens = vec![
6226
+ vec![1.0, 0.0, 0.0],
6227
+ vec![0.0, 1.0, 0.0],
6228
+ ];
6229
+
6230
+ let results = db
6231
+ .search_multi_vector("colbert", &query_tokens, MultiVectorSearchOptions::default())
6232
+ .expect("search");
6233
+
6234
+ assert_eq!(results.len(), 2);
6235
+ assert_eq!(results[0].id, "doc1"); // doc1 has perfect MaxSim match
6236
+
6237
+ cleanup(&path);
6238
+ }
6239
+
6240
+ #[test]
6241
+ fn multi_vector_empty_space_error() {
6242
+ let path = temp_file("mv-empty-space");
6243
+ let db = Database::create(&path, 3).expect("create");
6244
+
6245
+ let query_tokens = vec![vec![1.0, 0.0, 0.0]];
6246
+ let result = db.search_multi_vector("", &query_tokens, MultiVectorSearchOptions::default());
6247
+ assert!(result.is_err());
6248
+
6249
+ cleanup(&path);
6250
+ }
6251
+
6252
+ #[test]
6253
+ fn multi_vector_empty_query_tokens_error() {
6254
+ let path = temp_file("mv-empty-query");
6255
+ let db = Database::create(&path, 3).expect("create");
6256
+
6257
+ let query_tokens: Vec<Vec<f32>> = vec![];
6258
+ let result = db.search_multi_vector("colbert", &query_tokens, MultiVectorSearchOptions::default());
6259
+ assert!(result.is_err());
6260
+
6261
+ cleanup(&path);
6262
+ }
6263
+
6264
+ #[test]
6265
+ fn multi_vector_search_with_namespace_filter() {
6266
+ let path = temp_file("mv-ns-filter");
6267
+ let mut db = Database::create(&path, 3).expect("create");
6268
+
6269
+ let mut mv = MultiVectors::new();
6270
+ mv.insert("colbert".to_owned(), vec![vec![1.0, 0.0, 0.0]]);
6271
+ db.upsert_multi_vectors_in_namespace("ns1", "doc1", vec![1.0, 0.0, 0.0], Metadata::new(), mv.clone())
6272
+ .expect("upsert ns1");
6273
+ db.upsert_multi_vectors_in_namespace("ns2", "doc2", vec![0.0, 1.0, 0.0], Metadata::new(), mv.clone())
6274
+ .expect("upsert ns2");
6275
+
6276
+ let query_tokens = vec![vec![1.0, 0.0, 0.0]];
6277
+ let options = MultiVectorSearchOptions {
6278
+ top_k: 10,
6279
+ filter: None,
6280
+ namespace: Some("ns1".to_owned()),
6281
+ };
6282
+ let results = db.search_multi_vector("colbert", &query_tokens, options).expect("search");
6283
+
6284
+ assert_eq!(results.len(), 1);
6285
+ assert_eq!(results[0].id, "doc1");
6286
+ assert_eq!(results[0].namespace, "ns1");
6287
+
6288
+ cleanup(&path);
6289
+ }
6290
+
6291
+ #[test]
6292
+ fn multi_vector_quantization_enable_disable() {
6293
+ use super::quantization::{MultiVectorQuantizationConfig, TwoBitQuantizationConfig};
6294
+
6295
+ let path = temp_file("mv-quant");
6296
+ let mut db = Database::create(&path, 3).expect("create");
6297
+
6298
+ // Insert some records with multi-vectors
6299
+ for i in 0..10 {
6300
+ let mut mv = MultiVectors::new();
6301
+ mv.insert(
6302
+ "colbert".to_owned(),
6303
+ vec![
6304
+ vec![i as f32, 0.0, 0.0],
6305
+ vec![0.0, i as f32, 0.0],
6306
+ vec![0.0, 0.0, i as f32],
6307
+ ],
6308
+ );
6309
+ db.upsert_multi_vectors(
6310
+ &format!("doc{i}"),
6311
+ vec![i as f32, 0.0, 0.0],
6312
+ Metadata::new(),
6313
+ mv,
6314
+ )
6315
+ .expect("upsert");
6316
+ }
6317
+
6318
+ assert!(!db.is_multi_vector_quantized("colbert"));
6319
+
6320
+ // Enable quantization
6321
+ db.enable_multi_vector_quantization(
6322
+ "colbert",
6323
+ MultiVectorQuantizationConfig::TwoBit(TwoBitQuantizationConfig {
6324
+ rescore_multiplier: 4,
6325
+ }),
6326
+ )
6327
+ .expect("enable");
6328
+
6329
+ assert!(db.is_multi_vector_quantized("colbert"));
6330
+
6331
+ // Search should still work
6332
+ let query_tokens = vec![vec![9.0, 0.0, 0.0], vec![0.0, 9.0, 0.0]];
6333
+ let results = db
6334
+ .search_multi_vector("colbert", &query_tokens, MultiVectorSearchOptions::default())
6335
+ .expect("search");
6336
+
6337
+ assert!(!results.is_empty());
6338
+
6339
+ // Disable quantization
6340
+ db.disable_multi_vector_quantization("colbert").expect("disable");
6341
+ assert!(!db.is_multi_vector_quantized("colbert"));
6342
+
6343
+ cleanup(&path);
6344
+ }
6345
+
6346
+ #[test]
6347
+ fn multi_vector_quantization_persists_across_reopen() {
6348
+ use super::quantization::{MultiVectorQuantizationConfig, TwoBitQuantizationConfig};
6349
+
6350
+ let path = temp_file("mv-quant-persist");
6351
+
6352
+ {
6353
+ let mut db = Database::create(&path, 3).expect("create");
6354
+ for i in 0..10 {
6355
+ let mut mv = MultiVectors::new();
6356
+ mv.insert(
6357
+ "colbert".to_owned(),
6358
+ vec![
6359
+ vec![i as f32 * 0.1, 0.5, 0.5],
6360
+ vec![0.5, i as f32 * 0.1, 0.5],
6361
+ ],
6362
+ );
6363
+ db.upsert_multi_vectors(
6364
+ &format!("doc{i}"),
6365
+ vec![1.0, 0.0, 0.0],
6366
+ Metadata::new(),
6367
+ mv,
6368
+ )
6369
+ .expect("upsert");
6370
+ }
6371
+
6372
+ db.enable_multi_vector_quantization(
6373
+ "colbert",
6374
+ MultiVectorQuantizationConfig::TwoBit(TwoBitQuantizationConfig {
6375
+ rescore_multiplier: 4,
6376
+ }),
6377
+ )
6378
+ .expect("enable");
6379
+
6380
+ assert!(db.is_multi_vector_quantized("colbert"));
6381
+ }
6382
+
6383
+ // Reopen and verify quantization was loaded
6384
+ let db = Database::open(&path).expect("reopen");
6385
+ assert!(db.is_multi_vector_quantized("colbert"));
6386
+
6387
+ // Search should work on reopened database
6388
+ let query_tokens = vec![vec![0.9, 0.5, 0.5]];
6389
+ let results = db
6390
+ .search_multi_vector("colbert", &query_tokens, MultiVectorSearchOptions::default())
6391
+ .expect("search");
6392
+ assert!(!results.is_empty());
6393
+
6394
+ cleanup(&path);
6395
+ }
6396
+
6397
+ #[test]
6398
+ fn multi_vector_record_persists_across_reopen() {
6399
+ let path = temp_file("mv-persist");
6400
+ let mut mv = MultiVectors::new();
6401
+ mv.insert(
6402
+ "colbert".to_owned(),
6403
+ vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]],
6404
+ );
6405
+
6406
+ {
6407
+ let mut db = Database::create(&path, 3).expect("create");
6408
+ db.upsert_multi_vectors("doc1", vec![1.0, 0.0, 0.0], Metadata::new(), mv.clone())
6409
+ .expect("upsert");
6410
+ }
6411
+
6412
+ let db = Database::open(&path).expect("reopen");
6413
+ let record = db.get("doc1").expect("exists");
6414
+ let tokens = record.multi_vectors.get("colbert").expect("colbert space");
6415
+ assert_eq!(tokens.len(), 2);
6416
+ assert_eq!(tokens[0], vec![1.0, 2.0, 3.0]);
6417
+ assert_eq!(tokens[1], vec![4.0, 5.0, 6.0]);
6418
+
6419
+ cleanup(&path);
6420
+ }
6421
+
6422
+ #[test]
6423
+ fn multi_vector_maxsim_scoring_correctness() {
6424
+ use super::{maxsim_score, DistanceMetric};
6425
+
6426
+ // Two identical sets: MaxSim should be sum of 1.0 per query token
6427
+ let query = [&[1.0_f32, 0.0, 0.0][..], &[0.0, 1.0, 0.0]];
6428
+ let doc = vec![vec![1.0, 0.0, 0.0], vec![0.0, 1.0, 0.0]];
6429
+ let score = maxsim_score(&query, &doc, DistanceMetric::Cosine);
6430
+ // cosine(q0, d0) = 1.0, cosine(q0, d1) = 0.0 -> max = 1.0
6431
+ // cosine(q1, d0) = 0.0, cosine(q1, d1) = 1.0 -> max = 1.0
6432
+ // sum = 2.0
6433
+ assert!((score - 2.0).abs() < 1e-6);
6434
+
6435
+ // Orthogonal: each query token has zero max sim
6436
+ let query2 = [&[1.0_f32, 0.0, 0.0][..]];
6437
+ let doc2 = vec![vec![0.0, 1.0, 0.0], vec![0.0, 0.0, 1.0]];
6438
+ let score2 = maxsim_score(&query2, &doc2, DistanceMetric::Cosine);
6439
+ assert!(score2.abs() < 1e-6);
6440
+ }
6441
+
6442
+ // -----------------------------------------------------------------------
6443
+ // Distance metric tests
6444
+ // -----------------------------------------------------------------------
6445
+
6446
+ #[test]
6447
+ fn distance_metric_tag_roundtrip() {
6448
+ use super::DistanceMetric;
6449
+ for metric in [
6450
+ DistanceMetric::Cosine,
6451
+ DistanceMetric::Euclidean,
6452
+ DistanceMetric::DotProduct,
6453
+ DistanceMetric::Manhattan,
6454
+ ] {
6455
+ let tag = metric.to_tag();
6456
+ let back = DistanceMetric::from_tag(tag).expect("valid tag");
6457
+ assert_eq!(back, metric);
6458
+ }
6459
+ // Invalid tag
6460
+ assert!(DistanceMetric::from_tag(255).is_err());
6461
+ }
6462
+
6463
+ #[test]
6464
+ fn distance_metric_name_roundtrip() {
6465
+ use super::DistanceMetric;
6466
+ for metric in [
6467
+ DistanceMetric::Cosine,
6468
+ DistanceMetric::Euclidean,
6469
+ DistanceMetric::DotProduct,
6470
+ DistanceMetric::Manhattan,
6471
+ ] {
6472
+ let name = metric.name();
6473
+ let back = DistanceMetric::from_name(name).expect("valid name");
6474
+ assert_eq!(back, metric);
6475
+ }
6476
+ }
6477
+
6478
+ #[test]
6479
+ fn distance_metric_name_aliases() {
6480
+ use super::DistanceMetric;
6481
+ // Euclidean aliases
6482
+ assert_eq!(DistanceMetric::from_name("l2").unwrap(), DistanceMetric::Euclidean);
6483
+ assert_eq!(DistanceMetric::from_name("L2").unwrap(), DistanceMetric::Euclidean);
6484
+ assert_eq!(DistanceMetric::from_name("EUCLIDEAN").unwrap(), DistanceMetric::Euclidean);
6485
+ // DotProduct aliases
6486
+ assert_eq!(DistanceMetric::from_name("dot").unwrap(), DistanceMetric::DotProduct);
6487
+ assert_eq!(DistanceMetric::from_name("dot_product").unwrap(), DistanceMetric::DotProduct);
6488
+ assert_eq!(DistanceMetric::from_name("ip").unwrap(), DistanceMetric::DotProduct);
6489
+ assert_eq!(DistanceMetric::from_name("inner_product").unwrap(), DistanceMetric::DotProduct);
6490
+ // Manhattan aliases
6491
+ assert_eq!(DistanceMetric::from_name("l1").unwrap(), DistanceMetric::Manhattan);
6492
+ assert_eq!(DistanceMetric::from_name("L1").unwrap(), DistanceMetric::Manhattan);
6493
+ // Invalid
6494
+ assert!(DistanceMetric::from_name("hamming").is_err());
6495
+ }
6496
+
6497
+ #[test]
6498
+ fn distance_metric_score_cosine() {
6499
+ use super::DistanceMetric;
6500
+ let a = [1.0_f32, 0.0, 0.0];
6501
+ let b = [1.0_f32, 0.0, 0.0];
6502
+ let c = [0.0_f32, 1.0, 0.0];
6503
+ // Identical vectors -> similarity ~1.0
6504
+ let s1 = DistanceMetric::Cosine.score(&a, &b);
6505
+ assert!((s1 - 1.0).abs() < 1e-5, "cosine identical: {s1}");
6506
+ // Orthogonal vectors -> similarity ~0.0
6507
+ let s2 = DistanceMetric::Cosine.score(&a, &c);
6508
+ assert!(s2.abs() < 1e-5, "cosine orthogonal: {s2}");
6509
+ }
6510
+
6511
+ #[test]
6512
+ fn distance_metric_score_euclidean() {
6513
+ use super::DistanceMetric;
6514
+ let a = [1.0_f32, 0.0, 0.0];
6515
+ let b = [1.0_f32, 0.0, 0.0];
6516
+ let c = [4.0_f32, 0.0, 0.0];
6517
+ // Identical -> score = -0.0 (negated distance)
6518
+ let s1 = DistanceMetric::Euclidean.score(&a, &b);
6519
+ assert!(s1.abs() < 1e-5, "euclidean identical: {s1}");
6520
+ // Distance = 3.0, score = -3.0
6521
+ let s2 = DistanceMetric::Euclidean.score(&a, &c);
6522
+ assert!((s2 - (-3.0)).abs() < 1e-5, "euclidean dist=3: {s2}");
6523
+ // Closer is higher score
6524
+ let d = [2.0_f32, 0.0, 0.0];
6525
+ let s3 = DistanceMetric::Euclidean.score(&a, &d);
6526
+ assert!(s3 > s2, "closer should have higher score");
6527
+ }
6528
+
6529
+ #[test]
6530
+ fn distance_metric_score_dot_product() {
6531
+ use super::DistanceMetric;
6532
+ // Normalized unit vectors
6533
+ let a = [1.0_f32, 0.0, 0.0];
6534
+ let b = [1.0_f32, 0.0, 0.0];
6535
+ let c = [0.0_f32, 1.0, 0.0];
6536
+ // dot(a, b) = 1.0
6537
+ let s1 = DistanceMetric::DotProduct.score(&a, &b);
6538
+ assert!((s1 - 1.0).abs() < 1e-5, "dot identical: {s1}");
6539
+ // dot(a, c) = 0.0
6540
+ let s2 = DistanceMetric::DotProduct.score(&a, &c);
6541
+ assert!(s2.abs() < 1e-5, "dot orthogonal: {s2}");
6542
+ // Higher dot = higher score
6543
+ let d = [0.5_f32, 0.5, 0.0];
6544
+ let s3 = DistanceMetric::DotProduct.score(&a, &d);
6545
+ assert!(s3 > s2, "non-zero dot should be higher than orthogonal");
6546
+ }
6547
+
6548
+ #[test]
6549
+ fn distance_metric_score_manhattan() {
6550
+ use super::DistanceMetric;
6551
+ let a = [1.0_f32, 2.0, 3.0];
6552
+ let b = [1.0_f32, 2.0, 3.0];
6553
+ let c = [4.0_f32, 6.0, 3.0];
6554
+ // Identical -> score = 0.0 (negated L1 distance)
6555
+ let s1 = DistanceMetric::Manhattan.score(&a, &b);
6556
+ assert!(s1.abs() < 1e-5, "manhattan identical: {s1}");
6557
+ // L1 dist = |1-4| + |2-6| + |3-3| = 3 + 4 + 0 = 7, score = -7
6558
+ let s2 = DistanceMetric::Manhattan.score(&a, &c);
6559
+ assert!((s2 - (-7.0)).abs() < 1e-5, "manhattan dist=7: {s2}");
6560
+ }
6561
+
6562
+ #[test]
6563
+ fn distance_metric_is_similarity() {
6564
+ use super::DistanceMetric;
6565
+ assert!(DistanceMetric::Cosine.is_similarity());
6566
+ assert!(DistanceMetric::DotProduct.is_similarity());
6567
+ assert!(!DistanceMetric::Euclidean.is_similarity());
6568
+ assert!(!DistanceMetric::Manhattan.is_similarity());
6569
+ }
6570
+
6571
+ #[test]
6572
+ fn distance_metric_default_is_cosine() {
6573
+ use super::DistanceMetric;
6574
+ assert_eq!(DistanceMetric::default(), DistanceMetric::Cosine);
6575
+ }
6576
+
6577
+ #[test]
6578
+ fn distance_metric_display() {
6579
+ use super::DistanceMetric;
6580
+ assert_eq!(format!("{}", DistanceMetric::Cosine), "cosine");
6581
+ assert_eq!(format!("{}", DistanceMetric::Euclidean), "euclidean");
6582
+ assert_eq!(format!("{}", DistanceMetric::DotProduct), "dotproduct");
6583
+ assert_eq!(format!("{}", DistanceMetric::Manhattan), "manhattan");
6584
+ }
6585
+
6586
+ #[test]
6587
+ fn create_with_metric_persists_metric() {
6588
+ use super::DistanceMetric;
6589
+ for metric in [
6590
+ DistanceMetric::Cosine,
6591
+ DistanceMetric::Euclidean,
6592
+ DistanceMetric::DotProduct,
6593
+ DistanceMetric::Manhattan,
6594
+ ] {
6595
+ let path = temp_file(&format!("metric-persist-{}", metric.name()));
6596
+ {
6597
+ let db = Database::create_with_metric(&path, 4, metric).expect("create");
6598
+ assert_eq!(db.metric(), metric);
6599
+ }
6600
+ // Reopen and verify metric
6601
+ let db = Database::open(&path).expect("reopen");
6602
+ assert_eq!(db.metric(), metric, "metric should persist for {metric}");
6603
+ cleanup(&path);
6604
+ }
6605
+ }
6606
+
6607
+ #[test]
6608
+ fn default_create_uses_cosine_metric() {
6609
+ use super::DistanceMetric;
6610
+ let path = temp_file("metric-default-cosine");
6611
+ let db = Database::create(&path, 4).expect("create");
6612
+ assert_eq!(db.metric(), DistanceMetric::Cosine);
6613
+ drop(db);
6614
+ cleanup(&path);
6615
+ }
6616
+
6617
+ #[test]
6618
+ fn open_or_create_with_metric_creates_new() {
6619
+ use super::DistanceMetric;
6620
+ let path = temp_file("metric-ooc-new");
6621
+ let db = Database::open_or_create_with_metric(&path, 4, DistanceMetric::Euclidean)
6622
+ .expect("open_or_create");
6623
+ assert_eq!(db.metric(), DistanceMetric::Euclidean);
6624
+ drop(db);
6625
+ // Reopen with open_or_create again — should keep Euclidean
6626
+ let db2 = Database::open_or_create_with_metric(&path, 4, DistanceMetric::Euclidean)
6627
+ .expect("reopen");
6628
+ assert_eq!(db2.metric(), DistanceMetric::Euclidean);
6629
+ drop(db2);
6630
+ cleanup(&path);
6631
+ }
6632
+
6633
+ #[test]
6634
+ fn search_with_euclidean_metric() {
6635
+ use super::DistanceMetric;
6636
+ let path = temp_file("metric-search-euclidean");
6637
+ let mut db = Database::create_with_metric(&path, 3, DistanceMetric::Euclidean)
6638
+ .expect("create");
6639
+
6640
+ // Insert vectors at known distances from query [0, 0, 0]
6641
+ db.insert("close", vec![1.0, 0.0, 0.0], Metadata::new())
6642
+ .expect("insert close"); // L2 = 1
6643
+ db.insert("mid", vec![3.0, 0.0, 0.0], Metadata::new())
6644
+ .expect("insert mid"); // L2 = 3
6645
+ db.insert("far", vec![5.0, 5.0, 5.0], Metadata::new())
6646
+ .expect("insert far"); // L2 = sqrt(75) ≈ 8.66
6647
+
6648
+ let results = db
6649
+ .search(
6650
+ &[0.0, 0.0, 0.0],
6651
+ SearchOptions {
6652
+ top_k: 3,
6653
+ ..Default::default()
6654
+ },
6655
+ )
6656
+ .expect("search");
6657
+
6658
+ assert_eq!(results.len(), 3);
6659
+ assert_eq!(results[0].id, "close");
6660
+ assert_eq!(results[1].id, "mid");
6661
+ assert_eq!(results[2].id, "far");
6662
+ // Scores should be negative distances
6663
+ assert!(results[0].score > results[1].score);
6664
+ assert!(results[1].score > results[2].score);
6665
+
6666
+ drop(db);
6667
+ cleanup(&path);
6668
+ }
6669
+
6670
+ #[test]
6671
+ fn search_with_dotproduct_metric() {
6672
+ use super::DistanceMetric;
6673
+ let path = temp_file("metric-search-dot");
6674
+ let mut db = Database::create_with_metric(&path, 3, DistanceMetric::DotProduct)
6675
+ .expect("create");
6676
+
6677
+ // Vectors with different dot products with query [1, 0, 0]
6678
+ db.insert("high", vec![10.0, 0.0, 0.0], Metadata::new())
6679
+ .expect("insert high"); // dot = 10
6680
+ db.insert("medium", vec![5.0, 0.0, 0.0], Metadata::new())
6681
+ .expect("insert medium"); // dot = 5
6682
+ db.insert("low", vec![0.0, 1.0, 0.0], Metadata::new())
6683
+ .expect("insert low"); // dot = 0
6684
+
6685
+ let results = db
6686
+ .search(
6687
+ &[1.0, 0.0, 0.0],
6688
+ SearchOptions {
6689
+ top_k: 3,
6690
+ ..Default::default()
6691
+ },
6692
+ )
6693
+ .expect("search");
6694
+
6695
+ assert_eq!(results.len(), 3);
6696
+ assert_eq!(results[0].id, "high");
6697
+ assert_eq!(results[1].id, "medium");
6698
+ assert_eq!(results[2].id, "low");
6699
+ assert!(results[0].score > results[1].score);
6700
+ assert!(results[1].score > results[2].score);
6701
+
6702
+ drop(db);
6703
+ cleanup(&path);
6704
+ }
6705
+
6706
+ #[test]
6707
+ fn search_with_manhattan_metric() {
6708
+ use super::DistanceMetric;
6709
+ let path = temp_file("metric-search-manhattan");
6710
+ let mut db = Database::create_with_metric(&path, 3, DistanceMetric::Manhattan)
6711
+ .expect("create");
6712
+
6713
+ // Vectors at known Manhattan distances from query [0, 0, 0]
6714
+ db.insert("close", vec![1.0, 0.0, 0.0], Metadata::new())
6715
+ .expect("insert close"); // L1 = 1
6716
+ db.insert("mid", vec![2.0, 1.0, 0.0], Metadata::new())
6717
+ .expect("insert mid"); // L1 = 3
6718
+ db.insert("far", vec![3.0, 3.0, 3.0], Metadata::new())
6719
+ .expect("insert far"); // L1 = 9
6720
+
6721
+ let results = db
6722
+ .search(
6723
+ &[0.0, 0.0, 0.0],
6724
+ SearchOptions {
6725
+ top_k: 3,
6726
+ ..Default::default()
6727
+ },
6728
+ )
6729
+ .expect("search");
6730
+
6731
+ assert_eq!(results.len(), 3);
6732
+ assert_eq!(results[0].id, "close");
6733
+ assert_eq!(results[1].id, "mid");
6734
+ assert_eq!(results[2].id, "far");
6735
+ assert!(results[0].score > results[1].score);
6736
+ assert!(results[1].score > results[2].score);
6737
+
6738
+ drop(db);
6739
+ cleanup(&path);
6740
+ }
6741
+
6742
+ #[test]
6743
+ fn matryoshka_prefix_search_accepts_short_query() {
6744
+ let path = temp_file("matryoshka-short-query");
6745
+ let mut db = Database::create(&path, 4).expect("create");
6746
+ db.insert("prefix_match", vec![1.0, 0.0, -1.0, -1.0], Metadata::new())
6747
+ .expect("insert prefix");
6748
+ db.insert("full_match", vec![0.0, 1.0, 1.0, 1.0], Metadata::new())
6749
+ .expect("insert full");
6750
+
6751
+ let outcome = db
6752
+ .hybrid_search_in_namespace_with_stats(
6753
+ "",
6754
+ Some(&[1.0, 0.0]),
6755
+ None,
6756
+ HybridSearchOptions {
6757
+ top_k: 2,
6758
+ ..HybridSearchOptions::default()
6759
+ },
6760
+ )
6761
+ .expect("search");
6762
+
6763
+ assert_eq!(outcome.results[0].id, "prefix_match");
6764
+ assert_eq!(outcome.stats.effective_dimension, 2);
6765
+ assert!(outcome.stats.matryoshka_truncated);
6766
+ assert!(!outcome.stats.used_ann);
6767
+
6768
+ cleanup(&path);
6769
+ }
6770
+
6771
+ #[test]
6772
+ fn matryoshka_prefix_search_can_truncate_full_query() {
6773
+ let path = temp_file("matryoshka-truncate");
6774
+ let mut db = Database::create(&path, 4).expect("create");
6775
+ db.insert("prefix_match", vec![1.0, 0.0, -1.0, -1.0], Metadata::new())
6776
+ .expect("insert prefix");
6777
+ db.insert("tail_match", vec![0.0, 1.0, 1.0, 1.0], Metadata::new())
6778
+ .expect("insert tail");
6779
+
6780
+ let outcome = db
6781
+ .hybrid_search_in_namespace_with_stats(
6782
+ "",
6783
+ Some(&[1.0, 0.0, 1.0, 1.0]),
6784
+ None,
6785
+ HybridSearchOptions {
6786
+ top_k: 2,
6787
+ truncate_dim: Some(2),
6788
+ ..HybridSearchOptions::default()
6789
+ },
6790
+ )
6791
+ .expect("search");
6792
+
6793
+ assert_eq!(outcome.results[0].id, "prefix_match");
6794
+ assert_eq!(outcome.stats.effective_dimension, 2);
6795
+ assert!(outcome.stats.matryoshka_truncated);
6796
+
6797
+ cleanup(&path);
6798
+ }
6799
+
6800
+ #[test]
6801
+ fn search_with_cosine_metric_explicit() {
6802
+ use super::DistanceMetric;
6803
+ let path = temp_file("metric-search-cosine-explicit");
6804
+ let mut db = Database::create_with_metric(&path, 3, DistanceMetric::Cosine)
6805
+ .expect("create");
6806
+
6807
+ db.insert("aligned", vec![2.0, 0.0, 0.0], Metadata::new())
6808
+ .expect("insert aligned"); // cosine = 1.0
6809
+ db.insert("diagonal", vec![1.0, 1.0, 0.0], Metadata::new())
6810
+ .expect("insert diagonal"); // cosine = 1/sqrt(2) ≈ 0.707
6811
+ db.insert("orthogonal", vec![0.0, 0.0, 1.0], Metadata::new())
6812
+ .expect("insert orthogonal"); // cosine = 0.0
6813
+
6814
+ let results = db
6815
+ .search(
6816
+ &[1.0, 0.0, 0.0],
6817
+ SearchOptions {
6818
+ top_k: 3,
6819
+ ..Default::default()
6820
+ },
6821
+ )
6822
+ .expect("search");
6823
+
6824
+ assert_eq!(results.len(), 3);
6825
+ assert_eq!(results[0].id, "aligned");
6826
+ assert_eq!(results[1].id, "diagonal");
6827
+ assert_eq!(results[2].id, "orthogonal");
6828
+ assert!((results[0].score - 1.0).abs() < 1e-4);
6829
+
6830
+ drop(db);
6831
+ cleanup(&path);
6832
+ }
6833
+
6834
+ #[test]
6835
+ fn metric_persists_with_upsert_and_search_cycle() {
6836
+ use super::DistanceMetric;
6837
+ let path = temp_file("metric-upsert-cycle");
6838
+ {
6839
+ let mut db = Database::create_with_metric(&path, 3, DistanceMetric::Manhattan)
6840
+ .expect("create");
6841
+ db.upsert("a", vec![1.0, 0.0, 0.0], Metadata::new())
6842
+ .expect("upsert a");
6843
+ db.upsert("b", vec![0.0, 5.0, 0.0], Metadata::new())
6844
+ .expect("upsert b");
6845
+ }
6846
+
6847
+ // Reopen and search — metric should still be Manhattan
6848
+ let db = Database::open(&path).expect("reopen");
6849
+ assert_eq!(db.metric(), DistanceMetric::Manhattan);
6850
+
6851
+ let results = db
6852
+ .search(
6853
+ &[1.0, 0.0, 0.0],
6854
+ SearchOptions {
6855
+ top_k: 2,
6856
+ ..Default::default()
6857
+ },
6858
+ )
6859
+ .expect("search");
6860
+ // "a" is closer (L1=0) vs "b" (L1=6)
6861
+ assert_eq!(results[0].id, "a");
6862
+ assert_eq!(results[1].id, "b");
6863
+ assert!(results[0].score > results[1].score);
6864
+
6865
+ cleanup(&path);
6866
+ }
6867
+
6868
+ #[test]
6869
+ fn simd_cosine_matches_scalar() {
6870
+ use super::{scalar_cosine_similarity, simd_cosine_similarity};
6871
+ let a = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8];
6872
+ let b = [0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1];
6873
+ let simd_val = simd_cosine_similarity(&a, &b);
6874
+ let scalar_val = scalar_cosine_similarity(&a, &b);
6875
+ assert!(
6876
+ (simd_val - scalar_val).abs() < 1e-4,
6877
+ "simd={simd_val}, scalar={scalar_val}"
6878
+ );
6879
+ }
6880
+
6881
+ #[test]
6882
+ fn simd_euclidean_matches_scalar() {
6883
+ use super::{scalar_euclidean_distance, simd_euclidean_distance};
6884
+ let a = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
6885
+ let b = [8.0, 7.0, 6.0, 5.0, 4.0, 3.0, 2.0, 1.0];
6886
+ let simd_val = simd_euclidean_distance(&a, &b);
6887
+ let scalar_val = scalar_euclidean_distance(&a, &b);
6888
+ assert!(
6889
+ (simd_val - scalar_val).abs() < 1e-3,
6890
+ "simd={simd_val}, scalar={scalar_val}"
6891
+ );
6892
+ }
6893
+
6894
+ #[test]
6895
+ fn simd_dot_matches_scalar() {
6896
+ use super::{scalar_dot_product, simd_dot_product};
6897
+ let a = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8];
6898
+ let b = [0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1];
6899
+ let simd_val = simd_dot_product(&a, &b);
6900
+ let scalar_val = scalar_dot_product(&a, &b);
6901
+ assert!(
6902
+ (simd_val - scalar_val).abs() < 1e-4,
6903
+ "simd={simd_val}, scalar={scalar_val}"
6904
+ );
6905
+ }
6906
+
6907
+ // -----------------------------------------------------------------------
6908
+ // update_metadata tests
6909
+ // -----------------------------------------------------------------------
6910
+
6911
+ #[test]
6912
+ fn update_metadata_merges_patch() {
6913
+ let path = temp_file("update-metadata-merge");
6914
+ let mut db = Database::create(&path, 3).expect("create");
6915
+
6916
+ let mut meta = Metadata::new();
6917
+ meta.insert("source".into(), "blog".into());
6918
+ meta.insert("version".into(), MetadataValue::Integer(1));
6919
+ db.upsert("doc1", vec![1.0, 0.0, 0.0], meta).expect("upsert");
6920
+
6921
+ // Patch: update version, add new key
6922
+ let mut patch = Metadata::new();
6923
+ patch.insert("version".into(), MetadataValue::Integer(2));
6924
+ patch.insert("reviewed".into(), MetadataValue::Boolean(true));
6925
+
6926
+ let updated = db.update_metadata("doc1", patch).expect("update");
6927
+ assert!(updated);
6928
+
6929
+ let record = db.get("doc1").expect("found");
6930
+ assert_eq!(
6931
+ record.metadata.get("source"),
6932
+ Some(&MetadataValue::String("blog".into()))
6933
+ );
6934
+ assert_eq!(
6935
+ record.metadata.get("version"),
6936
+ Some(&MetadataValue::Integer(2))
6937
+ );
6938
+ assert_eq!(
6939
+ record.metadata.get("reviewed"),
6940
+ Some(&MetadataValue::Boolean(true))
6941
+ );
6942
+
6943
+ drop(db);
6944
+ cleanup(&path);
6945
+ }
6946
+
6947
+ #[test]
6948
+ fn update_metadata_returns_false_for_missing_record() {
6949
+ let path = temp_file("update-metadata-missing");
6950
+ let mut db = Database::create(&path, 3).expect("create");
6951
+
6952
+ let mut patch = Metadata::new();
6953
+ patch.insert("key".into(), "value".into());
6954
+
6955
+ let updated = db.update_metadata("nonexistent", patch).expect("update");
6956
+ assert!(!updated);
6957
+
6958
+ drop(db);
6959
+ cleanup(&path);
6960
+ }
6961
+
6962
+ #[test]
6963
+ fn update_metadata_does_not_touch_vector() {
6964
+ let path = temp_file("update-metadata-vector-intact");
6965
+ let mut db = Database::create(&path, 3).expect("create");
6966
+
6967
+ let mut meta = Metadata::new();
6968
+ meta.insert("source".into(), "blog".into());
6969
+ db.upsert("doc1", vec![1.0, 2.0, 3.0], meta).expect("upsert");
6970
+
6971
+ let mut patch = Metadata::new();
6972
+ patch.insert("source".into(), "updated".into());
6973
+ db.update_metadata("doc1", patch).expect("update");
6974
+
6975
+ let record = db.get("doc1").expect("found");
6976
+ assert_eq!(record.vector, vec![1.0, 2.0, 3.0]);
6977
+
6978
+ drop(db);
6979
+ cleanup(&path);
6980
+ }
6981
+
6982
+ #[test]
6983
+ fn update_metadata_persists_across_reopen() {
6984
+ let path = temp_file("update-metadata-persist");
6985
+ {
6986
+ let mut db = Database::create(&path, 3).expect("create");
6987
+ let mut meta = Metadata::new();
6988
+ meta.insert("source".into(), "blog".into());
6989
+ db.upsert("doc1", vec![1.0, 0.0, 0.0], meta).expect("upsert");
6990
+
6991
+ let mut patch = Metadata::new();
6992
+ patch.insert("source".into(), "updated".into());
6993
+ patch.insert("new_key".into(), MetadataValue::Integer(42));
6994
+ db.update_metadata("doc1", patch).expect("update");
6995
+ }
6996
+
6997
+ // Reopen and verify
6998
+ let db = Database::open(&path).expect("reopen");
6999
+ let record = db.get("doc1").expect("found");
7000
+ assert_eq!(
7001
+ record.metadata.get("source"),
7002
+ Some(&MetadataValue::String("updated".into()))
7003
+ );
7004
+ assert_eq!(
7005
+ record.metadata.get("new_key"),
7006
+ Some(&MetadataValue::Integer(42))
7007
+ );
7008
+ assert_eq!(record.vector, vec![1.0, 0.0, 0.0]);
7009
+
7010
+ cleanup(&path);
7011
+ }
7012
+
7013
+ #[test]
7014
+ fn update_metadata_in_namespace() {
7015
+ let path = temp_file("update-metadata-ns");
7016
+ let mut db = Database::create(&path, 3).expect("create");
7017
+
7018
+ let mut meta = Metadata::new();
7019
+ meta.insert("key".into(), "original".into());
7020
+ db.upsert_in_namespace("ns1", "doc1", vec![1.0, 0.0, 0.0], meta)
7021
+ .expect("upsert");
7022
+
7023
+ let mut patch = Metadata::new();
7024
+ patch.insert("key".into(), "patched".into());
7025
+ let updated = db
7026
+ .update_metadata_in_namespace("ns1", "doc1", patch)
7027
+ .expect("update");
7028
+ assert!(updated);
7029
+
7030
+ let record = db.get_in_namespace("ns1", "doc1").expect("found");
7031
+ assert_eq!(
7032
+ record.metadata.get("key"),
7033
+ Some(&MetadataValue::String("patched".into()))
7034
+ );
7035
+
7036
+ // Wrong namespace returns false
7037
+ let mut patch2 = Metadata::new();
7038
+ patch2.insert("key".into(), "nope".into());
7039
+ let updated2 = db
7040
+ .update_metadata_in_namespace("ns2", "doc1", patch2)
7041
+ .expect("update wrong ns");
7042
+ assert!(!updated2);
7043
+
7044
+ drop(db);
7045
+ cleanup(&path);
7046
+ }
7047
+
7048
+ #[test]
7049
+ fn update_metadata_searchable_after_patch() {
7050
+ let path = temp_file("update-metadata-search");
7051
+ let mut db = Database::create(&path, 3).expect("create");
7052
+
7053
+ let mut meta = Metadata::new();
7054
+ meta.insert("status".into(), "draft".into());
7055
+ db.upsert("doc1", vec![1.0, 0.0, 0.0], meta).expect("upsert");
7056
+
7057
+ // Before patch: filter matches
7058
+ let count = db.count_filtered(None, Some(&MetadataFilter::eq("status", "draft")));
7059
+ assert_eq!(count, 1);
7060
+
7061
+ // Patch to "published"
7062
+ let mut patch = Metadata::new();
7063
+ patch.insert("status".into(), "published".into());
7064
+ db.update_metadata("doc1", patch).expect("update");
7065
+
7066
+ // After patch: old filter misses, new filter matches
7067
+ let count_draft = db.count_filtered(None, Some(&MetadataFilter::eq("status", "draft")));
7068
+ assert_eq!(count_draft, 0);
7069
+ let count_pub = db.count_filtered(None, Some(&MetadataFilter::eq("status", "published")));
7070
+ assert_eq!(count_pub, 1);
7071
+
7072
+ drop(db);
7073
+ cleanup(&path);
7074
+ }
7075
+
7076
+ #[test]
7077
+ fn update_metadata_read_only_fails() {
7078
+ let path = temp_file("update-metadata-ro");
7079
+ {
7080
+ let mut db = Database::create(&path, 3).expect("create");
7081
+ db.upsert("doc1", vec![1.0, 0.0, 0.0], Metadata::new())
7082
+ .expect("upsert");
7083
+ }
7084
+
7085
+ let mut db = Database::open_read_only(&path).expect("open ro");
7086
+ let mut patch = Metadata::new();
7087
+ patch.insert("key".into(), "val".into());
7088
+ let result = db.update_metadata("doc1", patch);
7089
+ assert!(result.is_err());
7090
+
7091
+ cleanup(&path);
7092
+ }
7093
+
7094
+ // ── Payload Index tests ──────────────────────────────────────────────
7095
+
7096
+ #[test]
7097
+ fn create_keyword_index_returns_true_on_first_call() {
7098
+ let path = temp_file("pidx-create-kw");
7099
+ let mut db = Database::create(&path, 3).expect("create");
7100
+
7101
+ let created = db
7102
+ .create_index("source", PayloadIndexType::Keyword)
7103
+ .expect("create_index");
7104
+ assert!(created);
7105
+
7106
+ let indexes = db.list_indexes();
7107
+ assert_eq!(indexes.len(), 1);
7108
+ assert_eq!(indexes[0].0, "source");
7109
+ assert!(matches!(indexes[0].1, PayloadIndexType::Keyword));
7110
+
7111
+ drop(db);
7112
+ cleanup(&path);
7113
+ }
7114
+
7115
+ #[test]
7116
+ fn create_index_returns_false_on_duplicate() {
7117
+ let path = temp_file("pidx-dup");
7118
+ let mut db = Database::create(&path, 3).expect("create");
7119
+
7120
+ let first = db
7121
+ .create_index("source", PayloadIndexType::Keyword)
7122
+ .expect("first");
7123
+ assert!(first);
7124
+
7125
+ let second = db
7126
+ .create_index("source", PayloadIndexType::Keyword)
7127
+ .expect("second");
7128
+ assert!(!second);
7129
+
7130
+ assert_eq!(db.list_indexes().len(), 1);
7131
+
7132
+ drop(db);
7133
+ cleanup(&path);
7134
+ }
7135
+
7136
+ #[test]
7137
+ fn create_numeric_index() {
7138
+ let path = temp_file("pidx-numeric");
7139
+ let mut db = Database::create(&path, 3).expect("create");
7140
+
7141
+ let created = db
7142
+ .create_index("price", PayloadIndexType::Numeric)
7143
+ .expect("create");
7144
+ assert!(created);
7145
+
7146
+ let indexes = db.list_indexes();
7147
+ assert_eq!(indexes.len(), 1);
7148
+ assert!(matches!(indexes[0].1, PayloadIndexType::Numeric));
7149
+
7150
+ drop(db);
7151
+ cleanup(&path);
7152
+ }
7153
+
7154
+ #[test]
7155
+ fn drop_index_returns_true_and_removes() {
7156
+ let path = temp_file("pidx-drop");
7157
+ let mut db = Database::create(&path, 3).expect("create");
7158
+
7159
+ db.create_index("source", PayloadIndexType::Keyword)
7160
+ .expect("create");
7161
+ assert_eq!(db.list_indexes().len(), 1);
7162
+
7163
+ let dropped = db.drop_index("source").expect("drop");
7164
+ assert!(dropped);
7165
+ assert_eq!(db.list_indexes().len(), 0);
7166
+
7167
+ drop(db);
7168
+ cleanup(&path);
7169
+ }
7170
+
7171
+ #[test]
7172
+ fn drop_index_returns_false_for_nonexistent() {
7173
+ let path = temp_file("pidx-drop-missing");
7174
+ let mut db = Database::create(&path, 3).expect("create");
7175
+
7176
+ let dropped = db.drop_index("nope").expect("drop");
7177
+ assert!(!dropped);
7178
+
7179
+ drop(db);
7180
+ cleanup(&path);
7181
+ }
7182
+
7183
+ #[test]
7184
+ fn list_indexes_empty_by_default() {
7185
+ let path = temp_file("pidx-list-empty");
7186
+ let db = Database::create(&path, 3).expect("create");
7187
+ assert!(db.list_indexes().is_empty());
7188
+
7189
+ drop(db);
7190
+ cleanup(&path);
7191
+ }
7192
+
7193
+ #[test]
7194
+ fn keyword_index_accelerates_eq_count() {
7195
+ let path = temp_file("pidx-kw-eq");
7196
+ let mut db = Database::create(&path, 3).expect("create");
7197
+
7198
+ // Insert records with different sources
7199
+ for i in 0..50 {
7200
+ let mut meta = Metadata::new();
7201
+ meta.insert("source".into(), format!("cat{}", i % 5).into());
7202
+ meta.insert("idx".into(), MetadataValue::Integer(i));
7203
+ db.upsert(format!("doc{}", i), vec![1.0, 0.0, 0.0], meta)
7204
+ .expect("upsert");
7205
+ }
7206
+
7207
+ // Create keyword index on "source"
7208
+ db.create_index("source", PayloadIndexType::Keyword)
7209
+ .expect("create");
7210
+
7211
+ // count_filtered with $eq should use the index
7212
+ let count = db.count_filtered(None, Some(&MetadataFilter::eq("source", "cat0")));
7213
+ assert_eq!(count, 10); // 0, 5, 10, 15, 20, 25, 30, 35, 40, 45
7214
+
7215
+ let count2 = db.count_filtered(None, Some(&MetadataFilter::eq("source", "cat3")));
7216
+ assert_eq!(count2, 10);
7217
+
7218
+ // Non-matching value
7219
+ let count3 = db.count_filtered(None, Some(&MetadataFilter::eq("source", "cat99")));
7220
+ assert_eq!(count3, 0);
7221
+
7222
+ drop(db);
7223
+ cleanup(&path);
7224
+ }
7225
+
7226
+ #[test]
7227
+ fn keyword_index_accelerates_in_filter() {
7228
+ let path = temp_file("pidx-kw-in");
7229
+ let mut db = Database::create(&path, 3).expect("create");
7230
+
7231
+ for i in 0..20 {
7232
+ let mut meta = Metadata::new();
7233
+ meta.insert("tag".into(), format!("t{}", i % 4).into());
7234
+ db.upsert(format!("doc{}", i), vec![1.0, 0.0, 0.0], meta)
7235
+ .expect("upsert");
7236
+ }
7237
+
7238
+ db.create_index("tag", PayloadIndexType::Keyword)
7239
+ .expect("create");
7240
+
7241
+ let filter = MetadataFilter::r#in(
7242
+ "tag",
7243
+ vec![
7244
+ MetadataValue::String("t0".into()),
7245
+ MetadataValue::String("t2".into()),
7246
+ ],
7247
+ );
7248
+ let count = db.count_filtered(None, Some(&filter));
7249
+ assert_eq!(count, 10); // t0: 5, t2: 5
7250
+
7251
+ drop(db);
7252
+ cleanup(&path);
7253
+ }
7254
+
7255
+ #[test]
7256
+ fn numeric_index_accelerates_range_queries() {
7257
+ let path = temp_file("pidx-num-range");
7258
+ let mut db = Database::create(&path, 3).expect("create");
7259
+
7260
+ for i in 0..100 {
7261
+ let mut meta = Metadata::new();
7262
+ meta.insert("score".into(), MetadataValue::Float(i as f64));
7263
+ db.upsert(format!("doc{}", i), vec![1.0, 0.0, 0.0], meta)
7264
+ .expect("upsert");
7265
+ }
7266
+
7267
+ db.create_index("score", PayloadIndexType::Numeric)
7268
+ .expect("create");
7269
+
7270
+ // $gt 90 → 91..99 = 9 records
7271
+ let count_gt = db.count_filtered(None, Some(&MetadataFilter::gt("score", 90.0)));
7272
+ assert_eq!(count_gt, 9);
7273
+
7274
+ // $gte 90 → 90..99 = 10 records
7275
+ let count_gte = db.count_filtered(None, Some(&MetadataFilter::gte("score", 90.0)));
7276
+ assert_eq!(count_gte, 10);
7277
+
7278
+ // $lt 10 → 0..9 = 10 records
7279
+ let count_lt = db.count_filtered(None, Some(&MetadataFilter::lt("score", 10.0)));
7280
+ assert_eq!(count_lt, 10);
7281
+
7282
+ // $lte 10 → 0..10 = 11 records
7283
+ let count_lte = db.count_filtered(None, Some(&MetadataFilter::lte("score", 10.0)));
7284
+ assert_eq!(count_lte, 11);
7285
+
7286
+ drop(db);
7287
+ cleanup(&path);
7288
+ }
7289
+
7290
+ #[test]
7291
+ fn numeric_index_eq_lookup() {
7292
+ let path = temp_file("pidx-num-eq");
7293
+ let mut db = Database::create(&path, 3).expect("create");
7294
+
7295
+ for i in 0..20 {
7296
+ let mut meta = Metadata::new();
7297
+ meta.insert("priority".into(), MetadataValue::Float((i % 3) as f64));
7298
+ db.upsert(format!("doc{}", i), vec![1.0, 0.0, 0.0], meta)
7299
+ .expect("upsert");
7300
+ }
7301
+
7302
+ db.create_index("priority", PayloadIndexType::Numeric)
7303
+ .expect("create");
7304
+
7305
+ // $eq on numeric field via the index
7306
+ let filter = MetadataFilter::eq("priority", MetadataValue::Float(0.0));
7307
+ let count = db.count_filtered(None, Some(&filter));
7308
+ // 0 % 3 == 0: i=0,3,6,9,12,15,18 → 7 records
7309
+ assert_eq!(count, 7);
7310
+
7311
+ drop(db);
7312
+ cleanup(&path);
7313
+ }
7314
+
7315
+ #[test]
7316
+ fn payload_index_persists_across_reopen() {
7317
+ let path = temp_file("pidx-persist");
7318
+ {
7319
+ let mut db = Database::create(&path, 3).expect("create");
7320
+
7321
+ let mut meta = Metadata::new();
7322
+ meta.insert("source".into(), "blog".into());
7323
+ db.upsert("doc1", vec![1.0, 0.0, 0.0], meta).expect("upsert");
7324
+
7325
+ let mut meta2 = Metadata::new();
7326
+ meta2.insert("source".into(), "docs".into());
7327
+ db.upsert("doc2", vec![0.0, 1.0, 0.0], meta2)
7328
+ .expect("upsert");
7329
+
7330
+ db.create_index("source", PayloadIndexType::Keyword)
7331
+ .expect("create");
7332
+ }
7333
+
7334
+ // Reopen and verify index survives
7335
+ let db = Database::open(&path).expect("reopen");
7336
+ let indexes = db.list_indexes();
7337
+ assert_eq!(indexes.len(), 1);
7338
+ assert_eq!(indexes[0].0, "source");
7339
+
7340
+ // Index should be functional after reopen
7341
+ let count = db.count_filtered(None, Some(&MetadataFilter::eq("source", "blog")));
7342
+ assert_eq!(count, 1);
7343
+
7344
+ cleanup(&path);
7345
+ }
7346
+
7347
+ #[test]
7348
+ fn payload_index_incremental_upsert_adds_to_index() {
7349
+ let path = temp_file("pidx-incr-upsert");
7350
+ let mut db = Database::create(&path, 3).expect("create");
7351
+
7352
+ // Create index first (empty)
7353
+ db.create_index("source", PayloadIndexType::Keyword)
7354
+ .expect("create");
7355
+
7356
+ // Now upsert records — they should be indexed incrementally
7357
+ let mut meta = Metadata::new();
7358
+ meta.insert("source".into(), "blog".into());
7359
+ db.upsert("doc1", vec![1.0, 0.0, 0.0], meta).expect("upsert");
7360
+
7361
+ let count = db.count_filtered(None, Some(&MetadataFilter::eq("source", "blog")));
7362
+ assert_eq!(count, 1);
7363
+
7364
+ // Upsert another
7365
+ let mut meta2 = Metadata::new();
7366
+ meta2.insert("source".into(), "blog".into());
7367
+ db.upsert("doc2", vec![0.0, 1.0, 0.0], meta2)
7368
+ .expect("upsert");
7369
+
7370
+ let count2 = db.count_filtered(None, Some(&MetadataFilter::eq("source", "blog")));
7371
+ assert_eq!(count2, 2);
7372
+
7373
+ drop(db);
7374
+ cleanup(&path);
7375
+ }
7376
+
7377
+ #[test]
7378
+ fn payload_index_incremental_delete_removes_from_index() {
7379
+ let path = temp_file("pidx-incr-delete");
7380
+ let mut db = Database::create(&path, 3).expect("create");
7381
+
7382
+ let mut meta = Metadata::new();
7383
+ meta.insert("source".into(), "blog".into());
7384
+ db.upsert("doc1", vec![1.0, 0.0, 0.0], meta).expect("upsert");
7385
+
7386
+ let mut meta2 = Metadata::new();
7387
+ meta2.insert("source".into(), "blog".into());
7388
+ db.upsert("doc2", vec![0.0, 1.0, 0.0], meta2)
7389
+ .expect("upsert");
7390
+
7391
+ db.create_index("source", PayloadIndexType::Keyword)
7392
+ .expect("create");
7393
+
7394
+ assert_eq!(
7395
+ db.count_filtered(None, Some(&MetadataFilter::eq("source", "blog"))),
7396
+ 2
7397
+ );
7398
+
7399
+ // Delete one record
7400
+ db.delete("doc1").expect("delete");
7401
+
7402
+ assert_eq!(
7403
+ db.count_filtered(None, Some(&MetadataFilter::eq("source", "blog"))),
7404
+ 1
7405
+ );
7406
+
7407
+ drop(db);
7408
+ cleanup(&path);
7409
+ }
7410
+
7411
+ #[test]
7412
+ fn payload_index_incremental_upsert_replaces_old_value() {
7413
+ let path = temp_file("pidx-incr-replace");
7414
+ let mut db = Database::create(&path, 3).expect("create");
7415
+
7416
+ let mut meta = Metadata::new();
7417
+ meta.insert("source".into(), "blog".into());
7418
+ db.upsert_in_namespace("", "doc1", vec![1.0, 0.0, 0.0], meta)
7419
+ .expect("upsert");
7420
+
7421
+ db.create_index("source", PayloadIndexType::Keyword)
7422
+ .expect("create");
7423
+
7424
+ assert_eq!(
7425
+ db.count_filtered(None, Some(&MetadataFilter::eq("source", "blog"))),
7426
+ 1
7427
+ );
7428
+
7429
+ // Upsert same id with different metadata value (uses upsert_in_namespace for true upsert)
7430
+ let mut meta2 = Metadata::new();
7431
+ meta2.insert("source".into(), "docs".into());
7432
+ db.upsert_in_namespace("", "doc1", vec![1.0, 0.0, 0.0], meta2)
7433
+ .expect("upsert replace");
7434
+
7435
+ // Old value gone
7436
+ assert_eq!(
7437
+ db.count_filtered(None, Some(&MetadataFilter::eq("source", "blog"))),
7438
+ 0
7439
+ );
7440
+ // New value present
7441
+ assert_eq!(
7442
+ db.count_filtered(None, Some(&MetadataFilter::eq("source", "docs"))),
7443
+ 1
7444
+ );
7445
+
7446
+ drop(db);
7447
+ cleanup(&path);
7448
+ }
7449
+
7450
+ #[test]
7451
+ fn payload_index_update_metadata_maintains_index() {
7452
+ let path = temp_file("pidx-update-meta");
7453
+ let mut db = Database::create(&path, 3).expect("create");
7454
+
7455
+ let mut meta = Metadata::new();
7456
+ meta.insert("status".into(), "draft".into());
7457
+ db.upsert("doc1", vec![1.0, 0.0, 0.0], meta).expect("upsert");
7458
+
7459
+ db.create_index("status", PayloadIndexType::Keyword)
7460
+ .expect("create");
7461
+
7462
+ assert_eq!(
7463
+ db.count_filtered(None, Some(&MetadataFilter::eq("status", "draft"))),
7464
+ 1
7465
+ );
7466
+
7467
+ // update_metadata changes the indexed field
7468
+ let mut patch = Metadata::new();
7469
+ patch.insert("status".into(), "published".into());
7470
+ db.update_metadata("doc1", patch).expect("update");
7471
+
7472
+ assert_eq!(
7473
+ db.count_filtered(None, Some(&MetadataFilter::eq("status", "draft"))),
7474
+ 0
7475
+ );
7476
+ assert_eq!(
7477
+ db.count_filtered(None, Some(&MetadataFilter::eq("status", "published"))),
7478
+ 1
7479
+ );
7480
+
7481
+ drop(db);
7482
+ cleanup(&path);
7483
+ }
7484
+
7485
+ #[test]
7486
+ fn payload_index_and_filter_intersection() {
7487
+ let path = temp_file("pidx-and");
7488
+ let mut db = Database::create(&path, 3).expect("create");
7489
+
7490
+ // Insert records with source and priority
7491
+ for i in 0..30 {
7492
+ let mut meta = Metadata::new();
7493
+ meta.insert("source".into(), if i % 2 == 0 { "blog" } else { "docs" }.into());
7494
+ meta.insert("priority".into(), MetadataValue::Float((i % 3) as f64));
7495
+ db.upsert(format!("doc{}", i), vec![1.0, 0.0, 0.0], meta)
7496
+ .expect("upsert");
7497
+ }
7498
+
7499
+ db.create_index("source", PayloadIndexType::Keyword)
7500
+ .expect("create source");
7501
+ db.create_index("priority", PayloadIndexType::Numeric)
7502
+ .expect("create priority");
7503
+
7504
+ // AND(source == "blog", priority > 1) → source=blog(even i) AND priority=2(i%3==2)
7505
+ // Even i where i%3==2: 2,8,14,20,26 → 5
7506
+ let filter = MetadataFilter::and(vec![
7507
+ MetadataFilter::eq("source", "blog"),
7508
+ MetadataFilter::gt("priority", 1.0),
7509
+ ]);
7510
+ let count = db.count_filtered(None, Some(&filter));
7511
+ assert_eq!(count, 5);
7512
+
7513
+ drop(db);
7514
+ cleanup(&path);
7515
+ }
7516
+
7517
+ #[test]
7518
+ fn payload_index_with_namespace_filtering() {
7519
+ let path = temp_file("pidx-ns");
7520
+ let mut db = Database::create(&path, 3).expect("create");
7521
+
7522
+ let mut meta1 = Metadata::new();
7523
+ meta1.insert("tag".into(), "rust".into());
7524
+ db.upsert_in_namespace("ns1", "doc1", vec![1.0, 0.0, 0.0], meta1)
7525
+ .expect("upsert");
7526
+
7527
+ let mut meta2 = Metadata::new();
7528
+ meta2.insert("tag".into(), "rust".into());
7529
+ db.upsert_in_namespace("ns2", "doc2", vec![0.0, 1.0, 0.0], meta2)
7530
+ .expect("upsert");
7531
+
7532
+ db.create_index("tag", PayloadIndexType::Keyword)
7533
+ .expect("create");
7534
+
7535
+ // Without namespace → both
7536
+ assert_eq!(
7537
+ db.count_filtered(None, Some(&MetadataFilter::eq("tag", "rust"))),
7538
+ 2
7539
+ );
7540
+
7541
+ // With namespace → scoped
7542
+ assert_eq!(
7543
+ db.count_filtered(Some("ns1"), Some(&MetadataFilter::eq("tag", "rust"))),
7544
+ 1
7545
+ );
7546
+ assert_eq!(
7547
+ db.count_filtered(Some("ns2"), Some(&MetadataFilter::eq("tag", "rust"))),
7548
+ 1
7549
+ );
7550
+
7551
+ drop(db);
7552
+ cleanup(&path);
7553
+ }
7554
+
7555
+ #[test]
7556
+ fn create_index_read_only_fails() {
7557
+ let path = temp_file("pidx-ro");
7558
+ {
7559
+ let mut db = Database::create(&path, 3).expect("create");
7560
+ db.upsert("doc1", vec![1.0, 0.0, 0.0], Metadata::new())
7561
+ .expect("upsert");
7562
+ }
7563
+
7564
+ let mut db = Database::open_read_only(&path).expect("open ro");
7565
+ let result = db.create_index("source", PayloadIndexType::Keyword);
7566
+ assert!(result.is_err());
7567
+
7568
+ cleanup(&path);
7569
+ }
7570
+
7571
+ #[test]
7572
+ fn drop_index_read_only_fails() {
7573
+ let path = temp_file("pidx-drop-ro");
7574
+ {
7575
+ let mut db = Database::create(&path, 3).expect("create");
7576
+ db.create_index("source", PayloadIndexType::Keyword)
7577
+ .expect("create");
7578
+ }
7579
+
7580
+ let mut db = Database::open_read_only(&path).expect("open ro");
7581
+ let result = db.drop_index("source");
7582
+ assert!(result.is_err());
7583
+
7584
+ cleanup(&path);
7585
+ }
7586
+
7587
+ #[test]
7588
+ fn multiple_indexes_independent() {
7589
+ let path = temp_file("pidx-multi");
7590
+ let mut db = Database::create(&path, 3).expect("create");
7591
+
7592
+ db.create_index("source", PayloadIndexType::Keyword)
7593
+ .expect("kw");
7594
+ db.create_index("score", PayloadIndexType::Numeric)
7595
+ .expect("num");
7596
+
7597
+ assert_eq!(db.list_indexes().len(), 2);
7598
+
7599
+ // Drop one, other remains
7600
+ db.drop_index("source").expect("drop source");
7601
+ let indexes = db.list_indexes();
7602
+ assert_eq!(indexes.len(), 1);
7603
+ assert_eq!(indexes[0].0, "score");
7604
+
7605
+ drop(db);
7606
+ cleanup(&path);
7607
+ }
7608
+
7609
+ #[test]
7610
+ fn payload_index_search_returns_correct_results() {
7611
+ let path = temp_file("pidx-search");
7612
+ let mut db = Database::create(&path, 3).expect("create");
7613
+
7614
+ // Insert records where only some match the filter
7615
+ let mut meta1 = Metadata::new();
7616
+ meta1.insert("category".into(), "tech".into());
7617
+ db.upsert("doc1", vec![1.0, 0.0, 0.0], meta1)
7618
+ .expect("upsert");
7619
+
7620
+ let mut meta2 = Metadata::new();
7621
+ meta2.insert("category".into(), "science".into());
7622
+ db.upsert("doc2", vec![0.9, 0.1, 0.0], meta2)
7623
+ .expect("upsert");
7624
+
7625
+ let mut meta3 = Metadata::new();
7626
+ meta3.insert("category".into(), "tech".into());
7627
+ db.upsert("doc3", vec![0.8, 0.2, 0.0], meta3)
7628
+ .expect("upsert");
7629
+
7630
+ // Create keyword index on category
7631
+ db.create_index("category", PayloadIndexType::Keyword)
7632
+ .expect("create");
7633
+
7634
+ // Search with filter should only return tech records
7635
+ let results = db
7636
+ .search(
7637
+ &[1.0, 0.0, 0.0],
7638
+ SearchOptions {
7639
+ top_k: 10,
7640
+ filter: Some(MetadataFilter::eq("category", "tech")),
7641
+ truncate_dim: None,
7642
+ },
7643
+ )
7644
+ .expect("search");
7645
+ assert_eq!(results.len(), 2);
7646
+ let ids: Vec<&str> = results.iter().map(|r| r.id.as_str()).collect();
7647
+ assert!(ids.contains(&"doc1"));
7648
+ assert!(ids.contains(&"doc3"));
7649
+ assert!(!ids.contains(&"doc2"));
7650
+
7651
+ drop(db);
7652
+ cleanup(&path);
7653
+ }
7654
+
7655
+ #[test]
7656
+ fn payload_index_list_uses_index() {
7657
+ let path = temp_file("pidx-list");
7658
+ let mut db = Database::create(&path, 3).expect("create");
7659
+
7660
+ for i in 0..20 {
7661
+ let mut meta = Metadata::new();
7662
+ meta.insert("type".into(), if i % 2 == 0 { "even" } else { "odd" }.into());
7663
+ db.upsert(format!("doc{}", i), vec![1.0, 0.0, 0.0], meta)
7664
+ .expect("upsert");
7665
+ }
7666
+
7667
+ db.create_index("type", PayloadIndexType::Keyword)
7668
+ .expect("create");
7669
+
7670
+ let records = db.list(None, Some(&MetadataFilter::eq("type", "even")), 0, 0);
7671
+ assert_eq!(records.len(), 10);
7672
+ for r in &records {
7673
+ assert_eq!(
7674
+ r.metadata.get("type"),
7675
+ Some(&MetadataValue::String("even".into()))
7676
+ );
7677
+ }
7678
+
7679
+ drop(db);
7680
+ cleanup(&path);
7681
+ }
7682
+
7683
+ #[test]
7684
+ fn payload_index_rebuild_on_create_with_existing_data() {
7685
+ let path = temp_file("pidx-rebuild");
7686
+ let mut db = Database::create(&path, 3).expect("create");
7687
+
7688
+ // Insert data BEFORE creating the index
7689
+ for i in 0..10 {
7690
+ let mut meta = Metadata::new();
7691
+ meta.insert("color".into(), if i < 4 { "red" } else { "blue" }.into());
7692
+ db.upsert(format!("doc{}", i), vec![1.0, 0.0, 0.0], meta)
7693
+ .expect("upsert");
7694
+ }
7695
+
7696
+ // Create index AFTER data exists — should rebuild from existing records
7697
+ db.create_index("color", PayloadIndexType::Keyword)
7698
+ .expect("create");
7699
+
7700
+ let red_count = db.count_filtered(None, Some(&MetadataFilter::eq("color", "red")));
7701
+ assert_eq!(red_count, 4);
7702
+
7703
+ let blue_count = db.count_filtered(None, Some(&MetadataFilter::eq("color", "blue")));
7704
+ assert_eq!(blue_count, 6);
7705
+
7706
+ drop(db);
7707
+ cleanup(&path);
7708
+ }
7709
+
7710
+ #[test]
7711
+ fn numeric_index_combined_range() {
7712
+ let path = temp_file("pidx-num-combined");
7713
+ let mut db = Database::create(&path, 3).expect("create");
7714
+
7715
+ for i in 0..50 {
7716
+ let mut meta = Metadata::new();
7717
+ meta.insert("val".into(), MetadataValue::Float(i as f64));
7718
+ db.upsert(format!("doc{}", i), vec![1.0, 0.0, 0.0], meta)
7719
+ .expect("upsert");
7720
+ }
7721
+
7722
+ db.create_index("val", PayloadIndexType::Numeric)
7723
+ .expect("create");
7724
+
7725
+ // AND(val >= 10, val < 20) → 10..19 = 10 records
7726
+ let filter = MetadataFilter::and(vec![
7727
+ MetadataFilter::gte("val", 10.0),
7728
+ MetadataFilter::lt("val", 20.0),
7729
+ ]);
7730
+ let count = db.count_filtered(None, Some(&filter));
7731
+ assert_eq!(count, 10);
7732
+
7733
+ drop(db);
7734
+ cleanup(&path);
7735
+ }
7736
+
7737
+ #[test]
7738
+ fn payload_index_sidecar_cleaned_on_drop_last_index() {
7739
+ let path = temp_file("pidx-sidecar-clean");
7740
+ let mut db = Database::create(&path, 3).expect("create");
7741
+
7742
+ db.create_index("source", PayloadIndexType::Keyword)
7743
+ .expect("create");
7744
+
7745
+ // Sidecar should exist
7746
+ let sidecar = path.with_extension("vdb.pidx");
7747
+ assert!(sidecar.exists());
7748
+
7749
+ db.drop_index("source").expect("drop");
7750
+
7751
+ // After dropping the last index, sidecar might still exist (with empty content)
7752
+ // but on reopen, list_indexes should be empty
7753
+ drop(db);
7754
+
7755
+ let db2 = Database::open(&path).expect("reopen");
7756
+ assert!(db2.list_indexes().is_empty());
7757
+
7758
+ cleanup(&path);
7759
+ }
7760
+
7761
+ // -------------------------------------------------------------------
7762
+ // TTL / Expiry tests
7763
+ // -------------------------------------------------------------------
7764
+
7765
+ #[test]
7766
+ fn set_ttl_hides_record_from_get() {
7767
+ let path = temp_file("ttl-get");
7768
+ let mut db = Database::create(&path, 3).expect("create");
7769
+ db.upsert("doc1", vec![1.0, 0.0, 0.0], Metadata::new())
7770
+ .expect("upsert");
7771
+
7772
+ // Set TTL to 0 seconds — effectively already expired.
7773
+ assert!(db.set_ttl("doc1", 0.0).expect("set_ttl"));
7774
+ // Tiny sleep to ensure timestamp is strictly past
7775
+ std::thread::sleep(std::time::Duration::from_millis(10));
7776
+ assert!(db.get("doc1").is_none());
7777
+ cleanup(&path);
7778
+ }
7779
+
7780
+ #[test]
7781
+ fn clear_ttl_makes_record_visible_again() {
7782
+ let path = temp_file("ttl-clear");
7783
+ let mut db = Database::create(&path, 3).expect("create");
7784
+ db.upsert("doc1", vec![1.0, 0.0, 0.0], Metadata::new())
7785
+ .expect("upsert");
7786
+
7787
+ db.set_ttl("doc1", 0.0).expect("set_ttl");
7788
+ std::thread::sleep(std::time::Duration::from_millis(10));
7789
+ assert!(db.get("doc1").is_none());
7790
+
7791
+ db.clear_ttl("doc1").expect("clear_ttl");
7792
+ assert!(db.get("doc1").is_some());
7793
+ cleanup(&path);
7794
+ }
7795
+
7796
+ #[test]
7797
+ fn expired_records_excluded_from_count_and_list() {
7798
+ let path = temp_file("ttl-count-list");
7799
+ let mut db = Database::create(&path, 3).expect("create");
7800
+ db.upsert("a", vec![1.0, 0.0, 0.0], Metadata::new())
7801
+ .expect("upsert a");
7802
+ db.upsert("b", vec![0.0, 1.0, 0.0], Metadata::new())
7803
+ .expect("upsert b");
7804
+
7805
+ assert_eq!(db.count_filtered(None, None), 2);
7806
+ assert_eq!(db.list(None, None, 0, 0).len(), 2);
7807
+
7808
+ db.set_ttl("a", 0.0).expect("set_ttl");
7809
+ std::thread::sleep(std::time::Duration::from_millis(10));
7810
+
7811
+ assert_eq!(db.count_filtered(None, None), 1);
7812
+ assert_eq!(db.list(None, None, 0, 0).len(), 1);
7813
+ assert_eq!(db.list(None, None, 0, 0)[0].id, "b");
7814
+ cleanup(&path);
7815
+ }
7816
+
7817
+ #[test]
7818
+ fn expired_records_excluded_from_search() {
7819
+ let path = temp_file("ttl-search");
7820
+ let mut db = Database::create(&path, 3).expect("create");
7821
+ db.upsert("a", vec![1.0, 0.0, 0.0], Metadata::new())
7822
+ .expect("upsert a");
7823
+ db.upsert("b", vec![0.0, 1.0, 0.0], Metadata::new())
7824
+ .expect("upsert b");
7825
+
7826
+ db.set_ttl("a", 0.0).expect("set_ttl");
7827
+ std::thread::sleep(std::time::Duration::from_millis(10));
7828
+
7829
+ let results = db
7830
+ .search(&[1.0, 0.0, 0.0], SearchOptions::default())
7831
+ .expect("search");
7832
+ assert_eq!(results.len(), 1);
7833
+ assert_eq!(results[0].id, "b");
7834
+ cleanup(&path);
7835
+ }
7836
+
7837
+ #[test]
7838
+ fn ttl_persists_across_reopen() {
7839
+ let path = temp_file("ttl-persist");
7840
+ {
7841
+ let mut db = Database::create(&path, 3).expect("create");
7842
+ db.upsert("doc1", vec![1.0, 0.0, 0.0], Metadata::new())
7843
+ .expect("upsert");
7844
+ db.set_ttl("doc1", 0.0).expect("set_ttl");
7845
+ }
7846
+
7847
+ std::thread::sleep(std::time::Duration::from_millis(10));
7848
+ let db = Database::open(&path).expect("reopen");
7849
+ assert!(db.get("doc1").is_none());
7850
+ assert_eq!(db.count_filtered(None, None), 0);
7851
+ cleanup(&path);
7852
+ }
7853
+
7854
+ #[test]
7855
+ fn compact_removes_expired_records() {
7856
+ let path = temp_file("ttl-compact");
7857
+ let mut db = Database::create(&path, 3).expect("create");
7858
+ db.upsert("a", vec![1.0, 0.0, 0.0], Metadata::new())
7859
+ .expect("upsert a");
7860
+ db.upsert("b", vec![0.0, 1.0, 0.0], Metadata::new())
7861
+ .expect("upsert b");
7862
+
7863
+ db.set_ttl("a", 0.0).expect("set_ttl");
7864
+ std::thread::sleep(std::time::Duration::from_millis(10));
7865
+
7866
+ db.compact().expect("compact");
7867
+ // After compact, the expired record should be physically removed.
7868
+ assert_eq!(db.len(), 1);
7869
+ cleanup(&path);
7870
+ }
7871
+
7872
+ #[test]
7873
+ fn set_ttl_on_nonexistent_record_returns_false() {
7874
+ let path = temp_file("ttl-missing");
7875
+ let mut db = Database::create(&path, 3).expect("create");
7876
+ assert!(!db.set_ttl("ghost", 60.0).expect("set_ttl"));
7877
+ assert!(!db.clear_ttl("ghost").expect("clear_ttl"));
7878
+ cleanup(&path);
7879
+ }
7880
+
7881
+ #[test]
7882
+ fn upsert_with_expires_at() {
7883
+ let path = temp_file("ttl-upsert-ea");
7884
+ let mut db = Database::create(&path, 3).expect("create");
7885
+ let now = SystemTime::now()
7886
+ .duration_since(UNIX_EPOCH)
7887
+ .unwrap()
7888
+ .as_secs_f64();
7889
+
7890
+ // Already expired
7891
+ let record = Record {
7892
+ namespace: String::new(),
7893
+ id: "doc1".into(),
7894
+ vector: vec![1.0, 0.0, 0.0],
7895
+ vectors: NamedVectors::new(),
7896
+ sparse: SparseVector::new(),
7897
+ metadata: Metadata::new(),
7898
+ multi_vectors: MultiVectors::new(),
7899
+ expires_at: Some(now - 1.0),
7900
+ };
7901
+ db.upsert_many(std::iter::once(record)).expect("upsert");
7902
+ assert!(db.get("doc1").is_none());
7903
+
7904
+ // Far future — visible
7905
+ let record2 = Record {
7906
+ namespace: String::new(),
7907
+ id: "doc2".into(),
7908
+ vector: vec![0.0, 1.0, 0.0],
7909
+ vectors: NamedVectors::new(),
7910
+ sparse: SparseVector::new(),
7911
+ metadata: Metadata::new(),
7912
+ multi_vectors: MultiVectors::new(),
7913
+ expires_at: Some(now + 86400.0),
7914
+ };
7915
+ db.upsert_many(std::iter::once(record2)).expect("upsert");
7916
+ assert!(db.get("doc2").is_some());
7917
+ cleanup(&path);
7918
+ }
7919
+
7920
+ #[test]
7921
+ fn set_ttl_in_namespace() {
7922
+ let path = temp_file("ttl-ns");
7923
+ let mut db = Database::create(&path, 3).expect("create");
7924
+ db.upsert_in_namespace("ns1", "doc1", vec![1.0, 0.0, 0.0], Metadata::new())
7925
+ .expect("upsert");
7926
+
7927
+ assert!(db.set_ttl_in_namespace("ns1", "doc1", 0.0).expect("set"));
7928
+ std::thread::sleep(std::time::Duration::from_millis(10));
7929
+ assert!(db.get_in_namespace("ns1", "doc1").is_none());
7930
+
7931
+ // Wrong namespace returns false
7932
+ assert!(!db.set_ttl_in_namespace("ns2", "doc1", 60.0).expect("set wrong ns"));
7933
+
7934
+ cleanup(&path);
7935
+ }
7936
+
7937
+ // -------------------------------------------------------------------
7938
+ // Cursor-based pagination tests
7939
+ // -------------------------------------------------------------------
7940
+
7941
+ #[test]
7942
+ fn list_cursor_basic() {
7943
+ let path = temp_file("cursor-basic");
7944
+ let mut db = Database::create(&path, 3).expect("create");
7945
+ for i in 0..5 {
7946
+ db.upsert(
7947
+ &format!("doc{i}"),
7948
+ vec![1.0, 0.0, 0.0],
7949
+ Metadata::new(),
7950
+ )
7951
+ .expect("upsert");
7952
+ }
7953
+
7954
+ // First page of 2
7955
+ let (page1, cursor1) = db.list_cursor(None, None, 2, None);
7956
+ assert_eq!(page1.len(), 2);
7957
+ assert!(cursor1.is_some());
7958
+
7959
+ // Second page of 2
7960
+ let (page2, cursor2) = db.list_cursor(None, None, 2, cursor1.as_deref());
7961
+ assert_eq!(page2.len(), 2);
7962
+ assert!(cursor2.is_some());
7963
+
7964
+ // Third page (only 1 remaining)
7965
+ let (page3, cursor3) = db.list_cursor(None, None, 2, cursor2.as_deref());
7966
+ assert_eq!(page3.len(), 1);
7967
+ assert!(cursor3.is_none());
7968
+
7969
+ // No duplicates across pages
7970
+ let mut all_ids: Vec<String> = Vec::new();
7971
+ for r in page1.iter().chain(page2.iter()).chain(page3.iter()) {
7972
+ all_ids.push(r.id.clone());
7973
+ }
7974
+ all_ids.sort();
7975
+ all_ids.dedup();
7976
+ assert_eq!(all_ids.len(), 5);
7977
+
7978
+ cleanup(&path);
7979
+ }
7980
+
7981
+ #[test]
7982
+ fn list_cursor_with_namespace() {
7983
+ let path = temp_file("cursor-ns");
7984
+ let mut db = Database::create(&path, 3).expect("create");
7985
+ for i in 0..3 {
7986
+ db.upsert_in_namespace("ns1", &format!("doc{i}"), vec![1.0, 0.0, 0.0], Metadata::new())
7987
+ .expect("upsert");
7988
+ }
7989
+ for i in 0..2 {
7990
+ db.upsert_in_namespace("ns2", &format!("doc{i}"), vec![0.0, 1.0, 0.0], Metadata::new())
7991
+ .expect("upsert");
7992
+ }
7993
+
7994
+ let (page1, cursor1) = db.list_cursor(Some("ns1"), None, 2, None);
7995
+ assert_eq!(page1.len(), 2);
7996
+ assert!(cursor1.is_some());
7997
+
7998
+ let (page2, cursor2) = db.list_cursor(Some("ns1"), None, 2, cursor1.as_deref());
7999
+ assert_eq!(page2.len(), 1);
8000
+ assert!(cursor2.is_none());
8001
+
8002
+ cleanup(&path);
8003
+ }
8004
+
8005
+ #[test]
8006
+ fn list_cursor_excludes_expired() {
8007
+ let path = temp_file("cursor-ttl");
8008
+ let mut db = Database::create(&path, 3).expect("create");
8009
+ for i in 0..5 {
8010
+ db.upsert(
8011
+ &format!("doc{i}"),
8012
+ vec![1.0, 0.0, 0.0],
8013
+ Metadata::new(),
8014
+ )
8015
+ .expect("upsert");
8016
+ }
8017
+
8018
+ // Expire doc1
8019
+ db.set_ttl("doc1", 0.0).expect("set ttl");
8020
+ std::thread::sleep(std::time::Duration::from_millis(10));
8021
+
8022
+ // Paginate all — should get 4, not 5
8023
+ let mut all = Vec::new();
8024
+ let mut cursor = None;
8025
+ loop {
8026
+ let (page, next) = db.list_cursor(None, None, 10, cursor.as_deref());
8027
+ all.extend(page.into_iter().cloned());
8028
+ if next.is_none() {
8029
+ break;
8030
+ }
8031
+ cursor = next;
8032
+ }
8033
+ assert_eq!(all.len(), 4);
8034
+ assert!(!all.iter().any(|r| r.id == "doc1"));
8035
+
8036
+ cleanup(&path);
8037
+ }
8038
+
8039
+ #[test]
8040
+ fn list_cursor_empty_database() {
8041
+ let path = temp_file("cursor-empty");
8042
+ let db = Database::create(&path, 3).expect("create");
8043
+ let (page, cursor) = db.list_cursor(None, None, 10, None);
8044
+ assert!(page.is_empty());
8045
+ assert!(cursor.is_none());
8046
+ cleanup(&path);
8047
+ }
4360
8048
  }