Skip to content

Feat/indexer core consolidation - #1

Merged
mkotelnikov merged 12 commits into
mainfrom
feat/indexer-core-consolidation
May 27, 2026
Merged

Feat/indexer core consolidation#1
mkotelnikov merged 12 commits into
mainfrom
feat/indexer-core-consolidation

Conversation

@mkotelnikov

Copy link
Copy Markdown
Contributor

No description provided.

mkotelnikov and others added 12 commits April 24, 2026 14:17
Introduce @statewalker/indexer-core, a workspace-internal package that
consolidates engine-agnostic code previously forked across backends.
See openspec/changes/indexer-core-consolidation/ for full design/specs.

Phase 1 - Scaffold:
* New @statewalker/indexer-core (private:true, not published)
* Add as workspace:* dep in the 5 backends

Phase 2 - Extract pure helpers:
* compositeKey, matchesPrefix, sanitizePrefix, validateDimensionality
* toAsyncIterable (replaces 10 unsafe `as AsyncIterable<>` casts in
  deleteDocuments implementations)
* Persistence byte helpers (toBytes, singleChunk, readEntryBytes)
* resolveDocId DEFERRED to Phase 6 (absorbed into SqlRetrieverBase)

Phase 3 - Unify hybrid merge:
* One mergeByRRF/mergeByWeights/mergeHybrid in indexer-core
* Three byte-identical hybrid-search.ts files deleted
* Preserves composite-key semantics (path + blockId); not routed
  through indexer-api's reciprocalRankFusion, which keys by blockId
  alone and applies a top-rank bonus (deferred as separate change)

Phase 4 - Unify composite index:
* One createCompositeIndex in indexer-core (~225 LOC) replaces three
  ~275-LOC composites (MemIndex / DuckDbIndex / PGLiteIndex)
* Old classes become thin factory functions (~20-40 LOC each) passing
  getSize closure + optional onDeleteIndex hook
* Fixes O(N^2) getDocumentsBlocks bug: removes trackedBlocks map and
  persistence-replay dance; enumeration now unions sub-indexes once
  and de-dupes by composite key
* Regression test in indexer-tests document-paths.suite (500 blocks
  must complete in <3s; O(N^2) would take 2+ seconds)

Net: -1232 LOC working tree. No public API change: every
@statewalker/indexer-api symbol remains exported and behaviourally
identical. Tests: 575/575 passing across 6 packages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 5 of indexer-core-consolidation: extract the persistence-backed
Indexer factory pattern into @statewalker/indexer-core, and remove all
transitional compatibility wrappers.

New: createPersistenceBackedIndexer<F, V>
  6 hooks (createFts, serializeFts, deserializeFts, createVec,
  serializeVec, deserializeVec) + optional persistence. Generic over
  concrete sub-index types so backends keep their instance shapes.
  Preserves the wire format byte-for-byte: __manifest__ /
  <name>/__config__ / <name>/fts / <name>/vec.

Callers collapse:
  * flexsearch-indexer.ts: 243 -> 20 LOC
  * minisearch-indexer.ts: 243 -> 20 LOC

Compatibility wrappers removed (no transitional layer kept):
  * mem-index.ts (MemIndex function) deleted
  * duckdb-index.ts (DuckDbIndex function) deleted
  * pglite-index.ts (PGLiteIndex function) deleted
  * SQL factories now call createCompositeIndex directly via a local
    buildIndex closure shared between createIndex and getIndex

Barrel cleanup (indexer-mem):
  * Only MemVectorIndex remains exported; dead re-exports of MemIndex,
    mergeByRRF, mergeByWeights, bruteForceSearch, cosineSimilarity
    dropped (no external consumers)
  * Stale README rewritten to describe actual exports (closes audit
    task 10.2)

Net: -494 LOC. No public API change. Tests: 575/575 passing across 6
packages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… (Phases 6-7)

Extract the shared SQL scaffolding from indexer-duckdb and indexer-pglite
into indexer-core. Both backends now differ only in their dialect objects
and a minimal SqlDb adapter.

