Skip to content

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

Description

@sroussey

6d9ee18 replaced SqliteVectorStorage.similaritySearch's unbounded SELECT * FROM kb_chunk
with a paged scan, to stop one question hydrating the whole index. The memory bound is real and
the reasoning is right. The paging form is not: it is OFFSET-based, and SQLite walks the
primary-key index from the start on every page, so the total traversal is O(N²/page).

src/kb/PagedChunkVectorStorage.ts:86-101:

for (let offset = 0; ; offset += SCAN_PAGE) {
  const page = (await this.getAll({
    orderBy: [{ column: "chunk_id", direction: "ASC" }],
    limit: SCAN_PAGE,
    offset,
  })) ?? [];

Measured

node:sqlite, in-memory kb_chunk (uuid TEXT primary key, JSON-encoded vector, metadata),
scanning the whole table the way one question does. Three forms, same data, same process:

rows     OFFSET paging   keyset paging   unbounded (what this replaced)
 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

Doubling the corpus multiplies OFFSET time by ~3.3×. At 400k chunks the change costs
+18.6 s per question against the read it replaced and +18.4 s against the keyset form.
All three issue the same 782 statements at that size; only the work per statement differs.

That matters here specifically because the commit message's own framing is that sec index is
"a build measured in hours to days" — i.e. the index this has to read is the large one.

Fix

The ordering is already on the primary key, which is exactly the column a keyset cursor needs, so
the memory bound costs nothing if the page is selected by key instead of by position:

SELECT * FROM kb_chunk WHERE chunk_id > ? ORDER BY chunk_id ASC LIMIT 512

carrying the last row's chunk_id forward, starting from "". That keeps the "pages partition
the table" property the current comment argues for — it is in fact stronger, since it does not
depend on the table being unmodified during the scan — and it removes the whole regression.

ITabularStorage does not express WHERE chunk_id > ? today; this class already reaches for
getAll on the inherited SQLite storage, so a prepare against the same handle is the smallest
change, or query({ chunk_id: { value: last, operator: ">" } }) if the tabular layer grows it.

Two smaller things in the same file

  • toVector (:113-117) hardcodes Float32Array, where the base class uses its configurable
    vectorCtor. Likewise entity.vector / entity.metadata are hardcoded where the base uses
    vectorPropertyName / metadataPropertyName. Both are correct for ChunkVectorStorageSchema
    today and silently wrong for a differently-constructed instance. The base's fields are private,
    which is why the duplication exists — worth a comment saying so, or a protected accessor
    upstream.
  • secKnowledgeBaseSearch.test.ts pins that every kb_chunk read carries a LIMIT, which is the
    right assertion for the memory bound. Nothing pins the cost, so a keyset rewrite has no
    regression test to break — a page-count or statement-count assertion would keep it.

Verify

# reproduce the table above
node -e '<the probe above>'   # or add a bench under src/kb/

Found during the 2026-09-14 review. Snapshot: workglow-dev/prdanalysis/grades/2026-09-14/sec-detailed.md §4.2.

Activity

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