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.
- package/README.md +193 -1
- package/index.d.ts +55 -0
- package/index.js +171 -12
- package/native/Cargo.toml +1 -1
- package/native/src/lib.rs +612 -27
- package/native/vectlite-core/Cargo.toml +2 -1
- package/native/vectlite-core/src/lib.rs +3871 -183
- package/native/vectlite-core/src/quantization.rs +500 -0
- package/package.json +1 -1
- package/prebuilds/darwin-arm64/vectlite.node +0 -0
- package/prebuilds/darwin-x64/vectlite.node +0 -0
- package/prebuilds/linux-x64-gnu/vectlite.node +0 -0
- package/prebuilds/win32-x64-msvc/vectlite.node +0 -0
|
@@ -1,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::{
|
|
15
|
+
use quantization::{
|
|
16
|
+
MultiVectorQuantizationConfig, MultiVectorQuantizedIndex, QuantizationConfig, QuantizedIndex,
|
|
17
|
+
};
|
|
15
18
|
|
|
16
19
|
const MAGIC: &[u8; 4] = b"VDB1";
|
|
17
|
-
const VERSION: u16 =
|
|
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:
|
|
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::
|
|
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
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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
|
-
|
|
978
|
-
|
|
979
|
-
|
|
1529
|
+
if let Some(target_ns) = namespace {
|
|
1530
|
+
if ns != target_ns {
|
|
1531
|
+
return false;
|
|
1532
|
+
}
|
|
980
1533
|
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
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
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
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
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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
|
-
|
|
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.
|
|
1059
|
-
|
|
1060
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1453
|
-
self.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
fs::create_dir_all(parent)?;
|
|
1788
|
-
}
|
|
1789
|
-
}
|
|
2647
|
+
// -----------------------------------------------------------------------
|
|
2648
|
+
// Multi-vector (ColBERT / late interaction) API
|
|
2649
|
+
// -----------------------------------------------------------------------
|
|
1790
2650
|
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
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
|
|
1801
|
-
|
|
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
|
-
///
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
self.
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
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(¶ms_path)?;
|
|
1827
2805
|
}
|
|
1828
|
-
file.sync_all()?;
|
|
1829
2806
|
Ok(())
|
|
1830
2807
|
}
|
|
1831
2808
|
|
|
1832
|
-
///
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
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
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
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
|
-
//
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
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
|
-
|
|
1878
|
-
let
|
|
1879
|
-
|
|
1880
|
-
let
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
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(¶ms_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(¶ms_path)?;
|
|
1885
2875
|
}
|
|
1886
2876
|
}
|
|
1887
|
-
|
|
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
|
-
|
|
1938
|
-
|
|
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
|
-
|
|
1965
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
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 +=
|
|
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|
|
|
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
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
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
|
|
2765
|
-
|
|
2766
|
-
|
|
2767
|
-
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
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
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
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)) =>
|
|
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,
|
|
3553
|
-
|
|
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
|
}
|