New in @statewalker/indexer-core:
  * sql-db.ts (8 LOC) - SqlDb: minimal normalised async SQL client shape
  * create-sql-fts-retriever.ts (237 LOC) - shared FullTextIndex CRUD
    + resolveDocId + path-filter SQL, driven by a SqlFtsDialect
  * create-sql-vector-retriever.ts (267 LOC) - shared EmbeddingIndex
    CRUD + HNSW DDL + embedding bind/cast/decode, driven by a
    SqlVectorDialect
  * create-sql-backed-indexer.ts (~200 LOC) - shared Indexer shell:
    __indexer_manifest table, extension init, per-index DDL/drop,
    composite assembly with dialect-specific unionAliasSuffix

New in each backend:
  * dialect.ts (DuckDB: 146 LOC, PGlite: 161 LOC) - the pieces that
    genuinely differ: DDL, search SQL, embedding-literal strategy,
    embedding decode, extension init. Exports an aggregated
    duckdbDialect / pgliteDialect for createSqlBackedIndexer.
  * wrapDbAsSqlDb() adapter normalising each driver's result shape
    (db-api returns bare array; PGlite returns { rows }).

Sub-index / factory shrink:
  * duckdb-full-text-index.ts: 252 -> 24 LOC
  * duckdb-vector-index.ts:    243 -> 24 LOC
  * duckdb-indexer.ts:         228 -> 15 LOC
  * pglite-full-text-index.ts: 275 -> 24 LOC
  * pglite-vector-index.ts:    245 -> 24 LOC
  * pglite-indexer.ts:         220 -> 24 LOC

Absorbs deferred task 2.4 (resolveDocId extraction) which was held for
Phase 6 because the two backends' driver query methods return results
in different shapes - now normalised via SqlDb.

LIKE-based DuckDB FTS stays in the dialect for now; Phase 8 replaces
it with BM25 via the official `fts` community extension.

Net: -1426 LOC from backend files, -494 LOC effective across the
workspace. No public API change. Tests: 575/575 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 8 - Real DuckDB full-text search via the `fts` extension:
  * extensionInit now loads `fts` in addition to `vss`
  * FTS table gains a virtual `fts_id` column - required because
    create_fts_index takes a single-column identifier; ours is the
    concatenation of (doc_id, block_id) via a VIRTUAL GENERATED column
  * New SqlFtsDialect.rebuild hook: the DuckDB dialect implements
    PRAGMA create_fts_index(stemmer=<lang>, stopwords='none',
    strip_accents=1, lower=1, overwrite=1) with a Snowball-stemmer
    name derived from the FullTextIndexInfo.language via
    DUCKDB_STEMMER_MAP (en→english, fr→french, ...)
  * createSqlFtsRetriever tracks a dirty flag: set on every
    addDocument/deleteDocuments, forced true on first open, checked
    lazily before search and on flush. Dialects without a rebuild
    hook (PGlite) bypass this state entirely - tsvector is
    maintained automatically by the DB.
  * Search SQL: SELECT d.path, b.block_id, b.content,
    fts_main_<table>.match_bm25(b.fts_id, $1) AS score FROM ...
    WHERE ... IS NOT NULL ORDER BY score DESC LIMIT $k
  * README updated to document the fts + vss runtime extensions.
  * New indexer-tests cases (cross-backend):
    - multi-term rank: "alpha beta" doc ranks above "alpha" alone
    - flush + search preserves content (exercises rebuild on flush)

Phase 9 - HNSW cosine metric:
  * DuckDB HNSW DDL now emits `WITH (metric = 'cosine')`. Without
    this, array_cosine_distance queries silently fell back to
    sequential scan (vss default metric is l2sq).
  * Parameter-bound embeddings attempted but reverted: the
    @statewalker/db-duckdb-node driver rejects JS arrays passed via
    prepared.bind() - 38 tests failed. Kept the locale-independent
    string-literal path (documented at each dialect).
  * PGlite HNSW already declared vector_cosine_ops; verified
    unchanged.

Phase 10 - Cleanups:
  * indexer-api README corrected (removed references to
    non-existent helpers isCollectionPrefix et al.)
  * biome check --write --unsafe across touched packages

Tests: 585/585 passing across 6 packages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pre-refactor READMEs documented APIs that never existed (e.g.
createMemIndex, idx.add/idx.search, chunkByTokens/chunkByParagraph)
and used @repo/ package names that don't match the published
@statewalker/ namespace. This pass rewrites every README to reflect
what the code actually exports.

