Skip to content

Apply distance constraints in rescore KNN queries (fixes #308) - #309

Open
stumpylog wants to merge 1 commit into
asg017:mainfrom
stumpylog:fix/308-rescore-distance-constraints
Open

stumpylog wants to merge 1 commit into
asg017:mainfrom
stumpylog:fix/308-rescore-distance-constraints

Conversation

@stumpylog

Copy link
Copy Markdown

Summary

Fixes #308. On vec0 tables created with INDEXED BY rescore(...), KNN queries silently ignored distance <op> ? constraints:

SELECT rowid, distance FROM t
WHERE embedding MATCH :vector AND k = :limit AND distance <= :threshold
ORDER BY distance

This returned the top-k by distance without filtering out rows above the threshold.

Root cause

The standard chunk-scan path filters candidates against the distance constraints parsed from idxStr/argv. Rescore columns dispatch to rescore_knn, which received those constraints but never applied them.

Fix

Apply the constraints in rescore_knn phase 2, after the exact float distances are computed and before top-k selection — so they target the final rescored distance (what the distance column reports), not the coarse quantized distance from phase 1. Candidates failing a GE/GT/LE/LT constraint are dropped, with an early-out when none survive.

Testing

  • Added test_knn_distance_constraint_le and test_knn_distance_constraint_lt_gt to tests/test-rescore.py; both fail on main and pass with this change.
  • Full loadable suite: 484 passed, 132 skipped.

🤖 Generated with Claude Code

KNN queries on vec0 tables using INDEXED BY rescore(...) silently ignored
`distance <op> ?` constraints. The standard chunk-scan path filters
candidates against parsed distance constraints, but the rescore dispatch
(rescore_knn) never applied them.

Apply the constraints to the rescored float distances in phase 2, before
top-k selection, so they target the same distance the `distance` column
reports. Handle the now-possible zero-surviving-candidates case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mceachen pushed a commit to photostructure/sqlite-vec that referenced this pull request Nov 2, 2026
A float vector column declared `indexed by rescore(quantizer=bit)` or
`indexed by rescore(quantizer=int8)`, with an optional `oversample=N` from 1
to 128 (8 by default), also keeps its vectors quantized in _rescore_chunksNN,
in the layout of _vector_chunksNN, and its float32 vectors by rowid in
_rescore_vectorsNN. A KNN query scans the quantized vectors for the
k * oversample nearest rows, then ranks those candidates by the distances of
their float32 vectors. The `oversample=N` command sets the oversample of a
connection's queries. A full scan reads a rescore column's vectors row by row
through vec0_get_vector_data(), and a flat column's by chunk as before.

Ported from asg017#276 (ba0db0b) as asg017's main has it at
04d28bd: with the int8 quantizer that 575371d fixed, the fixes 5e4c557,
82f4eb0 and 5522e86, auxiliary columns (b7fc459), the oversample=N command
and its unknown-command message (6e2c4c6), and the distance constraints of
the unmerged asg017#309. SQLITE_VEC_ENABLE_RESCORE=0 builds without it.

Where this port differs from upstream:

- rescore_knn() takes vec0Filter_knn_chunks_iter()'s arguments and results,
  so vec0Filter_knn() picks between the two and the MMR rerank runs after
  either. It reports errors as the chunk scan does, so an interrupt stays an
  interrupt. It picks each chunk's candidates with this fork's min_idx(),
  whose heap needs no bitmap of taken slots.
- Distance constraints with a NULL, TEXT or BLOB value act as they do for a
  flat column.
- optimize, which upstream lacks, writes a moved row's quantized vectors to
  its new slot and leaves its row of _rescore_vectorsNN, whose rowid does not
  change, and deletes the old _rescore_chunksNN rows with the old chunks. A
  DELETE that empties a chunk deletes its _rescore_chunksNN row too.
- A point query for a rowid the table does not have returns no row, since
  vec0_get_vector_data() finds the row's position first, as for a flat
  column; upstream fails with "Could not fetch vector data".
- _rescore_chunksNN declares "rowid INTEGER PRIMARY KEY", as this fork's new
  tables do, and an oversample=N value must be all digits.

The tests are upstream's test-rescore.py and test-rescore-mutations.py, the
rescore cases of its test-auxiliary.py and test-rename.py, its rescore fuzz
targets, and test-unit.c's bit quantizer, byte size and parser cases.
Upstream's int8 quantizer unit tests, and the uniform-vector case of
rescore-quantize-edge.c, test the min/max quantizer that 575371d replaced, so
they are written here for the current one. Modes 1 and 2 of
rescore-quantize-edge.c copy the fuzz bytes out to float alignment before
reading them as floats, as asg017#343 has them; upstream's read floats one
byte past the mode byte, a misaligned load UBSan reports. New tests check
that each row's quantized slot holds the quantized form of its vector
through inserts, updates, deletes that empty chunks, and optimize, and
rescore-optimize.c fuzzes optimize. test-fullscan.py reads a rescore column
beside a flat one, and runs its delete and optimize cases on a rescore
column too; read by chunk, a rescore column fails all three with "Could not
fetch vector data".

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Distance threshold query silently fails when using bit quantization

1 participant