Skip to content

Select each chunk's k nearest rows with a heap - #2

Closed
MayCXC wants to merge 1 commit into
photostructure:mainfrom
MayCXC:knn-topk-heap-photostructure
Closed

MayCXC wants to merge 1 commit into
photostructure:mainfrom
MayCXC:knn-topk-heap-photostructure

Conversation

@MayCXC

@MayCXC MayCXC commented Oct 22, 2026 •

Copy link
Copy Markdown

min_idx() chose the k nearest candidates of a chunk by scanning the whole chunk once per result, k times n comparisons. With the default chunk size of 1024, a KNN query with k of 1024 or more spends about a million comparisons per chunk. On a 10,000-row float[768] table (medians of 5 interleaved runs, each the median of 7 queries):

k main this change
1 8.7 ms 8.7 ms
100 9.2 ms 8.8 ms
1000 17.8 ms 9.3 ms
4096 18.0 ms 9.3 ms

Upstream already has the fix behind a build option: asg017/sqlite-vec@0de765f added SQLITE_VEC_EXPERIMENTAL_MIN_IDX, a min_idx() that keeps the k best candidates in a binary heap in the output array, whose root is the one that ranks last, so a chunk costs n log k comparisons, then sorts the heap in place. That heap compares distances alone, so tied rows come back in another order than the scan's: built with the option, 779 of 1,000 tie-heavy queries on asg017's main return other rows or another order than its default build.

This makes that heap the only min_idx() and gives it one total order, min_idx_after(): by distance, between equal distances the larger index first, as the scan's <= chose, and a NaN distance after every number instead of wherever its position put it. A vec0 table cannot produce a NaN distance, since it rejects NaN and infinite elements, but a static blob can. The bitmap of taken slots the callers allocated for the scan goes with it, from the KNN loop, the static blob filter and the declaration in tests/sqlite-vec-internal.h.

To check the order is unchanged, I ran 4,000 random KNN queries against main and this branch on tables built to tie heavily (vectors on a 5 by 3 grid, chunk sizes 8 to 64, deletes, metadata filters, k from 1 to 1000), each build in its own process: every result matched row for row. With the tie rule reversed, 827 of 1,000 such queries differ. tests/test-knn-topk.py checks the k nearest against a brute-force order for k below, at and above the chunk size, with and without a filter, pins the order of tied rows, and checks that a static blob vector with a NaN element comes back last. On main the first two pass and the third fails: the scan returns the NaN vector first, from slot 0. The 289 snapshot tests, the full suite, make test-unit and make test-asan pass, and test-loadable.py and test-knn-topk.py pass against the AddressSanitizer build. The commit adds a ### Changed entry under [Unreleased] in CHANGELOG.md.

The first version of this branch carried a heap of its own with a separate scratch buffer; it was force-pushed to this port of upstream's, which needs no scratch and runs as fast. The timings above were re-measured on an idle machine, which is why they are lower than the first version's.

The same change is open on vlasky/sqlite-vec as vlasky#18 and upstream as asg017#337, which makes the build option's heap the default. There it also fixes a wrong answer: asg017 accepts zero vectors in cosine columns, their distance is NaN, and its scan returned such a row as the nearest whenever it sat in a chunk's first free slot.

min_idx() chose the k nearest candidates of a chunk by scanning the
whole chunk once per result, k times n comparisons. With the default
chunk size of 1024, a KNN query with k of 1024 or more spent about a
million comparisons per chunk: on a 10,000-row float[768] table,
k = 1000 and k = 4096 took 17.8 and 18.0 ms against 8.7 ms for k = 1.

Upstream has the fix behind a build option: asg017/sqlite-vec 0de765f
added SQLITE_VEC_EXPERIMENTAL_MIN_IDX, a min_idx() that keeps the k
best candidates in a binary heap in the output array, whose root is
the one that ranks last, so a chunk costs n log k comparisons, then
sorts the heap in place. That heap compares distances alone, so tied
rows come back in another order than the scan's: built with the
option, 779 of 1,000 tie-heavy queries on asg017's main return other
rows or another order. This makes that heap the only min_idx() and
gives it one total order, min_idx_after(): by distance, between equal
distances the larger index first, as the scan's <= chose, and a NaN
distance, which a vec0 table cannot produce because it refuses NaN and
infinite elements but a static blob can, after every number instead of
wherever its position put it. The bitmap of taken slots the callers
allocated for the scan goes with it, from the KNN loop, the static blob
filter and the declaration in tests/sqlite-vec-internal.h.

Measured on that table (medians of 5 interleaved runs of 7 queries),
k = 1000 and k = 4096 take 9.3 ms; k = 100 takes 8.8 ms instead of
9.2, and k = 1 8.7 ms either way. Over 4,000 random KNN queries on
tables built to tie heavily (vectors on a 5 by 3 grid, chunk sizes 8
to 64, deletes, metadata filters, k from 1 to 1000), every result
matches the previous selection row for row; with the tie rule
reversed, 827 of 1,000 such queries differ. tests/test-knn-topk.py
checks the k nearest against a brute-force order for k below, at and
above the chunk size, with and without a filter, pins the order of
tied rows, and checks that a static blob vector with a NaN element
comes back last; the scan returned it first, from slot 0.
@MayCXC
MayCXC force-pushed the knn-topk-heap-photostructure branch from 94ff073 to 696cff5 Compare October 23, 2026 03:33
mceachen added a commit that referenced this pull request Oct 23, 2026
#2 ranks a NaN distance after every number within a chunk, but
merge_sorted_lists() put a later chunk's NaN ahead of the numbers already
merged, which a vec0 table written before 2.1.0 can still produce. Its
CHANGELOG entry now also says what changes on such tables.
@mceachen

Copy link
Copy Markdown
Member

Landed as 8ffbdf1, thanks. Follow-up a791836: merge_sorted_lists() still put a later chunk's NaN ahead of numbers already merged, which a vec0 table written before 2.1.0 can hold. It now ranks a NaN after every number across chunks too, with a test that patches a NaN into a stored chunk, and the CHANGELOG says what changes on such tables. vlasky#18 and asg017#337 leave merge_sorted_lists() as it is.

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.

2 participants