mirror of
https://github.com/qdrant/qdrant.git
synced 2026-07-26 04:31:02 -05:00
* feat: add read-only QuantizedVectors generic over UniversalRead Introduce `QuantizedVectorsRead<S>` / `QuantizedVectorStorageRead<S>`, a read-only counterpart of `QuantizedVectors` organized like `VectorStorageReadEnum`: generic over the `UniversalRead` backend `S`, opened from existing on-disk data, with no create/upsert/builder path and no disk writes. Highlights: - Keep both in-RAM (`*Ram`) and read-only mmap (`*Mmap`) variants; drop the appendable `*ChunkedMmap` variants (the only mutable ones). - All bulk reads go through `S`: add `QuantizedRamStorage::from_universal_read` and `MultivectorOffsetsStorageRam::open`, and make `MultivectorOffsetsStorageMmap` generic over `S` (default `MmapFile`). - Share scorer construction between the read-write and read-only enums via a `QuantizedScorerDispatch` trait, so only the per-variant match is duplicated while the datatype/distance and per-query dispatch live once in the builder. Tested: read-only vs read-write scorer parity (scalar/binary/product, single and multivector, RAM and mmap), covering both `raw_scorer` and `raw_internal_scorer`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: route quantized RAM loading through UniversalRead Follow-up to the read-only quantized work, removing direct-filesystem reads and load-path duplication: - `EncodedVectors{U8,PQ,Bin,TQ}::load` now take a `UniversalRead` filesystem and read metadata via `read_json_via` instead of `fs::read_to_string` — every read flows through universal IO. Single-vector `load` returns `common::universal_io::Result`; `validate_storage_vector_size` stays `std::io::Result`. - Add `common::universal_io::OneshotFile<S>`: a thin RAII wrapper over any `UniversalRead` handle that evicts the data from cache via `clear_ram_cache` on drop (the universal-IO counterpart of `fs::OneshotFile`). - Collapse `QuantizedRamStorage::{from_file, from_universal_read}` into one `from_file<S: UniversalRead>`. It reads the whole file in a single access (no separate `len()` round-trip — cheaper on S3-like backends) and loads via the new `VolatileChunkedVectors::extend`, which inserts one chunk per `copy_from_slice` instead of one vector at a time. - RW callers pass the local `READ_FS` (mmap) backend; the read-only loader passes its `S`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: load appendable (chunked) quantization format read-only `storage_type` only selects the on-disk layout, so the read-only quantized storage must be able to load both — the immutable flat format and the appendable chunked format (produced only by Binary/TurboQuant). Previously the read-only loader rejected the Mutable layout outright. - Add `QuantizedChunkedStorageRead<S>` and `MultivectorOffsetsStorageChunkedRead<S>`, read-only wrappers over the existing `ChunkedVectorsRead<_, S>` primitive (mirrors the dense read-view's chunked read storage). Generic over the `UniversalRead` backend, on-disk, no write path. - Add `BinaryChunked`/`TQChunked` (+ multi) variants to `QuantizedVectorStorageRead` and wire them through every accessor and the scorer dispatch. - Route `storage_type == Mutable` to the chunked read variants in the loader and drop the blanket rejection. - Tests: parametrize the read-only/read-write parity tests over `storage_type` and add chunked (Mutable) cases for binary/turbo, single and multivector. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: split quantized chunked & multivector storage into modules `quantized_chunked_mmap_storage.rs` and `quantized_multivector_storage.rs` had grown to hold several unrelated structures, making them hard to navigate. Convert each into a module (pure code movement, no behavior change): - quantized_chunked_mmap_storage/{read_write,read_only}.rs — the appendable mmap storage + builder vs. the read-only chunked storage. - quantized_multivector_storage/{mod,offsets}.rs — the core `QuantizedMultivectorStorage` + offset traits stay in mod.rs; the four `MultivectorOffsetsStorage*` backends move to offsets.rs. Public paths are unchanged via re-exports. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: single source of truth for quantized vector size The on-disk stride (bytes per quantized vector), including the binary `u8`(multi)/`u128`(single) word-type choice, was independently re-derived in every load/open site with no compile-time link — the kind of value-level duplication that silently diverges (it already caused the binary multi `u8`/`u128` bug). Extract it into one place: - `QuantizedVectors::quantized_vector_size(quantization_config, vector_parameters, is_multi)` and the `QuantizedVectorsConfig::quantized_vector_size(is_multi)` convenience. Route every reader through it: - read-only `open_single`/`open_multi` and the read-write `{scalar,pq,binary,turbo}` loaders now hoist `config.quantized_vector_size(is_multi)` once instead of recomputing the per-method formula per branch. The create (write) path keeps its own computation for now; it stays guarded by `validate_storage_vector_size` and the read-only/read-write parity tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: flat-match quantized loaders on a shared QuantizedStorageKind The loaders chose the concrete storage variant via a nested `method × (storage_type / is_ram)` match, re-derived independently in the read-only and read-write paths — hard to follow and easy to drift. - Add `QuantizedStorageKind` + `QuantizedVectorsConfig::storage_kind(on_disk)` (and `is_ram(on_disk)`): the single place the method × backend decision lives. - Both loaders now compute the kind once and use a flat 10-arm match: - read-only `open_single`/`open_multi`; - read-write `load_single`/`load_multi` (consolidating the four per-method `{scalar,pq,binary,turbo}/load.rs` files, which are removed). Both matches are exhaustive over the same enum, so the read and read-write variants can no longer fall out of sync without a compile error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: genericize write-capable chunked quantized storages over Fs Drop the hardcoded `MmapFile` specialization from `QuantizedChunkedStorage`, `QuantizedChunkedStorageBuilder`, and `MultivectorOffsetsStorageChunked`. They now expose the `Fs` backend (defaulting to `MmapFile`) and accept the fs handle as a parameter, matching the read-only variants. Introduce a single shared `ReadFile` type alias and `READ_FS` value handle in the `quantized_vectors` module root, used by the create, load, and storage enum paths so the local-file backend is named in one place. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor: move is_in_ram_or_mmap match into UniversalKind Deduplicate the identical kind-to-residency match in the read-only and write-capable chunked quantized storages by adding UniversalKind::is_in_ram_or_mmap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: adapt TurboVectorStorage to quantized rename + update_from move Integration fix after rebasing onto dev's TurboQuant work: - QuantizedChunkedMmapStorage -> QuantizedChunkedStorage<MmapFile> - update_from moved off the VectorStorage trait onto an inherent method, matching the per-kind sub-trait refactor; TurboVectorStorage implements neither DenseVectorStorage<T> nor the other kind traits. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
167 lines
7.6 KiB
Rust
167 lines
7.6 KiB
Rust
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::atomic::AtomicBool;
|
|
|
|
use quantization::encoded_storage::{TestEncodedStorage, TestEncodedStorageBuilder};
|
|
use quantization::encoded_vectors::{DistanceType, VectorParameters};
|
|
use quantization::encoded_vectors_u8;
|
|
use quantization::encoded_vectors_u8::{EncodedVectorsU8, ScalarQuantizationMethod};
|
|
use tempfile::Builder;
|
|
|
|
/// The hot-path `score_bytes` asserts (in debug builds) that each encoded vector is at least
|
|
/// as large as the metadata expects. `load` performs a stricter, exact-size check for
|
|
/// storage-derived vectors once at load time, so the invariant also holds in release builds.
|
|
/// This verifies that a storage whose vector stride does not exactly match the metadata is
|
|
/// rejected at load time instead of reaching the hot path and reading out of bounds.
|
|
#[test]
|
|
fn load_requires_storage_vector_size_to_match_metadata() {
|
|
let dir = Builder::new().prefix("storage_dir").tempdir().unwrap();
|
|
|
|
let vectors_count = 4;
|
|
let vector_dim = 256;
|
|
let vector_parameters = VectorParameters {
|
|
dim: vector_dim,
|
|
deprecated_count: None,
|
|
distance_type: DistanceType::Dot,
|
|
invert: false,
|
|
};
|
|
let vector_data: Vec<Vec<f32>> = (0..vectors_count)
|
|
.map(|i| vec![i as f32; vector_dim])
|
|
.collect();
|
|
|
|
let data_path = dir.path().join("data.bin");
|
|
let meta_path = dir.path().join("meta.json");
|
|
let quantized_vector_size =
|
|
encoded_vectors_u8::get_quantized_vector_size(&vector_parameters);
|
|
EncodedVectorsU8::encode(
|
|
vector_data.iter(),
|
|
TestEncodedStorageBuilder::new(Some(data_path.as_path()), quantized_vector_size),
|
|
&vector_parameters,
|
|
vectors_count,
|
|
None,
|
|
ScalarQuantizationMethod::Int8,
|
|
Some(meta_path.as_path()),
|
|
&AtomicBool::new(false),
|
|
)
|
|
.unwrap();
|
|
|
|
let try_load = |stride: usize| {
|
|
EncodedVectorsU8::<TestEncodedStorage>::load(
|
|
&common::universal_io::MmapFs,
|
|
TestEncodedStorage::from_file(data_path.as_path(), stride).unwrap(),
|
|
meta_path.as_path(),
|
|
)
|
|
};
|
|
|
|
// Loading with the exact stride succeeds.
|
|
try_load(quantized_vector_size).unwrap();
|
|
|
|
// Loading with a stride that does not exactly match the metadata must be rejected,
|
|
// whether it is smaller or larger than expected. Both strides still divide the data
|
|
// file, so they pass the storage's own divisibility check and only our load-time
|
|
// consistency check catches the mismatch.
|
|
for stride in [quantized_vector_size / 2, quantized_vector_size * 2] {
|
|
match try_load(stride) {
|
|
Ok(_) => panic!(
|
|
"loading a storage with vector stride {stride} should fail, \
|
|
metadata expects {quantized_vector_size}"
|
|
),
|
|
Err(err) => assert!(
|
|
matches!(&err, common::universal_io::UniversalIoError::Io(e) if e.kind() == std::io::ErrorKind::InvalidData),
|
|
"unexpected error: {err:?}",
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Regression test for BBP-827 (vulnerability 1): heap out-of-bounds read via crafted
|
|
/// quantization metadata in a malicious snapshot.
|
|
///
|
|
/// A snapshot's `quantized.meta.json` is attacker-controlled. The scalar-quantization SIMD
|
|
/// scoring functions use `metadata.actual_dim` as the iteration count for raw pointer
|
|
/// arithmetic over each stored vector (e.g. `impl_score_dot(q_ptr, v_ptr, actual_dim)`). If
|
|
/// `actual_dim` is inflated far beyond the real vector size, every score reads heap memory
|
|
/// past the end of the (small) stored vector — leaking adjacent heap data into scores, or
|
|
/// crashing outright for large enough values.
|
|
///
|
|
/// This crafts that malicious snapshot: a storage honestly encoded for dimension 128 whose
|
|
/// `quantized.meta.json` is then patched to claim a far larger `actual_dim`, exactly as an
|
|
/// attacker would inside the snapshot archive. It asserts that `load` rejects the inconsistent
|
|
/// storage, so search queries can never reach the out-of-bounds read.
|
|
#[test]
|
|
fn bbp_827_load_rejects_inflated_actual_dim() {
|
|
let dir = Builder::new().prefix("storage_dir").tempdir().unwrap();
|
|
|
|
let vectors_count = 4;
|
|
let vector_dim = 128;
|
|
let vector_parameters = VectorParameters {
|
|
dim: vector_dim,
|
|
deprecated_count: None,
|
|
distance_type: DistanceType::Cosine,
|
|
invert: false,
|
|
};
|
|
let vector_data: Vec<Vec<f32>> = (0..vectors_count)
|
|
.map(|i| vec![i as f32; vector_dim])
|
|
.collect();
|
|
|
|
let data_path = dir.path().join("data.bin");
|
|
let meta_path = dir.path().join("quantized.meta.json");
|
|
let quantized_vector_size =
|
|
encoded_vectors_u8::get_quantized_vector_size(&vector_parameters);
|
|
EncodedVectorsU8::encode(
|
|
vector_data.iter(),
|
|
TestEncodedStorageBuilder::new(Some(data_path.as_path()), quantized_vector_size),
|
|
&vector_parameters,
|
|
vectors_count,
|
|
None,
|
|
ScalarQuantizationMethod::Int8,
|
|
Some(meta_path.as_path()),
|
|
&AtomicBool::new(false),
|
|
)
|
|
.unwrap();
|
|
|
|
let load = || {
|
|
EncodedVectorsU8::<TestEncodedStorage>::load(
|
|
&common::universal_io::MmapFs,
|
|
TestEncodedStorage::from_file(data_path.as_path(), quantized_vector_size).unwrap(),
|
|
meta_path.as_path(),
|
|
)
|
|
};
|
|
|
|
// The honest snapshot loads fine.
|
|
load().unwrap();
|
|
|
|
// Sanity check: the encoded metadata records the real dimension under `actual_dim`. This
|
|
// guards against the field being renamed, which would silently make the patch below (and
|
|
// thus this regression test) a no-op.
|
|
let original = fs_err::read_to_string(&meta_path).unwrap();
|
|
let metadata: serde_json::Value = serde_json::from_str(&original).unwrap();
|
|
assert_eq!(metadata["actual_dim"].as_u64(), Some(vector_dim as u64));
|
|
|
|
let patch_actual_dim = |actual_dim: u64| {
|
|
let contents = fs_err::read_to_string(&meta_path).unwrap();
|
|
let mut metadata: serde_json::Value = serde_json::from_str(&contents).unwrap();
|
|
metadata["actual_dim"] = serde_json::Value::from(actual_dim);
|
|
fs_err::write(&meta_path, serde_json::to_string(&metadata).unwrap()).unwrap();
|
|
};
|
|
|
|
// Each stored vector is only `quantized_vector_size` bytes, but the patched metadata
|
|
// claims `actual_dim` per vector — so scoring would iterate `actual_dim` times over that
|
|
// small buffer, reading well past its allocation. `8192` is the report's score-inflation
|
|
// value; `10_000_000` is its crash value. Both must be rejected at load time.
|
|
for inflated_actual_dim in [8192, 10_000_000] {
|
|
patch_actual_dim(inflated_actual_dim);
|
|
match load() {
|
|
Ok(_) => panic!(
|
|
"malicious snapshot with actual_dim={inflated_actual_dim} was accepted — \
|
|
heap OOB read is reachable (BBP-827 regression)"
|
|
),
|
|
Err(err) => assert!(
|
|
matches!(&err, common::universal_io::UniversalIoError::Io(e) if e.kind() == std::io::ErrorKind::InvalidData),
|
|
"unexpected error: {err:?}",
|
|
),
|
|
}
|
|
}
|
|
}
|
|
}
|