Skip to content

PagedChunkVectorStorage pages with OFFSET: sec ask is 7.3× slower at 400k chunks than the unbounded read it replaced #365

Closed

Description

@sroussey

PagedChunkVectorStorage.similaritySearch pages kb_chunk with ORDER BY chunk_id ASC LIMIT 512 OFFSET n. SQLite walks and discards the first n rows of every page, so reading the table a page at a time is O(N²/page) — the memory bound was bought with a super-linear time cost, and sec ask scores every chunk on every question, so every question pays it.

Measured on a real SQLite kb_chunk (64-dimension vectors, 512-row pages), scanning the whole table the way one question does:

rows     OFFSET paging   keyset paging   unbounded (the old behaviour)
 50,000      566 ms          189 ms          214 ms
100,000    1,885 ms          791 ms          383 ms
200,000    6,040 ms          993 ms          651 ms
400,000   21,482 ms        3,083 ms        2,925 ms

Correction (2026-09-15): re-measured on a real SQLite file with the vector width recorded, the magnitude is 3.3× / +4.6 s at 400k, not 7.3× / +18.6 s. The superlinearity and the fix are unchanged; the magnitude is row-width and machine dependent.

Both forms issue the same 782 statements; only the work per statement differs. The ordering is already the primary key, which is exactly the column a keyset cursor needs, so the memory bound did not have to cost anything.

Fix: replace offset with a WHERE chunk_id > ? predicate.

