Skip to content

Read a full scan's vectors through one blob handle per chunk - #17

Merged
vlasky merged 1 commit into
vlasky:mainfrom
MayCXC:fullscan-chunk-reads-vlasky
Oct 28, 2026
Merged

vlasky merged 1 commit into
vlasky:mainfrom
MayCXC:fullscan-chunk-reads-vlasky

Conversation

@MayCXC

@MayCXC MayCXC commented Oct 22, 2026 •

Copy link
Copy Markdown

A vec0 full scan reads each row's vector through vec0_get_vector_data(), which looks up the row's chunk, opens the chunk's vectors blob, reads one vector and closes the blob again. A chunk's blob holds chunk_size vectors (3 MiB for 1024 float[768]), and the first read through a newly opened handle walks the blob's overflow pages up to the row's offset, so the opens dominate the scan.

On a 10,000-row float[768] table (medians of 3 interleaved runs of 7 queries):

query main, file this change, file main, memory this change, memory
select sum(length(embedding)) from v 760.8 ms 11.7 ms 88.1 ms 5.5 ms
select sum(vec_distance_cosine(embedding, ?)) from v 777.1 ms 32.2 ms 110.6 ms 24.6 ms

The scan already visits rows in (chunk_id, chunk_offset) order, and the cursor now keeps one blob handle per vector column, moved to the next chunk with sqlite3_blob_reopen(). Each row's chunk position is looked up with vec0_get_chunk_position() when its vector is read: the scan's rowid statement sorts every row when the scan starts, so positions taken from it would miss a delete, insert or optimize the same connection runs during the scan. A write that replaces the chunk's row expires the handle, and a rollback of a transaction that changed the schema invalidates it (SQLITE_ABORT, or SQLITE_ABORT_ROLLBACK with extended result codes on); the read is then retried once on a newly opened handle. Any other failure to move the handle, such as an interrupt raised by a progress handler, fails the read.

The position lookup and that failure handling are mceachen's: photostructure/sqlite-vec@f793711 corrected this change after it merged there as photostructure#1, for the three problems his review found. Two adaptations here: this fork has no vec0_error_rc(), so a failed move fails the read with SQLITE_ERROR like the other failures in the function, and a cancellation inside vec0's own statements is reported here as a vec0 error rather than interrupted, so his interrupt test checks that the cancelled scan fails rather than its message.

tests/test-fullscan.py checks every row's vectors against point lookups across chunks with deleted rows and two vector columns, reads a chunk whose shadow row is rewritten in the middle of a scan, fails on a row deleted during the scan instead of returning the vector of the row that took its slot, reads rows that optimize moves during the scan, and fails a scan cancelled while its handle moves to the next chunk. tests/test-unit.c scans across a rollback that changed the schema, with extended result codes off and on. Those last four fail on this PR's previous head and pass here. The full suite passes (148 passed, 4 skipped, 259 snapshots), as do the C unit tests.

It merges cleanly with #9 through #14 and #18 through #24. The same change is merged on photostructure/sqlite-vec as photostructure#1 with f793711 on top, and open upstream as asg017#336.

A vec0 full scan read each row's vector through vec0_get_vector_data(),
which looks up the row's chunk, opens the chunk's vectors blob, reads one
vector and closes the blob again. A chunk's blob holds chunk_size vectors
(3 MiB for 1024 float[768]), and the first read through a newly opened
handle walks the blob's overflow pages up to the row's offset, so the opens
dominated the scan.

The scan already visits rows in (chunk_id, chunk_offset) order, and the
cursor now keeps one blob handle per vector column, moved to the next chunk
with sqlite3_blob_reopen(). Each row's chunk position is looked up when its
vector is read: the scan's rowid statement sorts every row when the scan
starts, so its positions would miss a delete, insert or optimize the same
connection runs during the scan. A write that replaces the chunk's row
expires the handle, and a rollback of a transaction that changed the schema
invalidates it (SQLITE_ABORT, or SQLITE_ABORT_ROLLBACK with extended result
codes on); the read is then retried once on a newly opened handle. Any other
failure to move the handle, such as an interrupt, fails the read.

The position lookup and that failure handling follow
photostructure/sqlite-vec@f793711 by Matthew McEachen, which corrected this
change after it merged there as photostructure#1.

On a 10,000-row float[768] table (medians of 3 interleaved runs of 7
queries), selecting every vector takes 11.7 ms instead of 760.8 ms in a
database file and 5.5 ms instead of 88.1 ms in memory, and
vec_distance_cosine() against each 32.2 ms instead of 777.1 ms and 24.6 ms
instead of 110.6 ms. tests/test-fullscan.py checks every row's vectors
against point lookups across chunks with gaps and two vector columns, reads
a chunk rewritten in the middle of a scan, fails on a row deleted during the
scan instead of returning another row's vector, reads rows that optimize
moves during the scan, and fails a scan cancelled while its handle moves to
the next chunk; tests/test-unit.c scans across a rollback that changed the
schema, with extended result codes off and on.
@vlasky

vlasky commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Thanks for the update, and for pulling in mceachen's correction. The updated head resolves the three cases I had found on the previous one, and I re-ran them here:

  • INSERT OR REPLACE of a row the scan has not reached yet, which moves it to a new chunk: the scan now returns the new vector [105, 1], where the previous head returned [0, 0].
  • A row deleted ahead of the cursor whose slot a later insert reuses: "Could not find a row with rowid 12", as on main, where the previous head returned the new row's vector under the old rowid.
  • ROLLBACK TO a savepoint that removes the chunk the retained handle sits on: the scan fails with the same error instead of returning a neighbouring chunk's vectors (on the previous head, rows 18 to 24 came back carrying chunk 2's vectors, and a SQLITE_DEBUG build hit assert(pCur->eState==CURSOR_VALID)).
  • A rollback of a schema change in the middle of a scan continues cleanly, and the (rc & 0xff) mask covers the extended-result-code case I could only reproduce from C.

The speedup is intact: a file-backed 10k x float[768] scan went from 1704 ms to 12.6 ms on the previous head here, and the per-row position lookup costs little on top of that. Full suite and make test-unit pass. It merges cleanly after #9 through #14 and #18 through #26 in the order I used. Merged.

Two optional follow-ups, not blocking: chunk_offset * size is now signed 64-bit arithmetic on a shadow-table value, so a check of 0 <= chunk_offset < chunk_size before the multiply would turn a corrupt _rowids row into a clean error rather than undefined behaviour; and the interrupt test would be stronger with assert total > 0, so it cannot pass without ever cancelling.

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