Skip to content

Commit 9b4a4be

Browse files
fix(qdrant): relocate multivector normalization guard, close review round 2
A second review round found the round-1 normalization guard (in create_multivector_collection) was reachable only through Engine::configure, which --skip-upload skips entirely -- so a cosine (or omitted-distance) multivector dataset run with --skip-upload against an out-of-band-loaded collection would bypass the guard and publish a wrong recall number: exactly the silent-wrong-result bug the guard was added to prevent, just reachable through a different, first-class entry point. Fix, per 9-way agent consensus: extract the check into a new Dataset::ensure_multivector_normalized(), called from both read_multivector_data and read_multivector_queries -- the two functions every entry point (configure/upload, search, ground-truth profiling, and skip-upload) actually funnels through -- with the Qdrant-side call kept as a fail-fast belt-and-braces on the one path that does call configure. Also fixed the error message, which falsely claimed a declared distance even when none was given (distance() defaults to "cosine" silently). Also addressed, from the same round: - Two "overflow" unit tests asserted bare is_err() but actually tripped a different guard than their name claimed (verified by hand); renamed and reasserted on the real error message. - Added a hand-written byte-literal test pinning the .mvec on-disk format (mirroring sparse_reader's CSR fixture), since every existing test round-tripped through the same writer+reader and couldn't catch a coherent endianness/block-order bug. - Added unit tests for the relocated guard (cosine/angular/omitted all reject, dot passes) and a live --skip-upload integration test that reproduces the reviewer's exact scenario end-to-end. - README: documented that only dot/euclid are accepted for multivector datasets, since cosine/angular/omitted (the common case) now hard-error. Deferred (documented, not filed as issues per agent consensus -- filing on a real org repo needs an explicit human go-ahead): a magic-bytes/version word for the still-unshipped .mvec format, and the three items already deferred from round 1. Verified live against Qdrant v1.18.2: full 29-test integration suite green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 6058527 commit 9b4a4be

6 files changed

Lines changed: 283 additions & 23 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -917,7 +917,11 @@ neighbours.jsonl # ground truth: one JSON array of ids per query line
917917
```
918918

919919
Register it in `datasets/datasets.json` with `"type": "multivector"` and the
920-
per-token `vector_size`/`distance`. Ground truth for a multivector dataset MUST
920+
per-token `vector_size`/`distance`. **Only `dot`/`euclid` are accepted**
921+
`cosine`/`angular` (and an *omitted* `distance`, which defaults to `cosine`)
922+
hard-error at load, since neither the reader nor the generator apply per-token
923+
normalization yet; this is the most likely first-attempt failure, since
924+
omitting `distance` is common elsewhere in this file. Ground truth for a multivector dataset MUST
921925
be a genuine brute-force MaxSim ranking, not a heuristic — see
922926
`generate_multivector`'s doc comment in `src/synthetic.rs` for why the hybrid
923927
generator's "planted" shortcut does not carry over. The end-to-end path

src/bin/vector_db_benchmark/dataset.rs

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,8 +704,37 @@ impl Dataset {
704704
self.config.dataset_type.as_deref() == Some("multivector")
705705
}
706706

707+
/// Refuse a multivector dataset whose distance requires per-token
708+
/// normalization, since neither `read_multivector_data`/
709+
/// `read_multivector_queries` nor `generate_multivector`'s brute-force
710+
/// ground truth apply it — silently proceeding would score
711+
/// normalized-in-engine vectors against un-normalized ground truth, a
712+
/// silent-wrong-result bug rather than a missing feature.
713+
///
714+
/// Called from BOTH `read_multivector_data` and `read_multivector_queries`
715+
/// (not just the Qdrant `configure` path) because `--skip-upload` skips
716+
/// `configure`/`upload` entirely — an engine-side-only guard would leave
717+
/// the search and ground-truth-profiling paths unprotected, which is
718+
/// exactly the entry point `--skip-upload` exists to use (#238).
719+
pub fn ensure_multivector_normalized(&self) -> Result<(), String> {
720+
if !self.needs_normalization() {
721+
return Ok(());
722+
}
723+
let declared = match self.config.distance.as_deref() {
724+
Some(d) => format!("declares distance '{}'", d),
725+
None => "declares no distance (defaults to 'cosine')".to_string(),
726+
};
727+
Err(format!(
728+
"multivector dataset '{}' {}, which requires per-token normalization \
729+
that read_multivector_data/read_multivector_queries do not yet apply — \
730+
refusing rather than silently scoring against un-normalized ground truth",
731+
self.config.name, declared
732+
))
733+
}
734+
707735
/// Read multi-vector upload data from `<dir>/data.mvec`. Ids are the row indices.
708736
pub fn read_multivector_data(&self) -> Result<(Vec<i64>, Vec<MultiVector>), String> {
737+
self.ensure_multivector_normalized()?;
709738
let dir = self.get_path()?;
710739
// Same declared-vs-actual cross-check as read_vectors/read_hybrid_data (#224).
711740
self.validate_vector_count()?;
@@ -723,6 +752,7 @@ impl Dataset {
723752
/// row-alignment guard: a short or misaligned ground truth must fail loudly
724753
/// rather than index out of bounds or score the wrong query.
725754
pub fn read_multivector_queries(&self) -> Result<(Vec<MultiVector>, Vec<Vec<i64>>), String> {
755+
self.ensure_multivector_normalized()?;
726756
let dir = self.get_path()?;
727757
let queries = read_multivector_matrix(
728758
dir.join("queries.mvec")
@@ -1330,4 +1360,73 @@ mod tests {
13301360
let ds = hybrid_dataset(p);
13311361
assert!(ds.read_hybrid_data(false).is_err());
13321362
}
1363+
1364+
/// A multivector dataset rooted at `dir`, with a configurable (possibly
1365+
/// omitted) `distance` — the exact axis the normalization guard branches on.
1366+
fn multivector_dataset(dir: &std::path::Path, distance: Option<&str>) -> Dataset {
1367+
let mut cfg = hybrid_dataset(dir).config;
1368+
cfg.name = "multivector-unit".to_string();
1369+
cfg.dataset_type = Some("multivector".to_string());
1370+
cfg.distance = distance.map(|d| d.to_string());
1371+
cfg.vector_count = None;
1372+
Dataset::new(cfg)
1373+
}
1374+
1375+
/// The normalization guard must fire from BOTH `read_multivector_data` and
1376+
/// `read_multivector_queries` — not just the Qdrant-side `configure` call —
1377+
/// because `--skip-upload` never calls `configure`/`upload` at all, yet
1378+
/// still reaches search and ground-truth profiling through these two
1379+
/// functions (#316 review round 2). Uses a nonexistent directory: the
1380+
/// guard must fire BEFORE any file access, so no fixture files are needed.
1381+
#[test]
1382+
fn multivector_normalization_guard_covers_both_read_paths() {
1383+
let dir = std::path::Path::new("/nonexistent/multivector-guard-test");
1384+
for distance in [Some("cosine"), Some("angular"), None] {
1385+
let ds = multivector_dataset(dir, distance);
1386+
let data_err = ds.read_multivector_data().unwrap_err();
1387+
assert!(
1388+
data_err.contains("per-token normalization"),
1389+
"distance={distance:?}: read_multivector_data got: {data_err}"
1390+
);
1391+
let queries_err = ds.read_multivector_queries().unwrap_err();
1392+
assert!(
1393+
queries_err.contains("per-token normalization"),
1394+
"distance={distance:?}: read_multivector_queries got: {queries_err}"
1395+
);
1396+
}
1397+
}
1398+
1399+
/// The error message must distinguish an EXPLICIT cosine/angular
1400+
/// declaration from an OMITTED `distance` (which defaults to cosine) —
1401+
/// `dataset.distance()` collapses both to `"cosine"`, so building the
1402+
/// message from it alone would claim a declaration that isn't there.
1403+
#[test]
1404+
fn multivector_normalization_error_distinguishes_declared_from_omitted_distance() {
1405+
let dir = std::path::Path::new("/nonexistent/multivector-guard-test");
1406+
let declared = multivector_dataset(dir, Some("cosine"))
1407+
.read_multivector_queries()
1408+
.unwrap_err();
1409+
assert!(
1410+
declared.contains("declares distance 'cosine'"),
1411+
"got: {declared}"
1412+
);
1413+
let omitted = multivector_dataset(dir, None)
1414+
.read_multivector_queries()
1415+
.unwrap_err();
1416+
assert!(omitted.contains("declares no distance"), "got: {omitted}");
1417+
}
1418+
1419+
/// A dataset that does NOT need normalization (dot/l2) must not trip the
1420+
/// guard — it should fail later, on the missing files, not on distance.
1421+
#[test]
1422+
fn multivector_dot_distance_does_not_trip_the_normalization_guard() {
1423+
let dir = std::path::Path::new("/nonexistent/multivector-guard-test");
1424+
let err = multivector_dataset(dir, Some("dot"))
1425+
.read_multivector_queries()
1426+
.unwrap_err();
1427+
assert!(
1428+
!err.contains("per-token normalization"),
1429+
"dot distance must not trip the normalization guard, got: {err}"
1430+
);
1431+
}
13331432
}

src/bin/vector_db_benchmark/engine/qdrant.rs

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -622,23 +622,14 @@ impl QdrantEngine {
622622
/// HNSW is applied separately below via `hnsw_config_diff()`, same as the
623623
/// dense and hybrid paths.
624624
fn create_multivector_collection(&mut self, dataset: &Dataset) -> Result<(), String> {
625-
// `read_multivector_data`/`read_multivector_queries` have no `normalize`
626-
// parameter and `generate_multivector`'s brute-force ground truth scores
627-
// raw (unnormalized) dot products unconditionally. Silently proceeding
628-
// on a cosine (or omitted-distance, which defaults to cosine) dataset
629-
// would score normalized-in-Qdrant vectors against un-normalized ground
630-
// truth — a silent-wrong-result bug, not a missing feature. Refuse
631-
// loudly until per-token normalization is actually threaded through.
632-
if dataset.needs_normalization() {
633-
return Err(format!(
634-
"multivector dataset '{}' declares distance '{}', which requires \
635-
per-token normalization that read_multivector_data/read_multivector_queries \
636-
do not yet apply — refusing rather than silently scoring against \
637-
un-normalized ground truth",
638-
dataset.config.name,
639-
dataset.distance()
640-
));
641-
}
625+
// The authoritative check lives on `Dataset` (`ensure_multivector_normalized`)
626+
// because it must also cover `read_multivector_data`/`read_multivector_queries`
627+
// directly — `--skip-upload` never calls `configure` (and so never reaches
628+
// this function), but search and ground-truth profiling still read via
629+
// those two functions, so an engine-side-only guard would leave that path
630+
// unprotected. This call is fail-fast belt-and-braces: it rejects before
631+
// any Qdrant RPC, on the one path that DOES call `configure`.
632+
dataset.ensure_multivector_normalized()?;
642633

643634
let distance = dataset.distance();
644635
let vector_size = dataset.vector_size();

src/readers/multivector_reader.rs

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,55 @@ mod tests {
206206
f
207207
}
208208

209+
/// GOLDEN test: hand-written bytes, no writer involved — mirrors
210+
/// `sparse_reader::csr_row_count_decodes_a_literal_little_endian_fixture`.
211+
///
212+
/// Every other test here round-trips through `write_multivector_matrix`
213+
/// then `read_multivector_matrix` / `mvec_row_count`. Since the writer and
214+
/// reader live in this same file and share the same assumptions, a
215+
/// COHERENT bug — an endianness flip, or swapping the relative order of
216+
/// the token-count block and the value block — would leave every one of
217+
/// those tests green, because every party to the comparison flipped
218+
/// together. `num_rows` and `dim` are given distinct values, and the two
219+
/// rows distinct token counts, so a field-order or block-order swap
220+
/// decodes to a wrong shape rather than accidentally matching.
221+
#[test]
222+
fn mvec_decodes_a_literal_little_endian_fixture() {
223+
#[rustfmt::skip]
224+
let bytes: &[u8] = &[
225+
// num_rows = 2
226+
0x02, 0x00, 0x00, 0x00,
227+
// dim = 1
228+
0x01, 0x00, 0x00, 0x00,
229+
// token_count[0] = 1, token_count[1] = 2
230+
0x01, 0x00, 0x00, 0x00,
231+
0x02, 0x00, 0x00, 0x00,
232+
// values, row-major: row0 = [10.0], row1 = [20.0, 30.0]
233+
0x00, 0x00, 0x20, 0x41, // 10.0
234+
0x00, 0x00, 0xA0, 0x41, // 20.0
235+
0x00, 0x00, 0xF0, 0x41, // 30.0
236+
];
237+
let f = write_tmp(bytes);
238+
assert_eq!(
239+
mvec_row_count(f.path().to_str().unwrap()).unwrap(),
240+
2,
241+
"num_rows must decode little-endian"
242+
);
243+
let rows = read_multivector_matrix(f.path().to_str().unwrap()).unwrap();
244+
assert_eq!(
245+
rows,
246+
vec![
247+
MultiVector {
248+
vectors: vec![vec![10.0]]
249+
},
250+
MultiVector {
251+
vectors: vec![vec![20.0], vec![30.0]]
252+
},
253+
],
254+
"header, token-count block, and value block must decode in the documented order"
255+
);
256+
}
257+
209258
#[test]
210259
fn round_trips_multivector_matrix() {
211260
let rows = vec![
@@ -261,23 +310,38 @@ mod tests {
261310
assert!(err.contains("not a readable multivector file"), "{err}");
262311
}
263312

313+
/// `num_rows = u32::MAX` claims a token-count array far larger than the
314+
/// 8-byte file could hold. `n.checked_mul(4)` itself cannot overflow here —
315+
/// `u32::MAX * 4` fits comfortably in a 64-bit `usize` — so despite the
316+
/// name this trips `read_u32_array`'s `max_bytes` cap, not an arithmetic
317+
/// overflow. Asserting on the message (rather than bare `is_err()`) is
318+
/// what makes that the test's actual claim.
264319
#[test]
265-
fn rejects_token_count_size_overflow() {
320+
fn rejects_a_declared_row_count_the_file_cannot_hold() {
266321
let mut b = Vec::new();
267322
b.extend_from_slice(&u32::MAX.to_le_bytes()); // num_rows
268323
b.extend_from_slice(&16u32.to_le_bytes()); // dim
269324
let f = write_tmp(&b);
270-
assert!(read_multivector_matrix(f.path().to_str().unwrap()).is_err());
325+
let err = read_multivector_matrix(f.path().to_str().unwrap()).unwrap_err();
326+
assert!(err.contains("token-count bytes but file is only"), "{err}");
271327
}
272328

329+
/// `total_tokens = dim = u32::MAX` makes `total_tokens.checked_mul(dim)` ≈
330+
/// 1.8446744065×10^19, which — deliberately — does NOT overflow `u64`
331+
/// (max ≈1.8446744074×10^19), so that guard and the `usize::MAX` check
332+
/// right after it both pass. The `Err` this test relies on actually comes
333+
/// one level down, from `read_f32_array`'s own `n.checked_mul(4)`
334+
/// overflowing on that same huge count. Asserting on the message is what
335+
/// pins the test to the branch it actually exercises.
273336
#[test]
274-
fn rejects_total_value_count_overflow() {
337+
fn rejects_value_block_byte_size_overflow() {
275338
let mut b = Vec::new();
276339
b.extend_from_slice(&1u32.to_le_bytes()); // num_rows = 1
277340
b.extend_from_slice(&u32::MAX.to_le_bytes()); // dim
278341
b.extend_from_slice(&u32::MAX.to_le_bytes()); // token_count[0]
279342
let f = write_tmp(&b);
280-
assert!(read_multivector_matrix(f.path().to_str().unwrap()).is_err());
343+
let err = read_multivector_matrix(f.path().to_str().unwrap()).unwrap_err();
344+
assert!(err.contains("multivector value size overflow"), "{err}");
281345
}
282346

283347
#[test]

tests/common/mod.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1724,6 +1724,18 @@ pub struct MultiVectorProject {
17241724
pub fn write_multivector_project(
17251725
dataset_name: &str,
17261726
engine_configs_json: &str,
1727+
) -> MultiVectorProject {
1728+
write_multivector_project_with_distance(dataset_name, engine_configs_json, "dot")
1729+
}
1730+
1731+
/// Same fixture as [`write_multivector_project`], but with a caller-chosen
1732+
/// `distance` — used to exercise the normalization guard against a
1733+
/// cosine/angular (or omitted) multivector dataset, which this repo does not
1734+
/// yet support (#316 review round 2).
1735+
pub fn write_multivector_project_with_distance(
1736+
dataset_name: &str,
1737+
engine_configs_json: &str,
1738+
distance: &str,
17271739
) -> MultiVectorProject {
17281740
const DIM: usize = 16;
17291741
const MIN_TOKENS: usize = 4;
@@ -1752,7 +1764,7 @@ pub fn write_multivector_project(
17521764

17531765
let datasets_json = serde_json::json!([{
17541766
"name": dataset_name, "type": "multivector", "path": dataset_name,
1755-
"distance": "dot", "vector_size": DIM,
1767+
"distance": distance, "vector_size": DIM,
17561768
}]);
17571769
fs::write(
17581770
root.join("datasets/datasets.json"),

tests/integration_qdrant.rs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,96 @@ fn test_binary_qdrant_multivector() {
675675
);
676676
}
677677

678+
/// The normalization guard must survive `--skip-upload`, which skips
679+
/// `configure`/`upload` entirely and so never reaches the Qdrant-side check
680+
/// inside `create_multivector_collection` — yet search still reads via
681+
/// `Dataset::read_multivector_queries`, which is where the guard now also
682+
/// lives (#316 review round 2: the guard was originally placed ONLY in
683+
/// `create_multivector_collection`, leaving this exact entry point open).
684+
///
685+
/// Reproduces the reviewer's scenario precisely: upload once under a
686+
/// `dot`-distance registration (the guard doesn't fire, so a real 150-point
687+
/// corpus lands in Qdrant), then re-register the SAME corpus under `cosine`
688+
/// and rerun with `--skip-upload` — corpus reuse succeeds (the collection
689+
/// genuinely holds all 150 points), so the run reaches search, which must now
690+
/// fail on the normalization guard rather than publish a wrong recall number.
691+
#[test]
692+
fn test_binary_qdrant_multivector_skip_upload_rejects_cosine() {
693+
wait_for_qdrant();
694+
delete_collection();
695+
696+
let configs = serde_json::json!([{
697+
"name": "qdrant-multivector-cosine-guard", "engine": "qdrant",
698+
"connection_params": {"timeout": 60}, "collection_params": {"timeout": 60},
699+
"search_params": [{"parallel": 1}], "upload_params": {"parallel": 1, "batch_size": 50}
700+
}]);
701+
let proj = common::write_multivector_project(
702+
"multivector-cosine-guard",
703+
&serde_json::to_string(&configs).unwrap(),
704+
);
705+
706+
let run = |extra: &[&str]| -> std::process::Output {
707+
let mut cmd = std::process::Command::new(binary_path());
708+
cmd.args([
709+
"--engines",
710+
"qdrant-multivector-cosine-guard",
711+
"--datasets",
712+
&proj.dataset_name,
713+
"--host",
714+
"localhost",
715+
"--skip-if-exists",
716+
"false",
717+
])
718+
.args(extra)
719+
.env("QDRANT_GRPC_PORT", qdrant_grpc_port().to_string())
720+
.env("QDRANT_REST_PORT", qdrant_rest_port().to_string())
721+
.current_dir(&proj.root);
722+
cmd.output().expect("run vector-db-benchmark")
723+
};
724+
725+
// --keep-data: phase 2 needs the collection still present under
726+
// --skip-upload — without it the normal end-of-run cleanup would delete
727+
// it, and phase 2 would fail on "corpus is empty" instead of the guard.
728+
let phase1 = run(&["--keep-data"]);
729+
assert!(
730+
phase1.status.success(),
731+
"phase 1 (dot upload) failed:\n{}{}",
732+
String::from_utf8_lossy(&phase1.stdout),
733+
String::from_utf8_lossy(&phase1.stderr)
734+
);
735+
736+
// Re-register the IDENTICAL corpus/collection under "cosine" — same
737+
// dataset name and path, so data.mvec and the Qdrant collection are
738+
// unchanged; only the declared distance flips.
739+
let datasets_json = serde_json::json!([{
740+
"name": proj.dataset_name, "type": "multivector", "path": proj.dataset_name,
741+
"distance": "cosine", "vector_size": 16,
742+
}]);
743+
std::fs::write(
744+
proj.root.join("datasets/datasets.json"),
745+
serde_json::to_string_pretty(&datasets_json).unwrap(),
746+
)
747+
.unwrap();
748+
749+
let phase2 = run(&["--skip-upload", "--keep-data"]);
750+
let combined = format!(
751+
"{}{}",
752+
String::from_utf8_lossy(&phase2.stdout),
753+
String::from_utf8_lossy(&phase2.stderr)
754+
);
755+
assert!(
756+
!phase2.status.success(),
757+
"--skip-upload against a cosine-declared multivector dataset must fail.\n{combined}"
758+
);
759+
assert!(
760+
combined.contains("per-token normalization"),
761+
"failure must be the normalization guard, not something else.\n{combined}"
762+
);
763+
764+
delete_collection();
765+
std::fs::remove_dir_all(&proj.root).ok();
766+
}
767+
678768
/// End-to-end HYBRID (dense + sparse) coverage WITH a negative control.
679769
///
680770
/// The planted dataset's ground truth is recoverable ONLY by fusing both

0 commit comments

Comments
 (0)