@statewalker/indexer-mem-flexsearch 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mikhail Kotelnikov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # @statewalker/indexer-mem-flexsearch
2
+
3
+ In-memory implementation of `@statewalker/indexer-api` combining [FlexSearch](https://github.com/nextapps-de/flexsearch) for full-text search with `@statewalker/indexer-mem`'s `MemVectorIndex` for embeddings. Optional streaming persistence via `IndexerPersistence`.
4
+
5
+ ## Installation
6
+
7
+ ```sh
8
+ pnpm add @statewalker/indexer-mem-flexsearch
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import { createFlexSearchIndexer } from "@statewalker/indexer-mem-flexsearch";
15
+
16
+ const indexer = createFlexSearchIndexer();
17
+ const index = await indexer.createIndex({
18
+ name: "docs",
19
+ fulltext: { language: "en" },
20
+ vector: { dimensionality: 384, model: "all-MiniLM-L6-v2" },
21
+ });
22
+
23
+ await index.addDocument([
24
+ { path: "/docs/a", blockId: "1", content: "hello world" },
25
+ ]);
26
+
27
+ for await (const hit of index.search({ queries: ["hello"], topK: 10 })) {
28
+ console.log(hit.path, hit.blockId, hit.score);
29
+ }
30
+ ```
31
+
32
+ ### With persistence
33
+
34
+ ```ts
35
+ import { createFlexSearchIndexer } from "@statewalker/indexer-mem-flexsearch";
36
+ import type { IndexerPersistence } from "@statewalker/indexer-api";
37
+
38
+ const persistence: IndexerPersistence = /* … your save/load adapter … */;
39
+ const indexer = createFlexSearchIndexer({ persistence });
40
+
41
+ // First use loads existing state; `indexer.flush()` writes current state back.
42
+ ```
43
+
44
+ Wire format (stable, byte-compatible across releases): `__manifest__` (JSON array of index names), `${name}/__config__`, `${name}/fts` (FlexSearch serialized state), `${name}/vec` (Arrow IPC from `MemVectorIndex`).
45
+
46
+ ## API
47
+
48
+ - `createFlexSearchIndexer(options?)` — returns an `Indexer`. Options: `persistence?: IndexerPersistence`.
49
+ - `FlexSearchIndexerOptions` — option type.
50
+
51
+ ## Related
52
+
53
+ - `@statewalker/indexer-api` — the pluggable contract.
54
+ - `@statewalker/indexer-mem-minisearch` — drop-in alternative using MiniSearch.
55
+ - `@statewalker/indexer-mem` — provides the vector sub-index (`MemVectorIndex`).
@@ -0,0 +1,67 @@
1
+ import { FullTextBlock, FullTextIndex, FullTextIndexInfo, FulltextQuery, FulltextResult } from "@statewalker/indexer-fulltext";
2
+ import { BlockReference, DocumentPath, PathSelector, PersistableSearchIndex, PersistenceEntry } from "@statewalker/indexer-api";
3
+ //#region src/flexsearch-full-text-index.d.ts
4
+ export declare class FlexSearchFullTextIndex implements FullTextIndex, PersistableSearchIndex<FullTextBlock, FulltextQuery, FulltextResult> {
5
+ private readonly info;
6
+ private flexIndex;
7
+ private keyToNum;
8
+ private numToKey;
9
+ private blocks;
10
+ private nextNum;
11
+ private closed;
12
+ /**
13
+ * Dirty bit. `true` whenever the FTS index has been mutated since the
14
+ * last `serialize()`. The serializer caches the last produced JSON and
15
+ * returns it directly when `!dirty`, so a no-op re-sync skips the
16
+ * expensive FlexSearch export entirely. Starts `true` so the first
17
+ * serialize after construction always runs.
18
+ */
19
+ private dirty;
20
+ private cachedSerialized?;
21
+ constructor(info: FullTextIndexInfo);
22
+ /**
23
+ * `true` when the in-memory state has changed since the last serialize.
24
+ * Callers (e.g. the persistence layer) may use it to skip serialization
25
+ * and writes when the index has not been touched.
26
+ */
27
+ isDirty(): boolean;
28
+ private ensureOpen;
29
+ private getOrAssignNum;
30
+ private searchInternal;
31
+ getIndexInfo(): Promise<FullTextIndexInfo>;
32
+ search(params: FulltextQuery): AsyncGenerator<FulltextResult>;
33
+ addDocument(blocks: FullTextBlock[]): Promise<void>;
34
+ addDocuments(blocks: Iterable<FullTextBlock[]> | AsyncIterable<FullTextBlock[]>): Promise<void>;
35
+ deleteDocuments(pathSelectors: PathSelector[] | AsyncIterable<PathSelector>): Promise<void>;
36
+ /** Returns true when the key was present and got removed. */
37
+ private removeByKey;
38
+ getSize(pathPrefix?: DocumentPath): Promise<number>;
39
+ getDocumentPaths(pathPrefix?: DocumentPath): AsyncGenerator<DocumentPath>;
40
+ getDocumentBlocksRefs(pathPrefix?: DocumentPath): AsyncGenerator<BlockReference>;
41
+ getDocumentsBlocks(pathPrefix?: DocumentPath): AsyncGenerator<FullTextBlock>;
42
+ close(_options?: {
43
+ force?: boolean;
44
+ }): Promise<void>;
45
+ flush(): Promise<void>;
46
+ deleteIndex(): Promise<void>;
47
+ /**
48
+ * Stream the FTS sub-index's state as a single named entry. The composite
49
+ * prefixes this with `${indexName}/${subName}/` before it hits persistence.
50
+ */
51
+ serialise(): AsyncIterable<PersistenceEntry>;
52
+ /** Restore the FTS sub-index from a previously-produced entry set. */
53
+ loadFrom(entries: Iterable<PersistenceEntry>): Promise<void>;
54
+ /**
55
+ * Serialize to JSON string (v3 format with path data).
56
+ *
57
+ * Caches the result; while `dirty` is false (no mutations since the
58
+ * last call) returns the same string, skipping the FlexSearch export
59
+ * entirely. The persistence layer still calls into us each flush, but
60
+ * the heavy work only runs when the index actually changed.
61
+ */
62
+ serialize(): Promise<string>;
63
+ /** Deserialize from JSON — handles v3 format */
64
+ static deserialize(info: FullTextIndexInfo, json: string): FlexSearchFullTextIndex;
65
+ }
66
+ //#endregion
67
+ //# sourceMappingURL=flexsearch-full-text-index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flexsearch-full-text-index.d.ts","names":["FullTextSearchParams","FullTextSearchResult"],"sources":["../src/flexsearch-full-text-index.ts"],"mappings":";;;qBAgCa,mCAET,eACA,uBAAuB,eAAeA,eAAsBC;mBAE7C;UACT;UACA;UACA;UACA;UACA;UACA;;;;;;;;UAQA;UACA;EAER,YAAY,MAAM;;;;;;EAclB;UAIQ;UAMA;UAUA;EAwCF,gBAAgB,QAAQ;EAKvB,OAAO,QAAQD,gBAAuB,eAAeC;EAqCtD,YAAY,QAAQ,kBAAkB;EAqBtC,aACJ,QAAQ,SAAS,mBAAmB,cAAc,mBACjD;EAOG,gBACJ,eAAe,iBAAiB,cAAc,gBAC7C;;UAuBK;EAYF,QAAQ,aAAa,eAAe;EAUnC,iBAAiB,aAAa,eAAe,eAAe;EAW5D,sBAAsB,aAAa,eAAe,eAAe;EASjE,mBAAmB,aAAa,eAAe,eAAe;EAc/D,MAAM;IAAa;MAAoB;EAIvC,SAAS;EAKT,eAAe;;;;;EAgBd,aAAa,cAAc;;EAM5B,SAAS,SAAS,SAAS,oBAAoB;;;;;;;;;EA2B/C,aAAa;;SAqCZ,YAAY,MAAM,mBAAmB,eAAe"}
@@ -0,0 +1,284 @@
1
+ import { compositeKey, matchesPrefix, readEntryBytes, singleChunk, toAsyncIterable, toBytes } from "@statewalker/indexer-core";
2
+ import FlexSearch from "flexsearch";
3
+ //#region src/flexsearch-full-text-index.ts
4
+ var FlexSearchFullTextIndex = class FlexSearchFullTextIndex {
5
+ info;
6
+ flexIndex;
7
+ keyToNum = /* @__PURE__ */ new Map();
8
+ numToKey = /* @__PURE__ */ new Map();
9
+ blocks = /* @__PURE__ */ new Map();
10
+ nextNum = 1;
11
+ closed = false;
12
+ /**
13
+ * Dirty bit. `true` whenever the FTS index has been mutated since the
14
+ * last `serialize()`. The serializer caches the last produced JSON and
15
+ * returns it directly when `!dirty`, so a no-op re-sync skips the
16
+ * expensive FlexSearch export entirely. Starts `true` so the first
17
+ * serialize after construction always runs.
18
+ */
19
+ dirty = true;
20
+ cachedSerialized;
21
+ constructor(info) {
22
+ this.info = info;
23
+ this.flexIndex = new FlexSearch.Index({
24
+ tokenize: "forward",
25
+ resolution: 9,
26
+ cache: true
27
+ });
28
+ }
29
+ /**
30
+ * `true` when the in-memory state has changed since the last serialize.
31
+ * Callers (e.g. the persistence layer) may use it to skip serialization
32
+ * and writes when the index has not been touched.
33
+ */
34
+ isDirty() {
35
+ return this.dirty;
36
+ }
37
+ ensureOpen() {
38
+ if (this.closed) throw new Error("FullTextIndex is closed");
39
+ }
40
+ getOrAssignNum(key) {
41
+ let num = this.keyToNum.get(key);
42
+ if (num === void 0) {
43
+ num = this.nextNum++;
44
+ this.keyToNum.set(key, num);
45
+ this.numToKey.set(num, key);
46
+ }
47
+ return num;
48
+ }
49
+ searchInternal(query, topK, pathPrefixes) {
50
+ const ids = this.flexIndex.search(query, topK * 3, { suggest: true });
51
+ let results = ids.map((numId, rank) => ({
52
+ key: this.numToKey.get(numId) ?? "",
53
+ score: 1 - rank / ids.length
54
+ })).filter((r) => r.key !== "");
55
+ if (pathPrefixes && pathPrefixes.length > 0) results = results.filter((r) => {
56
+ const block = this.blocks.get(r.key);
57
+ return block !== void 0 && pathPrefixes.some((p) => matchesPrefix(block.path, p));
58
+ });
59
+ results.sort((a, b) => b.score - a.score);
60
+ return results.slice(0, topK).map((r) => {
61
+ const block = this.blocks.get(r.key);
62
+ return {
63
+ path: block?.path ?? "/",
64
+ blockId: block?.blockId ?? "",
65
+ snippet: block?.content ?? "",
66
+ score: r.score
67
+ };
68
+ });
69
+ }
70
+ async getIndexInfo() {
71
+ this.ensureOpen();
72
+ return { ...this.info };
73
+ }
74
+ async *search(params) {
75
+ this.ensureOpen();
76
+ const { queries, paths } = params;
77
+ const topK = params.topK ?? 100;
78
+ if (!queries || queries.length === 0) return;
79
+ const merged = /* @__PURE__ */ new Map();
80
+ for (const query of queries) {
81
+ const results = this.searchInternal(query, topK, paths);
82
+ for (const r of results) {
83
+ const key = compositeKey(r.path, r.blockId);
84
+ const existing = merged.get(key);
85
+ if (!existing) merged.set(key, {
86
+ result: r,
87
+ best: r.score,
88
+ total: r.score
89
+ });
90
+ else {
91
+ existing.total += r.score;
92
+ if (r.score > existing.best) {
93
+ existing.best = r.score;
94
+ existing.result = r;
95
+ }
96
+ }
97
+ }
98
+ }
99
+ const sorted = [...merged.values()].sort((a, b) => b.total - a.total);
100
+ for (const { result, total } of sorted.slice(0, topK)) yield {
101
+ ...result,
102
+ score: total
103
+ };
104
+ }
105
+ async addDocument(blocks) {
106
+ this.ensureOpen();
107
+ if (blocks.length === 0) return;
108
+ for (const block of blocks) {
109
+ const key = compositeKey(block.path, block.blockId);
110
+ const num = this.getOrAssignNum(key);
111
+ if (this.blocks.has(key)) this.flexIndex.remove(num);
112
+ this.flexIndex.add(num, block.content);
113
+ this.blocks.set(key, {
114
+ path: block.path,
115
+ blockId: block.blockId,
116
+ content: block.content,
117
+ metadata: block.metadata
118
+ });
119
+ }
120
+ this.dirty = true;
121
+ }
122
+ async addDocuments(blocks) {
123
+ this.ensureOpen();
124
+ for await (const batch of blocks) await this.addDocument(batch);
125
+ }
126
+ async deleteDocuments(pathSelectors) {
127
+ this.ensureOpen();
128
+ let removed = 0;
129
+ for await (const sel of toAsyncIterable(pathSelectors)) if (sel.blockId !== void 0) {
130
+ const key = compositeKey(sel.path, sel.blockId);
131
+ if (this.removeByKey(key)) removed += 1;
132
+ } else {
133
+ const keysToDelete = [];
134
+ for (const [key, block] of this.blocks) if (matchesPrefix(block.path, sel.path)) keysToDelete.push(key);
135
+ for (const key of keysToDelete) if (this.removeByKey(key)) removed += 1;
136
+ }
137
+ if (removed > 0) this.dirty = true;
138
+ }
139
+ /** Returns true when the key was present and got removed. */
140
+ removeByKey(key) {
141
+ const num = this.keyToNum.get(key);
142
+ const had = this.blocks.has(key);
143
+ if (num !== void 0) {
144
+ this.flexIndex.remove(num);
145
+ this.keyToNum.delete(key);
146
+ this.numToKey.delete(num);
147
+ }
148
+ this.blocks.delete(key);
149
+ return had;
150
+ }
151
+ async getSize(pathPrefix) {
152
+ this.ensureOpen();
153
+ if (pathPrefix === void 0) return this.blocks.size;
154
+ let count = 0;
155
+ for (const block of this.blocks.values()) if (matchesPrefix(block.path, pathPrefix)) count++;
156
+ return count;
157
+ }
158
+ async *getDocumentPaths(pathPrefix) {
159
+ this.ensureOpen();
160
+ const paths = /* @__PURE__ */ new Set();
161
+ for (const block of this.blocks.values()) if (pathPrefix === void 0 || matchesPrefix(block.path, pathPrefix)) paths.add(block.path);
162
+ for (const p of paths) yield p;
163
+ }
164
+ async *getDocumentBlocksRefs(pathPrefix) {
165
+ this.ensureOpen();
166
+ for (const block of this.blocks.values()) if (pathPrefix === void 0 || matchesPrefix(block.path, pathPrefix)) yield {
167
+ path: block.path,
168
+ blockId: block.blockId
169
+ };
170
+ }
171
+ async *getDocumentsBlocks(pathPrefix) {
172
+ this.ensureOpen();
173
+ for (const block of this.blocks.values()) if (pathPrefix === void 0 || matchesPrefix(block.path, pathPrefix)) yield {
174
+ path: block.path,
175
+ blockId: block.blockId,
176
+ content: block.content,
177
+ metadata: block.metadata
178
+ };
179
+ }
180
+ async close(_options) {
181
+ this.closed = true;
182
+ }
183
+ async flush() {
184
+ this.ensureOpen();
185
+ }
186
+ async deleteIndex() {
187
+ this.ensureOpen();
188
+ this.blocks.clear();
189
+ this.keyToNum.clear();
190
+ this.numToKey.clear();
191
+ this.cachedSerialized = void 0;
192
+ this.dirty = true;
193
+ this.closed = true;
194
+ }
195
+ /**
196
+ * Stream the FTS sub-index's state as a single named entry. The composite
197
+ * prefixes this with `${indexName}/${subName}/` before it hits persistence.
198
+ */
199
+ async *serialise() {
200
+ const json = await this.serialize();
201
+ yield {
202
+ name: "json",
203
+ content: singleChunk(toBytes(json))
204
+ };
205
+ }
206
+ /** Restore the FTS sub-index from a previously-produced entry set. */
207
+ async loadFrom(entries) {
208
+ this.ensureOpen();
209
+ for (const e of entries) {
210
+ if (e.name !== "json") continue;
211
+ const bytes = await readEntryBytes(e);
212
+ const json = new TextDecoder().decode(bytes);
213
+ const rebuilt = FlexSearchFullTextIndex.deserialize(this.info, json);
214
+ this.flexIndex = rebuilt.flexIndex;
215
+ this.keyToNum = rebuilt.keyToNum;
216
+ this.numToKey = rebuilt.numToKey;
217
+ this.blocks = rebuilt.blocks;
218
+ this.nextNum = rebuilt.nextNum;
219
+ this.cachedSerialized = json;
220
+ this.dirty = false;
221
+ return;
222
+ }
223
+ }
224
+ /**
225
+ * Serialize to JSON string (v3 format with path data).
226
+ *
227
+ * Caches the result; while `dirty` is false (no mutations since the
228
+ * last call) returns the same string, skipping the FlexSearch export
229
+ * entirely. The persistence layer still calls into us each flush, but
230
+ * the heavy work only runs when the index actually changed.
231
+ */
232
+ async serialize() {
233
+ if (!this.dirty && this.cachedSerialized !== void 0) return this.cachedSerialized;
234
+ const chunks = /* @__PURE__ */ new Map();
235
+ await this.flexIndex.export((key, data) => {
236
+ chunks.set(String(key), data);
237
+ });
238
+ const blocksData = [];
239
+ for (const block of this.blocks.values()) blocksData.push({
240
+ path: block.path,
241
+ blockId: block.blockId,
242
+ content: block.content,
243
+ metadata: block.metadata
244
+ });
245
+ const out = JSON.stringify({
246
+ version: 3,
247
+ chunks: Object.fromEntries(chunks),
248
+ blocks: blocksData,
249
+ keyToNum: [...this.keyToNum.entries()],
250
+ nextNum: this.nextNum
251
+ });
252
+ this.cachedSerialized = out;
253
+ this.dirty = false;
254
+ return out;
255
+ }
256
+ /** Deserialize from JSON — handles v3 format */
257
+ static deserialize(info, json) {
258
+ const parsed = JSON.parse(json);
259
+ if (parsed.version !== 3) throw new Error(`FlexSearchFullTextIndex: unsupported serialised version ${String(parsed.version)} (expected 3)`);
260
+ const fts = new FlexSearchFullTextIndex(info);
261
+ for (const [key, data] of Object.entries(parsed.chunks)) fts.flexIndex.import(key, data);
262
+ for (const [key, num] of parsed.keyToNum) {
263
+ fts.keyToNum.set(key, num);
264
+ fts.numToKey.set(num, key);
265
+ }
266
+ fts.nextNum = parsed.nextNum;
267
+ for (const block of parsed.blocks) {
268
+ const key = compositeKey(block.path, block.blockId);
269
+ fts.blocks.set(key, {
270
+ path: block.path,
271
+ blockId: block.blockId,
272
+ content: block.content,
273
+ metadata: block.metadata
274
+ });
275
+ }
276
+ fts.cachedSerialized = json;
277
+ fts.dirty = false;
278
+ return fts;
279
+ }
280
+ };
281
+ //#endregion
282
+ export { FlexSearchFullTextIndex };
283
+
284
+ //# sourceMappingURL=flexsearch-full-text-index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flexsearch-full-text-index.js","names":[],"sources":["../src/flexsearch-full-text-index.ts"],"sourcesContent":["import type {\n BlockReference,\n DocumentPath,\n Metadata,\n PathSelector,\n PersistableSearchIndex,\n PersistenceEntry,\n} from \"@statewalker/indexer-api\";\nimport {\n compositeKey,\n matchesPrefix,\n readEntryBytes,\n singleChunk,\n toAsyncIterable,\n toBytes,\n} from \"@statewalker/indexer-core\";\nimport type {\n FullTextBlock,\n FullTextIndex,\n FullTextIndexInfo,\n FulltextQuery as FullTextSearchParams,\n FulltextResult as FullTextSearchResult,\n} from \"@statewalker/indexer-fulltext\";\nimport FlexSearch, { type Index as FlexIndex } from \"flexsearch\";\n\ninterface StoredBlock {\n path: DocumentPath;\n blockId: string;\n content: string;\n metadata?: Metadata;\n}\n\nexport class FlexSearchFullTextIndex\n implements\n FullTextIndex,\n PersistableSearchIndex<FullTextBlock, FullTextSearchParams, FullTextSearchResult>\n{\n private readonly info: FullTextIndexInfo;\n private flexIndex: FlexIndex;\n private keyToNum = new Map<string, number>();\n private numToKey = new Map<number, string>();\n private blocks = new Map<string, StoredBlock>();\n private nextNum = 1;\n private closed = false;\n /**\n * Dirty bit. `true` whenever the FTS index has been mutated since the\n * last `serialize()`. The serializer caches the last produced JSON and\n * returns it directly when `!dirty`, so a no-op re-sync skips the\n * expensive FlexSearch export entirely. Starts `true` so the first\n * serialize after construction always runs.\n */\n private dirty = true;\n private cachedSerialized?: string;\n\n constructor(info: FullTextIndexInfo) {\n this.info = info;\n this.flexIndex = new FlexSearch.Index({\n tokenize: \"forward\",\n resolution: 9,\n cache: true,\n });\n }\n\n /**\n * `true` when the in-memory state has changed since the last serialize.\n * Callers (e.g. the persistence layer) may use it to skip serialization\n * and writes when the index has not been touched.\n */\n isDirty(): boolean {\n return this.dirty;\n }\n\n private ensureOpen(): void {\n if (this.closed) {\n throw new Error(\"FullTextIndex is closed\");\n }\n }\n\n private getOrAssignNum(key: string): number {\n let num = this.keyToNum.get(key);\n if (num === undefined) {\n num = this.nextNum++;\n this.keyToNum.set(key, num);\n this.numToKey.set(num, key);\n }\n return num;\n }\n\n private searchInternal(\n query: string,\n topK: number,\n pathPrefixes?: DocumentPath[],\n ): FullTextSearchResult[] {\n // `suggest: true` relaxes the default all-terms-AND for a multi-word query into a\n // ranked partial match — sections are ordered by how much of the term set they match\n // (FlexSearch's own resolution scoring), instead of returning nothing unless one\n // section contains every term. For single-word or fully-intersecting queries it is\n // identical to the default, so it only ever broadens recall, never narrows it.\n const ids = this.flexIndex.search(query, topK * 3, { suggest: true }) as number[];\n\n let results = ids\n .map((numId, rank) => ({\n key: this.numToKey.get(numId) ?? \"\",\n score: 1 - rank / ids.length,\n }))\n .filter((r) => r.key !== \"\");\n\n // Filter by path prefixes\n if (pathPrefixes && pathPrefixes.length > 0) {\n results = results.filter((r) => {\n const block = this.blocks.get(r.key);\n return block !== undefined && pathPrefixes.some((p) => matchesPrefix(block.path, p));\n });\n }\n\n results.sort((a, b) => b.score - a.score);\n\n return results.slice(0, topK).map((r) => {\n const block = this.blocks.get(r.key);\n return {\n path: block?.path ?? (\"/\" as DocumentPath),\n blockId: block?.blockId ?? \"\",\n snippet: block?.content ?? \"\",\n score: r.score,\n };\n });\n }\n\n async getIndexInfo(): Promise<FullTextIndexInfo> {\n this.ensureOpen();\n return { ...this.info };\n }\n\n async *search(params: FullTextSearchParams): AsyncGenerator<FullTextSearchResult> {\n this.ensureOpen();\n const { queries, paths } = params;\n const topK = params.topK ?? 100;\n\n if (!queries || queries.length === 0) return;\n\n // Multi-query: search for each and ACCUMULATE per-block scores, so a block\n // matching more queries ranks higher (the FulltextQuery contract). Taking the\n // max instead would let a block that is the top hit for one query outrank a\n // block matching several queries. The snippet is kept from the strongest\n // single-query match.\n const merged = new Map<string, { result: FullTextSearchResult; best: number; total: number }>();\n\n for (const query of queries) {\n const results = this.searchInternal(query, topK, paths);\n for (const r of results) {\n const key = compositeKey(r.path, r.blockId);\n const existing = merged.get(key);\n if (!existing) {\n merged.set(key, { result: r, best: r.score, total: r.score });\n } else {\n existing.total += r.score;\n if (r.score > existing.best) {\n existing.best = r.score;\n existing.result = r;\n }\n }\n }\n }\n\n const sorted = [...merged.values()].sort((a, b) => b.total - a.total);\n for (const { result, total } of sorted.slice(0, topK)) {\n yield { ...result, score: total };\n }\n }\n\n async addDocument(blocks: FullTextBlock[]): Promise<void> {\n this.ensureOpen();\n if (blocks.length === 0) return;\n for (const block of blocks) {\n const key = compositeKey(block.path, block.blockId);\n const num = this.getOrAssignNum(key);\n // Remove old content if exists\n if (this.blocks.has(key)) {\n this.flexIndex.remove(num);\n }\n this.flexIndex.add(num, block.content);\n this.blocks.set(key, {\n path: block.path,\n blockId: block.blockId,\n content: block.content,\n metadata: block.metadata,\n });\n }\n this.dirty = true;\n }\n\n async addDocuments(\n blocks: Iterable<FullTextBlock[]> | AsyncIterable<FullTextBlock[]>,\n ): Promise<void> {\n this.ensureOpen();\n for await (const batch of blocks) {\n await this.addDocument(batch);\n }\n }\n\n async deleteDocuments(\n pathSelectors: PathSelector[] | AsyncIterable<PathSelector>,\n ): Promise<void> {\n this.ensureOpen();\n let removed = 0;\n for await (const sel of toAsyncIterable(pathSelectors)) {\n if (sel.blockId !== undefined) {\n const key = compositeKey(sel.path, sel.blockId);\n if (this.removeByKey(key)) removed += 1;\n } else {\n const keysToDelete: string[] = [];\n for (const [key, block] of this.blocks) {\n if (matchesPrefix(block.path, sel.path)) {\n keysToDelete.push(key);\n }\n }\n for (const key of keysToDelete) {\n if (this.removeByKey(key)) removed += 1;\n }\n }\n }\n if (removed > 0) this.dirty = true;\n }\n\n /** Returns true when the key was present and got removed. */\n private removeByKey(key: string): boolean {\n const num = this.keyToNum.get(key);\n const had = this.blocks.has(key);\n if (num !== undefined) {\n this.flexIndex.remove(num);\n this.keyToNum.delete(key);\n this.numToKey.delete(num);\n }\n this.blocks.delete(key);\n return had;\n }\n\n async getSize(pathPrefix?: DocumentPath): Promise<number> {\n this.ensureOpen();\n if (pathPrefix === undefined) return this.blocks.size;\n let count = 0;\n for (const block of this.blocks.values()) {\n if (matchesPrefix(block.path, pathPrefix)) count++;\n }\n return count;\n }\n\n async *getDocumentPaths(pathPrefix?: DocumentPath): AsyncGenerator<DocumentPath> {\n this.ensureOpen();\n const paths = new Set<DocumentPath>();\n for (const block of this.blocks.values()) {\n if (pathPrefix === undefined || matchesPrefix(block.path, pathPrefix)) {\n paths.add(block.path);\n }\n }\n for (const p of paths) yield p;\n }\n\n async *getDocumentBlocksRefs(pathPrefix?: DocumentPath): AsyncGenerator<BlockReference> {\n this.ensureOpen();\n for (const block of this.blocks.values()) {\n if (pathPrefix === undefined || matchesPrefix(block.path, pathPrefix)) {\n yield { path: block.path, blockId: block.blockId };\n }\n }\n }\n\n async *getDocumentsBlocks(pathPrefix?: DocumentPath): AsyncGenerator<FullTextBlock> {\n this.ensureOpen();\n for (const block of this.blocks.values()) {\n if (pathPrefix === undefined || matchesPrefix(block.path, pathPrefix)) {\n yield {\n path: block.path,\n blockId: block.blockId,\n content: block.content,\n metadata: block.metadata,\n };\n }\n }\n }\n\n async close(_options?: { force?: boolean }): Promise<void> {\n this.closed = true;\n }\n\n async flush(): Promise<void> {\n this.ensureOpen();\n // no-op for in-memory\n }\n\n async deleteIndex(): Promise<void> {\n this.ensureOpen();\n this.blocks.clear();\n this.keyToNum.clear();\n this.numToKey.clear();\n this.cachedSerialized = undefined;\n this.dirty = true;\n this.closed = true;\n }\n\n // --- PersistableSearchIndex --------------------------------------------------\n\n /**\n * Stream the FTS sub-index's state as a single named entry. The composite\n * prefixes this with `${indexName}/${subName}/` before it hits persistence.\n */\n async *serialise(): AsyncIterable<PersistenceEntry> {\n const json = await this.serialize();\n yield { name: \"json\", content: singleChunk(toBytes(json)) };\n }\n\n /** Restore the FTS sub-index from a previously-produced entry set. */\n async loadFrom(entries: Iterable<PersistenceEntry>): Promise<void> {\n this.ensureOpen();\n for (const e of entries) {\n if (e.name !== \"json\") continue;\n const bytes = await readEntryBytes(e);\n const json = new TextDecoder().decode(bytes);\n const rebuilt = FlexSearchFullTextIndex.deserialize(this.info, json);\n // Adopt the rebuilt instance's internal state.\n this.flexIndex = rebuilt.flexIndex;\n this.keyToNum = rebuilt.keyToNum;\n this.numToKey = rebuilt.numToKey;\n this.blocks = rebuilt.blocks;\n this.nextNum = rebuilt.nextNum;\n this.cachedSerialized = json;\n this.dirty = false;\n return;\n }\n }\n\n /**\n * Serialize to JSON string (v3 format with path data).\n *\n * Caches the result; while `dirty` is false (no mutations since the\n * last call) returns the same string, skipping the FlexSearch export\n * entirely. The persistence layer still calls into us each flush, but\n * the heavy work only runs when the index actually changed.\n */\n async serialize(): Promise<string> {\n if (!this.dirty && this.cachedSerialized !== undefined) {\n return this.cachedSerialized;\n }\n const chunks = new Map<string, string>();\n await this.flexIndex.export((key: string | number, data: string) => {\n chunks.set(String(key), data);\n });\n\n const blocksData: Array<{\n path: string;\n blockId: string;\n content: string;\n metadata?: Metadata;\n }> = [];\n for (const block of this.blocks.values()) {\n blocksData.push({\n path: block.path,\n blockId: block.blockId,\n content: block.content,\n metadata: block.metadata,\n });\n }\n\n const out = JSON.stringify({\n version: 3,\n chunks: Object.fromEntries(chunks),\n blocks: blocksData,\n keyToNum: [...this.keyToNum.entries()],\n nextNum: this.nextNum,\n });\n this.cachedSerialized = out;\n this.dirty = false;\n return out;\n }\n\n /** Deserialize from JSON — handles v3 format */\n static deserialize(info: FullTextIndexInfo, json: string): FlexSearchFullTextIndex {\n const parsed = JSON.parse(json) as {\n version?: number;\n chunks: Record<string, string>;\n blocks: Array<{\n path: string;\n blockId: string;\n content: string;\n metadata?: Metadata;\n }>;\n keyToNum: Array<[string, number]>;\n nextNum: number;\n };\n\n if (parsed.version !== 3) {\n throw new Error(\n `FlexSearchFullTextIndex: unsupported serialised version ${String(parsed.version)} (expected 3)`,\n );\n }\n\n const fts = new FlexSearchFullTextIndex(info);\n for (const [key, data] of Object.entries(parsed.chunks)) {\n fts.flexIndex.import(key, data);\n }\n for (const [key, num] of parsed.keyToNum) {\n fts.keyToNum.set(key, num);\n fts.numToKey.set(num, key);\n }\n fts.nextNum = parsed.nextNum;\n for (const block of parsed.blocks) {\n const key = compositeKey(block.path as DocumentPath, block.blockId);\n fts.blocks.set(key, {\n path: block.path as DocumentPath,\n blockId: block.blockId,\n content: block.content,\n metadata: block.metadata,\n });\n }\n // Prime the cache: a deserialized index that's never mutated should\n // return the same JSON it was loaded from when re-serialized.\n fts.cachedSerialized = json;\n fts.dirty = false;\n return fts;\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,13 @@
1
+ import { Indexer, IndexerPersistence } from "@statewalker/indexer-api";
2
+ //#region src/flexsearch-indexer.d.ts
3
+ export interface FlexSearchIndexerOptions {
4
+ persistence?: IndexerPersistence;
5
+ }
6
+ /**
7
+ * In-memory Indexer combining FlexSearch full-text and brute-force vector
8
+ * sub-indexes via the Provider model. Multiple sub-indexes of either modality
9
+ * can be created on one Index (e.g. English and French FTS sub-indexes).
10
+ */
11
+ export declare function createFlexSearchIndexer(options?: FlexSearchIndexerOptions): Indexer;
12
+ //#endregion
13
+ //# sourceMappingURL=flexsearch-indexer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flexsearch-indexer.d.ts","names":[],"sources":["../src/flexsearch-indexer.ts"],"mappings":";;iBAKiB;EACf,cAAc;;;;;;;wBAQA,wBAAwB,UAAU,2BAA2B"}
@@ -0,0 +1,19 @@
1
+ import { flexSearchFullTextProvider } from "./flexsearch-provider.js";
2
+ import { createPersistenceBackedIndexer } from "@statewalker/indexer-core";
3
+ import { memVectorProvider } from "@statewalker/indexer-mem";
4
+ //#region src/flexsearch-indexer.ts
5
+ /**
6
+ * In-memory Indexer combining FlexSearch full-text and brute-force vector
7
+ * sub-indexes via the Provider model. Multiple sub-indexes of either modality
8
+ * can be created on one Index (e.g. English and French FTS sub-indexes).
9
+ */
10
+ function createFlexSearchIndexer(options) {
11
+ return createPersistenceBackedIndexer({
12
+ persistence: options?.persistence,
13
+ providers: [flexSearchFullTextProvider, memVectorProvider]
14
+ });
15
+ }
16
+ //#endregion
17
+ export { createFlexSearchIndexer };
18
+
19
+ //# sourceMappingURL=flexsearch-indexer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flexsearch-indexer.js","names":[],"sources":["../src/flexsearch-indexer.ts"],"sourcesContent":["import type { Indexer, IndexerPersistence } from \"@statewalker/indexer-api\";\nimport { createPersistenceBackedIndexer } from \"@statewalker/indexer-core\";\nimport { memVectorProvider } from \"@statewalker/indexer-mem\";\nimport { flexSearchFullTextProvider } from \"./flexsearch-provider.js\";\n\nexport interface FlexSearchIndexerOptions {\n persistence?: IndexerPersistence;\n}\n\n/**\n * In-memory Indexer combining FlexSearch full-text and brute-force vector\n * sub-indexes via the Provider model. Multiple sub-indexes of either modality\n * can be created on one Index (e.g. English and French FTS sub-indexes).\n */\nexport function createFlexSearchIndexer(options?: FlexSearchIndexerOptions): Indexer {\n return createPersistenceBackedIndexer({\n persistence: options?.persistence,\n providers: [flexSearchFullTextProvider, memVectorProvider],\n });\n}\n"],"mappings":";;;;;;;;;AAcA,SAAgB,wBAAwB,SAA6C;CACnF,OAAO,+BAA+B;EACpC,aAAa,SAAS;EACtB,WAAW,CAAC,4BAA4B,iBAAiB;CAC3D,CAAC;AACH"}
@@ -0,0 +1,10 @@
1
+ import { FullTextProvider } from "@statewalker/indexer-fulltext";
2
+ //#region src/flexsearch-provider.d.ts
3
+ /**
4
+ * `FullTextProvider` backed by {@link FlexSearchFullTextIndex}. Wired into an
5
+ * `Indexer` alongside a `VectorProvider` to compose a full-text + vector
6
+ * Indexer.
7
+ */
8
+ export declare const flexSearchFullTextProvider: FullTextProvider;
9
+ //#endregion
10
+ //# sourceMappingURL=flexsearch-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flexsearch-provider.d.ts","names":[],"sources":["../src/flexsearch-provider.ts"],"mappings":";;;;;;;qBAYa,4BAA4B"}
@@ -0,0 +1,21 @@
1
+ import { FlexSearchFullTextIndex } from "./flexsearch-full-text-index.js";
2
+ import { FULL_TEXT_TYPE } from "@statewalker/indexer-fulltext";
3
+ //#region src/flexsearch-provider.ts
4
+ /**
5
+ * `FullTextProvider` backed by {@link FlexSearchFullTextIndex}. Wired into an
6
+ * `Indexer` alongside a `VectorProvider` to compose a full-text + vector
7
+ * Indexer.
8
+ */
9
+ const flexSearchFullTextProvider = {
10
+ type: FULL_TEXT_TYPE,
11
+ create(config) {
12
+ return new FlexSearchFullTextIndex({
13
+ language: config.language,
14
+ metadata: config.metadata
15
+ });
16
+ }
17
+ };
18
+ //#endregion
19
+ export { flexSearchFullTextProvider };
20
+
21
+ //# sourceMappingURL=flexsearch-provider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"flexsearch-provider.js","names":[],"sources":["../src/flexsearch-provider.ts"],"sourcesContent":["import {\n FULL_TEXT_TYPE,\n type FullTextConfig,\n type FullTextProvider,\n} from \"@statewalker/indexer-fulltext\";\nimport { FlexSearchFullTextIndex } from \"./flexsearch-full-text-index.js\";\n\n/**\n * `FullTextProvider` backed by {@link FlexSearchFullTextIndex}. Wired into an\n * `Indexer` alongside a `VectorProvider` to compose a full-text + vector\n * Indexer.\n */\nexport const flexSearchFullTextProvider: FullTextProvider = {\n type: FULL_TEXT_TYPE,\n create(config: FullTextConfig): FlexSearchFullTextIndex {\n return new FlexSearchFullTextIndex({\n language: config.language,\n metadata: config.metadata,\n });\n },\n};\n"],"mappings":";;;;;;;;AAYA,MAAa,6BAA+C;CAC1D,MAAM;CACN,OAAO,QAAiD;EACtD,OAAO,IAAI,wBAAwB;GACjC,UAAU,OAAO;GACjB,UAAU,OAAO;EACnB,CAAC;CACH;AACF"}
@@ -0,0 +1,4 @@
1
+ import { FlexSearchFullTextIndex } from "./flexsearch-full-text-index.js";
2
+ import { FlexSearchIndexerOptions, createFlexSearchIndexer } from "./flexsearch-indexer.js";
3
+ import { flexSearchFullTextProvider } from "./flexsearch-provider.js";
4
+ export { FlexSearchFullTextIndex, type FlexSearchIndexerOptions, createFlexSearchIndexer, flexSearchFullTextProvider };
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ import { FlexSearchFullTextIndex } from "./flexsearch-full-text-index.js";
2
+ import { flexSearchFullTextProvider } from "./flexsearch-provider.js";
3
+ import { createFlexSearchIndexer } from "./flexsearch-indexer.js";
4
+ export { FlexSearchFullTextIndex, createFlexSearchIndexer, flexSearchFullTextProvider };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@statewalker/indexer-mem-flexsearch",
3
+ "version": "0.1.2",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "In-memory indexer implementation backed by FlexSearch for full-text search.",
7
+ "homepage": "https://github.com/statewalker/statewalker-indexer",
8
+ "author": {
9
+ "name": "Mikhail Kotelnikov",
10
+ "email": "mikhail.kotelnikov@gmail.com"
11
+ },
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+ssh://git@github.com/statewalker/statewalker-indexer.git"
16
+ },
17
+ "exports": {
18
+ ".": {
19
+ "source": "./src/index.ts",
20
+ "types": "./dist/index.d.ts",
21
+ "import": "./dist/index.js",
22
+ "default": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src"
28
+ ],
29
+ "dependencies": {
30
+ "flexsearch": "^0.8.212",
31
+ "@statewalker/indexer-api": "^0.1.1",
32
+ "@statewalker/indexer-core": "^0.1.1",
33
+ "@statewalker/indexer-mem": "^0.1.2",
34
+ "@statewalker/indexer-fulltext": "^0.1.1"
35
+ },
36
+ "devDependencies": {
37
+ "rimraf": "^6.1.3",
38
+ "tsdown": "^0.23.0",
39
+ "typescript": "^7.0.2",
40
+ "vitest": "^5.0.3",
41
+ "@statewalker/indexer-search": "^0.1.0",
42
+ "@statewalker/indexer-tests": "^0.0.0",
43
+ "@statewalker/indexer-vector": "^0.1.1"
44
+ },
45
+ "sideEffects": false,
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "scripts": {
50
+ "build": "tsdown",
51
+ "dev": "tsdown --watch",
52
+ "test": "vitest run --passWithNoTests",
53
+ "test:watch": "vitest",
54
+ "typecheck": "tsc --noEmit",
55
+ "clean": "rimraf dist",
56
+ "lint": "biome check --write .",
57
+ "format": "biome format --write ."
58
+ }
59
+ }
@@ -0,0 +1,421 @@
1
+ import type {
2
+ BlockReference,
3
+ DocumentPath,
4
+ Metadata,
5
+ PathSelector,
6
+ PersistableSearchIndex,
7
+ PersistenceEntry,
8
+ } from "@statewalker/indexer-api";
9
+ import {
10
+ compositeKey,
11
+ matchesPrefix,
12
+ readEntryBytes,
13
+ singleChunk,
14
+ toAsyncIterable,
15
+ toBytes,
16
+ } from "@statewalker/indexer-core";
17
+ import type {
18
+ FullTextBlock,
19
+ FullTextIndex,
20
+ FullTextIndexInfo,
21
+ FulltextQuery as FullTextSearchParams,
22
+ FulltextResult as FullTextSearchResult,
23
+ } from "@statewalker/indexer-fulltext";
24
+ import FlexSearch, { type Index as FlexIndex } from "flexsearch";
25
+
26
+ interface StoredBlock {
27
+ path: DocumentPath;
28
+ blockId: string;
29
+ content: string;
30
+ metadata?: Metadata;
31
+ }
32
+
33
+ export class FlexSearchFullTextIndex
34
+ implements
35
+ FullTextIndex,
36
+ PersistableSearchIndex<FullTextBlock, FullTextSearchParams, FullTextSearchResult>
37
+ {
38
+ private readonly info: FullTextIndexInfo;
39
+ private flexIndex: FlexIndex;
40
+ private keyToNum = new Map<string, number>();
41
+ private numToKey = new Map<number, string>();
42
+ private blocks = new Map<string, StoredBlock>();
43
+ private nextNum = 1;
44
+ private closed = false;
45
+ /**
46
+ * Dirty bit. `true` whenever the FTS index has been mutated since the
47
+ * last `serialize()`. The serializer caches the last produced JSON and
48
+ * returns it directly when `!dirty`, so a no-op re-sync skips the
49
+ * expensive FlexSearch export entirely. Starts `true` so the first
50
+ * serialize after construction always runs.
51
+ */
52
+ private dirty = true;
53
+ private cachedSerialized?: string;
54
+
55
+ constructor(info: FullTextIndexInfo) {
56
+ this.info = info;
57
+ this.flexIndex = new FlexSearch.Index({
58
+ tokenize: "forward",
59
+ resolution: 9,
60
+ cache: true,
61
+ });
62
+ }
63
+
64
+ /**
65
+ * `true` when the in-memory state has changed since the last serialize.
66
+ * Callers (e.g. the persistence layer) may use it to skip serialization
67
+ * and writes when the index has not been touched.
68
+ */
69
+ isDirty(): boolean {
70
+ return this.dirty;
71
+ }
72
+
73
+ private ensureOpen(): void {
74
+ if (this.closed) {
75
+ throw new Error("FullTextIndex is closed");
76
+ }
77
+ }
78
+
79
+ private getOrAssignNum(key: string): number {
80
+ let num = this.keyToNum.get(key);
81
+ if (num === undefined) {
82
+ num = this.nextNum++;
83
+ this.keyToNum.set(key, num);
84
+ this.numToKey.set(num, key);
85
+ }
86
+ return num;
87
+ }
88
+
89
+ private searchInternal(
90
+ query: string,
91
+ topK: number,
92
+ pathPrefixes?: DocumentPath[],
93
+ ): FullTextSearchResult[] {
94
+ // `suggest: true` relaxes the default all-terms-AND for a multi-word query into a
95
+ // ranked partial match — sections are ordered by how much of the term set they match
96
+ // (FlexSearch's own resolution scoring), instead of returning nothing unless one
97
+ // section contains every term. For single-word or fully-intersecting queries it is
98
+ // identical to the default, so it only ever broadens recall, never narrows it.
99
+ const ids = this.flexIndex.search(query, topK * 3, { suggest: true }) as number[];
100
+
101
+ let results = ids
102
+ .map((numId, rank) => ({
103
+ key: this.numToKey.get(numId) ?? "",
104
+ score: 1 - rank / ids.length,
105
+ }))
106
+ .filter((r) => r.key !== "");
107
+
108
+ // Filter by path prefixes
109
+ if (pathPrefixes && pathPrefixes.length > 0) {
110
+ results = results.filter((r) => {
111
+ const block = this.blocks.get(r.key);
112
+ return block !== undefined && pathPrefixes.some((p) => matchesPrefix(block.path, p));
113
+ });
114
+ }
115
+
116
+ results.sort((a, b) => b.score - a.score);
117
+
118
+ return results.slice(0, topK).map((r) => {
119
+ const block = this.blocks.get(r.key);
120
+ return {
121
+ path: block?.path ?? ("/" as DocumentPath),
122
+ blockId: block?.blockId ?? "",
123
+ snippet: block?.content ?? "",
124
+ score: r.score,
125
+ };
126
+ });
127
+ }
128
+
129
+ async getIndexInfo(): Promise<FullTextIndexInfo> {
130
+ this.ensureOpen();
131
+ return { ...this.info };
132
+ }
133
+
134
+ async *search(params: FullTextSearchParams): AsyncGenerator<FullTextSearchResult> {
135
+ this.ensureOpen();
136
+ const { queries, paths } = params;
137
+ const topK = params.topK ?? 100;
138
+
139
+ if (!queries || queries.length === 0) return;
140
+
141
+ // Multi-query: search for each and ACCUMULATE per-block scores, so a block
142
+ // matching more queries ranks higher (the FulltextQuery contract). Taking the
143
+ // max instead would let a block that is the top hit for one query outrank a
144
+ // block matching several queries. The snippet is kept from the strongest
145
+ // single-query match.
146
+ const merged = new Map<string, { result: FullTextSearchResult; best: number; total: number }>();
147
+
148
+ for (const query of queries) {
149
+ const results = this.searchInternal(query, topK, paths);
150
+ for (const r of results) {
151
+ const key = compositeKey(r.path, r.blockId);
152
+ const existing = merged.get(key);
153
+ if (!existing) {
154
+ merged.set(key, { result: r, best: r.score, total: r.score });
155
+ } else {
156
+ existing.total += r.score;
157
+ if (r.score > existing.best) {
158
+ existing.best = r.score;
159
+ existing.result = r;
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ const sorted = [...merged.values()].sort((a, b) => b.total - a.total);
166
+ for (const { result, total } of sorted.slice(0, topK)) {
167
+ yield { ...result, score: total };
168
+ }
169
+ }
170
+
171
+ async addDocument(blocks: FullTextBlock[]): Promise<void> {
172
+ this.ensureOpen();
173
+ if (blocks.length === 0) return;
174
+ for (const block of blocks) {
175
+ const key = compositeKey(block.path, block.blockId);
176
+ const num = this.getOrAssignNum(key);
177
+ // Remove old content if exists
178
+ if (this.blocks.has(key)) {
179
+ this.flexIndex.remove(num);
180
+ }
181
+ this.flexIndex.add(num, block.content);
182
+ this.blocks.set(key, {
183
+ path: block.path,
184
+ blockId: block.blockId,
185
+ content: block.content,
186
+ metadata: block.metadata,
187
+ });
188
+ }
189
+ this.dirty = true;
190
+ }
191
+
192
+ async addDocuments(
193
+ blocks: Iterable<FullTextBlock[]> | AsyncIterable<FullTextBlock[]>,
194
+ ): Promise<void> {
195
+ this.ensureOpen();
196
+ for await (const batch of blocks) {
197
+ await this.addDocument(batch);
198
+ }
199
+ }
200
+
201
+ async deleteDocuments(
202
+ pathSelectors: PathSelector[] | AsyncIterable<PathSelector>,
203
+ ): Promise<void> {
204
+ this.ensureOpen();
205
+ let removed = 0;
206
+ for await (const sel of toAsyncIterable(pathSelectors)) {
207
+ if (sel.blockId !== undefined) {
208
+ const key = compositeKey(sel.path, sel.blockId);
209
+ if (this.removeByKey(key)) removed += 1;
210
+ } else {
211
+ const keysToDelete: string[] = [];
212
+ for (const [key, block] of this.blocks) {
213
+ if (matchesPrefix(block.path, sel.path)) {
214
+ keysToDelete.push(key);
215
+ }
216
+ }
217
+ for (const key of keysToDelete) {
218
+ if (this.removeByKey(key)) removed += 1;
219
+ }
220
+ }
221
+ }
222
+ if (removed > 0) this.dirty = true;
223
+ }
224
+
225
+ /** Returns true when the key was present and got removed. */
226
+ private removeByKey(key: string): boolean {
227
+ const num = this.keyToNum.get(key);
228
+ const had = this.blocks.has(key);
229
+ if (num !== undefined) {
230
+ this.flexIndex.remove(num);
231
+ this.keyToNum.delete(key);
232
+ this.numToKey.delete(num);
233
+ }
234
+ this.blocks.delete(key);
235
+ return had;
236
+ }
237
+
238
+ async getSize(pathPrefix?: DocumentPath): Promise<number> {
239
+ this.ensureOpen();
240
+ if (pathPrefix === undefined) return this.blocks.size;
241
+ let count = 0;
242
+ for (const block of this.blocks.values()) {
243
+ if (matchesPrefix(block.path, pathPrefix)) count++;
244
+ }
245
+ return count;
246
+ }
247
+
248
+ async *getDocumentPaths(pathPrefix?: DocumentPath): AsyncGenerator<DocumentPath> {
249
+ this.ensureOpen();
250
+ const paths = new Set<DocumentPath>();
251
+ for (const block of this.blocks.values()) {
252
+ if (pathPrefix === undefined || matchesPrefix(block.path, pathPrefix)) {
253
+ paths.add(block.path);
254
+ }
255
+ }
256
+ for (const p of paths) yield p;
257
+ }
258
+
259
+ async *getDocumentBlocksRefs(pathPrefix?: DocumentPath): AsyncGenerator<BlockReference> {
260
+ this.ensureOpen();
261
+ for (const block of this.blocks.values()) {
262
+ if (pathPrefix === undefined || matchesPrefix(block.path, pathPrefix)) {
263
+ yield { path: block.path, blockId: block.blockId };
264
+ }
265
+ }
266
+ }
267
+
268
+ async *getDocumentsBlocks(pathPrefix?: DocumentPath): AsyncGenerator<FullTextBlock> {
269
+ this.ensureOpen();
270
+ for (const block of this.blocks.values()) {
271
+ if (pathPrefix === undefined || matchesPrefix(block.path, pathPrefix)) {
272
+ yield {
273
+ path: block.path,
274
+ blockId: block.blockId,
275
+ content: block.content,
276
+ metadata: block.metadata,
277
+ };
278
+ }
279
+ }
280
+ }
281
+
282
+ async close(_options?: { force?: boolean }): Promise<void> {
283
+ this.closed = true;
284
+ }
285
+
286
+ async flush(): Promise<void> {
287
+ this.ensureOpen();
288
+ // no-op for in-memory
289
+ }
290
+
291
+ async deleteIndex(): Promise<void> {
292
+ this.ensureOpen();
293
+ this.blocks.clear();
294
+ this.keyToNum.clear();
295
+ this.numToKey.clear();
296
+ this.cachedSerialized = undefined;
297
+ this.dirty = true;
298
+ this.closed = true;
299
+ }
300
+
301
+ // --- PersistableSearchIndex --------------------------------------------------
302
+
303
+ /**
304
+ * Stream the FTS sub-index's state as a single named entry. The composite
305
+ * prefixes this with `${indexName}/${subName}/` before it hits persistence.
306
+ */
307
+ async *serialise(): AsyncIterable<PersistenceEntry> {
308
+ const json = await this.serialize();
309
+ yield { name: "json", content: singleChunk(toBytes(json)) };
310
+ }
311
+
312
+ /** Restore the FTS sub-index from a previously-produced entry set. */
313
+ async loadFrom(entries: Iterable<PersistenceEntry>): Promise<void> {
314
+ this.ensureOpen();
315
+ for (const e of entries) {
316
+ if (e.name !== "json") continue;
317
+ const bytes = await readEntryBytes(e);
318
+ const json = new TextDecoder().decode(bytes);
319
+ const rebuilt = FlexSearchFullTextIndex.deserialize(this.info, json);
320
+ // Adopt the rebuilt instance's internal state.
321
+ this.flexIndex = rebuilt.flexIndex;
322
+ this.keyToNum = rebuilt.keyToNum;
323
+ this.numToKey = rebuilt.numToKey;
324
+ this.blocks = rebuilt.blocks;
325
+ this.nextNum = rebuilt.nextNum;
326
+ this.cachedSerialized = json;
327
+ this.dirty = false;
328
+ return;
329
+ }
330
+ }
331
+
332
+ /**
333
+ * Serialize to JSON string (v3 format with path data).
334
+ *
335
+ * Caches the result; while `dirty` is false (no mutations since the
336
+ * last call) returns the same string, skipping the FlexSearch export
337
+ * entirely. The persistence layer still calls into us each flush, but
338
+ * the heavy work only runs when the index actually changed.
339
+ */
340
+ async serialize(): Promise<string> {
341
+ if (!this.dirty && this.cachedSerialized !== undefined) {
342
+ return this.cachedSerialized;
343
+ }
344
+ const chunks = new Map<string, string>();
345
+ await this.flexIndex.export((key: string | number, data: string) => {
346
+ chunks.set(String(key), data);
347
+ });
348
+
349
+ const blocksData: Array<{
350
+ path: string;
351
+ blockId: string;
352
+ content: string;
353
+ metadata?: Metadata;
354
+ }> = [];
355
+ for (const block of this.blocks.values()) {
356
+ blocksData.push({
357
+ path: block.path,
358
+ blockId: block.blockId,
359
+ content: block.content,
360
+ metadata: block.metadata,
361
+ });
362
+ }
363
+
364
+ const out = JSON.stringify({
365
+ version: 3,
366
+ chunks: Object.fromEntries(chunks),
367
+ blocks: blocksData,
368
+ keyToNum: [...this.keyToNum.entries()],
369
+ nextNum: this.nextNum,
370
+ });
371
+ this.cachedSerialized = out;
372
+ this.dirty = false;
373
+ return out;
374
+ }
375
+
376
+ /** Deserialize from JSON — handles v3 format */
377
+ static deserialize(info: FullTextIndexInfo, json: string): FlexSearchFullTextIndex {
378
+ const parsed = JSON.parse(json) as {
379
+ version?: number;
380
+ chunks: Record<string, string>;
381
+ blocks: Array<{
382
+ path: string;
383
+ blockId: string;
384
+ content: string;
385
+ metadata?: Metadata;
386
+ }>;
387
+ keyToNum: Array<[string, number]>;
388
+ nextNum: number;
389
+ };
390
+
391
+ if (parsed.version !== 3) {
392
+ throw new Error(
393
+ `FlexSearchFullTextIndex: unsupported serialised version ${String(parsed.version)} (expected 3)`,
394
+ );
395
+ }
396
+
397
+ const fts = new FlexSearchFullTextIndex(info);
398
+ for (const [key, data] of Object.entries(parsed.chunks)) {
399
+ fts.flexIndex.import(key, data);
400
+ }
401
+ for (const [key, num] of parsed.keyToNum) {
402
+ fts.keyToNum.set(key, num);
403
+ fts.numToKey.set(num, key);
404
+ }
405
+ fts.nextNum = parsed.nextNum;
406
+ for (const block of parsed.blocks) {
407
+ const key = compositeKey(block.path as DocumentPath, block.blockId);
408
+ fts.blocks.set(key, {
409
+ path: block.path as DocumentPath,
410
+ blockId: block.blockId,
411
+ content: block.content,
412
+ metadata: block.metadata,
413
+ });
414
+ }
415
+ // Prime the cache: a deserialized index that's never mutated should
416
+ // return the same JSON it was loaded from when re-serialized.
417
+ fts.cachedSerialized = json;
418
+ fts.dirty = false;
419
+ return fts;
420
+ }
421
+ }
@@ -0,0 +1,20 @@
1
+ import type { Indexer, IndexerPersistence } from "@statewalker/indexer-api";
2
+ import { createPersistenceBackedIndexer } from "@statewalker/indexer-core";
3
+ import { memVectorProvider } from "@statewalker/indexer-mem";
4
+ import { flexSearchFullTextProvider } from "./flexsearch-provider.js";
5
+
6
+ export interface FlexSearchIndexerOptions {
7
+ persistence?: IndexerPersistence;
8
+ }
9
+
10
+ /**
11
+ * In-memory Indexer combining FlexSearch full-text and brute-force vector
12
+ * sub-indexes via the Provider model. Multiple sub-indexes of either modality
13
+ * can be created on one Index (e.g. English and French FTS sub-indexes).
14
+ */
15
+ export function createFlexSearchIndexer(options?: FlexSearchIndexerOptions): Indexer {
16
+ return createPersistenceBackedIndexer({
17
+ persistence: options?.persistence,
18
+ providers: [flexSearchFullTextProvider, memVectorProvider],
19
+ });
20
+ }
@@ -0,0 +1,21 @@
1
+ import {
2
+ FULL_TEXT_TYPE,
3
+ type FullTextConfig,
4
+ type FullTextProvider,
5
+ } from "@statewalker/indexer-fulltext";
6
+ import { FlexSearchFullTextIndex } from "./flexsearch-full-text-index.js";
7
+
8
+ /**
9
+ * `FullTextProvider` backed by {@link FlexSearchFullTextIndex}. Wired into an
10
+ * `Indexer` alongside a `VectorProvider` to compose a full-text + vector
11
+ * Indexer.
12
+ */
13
+ export const flexSearchFullTextProvider: FullTextProvider = {
14
+ type: FULL_TEXT_TYPE,
15
+ create(config: FullTextConfig): FlexSearchFullTextIndex {
16
+ return new FlexSearchFullTextIndex({
17
+ language: config.language,
18
+ metadata: config.metadata,
19
+ });
20
+ },
21
+ };
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { FlexSearchFullTextIndex } from "./flexsearch-full-text-index.js";
2
+ export { createFlexSearchIndexer, type FlexSearchIndexerOptions } from "./flexsearch-indexer.js";
3
+ export { flexSearchFullTextProvider } from "./flexsearch-provider.js";
@@ -0,0 +1,3 @@
1
+ // See ../../indexer-core/src/text-codec.d.ts for rationale.
2
+ declare const TextEncoder: { new (): { encode(input: string): Uint8Array } };
3
+ declare const TextDecoder: { new (label?: string): { decode(input?: Uint8Array): string } };