Files
5f699480e0 [UpdateOnly] implement the appendable quantized-vector overlay (dense, Binary/Turbo) (#10161)
* [UpdateOnly] implement the appendable quantized-vector overlay (dense, Binary/Turbo)

Appendable/plain segments can carry live quantized vectors today: PlainVectorIndex::
update_vector calls quantized_vectors.upsert_vector alongside the raw vector on every
insert (lib/segment/src/index/plain_vector_index/lifecycle.rs), auto-created for a
fresh segment when appendable_quantization is on and the method supports it
(QuantizationConfig::supports_appendable — Binary and Turbo only; Scalar/Product are
policy-gated off regardless of storage backend). The update-only vector-storage stack
(this PR's base) had no equivalent: UpdateOnlyVectorStorage::open never read
quantization_config, and nothing under vector_storage/*/update_only/ mentioned
quantization at all — a segment configured with quantization would silently lose it
end-to-end once written through this path.

This adds UpdateOnlyQuantizedVectors, mirroring QuantizedVectors' auto-create/reopen
behavior but scoped to dense (single-vector) Binary/Turbo — the two methods that
support incremental appends, matching current capability exactly (multivector support
is a follow-up: it needs its own append-only offsets storage, mirroring
MultivectorOffsetsStorageChunked the same way this mirrors QuantizedChunkedStorage).

The only new machinery is UpdateOnlyQuantizedChunkedStorage, an EncodedStorage backed
by UpdateOnlyChunkedVectors (append-only, S: UniversalAppend) instead of
ChunkedVectors' positional writes (S: UniversalWrite) — everything else reuses the
quantization crate's EncodedVectorsBin::encode/load and EncodedVectorsTQ::encode/load
completely unchanged, since both are already generic over the storage backend. It
writes files in the exact layout QuantizedChunkedStorage reads, so a promoted segment's
quantized data reads through the existing, unmodified reader with no new reading code.
UpdateOnlyChunkedVectors gains one addition: a `get` method to read back a single
vector, needed because EncodedVectors::load validates the storage's vector size by
reading vector 0 (skipped when the store is still empty).

Verified: the update-only writer's persisted bytes, read back through the standard
(non-update-only) QuantizedChunkedStorage + EncodedVectorsBin/TQ::load, match a
RAM-backed reference fed the same vectors one at a time through upsert_vector,
byte-for-byte, for both Binary and Turbo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* [UpdateOnly] fix quantized reopen: resume writing shouldn't validate stored reads

The previous commit made reopening a non-empty quantized overlay panic
(EncodedVectorsBin/TQ::load validates a non-empty store by reading its
vector 0, which UpdateOnlyQuantizedChunkedStorage's write-only design
cannot serve) and worked around it with a redundant pre-check plus a
todo!(), narrowing the tests to single-session-only writes.

Both of those were the wrong fix. A writer resuming appends doesn't need
`load`'s read-and-validate — it only needs the fitted metadata (encoding,
stats) to keep encoding consistently, and that invariant already holds by
construction: every vector this writer ever encodes is sized from the same
`quantized_vector_size` `load` and the new path both read. Added
`EncodedVectorsBin`/`EncodedVectorsTQ::reopen_for_write` to the
quantization crate — identical to `load` minus the validating read — and
switched `open_existing` to it. `UpdateOnlyQuantizedChunkedStorage` stays
write-only as originally designed; no new read capability, no pre-check,
no todo.

Tests restored to the original two-writer split (write half, drop, reopen,
write the rest), now genuinely exercising resume-with-data instead of
avoiding it, and still passing byte-for-byte against the reference.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* [UpdateOnly] split EncodedStorage into EncodedStorageWrite + EncodedStorage

A write-only storage (the update-only quantized overlay) had to fake a
full EncodedStorage impl with unreachable!() read stubs just to satisfy
EncodedVectorsBin/TQ's generic bound. Split the trait so a write-only
backend only needs to implement EncodedStorageWrite; EncodedStorage adds
the read methods on top. The overlay now implements EncodedStorageWrite
alone — no panicking stand-ins for methods that don't exist.

* [UpdateOnly] remove UpdateOnlyQuantizedVectors::create

Nothing in this stack builds the first appendable segment of a
collection yet (that's still a todo!() in edge/src/update_only), so
create() had no real caller and open() had to guess from file absence
whether to invoke it. open() now only reopens an overlay create()
already persisted; the bootstrap logic moved into tests.rs as a
private fixture helper, since tests still need it to build fixtures.

* [UpdateOnly] fix CI: codespell typo and lint dead-code on unwired write path

codespell flagged "implementors" (wants "implementers") in two doc
comments. Separately, CI's lint job runs clippy without --all-targets,
so the update-only quantized write path — genuinely unreachable from
any non-test code until #10152 wires it into a segment — trips
-D warnings dead-code. Scope #![allow(dead_code)] to the two files
that are only exercised by their own tests today, and allow the now
test-only UpdateOnlyQuantizedChunkedStorageBuilder re-export.

* [UpdateOnly] fix ast-grep: use expect(dead_code) instead of allow

* fix CI: remove unused EncodedStorageWrite import in gpu vector storage

Left over from splitting EncodedStorage into EncodedStorageWrite +
EncodedStorage; only caught under --all-features since gpu is gated
behind a feature flag.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: qdrant-cloud-bot <111755117+qdrant-cloud-bot@users.noreply.github.com>
2026-09-03 12:38:58 +02:00

201 lines
7.2 KiB
Rust

//! Regression tests for storages whose `get_vector_data` returns `Cow::Owned` (e.g. the
//! disk-cache / uring backends), as opposed to the borrowed views of mmap-based storages.
//!
//! `EncodedVectorsU8` used to extract a raw pointer from the returned buffer and drop the
//! buffer before dereferencing it — a use-after-free on any owning storage. These tests run
//! the scoring and vector-access paths over an owning storage and check they agree with the
//! borrowed baseline, so the owned code path stays exercised (and fails under Miri/ASAN if
//! the buffer lifetime is ever mishandled again).
#[cfg(test)]
mod tests {
use std::borrow::Cow;
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use common::counter::hardware_counter::HardwareCounterCell;
use common::mmap::MmapFlusher;
use common::types::PointOffsetType;
use quantization::encoded_storage::{
EncodedStorage, EncodedStorageBuilder, EncodedStorageWrite, TestEncodedStorage,
TestEncodedStorageBuilder, default_for_each_batch,
};
use quantization::encoded_vectors::{DistanceType, EncodedVectors, VectorParameters};
use quantization::encoded_vectors_u8;
use quantization::encoded_vectors_u8::{EncodedVectorsU8, ScalarQuantizationMethod};
use rand::{RngExt, SeedableRng};
/// Wraps a storage so every read returns a freshly allocated `Cow::Owned` buffer.
struct OwnedStorage(TestEncodedStorage);
impl EncodedStorageWrite for OwnedStorage {
fn is_in_ram_or_mmap() -> bool {
TestEncodedStorage::is_in_ram_or_mmap()
}
fn is_on_disk(&self) -> bool {
let Self(inner) = self;
inner.is_on_disk()
}
fn upsert_vector(
&mut self,
id: PointOffsetType,
vector: &[u8],
hw_counter: &HardwareCounterCell,
) -> std::io::Result<()> {
let Self(inner) = self;
inner.upsert_vector(id, vector, hw_counter)
}
fn vectors_count(&self) -> usize {
let Self(inner) = self;
inner.vectors_count()
}
fn flusher(&self) -> MmapFlusher {
let Self(inner) = self;
inner.flusher()
}
fn heap_size_bytes(&self) -> usize {
let Self(inner) = self;
inner.heap_size_bytes()
}
}
impl EncodedStorage for OwnedStorage {
fn get_vector_data(&self, index: PointOffsetType) -> Cow<'_, [u8]> {
let Self(inner) = self;
Cow::Owned(inner.get_vector_data(index).into_owned())
}
fn get_vector_data_opt(&self, index: PointOffsetType) -> Option<Cow<'_, [u8]>> {
let Self(inner) = self;
Some(Cow::Owned(inner.get_vector_data_opt(index)?.into_owned()))
}
fn for_each_batch(
&self,
offsets: &[PointOffsetType],
callback: impl FnMut(usize, Cow<'_, [u8]>),
) {
default_for_each_batch(self, offsets, callback);
}
fn files(&self) -> Vec<PathBuf> {
let Self(inner) = self;
inner.files()
}
fn immutable_files(&self) -> Vec<PathBuf> {
let Self(inner) = self;
inner.immutable_files()
}
}
struct OwnedStorageBuilder(TestEncodedStorageBuilder);
impl EncodedStorageBuilder for OwnedStorageBuilder {
type Storage = OwnedStorage;
type Error = std::io::Error;
fn build(self) -> std::io::Result<Self::Storage> {
let Self(inner) = self;
Ok(OwnedStorage(inner.build()?))
}
fn push_vector_data(&mut self, other: &[u8]) -> std::io::Result<()> {
let Self(inner) = self;
inner.push_vector_data(other)
}
}
#[test]
fn test_u8_owned_storage_matches_borrowed() {
let vectors_count = 65;
let vector_dim = 65;
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let vector_data: Vec<Vec<f32>> = (0..vectors_count)
.map(|_| (0..vector_dim).map(|_| rng.random()).collect())
.collect();
let query: Vec<f32> = (0..vector_dim).map(|_| rng.random()).collect();
let vector_parameters = VectorParameters {
dim: vector_dim,
deprecated_count: None,
distance_type: DistanceType::Dot,
invert: false,
};
let quantized_vector_size =
encoded_vectors_u8::get_quantized_vector_size(&vector_parameters);
let encoded_owned = EncodedVectorsU8::encode(
vector_data.iter(),
OwnedStorageBuilder(TestEncodedStorageBuilder::new(None, quantized_vector_size)),
&vector_parameters,
vectors_count,
None,
ScalarQuantizationMethod::Int8,
None,
&AtomicBool::new(false),
)
.unwrap();
let encoded_borrowed = EncodedVectorsU8::encode(
vector_data.iter(),
TestEncodedStorageBuilder::new(None, quantized_vector_size),
&vector_parameters,
vectors_count,
None,
ScalarQuantizationMethod::Int8,
None,
&AtomicBool::new(false),
)
.unwrap();
let counter = HardwareCounterCell::new();
// Offset + code accessor must return identical data through owning and borrowed
// storages, and the code must have the quantized vector size minus the offset constant.
let code_size = encoded_borrowed.quantized_vector_size() - size_of::<f32>();
for i in 0..vectors_count as PointOffsetType {
let (offset_owned, code_owned) = encoded_owned.get_quantized_vector_offset_and_code(i);
let (offset_borrowed, code_borrowed) =
encoded_borrowed.get_quantized_vector_offset_and_code(i);
assert_eq!(offset_owned, offset_borrowed);
assert_eq!(code_owned, code_borrowed);
assert_eq!(code_owned.len(), code_size);
}
// Internal (point-to-point) scoring reads two vectors at once from the storage.
for i in 0..vectors_count as PointOffsetType {
let j = (i + 7) % vectors_count as PointOffsetType;
assert_eq!(
encoded_owned.score_internal(i, j, &counter),
encoded_borrowed.score_internal(i, j, &counter),
);
}
// Queries encoded from a stored vector must score identically as well.
let query_owned = encoded_owned.encode_internal_vector(0).unwrap();
let query_borrowed = encoded_borrowed.encode_internal_vector(0).unwrap();
for i in 0..vectors_count as PointOffsetType {
assert_eq!(
encoded_owned.score_point(&query_owned, i, &counter),
encoded_borrowed.score_point(&query_borrowed, i, &counter),
);
}
// External query scoring by point id.
let query_u8_owned = encoded_owned.encode_query(&query);
let query_u8_borrowed = encoded_borrowed.encode_query(&query);
for i in 0..vectors_count as PointOffsetType {
assert_eq!(
encoded_owned.score_point(&query_u8_owned, i, &counter),
encoded_borrowed.score_point(&query_u8_borrowed, i, &counter),
);
}
}
}