Sub-repo root:
  * Add @statewalker/indexer-core to the package table with a
    Published=no marker; restate each backend's actual
    implementation (BM25 via `fts` extension for DuckDB, HNSW
    cosine everywhere, etc.)

Per-package:
  * indexer-api - s/@repo\//@statewalker\//g (7 replacements);
    content already accurate
  * indexer-chunker - actual exports (chunkMarkdown, scanBreakPoints,
    findBestCutoff, findCodeFences/isInsideCodeFence); drop the
    fictional chunkByTokens/chunkByParagraph
  * indexer-core - document the full post-refactor surface:
    createCompositeIndex, mergeByRRF/mergeByWeights/mergeHybrid,
    createPersistenceBackedIndexer, createSqlBackedIndexer,
    createSqlFtsRetriever, createSqlVectorRetriever, SqlDb,
    SqlBackedDialect, plus pure helpers
  * indexer-mem-flexsearch / -minisearch - real API is
    createXxxIndexer({persistence?}) returning Indexer with the
    standard indexer.createIndex(...).index.addDocument(...)/
    index.search(...) flow; document the persistence wire format
  * indexer-pglite - real function is createPGLiteIndexer
    (was: createPgliteIndexer); document the `vector` extension
    load, the SQL shape (idx_<prefix>_docs/_fts/_vec with GIN +
    HNSW cosine), the owns-db-if-none-passed semantics
  * indexer-tests - real export is runIndexerTestSuite
    (was: runIndexerSuite); add the fixture-loader exports

indexer-duckdb/README.md and indexer-mem/README.md were already
refreshed in Phases 8 and 5 respectively.

No code changes; tests remain 585/585.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move the two integration tests that exercise the indexer-api helpers
against a real flexsearch backend into indexer-mem-flexsearch/tests/
where they belong. Drop the indexer-mem-flexsearch devDependency from
indexer-api so neither the direct cycle nor the longer
indexer-api → mem-flexsearch → indexer-tests → indexer-api loop
remains.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…exer-search strategy stack

- @statewalker/indexer-api becomes pure contract (types/interfaces, zero runtime)
- new @statewalker/indexer-search owns SearchPipeline, SemanticIndex, query parser, intent, reranker blending, mocks
- consolidate two RRF implementations into @statewalker/indexer-core; mergeByRRF delegates to reciprocalRankFusion
- defaultMultiSearch renamed to fanOutSearch and moved to indexer-core (backend-internal)
- remove indexDocuments helper (callers use SemanticIndex.addDocuments)
- remove RerankResult type alias (RerankerFn now returns ScoredItem[])
- remove indexer-tests/multi-search.suite.ts; semantic-index suite moves SemanticIndex import to indexer-search
- update README for both packages and reorganise indexer-api/src into contract/ subdirectory

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the inline `import("@statewalker/indexer-api").DocumentPath`
type casts with a module-level type-only import. Same behavior; a few
unrelated formatting tweaks come along (SqlDb.query and the vector
search row generic).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…xer-search

Five conflict-free cleanups identified by /grill-complexity on 2026-05-23
(see umbrella notes/2026-05/2026-05-23/grill-complexity-statewalker-indexer-packages.md):

R1 — drop dead exports from @statewalker/indexer-core:
  - delete fan-out-search.ts (95 LOC, docstring already admitted "not part
    of any consumer-facing public surface")
  - drop mergeHybrid wrapper (createCompositeIndex inlines the RRF/weights
    branch directly)
  - drop buildRrfTrace + RRFTrace/RRFContribution (no caller)

R2 — delete 4 unused SQL sub-index factory files in indexer-duckdb /
indexer-pglite that were never re-exported from any barrel and never
imported (~96 LOC).

R3 — relocate QMD-port utilities (parseStructuredQuery, validateLexQuery,
validateSemanticQuery, extractIntentTerms, selectBestChunk) from the
top-level @statewalker/indexer-search barrel into a secondary ./utils
sub-export. Marks them clearly as "unwired primitives", not active
orchestration surface.

R4 — collapse SemanticIndex (119 LOC class, five pass-through methods)
to two free functions: embedAndAdd, embedAndSearch. Tests rewritten.

B5 — demote @statewalker/indexer-search to "private": true (no umbrella
consumer imports it; published-package shape unearned).

Verified: all 525 indexer tests pass; 41 content-pipeline + 10
content-cli tests pass; typecheck clean except pre-existing
TextEncoder/TextDecoder baseline; biome clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…adversarial inputs

Each fix lands red/green: failing test first, then the minimum change to make
it pass. Highlights:

- path-prefix: boundary-aware match (/foo no longer matches /foobar)
- compositeKey: length-prefixed segments so NUL/delimiter can't collide keys
- merge: weighted blend preserves order when all input scores are equal
- mergeByRRF / SearchPipeline: stop feeding blockId as candidate text
- persistence-backed indexer: init failure no longer marks indexer initialised
- FlexSearch/MiniSearch deserialize: throw on unsupported version
- MemVectorIndex Arrow roundtrip: metadata preserved
- SQL retrievers: LIKE wildcards in path prefixes are now escaped, and
  prefix matching respects path-component boundaries (new sql-path-prefix
  helper used by every retriever and dialect)
- SQL retrievers: addDocument is a single INSERT … ON CONFLICT DO UPDATE
- PGlite dialect: language is whitelisted (no SQL injection via info.language)
  and FTS no longer strips non-ASCII (Russian/CJK/etc. reach the engine)
- cosineSimilarity: throws on dimension mismatch instead of silent zero-pad

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eline error surface

Implements the six deferred items from the 2026-05-23 adversarial review,
red/green-driven by per-backend integration suites and unit tests.

D1 — orphaned docs rows: createCompositeIndex grows an `onAfterDelete` hook;
     createSqlBackedIndexer wires it to a correlated NOT EXISTS reclamation
     against the per-index fts/vec tables. New docs-reclamation.suite runs
     against pglite (87→91 tests) and duckdb (166→170 tests).

D2 — atomic createIndex: SqlBackedDialect grows `supportsDDLInTransaction`;
     pglite sets it true (BEGIN/COMMIT/ROLLBACK wrapping), duckdb leaves it
     false (compensating cleanup via dropIndexTables + manifest DELETE). The
     full sequence is restructured so in-memory maps update only after SQL
     succeeds. New create-index-atomicity.suite injects failures and asserts
     the indexer recovers to a consistent state.

D3 — getIndex restore: SQL backend now calls fts.init() / vec.init() in the
     manifest-cache-miss path so externally-dropped tables get recreated
     before the index is returned. Regression test in pglite.

D4 — serialised mutations: new createSerialiser() helper feeds an in-memory
     mutex that wraps createIndex / deleteIndex in both factories. Reads stay
     unwrapped (snapshot-consistent against the in-memory maps). Unit test
     against createPersistenceBackedIndexer + integration test on pglite
     verify concurrent overwrite produces exactly one live index.

D5 — chunker fence boundary: chunk-markdown.ts now treats cutoff bounds the
     same way isInsideCodeFence does (inclusive), advances past the closing
     fence line (scanning to the next \n + 1), and re-scans so back-to-back
     fences cannot trap the cutoff in the next one. Three boundary tests.

D6 — SearchPipeline onError: PipelineConfig grows
     `onError?: (stage, error) => void`. Expander/rerank/citation stages
     call it on failure before degrading to the documented fallback. When
     omitted, the silent-fallback behaviour is preserved.

Misc: PGlite hookTimeout/testTimeout bumped to 30s (WASM startup cost adds
up across 92 fresh-PGlite instances per suite run).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a dirty-bit + cached-output to MemVectorIndex and
FlexSearchFullTextIndex. addDocument/deleteDocuments set dirty; the
serializers (serializeToArrow / serialize) return the cached bytes/JSON
while clean, and the deserializers prime the cache from the input so a
just-loaded index also short-circuits its first serialize.

Makes no-op re-syncs cheap: the persistence layer still calls the
serializer each flush, but the heavy Arrow/FlexSearch encoding only runs
when the index actually changed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@mkotelnikov
mkotelnikov merged commit fbb24c7 into main May 27, 2026
1 check failed
mkotelnikov added a commit that referenced this pull request May 28, 2026
@mkotelnikov
mkotelnikov deleted the feat/indexer-core-consolidation branch June 12, 2026 13:20
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.

1 participant