Skip to content

Paginate chunk search with keyset cursors instead of OFFSET - #373

Merged
sroussey merged 3 commits into
mainfrom
claude/admiring-lovelace-59uare
Sep 15, 2026
Merged

sroussey merged 3 commits into
mainfrom
claude/admiring-lovelace-59uare

Conversation

@sroussey

Copy link
Copy Markdown
Contributor

Summary

Replaces OFFSET-based pagination in the knowledge base's chunk vector search with keyset (seek) pagination. This eliminates O(rows²) complexity when scanning large indexes, making similarity search linear again while keeping the working set bounded.

Key Changes

  • PagedChunkVectorStorage: Switched from OFFSET n pagination to cursor-based pagination using getPage() with PageCursor. The cursor resumes from the last chunk_id seen rather than counting rows, so each row is read exactly once during a full scan.

  • Test coverage: Added three new test cases to verify pagination behavior:

    • Keyset pagination seeks to the last key rather than using OFFSET
    • Correctly terminates when corpus size is an exact multiple of page size
    • Handles empty indexes without reading a page twice
  • Index optimization: Added ["filing_date", "accession_number"] index to filing_document table in storageRegistry.ts. This matches the ORDER BY in the index selection query exactly, allowing SQLite to walk the index backwards instead of sorting the entire table into a temp B-tree.

  • Task output refinement: Made skipped count optional in IndexFilingSectionsTaskOutput. The count requires a join over every converted filing, which is expensive when the caller won't display it. Added countSkipped input flag (defaults to true for backward compatibility) so sec ask can skip this count on pre-indexing runs.

  • CLI adjustment: Updated ask.ts to handle optional skipped value and pass countSkipped: false when pre-indexing before answering questions.

Implementation Details

The pagination change addresses a critical performance issue: OFFSET n forces SQLite to walk and discard the first n rows on every page, making a large corpus slower to read a page at a time than to read whole. Keyset pagination resumes from the last primary key value, which SQLite already indexes, so the scan becomes linear O(rows) instead of quadratic.

The termination logic handles the edge case where a table's size is an exact multiple of the page size: the last full page still returns a cursor, but the next page is empty. The loop checks both nextCursor === undefined and items.length === 0 to avoid infinite loops.

The filing_document index addition is a complementary optimization: the index selection query reads newest filings first, and the new index's column order matches that ORDER BY exactly, eliminating the need for a temporary sort.

https://claude.ai/code/session_01UP2rTRUU3HjFz5yvyT3dXp

The chunk scan `sec ask` runs paged the table with `LIMIT`/`OFFSET`. That
bounds memory, which is what it was for, but `OFFSET n` makes SQLite walk
and discard the first n rows of every page, so reading the table a page at
a time costs O(rows²) — at 400k chunks, measurably slower than the
unbounded read it replaced, and every question pays it.

The scan is already ordered by the primary key, so the next page is a seek
into the index SQLite keeps for it rather than a count of rows to throw
away. `getPage` expresses exactly that and pushes the predicate down, so
the page is `WHERE chunk_id > ? ORDER BY chunk_id LIMIT ?` and the plan
goes from `SCAN kb_chunk USING INDEX` to `SEARCH kb_chunk USING INDEX
(chunk_id>?)`. The memory bound is unchanged: still one page of rows and a
list held at topK.

Measured over a real SQLite kb_chunk, 64-dimension vectors, 512-row pages:

| rows | OFFSET | keyset | unbounded read |
|---|---|---|---|
| 50k | 358 ms | 307 ms | 277 ms |
| 100k | 841 ms | 515 ms | 546 ms |
| 200k | 2,209 ms | 1,024 ms | 1,359 ms |
| 400k | 7,042 ms | 2,062 ms | 2,133 ms |

The loop ends on an empty page as well as on an absent cursor: a table
whose size is an exact multiple of the page hands back a cursor for its
last full page, and following it alone never terminates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UP2rTRUU3HjFz5yvyT3dXp
… count nobody reads

Two full scans of `filing_document` on every `sec ask`, which pre-indexes
before it answers.

The selection reads the table newest first and takes a page of it, and
there was no index in that order — so SQLite read every row and sorted the
lot into a temp B-tree to hand back the first 26. Declaring
`(filing_date, accession_number)` alongside the two indexes already there
matches the ORDER BY exactly, so the index is walked backwards instead. On
300k rows:

    SCAN d                                              139.7 ms
    USE TEMP B-TREE FOR ORDER BY
    SCAN d USING INDEX
      filing_document_filing_date_accession_number        0.2 ms

`countAlreadyIndexed` is the other scan: a COUNT over the same table joined
to the knowledge base, ~139 ms on the same corpus and unaffected by any
index, since an unbounded aggregate has to visit every matching row. `sec
index` prints the number; `sec ask` never does, and paid for it anyway. It
is now asked for rather than assumed, and `skipped` is left absent when a
run did not count — absent rather than zero, because zero says the index
holds nothing in scope.

An existing database picks the index up from `sec db setup`, which emits
every declared index as `CREATE INDEX IF NOT EXISTS` on every run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UP2rTRUU3HjFz5yvyT3dXp
…nows

`toVector`'s `Float32Array`, and the `vector` / `metadata` property names the
scan reads, are all resolved from the schema by the base class — from private
fields with no accessor, which is the only reason they are restated here. They
are right for the one store this class is constructed for and silently wrong
for any other, so a reader deciding whether to reuse it needs that said out
loud rather than inferred from the fact that it works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UP2rTRUU3HjFz5yvyT3dXp
@sroussey
sroussey merged commit 31d72bd into main Sep 15, 2026
1 check passed
@sroussey
sroussey deleted the claude/admiring-lovelace-59uare branch September 15, 2026 02:02
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