Skip to content

fix(mem_wal): rank every vector search arm on one distance scale - #9849

Open
hamersaw wants to merge 3 commits into
lance-format:mainfrom
hamersaw:fix/mem-wal-vector-distance-scale
Open

hamersaw wants to merge 3 commits into
lance-format:mainfrom
hamersaw:fix/mem-wal-vector-distance-scale

Conversation

@hamersaw

@hamersaw hamersaw commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Problem

LsmVectorSearchPlanner merges its arms by _distance, but with cosine the arms reach the merge on different scales:

  • A flushed generation is indexed as IVF_HNSW_SQ, which ranks cosine as L2 over normalized vectors: 2 * (1 - cos).
  • The memtable ranks cosine exactly: 1 - cos.
  • A caller merging its own base-table search (sophon's query node does) gets whatever that index reports, which is 2 * (1 - cos) for SQ and PQ but 1 - cos for FLAT and RQ.

So SSTable rows always lose to memtable rows of equal or worse similarity, and distance_range cuts each arm on a different scale.

Separately, flush wrote the memtable's unnormalized vectors into the cosine SQ index. The index's search normalizes the query and ranks by L2, so a generation of non-unit embeddings ranked by ‖q̂ − x‖², not cosine: recall@10 was 2/10 against exact cosine.

Fix

  • write_sq_storage normalizes vectors before SQ training when the index is cosine, as a regular cosine build does. The graph is unchanged: cosine over raw vectors orders neighbors the same as L2 over normalized ones.
  • Two planner builders:
    • with_refine_factor(Option<u32>): refines the base and SSTable arms, so every arm reports exact distances.
    • with_distance_scale(f32): when unrefined, every arm reports this multiple of the exact distance. The memtable scales its exact distances, SSTables convert from their SQ index's multiple, and the distance range is converted per arm. Defaults to 1.0.
  • plan_search keeps its signature (the Python and Java bindings expose it). refine_base_table now refines SSTable arms too, so the arms stay on one scale.
  • LsmScanner::refine passes its factor through instead of collapsing it to a bool.

Tests

  • test_flusher_cosine_index_ranks_unnormalized_vectors: non-unit cosine vectors flushed to a generation keep recall@10 ≥ 9/10 and report 2 * (1 - cos). Without the fix: 2/10.
  • test_vector_search_cosine_arms_share_one_scale: an SSTable and a memtable come back in true order at scale 1, at scale 2, and refined, and distance_range admits one row from each arm on the 2× scale.
  • cargo test -p lance --lib dataset::mem_wal: 856 passed. cargo fmt is clean.

🤖 Generated with Claude Code

The LSM vector planner merges its arms by `_distance`, but cosine reached
the merge on two scales: flushed generations index as IVF_HNSW_SQ, which
ranks cosine as L2 over normalized vectors (twice `1 - cos`), while the
memtable ranks it exactly. Flush also wrote unnormalized vectors into the
cosine index, so a generation's search ranked non-unit embeddings by L2
against a normalized query.

Normalize vectors at flush for a cosine index, and add two planner
builders: `with_refine_factor` refines the base and SSTable arms so every
arm is exact, and `with_distance_scale` puts unrefined arms on the scale a
caller's own base search reports, converting the distance range to match.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@github-actions github-actions Bot added A-python Python bindings A-java Java bindings + JNI bug Something isn't working labels Oct 9, 2026

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ Gate recommendation: request changes.

Keep candidate-selection bounds and exact-rerank bounds in their respective distance units. The new SSTable refinement path still drops valid cosine neighbors when a distance range is supplied.

Comment on lines +689 to +690
self.distance_range.0.map(|lower| lower / scale),
self.distance_range.1.map(|upper| upper / scale),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refined cosine searches silently lose in-range SSTable rows. When refining, arm_scale returns 1.0, so these bounds remain in exact cosine units. However, Scanner::ann forwards them to HNSW/SQ, which filters on 2 * (1 - cos), before Scanner::flat_knn applies the same bounds to exact distances. In this PR's fixture, id 2 has exact distance 0.4 but SQ distance about 0.763; an upper bound of 0.5 discards it before refinement. Automatic refinement with a base table reaches this path too, and retrying the query cannot recover the missing neighbor.

Use separate bounds for SQ candidate selection and exact reranking, or an exact bounded search path, and cover the refined-range case. Rescaling the final distances cannot restore candidates already discarded.

Reproducer

Add this at the end of test_vector_search_cosine_arms_share_one_scale:

let unrefined = search(1.0, None, (Some(0.15), Some(0.5))).await;
assert_eq!(
    unrefined.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
    vec![3, 2],
);
let refined = search(1.0, Some(1), (Some(0.15), Some(0.5))).await;
assert_eq!(
    refined.iter().map(|(id, _)| *id).collect::<Vec<_>>(),
    vec![3, 2],
    "refined bounded rows: {refined:?}",
);

Run cargo test -p lance --lib test_vector_search_cosine_arms_share_one_scale -- --nocapture.

On 8c504101aaf8380ede4f07d253fc23e8d60ed9b7, the unrefined assertion passes. The refined assertion fails with left: [3], right: [3, 2]; its only row is (3, 0.19999999).

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Oct 9, 2026
@hamersaw

Copy link
Copy Markdown
Contributor Author

❌ Gate recommendation: request changes.

Keep candidate-selection bounds and exact-rerank bounds in their respective distance units. The new SSTable refinement path still drops valid cosine neighbors when a distance range is supplied.

I think we're fixing this in #9851 as part of the follow up. The plan was to merge this fix afterwards because it applies to current non-WAL tables as well. So this is partitioning WAL-fixes with general vector fixes.

@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Oct 11, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Oct 11, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Oct 11, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Oct 11, 2026
…e docs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@lance-gatekeeper lance-gatekeeper Bot removed the K-changes Latest Gatekeeper recommendation requests changes. label Oct 11, 2026

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ Gate recommendation: request changes.

Keep the shared scanner fix in #9851, as you proposed, and land it before #9849. Then rebase this PR and verify the refined SSTable distance-range regression.

The current head still silently drops valid cosine neighbors before exact reranking. #9851 targets this path by converting candidate-selection bounds to the index’s distance units while preserving exact bounds for refinement.

@lance-gatekeeper lance-gatekeeper Bot added the K-changes Latest Gatekeeper recommendation requests changes. label Oct 11, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-java Java bindings + JNI A-python Python bindings bug Something isn't working K-changes Latest Gatekeeper recommendation requests changes.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant