Fix normalized cosine distance consistency for PQ graphs - #1298
Conversation
There was a problem hiding this comment.
Pull request overview
This PR makes Metric::CosineNormalized behavior internally consistent across all PQ-involved distance paths by using 0.5 * squared_l2 for query↔PQ, full↔PQ, and PQ↔PQ comparisons. This prevents pruning/search from comparing incompatible distance scales when PQ vectors are involved.
Changes:
- Introduces a shared scaling constant and applies it to
FixedChunkPQTableCosineNormalized distances (query↔PQ and PQ↔PQ). - Adds a scaled lookup-table construction path for
CosineNormalizedso query-time evaluation remains a table lookup. - Adds regression tests to ensure cross-computer consistency and correct hybrid (full/quant) dispatch behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
diskann-providers/src/model/pq/fixed_chunk_pq_table.rs |
Defines the scale constant and applies scaled squared-L2 for CosineNormalized in direct PQ distance paths. |
diskann-providers/src/model/pq/distance/l2.rs |
Adds new_scaled to scale precomputed L2 lookup tables for CosineNormalized preprocessing. |
diskann-providers/src/model/pq/distance/dynamic.rs |
Wires CosineNormalized to the scaled L2 preprocessing and updates QQ dispatch + tests. |
diskann-providers/src/model/graph/provider/async_/distances.rs |
Adds a regression test ensuring hybrid full/quant and quant/quant CosineNormalized paths use the scaled L2 definition. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1298 +/- ##
==========================================
+ Coverage 92.30% 92.61% +0.30%
==========================================
Files 517 522 +5
Lines 98520 99531 +1011
==========================================
+ Hits 90943 92183 +1240
+ Misses 7577 7348 -229
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (4)
diskann-providers/src/model/pq/distance/dynamic.rs:505
- The relative tolerance here (6.3e-7) is tighter than other SIMD-vs-scalar distance tests in this crate (commonly 1e-6). Relaxing to 1e-6 would make this regression test less likely to be flaky across platforms.
assert_relative_eq!(
cosine_normalized.evaluate_similarity(&*code0, &*code1),
expected,
max_relative = 6.3e-7,
);
diskann-providers/src/model/graph/provider/async_/distances.rs:212
- This assertion uses a very tight relative tolerance (1e-7). Using 1e-6 would better match other SIMD-vs-scalar comparisons in diskann-providers and reduce the risk of cross-platform FP flakiness.
assert_relative_eq!(quant_quant, expected_quant_quant, max_relative = 1.0e-7);
diskann-providers/src/model/graph/provider/async_/distances.rs:203
- This assertion uses a very tight relative tolerance (1e-7). Using 1e-6 would better match other SIMD-vs-scalar comparisons in diskann-providers and reduce the risk of cross-platform FP flakiness.
This issue also appears on line 212 of the same file.
assert_relative_eq!(full_quant, expected_full_quant, max_relative = 1.0e-7);
diskann-providers/src/model/pq/distance/dynamic.rs:439
- These new floating-point assertions use a tighter relative tolerance (5e-7) than similar SIMD-vs-scalar comparisons elsewhere in this crate (often 1e-6). Consider relaxing to 1e-6 to reduce cross-arch / compiler flakiness.
This issue also appears on line 501 of the same file.
assert_relative_eq!(query_distance, expected, max_relative = 5.0e-7);
assert_relative_eq!(random_access_distance, expected, max_relative = 5.0e-7);
assert_relative_eq!(
query_distance,
random_access_distance,
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
diskann-providers/src/model/pq/distance/dynamic.rs:59
- The doc comment claims that for non-normalized operands the
0.5 * squared_l2approximation differs from normalized cosine distance only by a positive factor and therefore preserves candidate ordering. That relationship is not generally true when norms vary; the difference is not just a constant scale, and ordering can change. Please adjust the comment to avoid stating an incorrect guarantee.
/// In other words, half the squared L2 distance equals normalized cosine distance when
/// both operands are normalized, and the two differ by a positive factor otherwise, so
/// candidate ordering is preserved either way.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
diskann-providers/src/model/graph/provider/async_/distances.rs:187
- The hybrid regression test uses non-unit full vectors (
[1, 0, 0, 2],[2, 0, 0, 1]), butMetric::CosineNormalizedis documented/implemented under a unit-norm assumption. Using unit vectors here would make the test’s intent clearer (CosineNormalized == 0.5*squared-L2) and avoid locking in behavior that only matches scaled-L2 for non-normalized inputs.
let table = FixedChunkPQTable::new(
4,
vec![1.0, 0.0, 0.0, 1.0, 2.0, 0.0, 0.0, 2.0].into(),
vec![0, 2, 4].into(),
)
diskann-providers/src/model/graph/provider/async_/distances.rs:141
HybridComputer::newmapsMetric::CosineNormalizedfull/full comparisons ontoMetric::L2with a 0.5 scale. This contradicts the PR description’s stated scope that full/full comparisons remain on the nativeCosineNormalizedimplementation, and it can also change behavior when full vectors are not perfectly unit-norm. Consider keeping the full-precision path onMetric::CosineNormalized(scale 1.0) and relying on the PQ side’s scaled-L2 approximation for compatibility.
This issue also appears on line 183 of the same file.
pub fn new(quant: pq::distance::DistanceComputer<'a>, dim: Option<usize>) -> Self {
let (full_metric, full_scale) = match quant.metric() {
Metric::CosineNormalized => (Metric::L2, pq::COSINE_NORMALIZED_L2_SCALE),
metric => (metric, 1.0),
};
Kept full/full scaled-L2 intentionally because Hybrid pruning mixes full/full, full/PQ, and PQ/PQ comparisons. Restoring native CosineNormalized only for full/full would reintroduce incompatible distance definitions, especially for u8/i8. The tests now separate and document unit-vector semantics and integer consistency. |
|
Thank you for finding the bug and contributing this! I was wondering if you could add an integration test that would have caught this, as it seems like a fault in our testing that this wasn't found earlier? |
|
Magdalen Dobson Manohar (@magdalendobson) Added in f140b00. I extended the existing SIFT build-and-search coverage with normalized |
Magdalen Dobson Manohar (magdalendobson)
left a comment
There was a problem hiding this comment.
Thanks for adding testing. Looks good to me now. Request that you take a second look at all the quantizer x metric combinations and make sure all of them are tested properly inside the index test, but not blocking.
f140b00 to
2a92bf5
Compare
55d5598 to
2c28add
Compare
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2c28add to
12da2d2
Compare
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Mark Hildebrand (hildebrandmw)
left a comment
There was a problem hiding this comment.
Thanks!
Problem
For Product-PQ with
CosineNormalized, graph-search queries used raw squared L2 while graph pruning used cosine distance.Vamana pruning compares
distance_ik / distance_jk. For normalized vectors, cosine distance is half squared L2, so mixing the two approximately doubled this ratio. This madealpha = 1.2behave likealpha = 0.6, causing over-pruning and poor recall.Fix
Use raw squared L2 for Product-PQ Hybrid and Quantized pruning, matching the existing PQ graph-search query approximation.
The change is scoped to Product-PQ pruning. Search behavior, public PQ distance APIs, other metrics, and other quantization strategies are unchanged.
Validation
Regression tests cover all Hybrid operand combinations and the Quantized PQ/PQ pruning path.
The final commit was benchmarked on the first 100,000 BigANN SIFT base vectors and first 1,000 queries, converted to unit-normalized
f32as required byCosineNormalized. Exact ground truth was recomputed for this subset. Configuration: 50 PQ chunks,max_fp_vecs_per_prune = 48,max_degree = 64,l_build = 100,alpha = 1.2, andsearch_l = 100.Search results
Graph structure
Checks:
cargo test -p diskann-providers --libcargo clippy --workspace --all-targets -- -D warnings