1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
use std::collections::HashMap;
use std::io::{Read, Seek, SeekFrom};
use std::sync::{Arc, RwLock};
use crate::error::{LaurusError, Result};
use crate::storage::{Storage, StorageInput};
use crate::vector::core::quantization::{QuantizedVectorMeta, ScalarQuantParams};
use crate::vector::core::vector::Vector;
#[cfg(feature = "pq-fastscan")]
use crate::vector::index::pq_fastscan_storage::PqFastScanPool;
use crate::vector::index::pq_storage::PqVectorPool;
use crate::vector::index::quantized_storage::QuantizedVectorPool;
/// Storage for vectors (in-memory or on-demand from disk).
///
/// # Thread Safety
///
/// - The `Owned` variant holds an immutable `Arc<HashMap>` that is freely
/// shareable across threads.
/// - The `OnDemand` variant stores a reference to the underlying
/// [`Storage`] and the file name so that each call to [`get`](Self::get)
/// opens an independent file handle. This eliminates the previous
/// `Mutex`-based serialization and allows fully concurrent reads.
#[derive(Debug, Clone)]
pub enum VectorStorage {
/// All vectors are loaded into memory as f32 (legacy path used by
/// Flat / IVF until Step 7 of Issue #481 Stage 1).
Owned(Arc<HashMap<(u64, String), Vector>>),
/// All vectors are loaded into memory as int8 + per-vector meta
/// (Issue #481 Stage 1, Step 6). Used by HNSW Eager mode; the
/// search hot loop accesses the inner [`QuantizedVectorPool`]
/// directly via [`Self::quantized_pool`] instead of going through
/// [`Self::get`], which dequantizes lazily for the legacy
/// [`crate::vector::reader::VectorIndexReader::get_vector`] API.
OwnedQuantized(Arc<QuantizedVectorPool>),
/// All vectors are loaded into memory as PQ codes plus the
/// per-segment codebook (Issue #481 Stage 3, HNSW only). The
/// search hot loop accesses the inner [`PqVectorPool`] directly
/// via [`Self::pq_pool`] and feeds codes + the per-query LUT to
/// [`crate::vector::core::distance_quantized::distance_pq_adc`].
OwnedPq(Arc<PqVectorPool>),
/// All vectors are loaded into memory as 4-bit packed FastScan
/// codes plus the per-segment K=16 codebook (Issue #695 / part D
/// of #651, HNSW only, experimental). The search hot loop walks
/// the inner [`PqFastScanPool`] directly through
/// [`crate::vector::index::pq_fastscan_avx2::distance_pq_fastscan_block`]
/// which dispatches to AVX2 / NEON / scalar by CPU. Available only
/// when the crate is built with the `pq-fastscan` cargo feature.
#[cfg(feature = "pq-fastscan")]
OwnedPqFastScan(Arc<PqFastScanPool>),
/// Vectors are read from disk on demand.
///
/// Each [`get`](Self::get) call opens a fresh [`StorageInput`](crate::storage::StorageInput)
/// via [`Storage::open_input`], performs a single seek + read, and closes
/// the handle. For mmap-backed storage this is essentially free; for
/// file-backed storage the OS typically caches the file descriptor.
///
/// `quant_params` controls how the per-vector data section is decoded:
/// `Some(params)` -> Issue #481 Stage 1 quantized format
/// (int8 + per-vector meta, dequantized on read);
/// `None` -> legacy f32 format (still in use by Flat / IVF until
/// Step 7 of #481 Stage 1 migrates them).
OnDemand {
/// Reference to the storage backend (e.g. file system, mmap).
storage: Arc<dyn Storage>,
/// Name of the vector index file within the storage.
file_name: String,
/// Pre-built mapping from `(doc_id, field_id)` to the byte
/// offset of the record's payload (Issue #633 PR-B: interned
/// u16 field ids instead of one heap `String` per record).
offsets: Arc<HashMap<(u64, u16), u64>>,
/// Per-segment field-name dictionary; `field_id` indexes into
/// it. Resolution from a name is a linear scan (segments hold
/// 1–3 fields in practice), which allocates nothing.
field_dict: Arc<[Arc<str>]>,
/// Per-segment quantization params, if the on-disk vector
/// format is the Stage-1 quantized layout. `None` means the
/// legacy f32 layout.
quant_params: Option<ScalarQuantParams>,
/// Lazily-opened input handle, shared across `get()` calls in
/// the same search. Avoids paying the
/// `Storage::open_input(file_name)` cost (`statx` syscall via
/// the mmap-cache metadata check, mmap-cache lookup, `Arc`
/// clone, and `Box` allocation) on every candidate-vector
/// lookup. Subsequent gets call `clone_input()` to obtain a
/// fresh seek cursor without re-opening the file. See #522.
///
/// The lock is read-heavy: after the first `get()` populates
/// it, every subsequent `get()` takes a read lock and never
/// blocks another reader, so concurrent HNSW searches do not
/// serialise on this field.
cached_input: Arc<RwLock<Option<Box<dyn StorageInput>>>>,
},
}
impl VectorStorage {
/// If this storage is the in-memory Scalar8Bit quantized variant,
/// return the underlying [`QuantizedVectorPool`] so the search hot
/// loop can pull `(int8 slice, meta)` directly without going
/// through the dequantizing [`Self::get`] path.
pub fn quantized_pool(&self) -> Option<&Arc<QuantizedVectorPool>> {
match self {
VectorStorage::OwnedQuantized(pool) => Some(pool),
_ => None,
}
}
/// If this storage is the in-memory PQ variant (Stage 3), return
/// the underlying [`PqVectorPool`] so the search hot loop can pull
/// `(codes, codebook)` directly and dispatch to the PQ ADC
/// kernel.
pub fn pq_pool(&self) -> Option<&Arc<PqVectorPool>> {
match self {
VectorStorage::OwnedPq(pool) => Some(pool),
_ => None,
}
}
/// If this storage is the in-memory PQ FastScan variant (Issue
/// #695 / part D of #651), return the underlying
/// [`PqFastScanPool`] so the search hot loop can walk the
/// block-transposed 4-bit packed codes directly and dispatch to
/// the AVX2 / NEON / scalar FastScan kernel via
/// [`crate::vector::index::pq_fastscan_avx2::distance_pq_fastscan_block`].
#[cfg(feature = "pq-fastscan")]
pub fn pq_fastscan_pool(&self) -> Option<&Arc<PqFastScanPool>> {
match self {
VectorStorage::OwnedPqFastScan(pool) => Some(pool),
_ => None,
}
}
/// Returns all keys stored in this vector storage.
pub fn keys(&self) -> Vec<(u64, String)> {
match self {
VectorStorage::Owned(map) => map.keys().cloned().collect(),
VectorStorage::OwnedQuantized(pool) => pool.keys(),
VectorStorage::OwnedPq(pool) => pool.keys(),
#[cfg(feature = "pq-fastscan")]
VectorStorage::OwnedPqFastScan(pool) => pool.keys(),
VectorStorage::OnDemand {
offsets,
field_dict,
..
} => offsets
.keys()
.map(|&(doc_id, fid)| (doc_id, field_dict[fid as usize].to_string()))
.collect(),
}
}
/// Returns the number of vectors stored.
pub fn len(&self) -> usize {
match self {
VectorStorage::Owned(map) => map.len(),
VectorStorage::OwnedQuantized(pool) => pool.vector_count,
VectorStorage::OwnedPq(pool) => pool.vector_count,
#[cfg(feature = "pq-fastscan")]
VectorStorage::OwnedPqFastScan(pool) => pool.vector_count(),
VectorStorage::OnDemand { offsets, .. } => offsets.len(),
}
}
/// Returns `true` if no vectors are stored.
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Returns `true` if a vector exists for `(doc_id, field_name)`.
///
/// Allocation-free on every variant (Issue #633 PR-B): the
/// `OnDemand` arm resolves the field name against the segment
/// dictionary instead of materializing an owned key.
///
/// # Arguments
///
/// * `doc_id` - The document id.
/// * `field_name` - The vector field name.
pub fn contains(&self, doc_id: u64, field_name: &str) -> bool {
match self {
VectorStorage::Owned(map) => map.contains_key(&(doc_id, field_name.to_string())),
VectorStorage::OwnedQuantized(pool) => pool.contains(doc_id, field_name),
VectorStorage::OwnedPq(pool) => pool.contains(doc_id, field_name),
#[cfg(feature = "pq-fastscan")]
VectorStorage::OwnedPqFastScan(pool) => pool.contains(doc_id, field_name),
VectorStorage::OnDemand {
offsets,
field_dict,
..
} => crate::vector::index::format::resolve_field_id(field_dict, field_name)
.is_some_and(|fid| offsets.contains_key(&(doc_id, fid))),
}
}
/// Retrieves a vector by its key.
///
/// For the `Owned` variant the vector is cloned (O(1) due to `Arc`
/// wrapping). For the `OnDemand` variant a fresh file handle is opened,
/// the reader seeks to the recorded offset, and the vector data is read
/// directly.
///
/// Allocation-free key handling (Issue #633 PR-B): callers pass
/// `(doc_id, &str)` and the `OnDemand` arm resolves the name against
/// the segment dictionary — the former per-call
/// `field_name.to_string()` key materialization is gone.
///
/// # Arguments
///
/// * `doc_id` - The document id.
/// * `field_name` - The vector field name.
/// * `dimension` - The expected number of dimensions (used to size the read buffer).
///
/// # Returns
///
/// `Ok(Some(vector))` if the key exists, `Ok(None)` otherwise.
///
/// # Errors
///
/// Returns [`LaurusError`] on I/O failure.
pub fn get(&self, doc_id: u64, field_name: &str, dimension: usize) -> Result<Option<Vector>> {
match self {
// Match-only legacy variant (never constructed by current
// readers); the owned-key probe is acceptable here.
VectorStorage::Owned(map) => Ok(map.get(&(doc_id, field_name.to_string())).cloned()),
VectorStorage::OwnedQuantized(pool) => {
Ok(pool.dequantize_to_vector(doc_id, field_name))
}
VectorStorage::OwnedPq(pool) => Ok(pool.dequantize_to_vector(doc_id, field_name)),
#[cfg(feature = "pq-fastscan")]
VectorStorage::OwnedPqFastScan(pool) => {
Ok(pool.dequantize_to_vector(doc_id, field_name))
}
VectorStorage::OnDemand {
storage,
file_name,
offsets,
field_dict,
quant_params,
cached_input,
} => {
let Some(fid) =
crate::vector::index::format::resolve_field_id(field_dict, field_name)
else {
return Ok(None);
};
let Some(&offset) = offsets.get(&(doc_id, fid)) else {
return Ok(None);
};
// Reuse the cached input handle if it has been opened by a
// previous `get()` on this storage. The cache is read-heavy:
// after the first opener wins the write lock, every subsequent
// call takes the read lock and clones via `clone_input()` —
// no `statx` syscall, no mmap-cache lookup, just an `Arc`
// clone and a `Box` allocation for the fresh cursor.
let mut input = {
let guard = cached_input
.read()
.map_err(|_| LaurusError::internal("cached_input RwLock poisoned"))?;
if let Some(cached) = guard.as_ref() {
cached.clone_input()?
} else {
// First call — drop the read lock and acquire the
// write lock so we can lazily open the file. The
// double-check after locking handles the rare case
// where another thread populated the cache between
// our read-lock release and write-lock acquisition.
drop(guard);
let mut wguard = cached_input
.write()
.map_err(|_| LaurusError::internal("cached_input RwLock poisoned"))?;
if wguard.is_none() {
*wguard = Some(storage.open_input(file_name).map_err(|e| {
LaurusError::internal(format!("Failed to open vector file: {e}"))
})?);
}
wguard
.as_ref()
.expect("cached_input populated above")
.clone_input()?
}
};
// Offsets point at the record's payload start (the readers
// compute them past the doc_id + field-reference prefix at
// load, Issue #633), so no per-access prefix re-parse is
// needed — one seek lands directly on the vector data.
input
.seek(SeekFrom::Start(offset))
.map_err(LaurusError::Io)?;
// Read vector data — branch on the on-disk format.
let values = match quant_params {
Some(params) => {
// Stage-1 quantized: int8 payload + per-vector meta.
// Dequantize back to f32 here so callers see the same
// Vector type as the legacy path. Step 6 of #481
// Stage 1 will offer an int8-native read path for the
// search hot loop.
let mut int8_buf = vec![0u8; dimension];
input.read_exact(&mut int8_buf)?;
let mut meta_buf = [0u8; QuantizedVectorMeta::SERIALIZED_SIZE];
input.read_exact(&mut meta_buf)?;
int8_buf
.iter()
.map(|&b| params.dequantize_value(b))
.collect()
}
None => {
// Legacy f32 layout (Flat / IVF until Step 7).
let mut values = vec![0.0f32; dimension];
for value in &mut values {
let mut value_buf = [0u8; 4];
input.read_exact(&mut value_buf)?;
*value = f32::from_le_bytes(value_buf);
}
values
}
};
Ok(Some(Vector::new(values)))
}
}
}
}