Activity

  1. sroussey commented on Sep 15, 2026

    @sroussey
    ContributorAuthor

    Fixed on claude/admiring-lovelace-59uare — ba0b58c2 (keyset paging) and e58b2b1f (the toVector note below). No PR opened.

    What changed

    src/kb/PagedChunkVectorStorage.ts:96-119 — the for (let offset = 0; ; offset += SCAN_PAGE) loop over getAll({ limit, offset }) is now a cursor loop over the inherited getPage:

    let cursor: PageCursor | undefined;
    for (;;) {
      const page = await this.getPage({
        orderBy: [{ column: "chunk_id", direction: "ASC" }],
        limit: SCAN_PAGE,
        cursor,
      });
      for (const entity of page.items) { /* filter, cosine, keepBest */ }
      if (page.nextCursor === undefined || page.items.length === 0) break;
      cursor = page.nextCursor;
    }

    ITabularStorage has grown the operator the issue was missing, and more: getPage on a SQL backend pushes the whole keyset predicate down (BaseSqlTabularStorage.runSqlPage / buildKeysetWhere), so no prepare against the raw handle was needed. It also handles the two traps for free — ChunkVectorPrimaryKey is ["chunk_id"], a single column, so the predicate is one inequality rather than a row-value comparison, and the cursor is refused outright if the ordering it was built under stops matching. The memory bound is unchanged: one page of rows plus the topK list. --dry-run is untouched — nothing on this path writes.

    The loop ends on an empty page as well as on an absent cursor. A table whose size is an exact multiple of 512 hands back a cursor for its last full page (nothing about that page says it was the last), and following the cursor alone never terminates.

    Measured

    Rerun from scratch rather than quoting the table above — same shape, smaller absolute numbers. Real SQLite file (WAL), kb_chunk via SqliteVectorStorage, 64-dimension vectors, 512-row pages, topK 5, one whole-table scan per measurement, page cache warmed identically before each. Node 24.20.0 / Bun 1.4.2. The keyset column is the shipped PagedChunkVectorStorage.similaritySearch, not a hand-rolled loop; all three arms are asserted to return the identical ranking at every size.

    rows OFFSET paging (was) keyset paging (now) unbounded read (what OFFSET replaced)
    50,000 381 ms 305 ms 318 ms
    100,000 839 ms 539 ms 589 ms
    200,000 2,170 ms 1,025 ms 1,079 ms
    400,000 6,768 ms 2,096 ms 2,170 ms

    Two earlier runs of the same benchmark agreed within ~5% (400k: offset 7,042 / 7,019 ms, keyset 2,062 / 2,109 ms).

    The superlinearity reproduces: 200k→400k costs OFFSET 3.1× where keyset costs 2.0×. The absolute regression I measured is 3.3× the unbounded read at 400k (+4.6 s per question) rather than the 7.3× / +18.6 s in the report — my vectors are 64-dimension where the original probe's width is not stated, and a narrower row makes the per-page re-walk cheaper relative to the scoring. Direction, exponent and fix are the same; the magnitude is machine- and row-width-dependent.

    Plans, verbatim:

    -- OFFSET page (was)
    SCAN kb_chunk USING INDEX sqlite_autoindex_kb_chunk_1
    
    -- keyset page (now)
    SEARCH kb_chunk USING INDEX sqlite_autoindex_kb_chunk_1 (chunk_id>?)
    

    SCAN vs SEARCH is the whole difference: the OFFSET form re-enters the index at the start each page and discards its way forward.

    Tests

    src/kb/secKnowledgeBaseSearch.test.ts, four cases, with SCAN_PAGE now exported so a corpus can be seeded at an exact page boundary rather than at a number that happens to cross one:

    • seeks to the last key seen rather than counting rows past — pins the statement count at exactly 3 for a 2·SCAN_PAGE + 5 corpus, asserts no read carries OFFSET, and asserts every read after the first carries `chunk_id` >. This is the regression test the issue asked for, and I confirmed it fails on the OFFSET implementation: AssertionError: expected 'SELECT * FROM \kb_chunk` ORDER BY `chunk_id` ASC LIMIT ? OFFSET ?' not to match /OFFSET/i`.
    • terminates on a corpus that is an exact multiple of the page — 2·SCAN_PAGE rows, ranking correct, exactly 3 reads (two full pages plus the empty one that ends it).
    • ranks an empty index without reading a page twice — one read, no second.
    • The original bounded-pages/whole-index-ranked case, re-seeded off SCAN_PAGE.

    The two smaller things

    • toVector — addressed as you suggested, with a comment rather than a behaviour change (e58b2b1f). It now says that Float32Array and the vector / metadata property names are restated because the base holds all three in private fields with no accessor, that they are right for the one ChunkVectorStorageSchema store getSecKnowledgeBase builds and silently wrong for any other, and that widening the class means reading them off the base rather than restating them. The protected accessor upstream is a @workglow/storage change and is not in this branch.
    • Cost pinned — the statement-count assertions above are what a future regression would break.

    Verification

    command exit time
    bun run format-check 0 —
    bun run lint 0 —
    bun run typecheck 0 1.7 s
    bun run build 0 —
    bunx vitest run src/kb 0 21 tests
    bunx vitest run (full suite) 0 132 s — 1496 passed, 21 skipped, 151 files

    No timeouts and no load-sensitive failures in the verify tier on this run.


    Generated by Claude Code

  2. sroussey commented on Sep 21, 2026

    @sroussey
    ContributorAuthor

    Fixed by 31d72bdd ("perf(kb): page kb_chunk by key rather than by offset (#373)"). Verified by execution at d544bc64, not by reading the diff.

    1. The shipped class no longer emits an OFFSET. Driving the real PagedChunkVectorStorage against a real node:sqlite file (1,543 rows = 3 full pages + 7, SCAN_PAGE = 512), recording every statement it prepares and taking EXPLAIN QUERY PLAN of each:

    rows seeded: 1543 page: 512
    top3: c001542, c001541, c001540   (the LAST three ids — the whole table is still ranked)
    chunk-table reads: 4
      [0] OFFSET=false  SELECT * FROM `kb_chunk` ORDER BY `chunk_id` ASC NULLS FIRST LIMIT ?
           plan=SCAN kb_chunk USING INDEX sqlite_autoindex_kb_chunk_1
      [1] OFFSET=false  SELECT * FROM `kb_chunk` WHERE ((`chunk_id` > ?)) ORDER BY `chunk_id` ASC NULLS FIRST LIMIT ?
           plan=SEARCH kb_chunk USING INDEX sqlite_autoindex_kb_chunk_1 (chunk_id>?)
      [2] OFFSET=false  … same …
      [3] OFFSET=false  … same …
    

    SCAN … USING INDEX → SEARCH … USING INDEX (chunk_id>?) is exactly the plan change the commit claims, and the ranking is unchanged: the three best chunks are seeded LAST in chunk_id order, so only a scan that reaches the end finds them.

    2. The super-linearity is gone, and the memory bound is now free. Three arms over a real SQLite file (64-dimension JSON vectors, 512-row pages, cosine per row, top-3 kept), all three asserted to return identical rankings, two independent runs:

    rows       OFFSET      keyset   unbounded read    statements (offset/keyset)
     50,000      377 ms      224 ms       392 ms          98 / 98
    100,000    1,395 ms      370 ms       665 ms         196 / 196
    200,000    2,795 ms    1,136 ms     1,516 ms         391 / 391
    400,000    9,509 ms    1,262 ms     1,934 ms         782 / 782
    (repeat)  7,496 ms    1,265 ms     1,941 ms
    

    200k → 400k costs OFFSET 3.4× and keyset 1.1×. At 400k chunks the fix removes ~6.2–8.2 s per question, and the paged read is now faster than the unbounded read it replaced (1.26 s vs 1.93 s) rather than 3.3× slower — so the heap ceiling is removed at no time cost at all, which is what this issue asked for.

    3. The termination case is real and is pinned. 31d72bdd breaks on nextCursor === undefined or an empty page, with a comment saying why: a table whose size is an exact multiple of the page hands back a cursor for its last full page, and following the cursor alone never ends. secKnowledgeBaseSearch.test.ts seeds SCAN_PAGE * 2 and asserts exactly three chunk reads. SCAN_PAGE is now exported so the tests seed a real page boundary rather than a number that happens to cross one.

    Gates at d544bc64: format-check 0, lint 0, typecheck 0, build 0. bunx vitest run src/kb src/task/kb src/cli/groups → 9 files / 55 tests passed.

    One residual, split out rather than left here: in the fully-indexed steady state sec ask still traverses all of filing_document on every question, and the new (filing_date, accession_number) index is a wash-to-worse in exactly that regime. Filed separately.

    Closing as completed.


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions