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/prd → analysis/grades/2026-09-14/sec-detailed.md §4.2.
6d9ee18replacedSqliteVectorStorage.similaritySearch's unboundedSELECT * FROM kb_chunkwith 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 theprimary-key index from the start on every page, so the total traversal is
O(N²/page).src/kb/PagedChunkVectorStorage.ts:86-101:Measured
node:sqlite, in-memorykb_chunk(uuid TEXT primary key, JSON-encoded vector, metadata),scanning the whole table the way one question does. Three forms, same data, same process:
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 indexis"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:
carrying the last row's
chunk_idforward, starting from"". That keeps the "pages partitionthe 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.
ITabularStoragedoes not expressWHERE chunk_id > ?today; this class already reaches forgetAllon the inherited SQLite storage, so aprepareagainst the same handle is the smallestchange, or
query({ chunk_id: { value: last, operator: ">" } })if the tabular layer grows it.Two smaller things in the same file
toVector(:113-117) hardcodesFloat32Array, where the base class uses its configurablevectorCtor. Likewiseentity.vector/entity.metadataare hardcoded where the base usesvectorPropertyName/metadataPropertyName. Both are correct forChunkVectorStorageSchematoday 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.tspins that everykb_chunkread carries aLIMIT, which is theright 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
Found during the 2026-09-14 review. Snapshot:
workglow-dev/prd→analysis/grades/2026-09-14/sec-detailed.md§4.2.