fix(kb): honour --dry-run when indexing, and page the chunk scan - #364
Merged
Conversation
`sec index --dry-run` and `sec ask --dry-run` wrote for real. `secKnowledgeBase` already recognised that the knowledge base's three storages are built against `getDb()` and never pass through `createStorage`'s `ReadOnlyTabularStorage` wrapper, and guarded the DDL and the `kb_index` row — but nothing guarded the ingest, so on an already-indexed database `runCommand` printed "Dry run — no data will be written" and then committed `kb_document` and `kb_chunk` rows. `IndexFilingSectionsTask` now reports what it would index and returns before the first upsert, the way the other write tasks branch on `isDryRun()`. Retrieval was an unbounded full scan: `SqliteVectorStorage.similaritySearch` is `getAll()` — a bare `SELECT * FROM kb_chunk` — plus a JS cosine per row, so every question hydrated the entire index while `sec index` is documented as a build taking hours to days and `sec ask` tells the user to run it. `PagedChunkVectorStorage` scores the table a page at a time, ordered by the primary key so the pages partition it, keeping only the best `topK`. That bounds memory, not time — there is no ANN index — so the `ask` advice now says a larger index is a slower question and suggests scoping the build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T8DU24G9GWuRUJncc5TJFZ
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two correctness bugs in the knowledge base, one on the write side and one on the read side.
1.
--dry-runwrote to the knowledge base for realWhat was wrong.
secKnowledgeBase.ts:160-193already recognised the hazard: the KB's three storages are constructed directly againstgetDb()and never pass throughcreateStorage, so noReadOnlyTabularStoragewrapper stands between them and a committed row. It guards the DDL (refusing under a dry run when a table is missing, skippingsetupDatabase()) and thekb_indexrow write. Nothing guarded the ingest:IndexFilingSectionsTask.executehad noisDryRun()check at all, andKbAddDocumentTask→KnowledgeBase.upsertlands straight on the rawSqliteTabularStorage/SqliteVectorStorage.The concrete failure. On a database that has been indexed once (so the three tables exist),
sec --dry-run index --company AAPLhasrunCommandprint "Dry run — no data will be written", then embeds every unindexed Apple filing and commitskb_documentandkb_chunkrows.sec --dry-run ask "…"is worse: the implicit pre-index (src/cli/groups/ask.ts:112-116) does the same for up toDEFAULT_ASK_INDEX_LIMIT= 25 filings before the question is read. The added test confirms it against the unfixed code —expect(upsert).not.toHaveBeenCalled()fails with Number of calls: 1.What changed.
IndexFilingSectionsTask.executenow branches onisDryRun()after the selection (which is a read, so the run can still say what it would do) and before the first upsert: it printsWould index N filing(s) into the knowledge base.and returns{ success: true, indexed: 0, sections: 0, skipped, truncated }. That is the shapeBootstrapDownloadTaskandIngestAdvSnapshotTaskalready use.getSecKnowledgeBase()is still called first, so its existing dry-run refusal for an index that does not exist yet is untouched.2.
sec askretrieval was an unbounded full scan ofkb_chunkWhat was wrong. The chunk store was
@workglow/sqlite'sSqliteVectorStorage, whosesimilaritySearchisconst allEntities = (await this.getAll()) || []followed by a JScosineSimilarityper row — andSqliteTabularStorage.getAll()with no options is a bareSELECT * FROM `kb_chunk`with noLIMIT. Every question materialised the entire index.The concrete failure. This is not hypothetical scale; the product directs users at it.
IndexFilingSectionsTask's own doc says a corpus "takes hours to days", andsec askprints "Runsec indexfor the full build" when it truncates. Follow that advice over a few thousand converted filings — hundreds of thousands of chunks, each row carrying its text plus a vector stored as a JSON string — and the nextsec askloads all of it into the heap and hydrates every row before scoring one query. The user pays days of CPU embedding to produce an index that cannot be queried.What changed.
PagedChunkVectorStorage(src/kb/PagedChunkVectorStorage.ts) overridessimilaritySearchto readkb_chunkin 512-row pages, scoring as it goes and keeping only the besttopK. Pages are ordered by the primary key, becauseLIMIT/OFFSETwith noORDER BYis free to hand back a row twice and skip another.secKnowledgeBase.tsconstructs that instead of the base class.What this does not fix, stated where it matters. There is no approximate-nearest-neighbour index here, so every question still scores every chunk: this bounds memory, not time. That is written on the class, and
sec ask's truncation advice now says so too — a larger index is a slower question, and--company/--formscope the build. Adding an ANN index is a@workglow/sqlitechange, not one this repo can make.Tests
Three new cases, all run against the unfixed code first:
src/kb/secKnowledgeBaseSearch.test.ts— seeds 1200 chunks (at a 4-wide vector, viaSEC_EMBEDDING_DIMENSIONS, so no model is loaded), spies on the SQLite connection'sprepare, and asserts every read ofkb_chunkcarries aLIMITwhile the ranking still returns the three best rows — which are seeded last in key order, so only a scan that reaches the end finds them. Failed before (expected 1 to be greater than 1: one unbounded read), passes after.IndexFilingSectionsTask under --dry-run > embeds and persists nothing— failed before (upsertcalled once), passes after.… > still indexes when this is not a dry run— the control; passed before and after, so the guard cannot be a blanket disable.Verified
bunx vitest run src/kb src/task/kb— 41 passedbunx vitest run(full suite) — 151 files passed, 3 skipped; 1486 tests passed, 21 skippedbun run typecheck— cleanbun run lint— cleanbunx oxfmt --check— all matched files correctly formattedbun run build— both binaries bundleNothing was skipped. All checks were run in a fresh worktree after
bun install.Not verified
The paging is exercised over 1200 rows at a 4-wide vector, not against a real multi-hundred-thousand-chunk index at 768 dimensions — no such corpus exists here, and building one is the multi-day job this fix is about. The property under test is that the reads are bounded and the ranking is unchanged, which is what the size-independent part of the claim rests on.
🤖 Generated with Claude Code
https://claude.ai/code/session_01T8DU24G9GWuRUJncc5TJFZ
Generated by Claude Code