@statewalker/indexer-mem 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,37 @@
1
+ # @statewalker/indexer-mem
2
+
3
+ In-memory vector sub-index (`MemVectorIndex`) implementing the `EmbeddingIndex` contract from `@statewalker/indexer-api`. Consumed by the FTS-backed mem indexers (`indexer-mem-flexsearch`, `indexer-mem-minisearch`), which combine it with a full-text sub-index via `@statewalker/indexer-core`'s composite-index factory.
4
+
5
+ ## Usage
6
+
7
+ This package is a piece of scaffolding — consumers should reach for the concrete indexer packages:
8
+
9
+ ```ts
10
+ import { createFlexSearchIndexer } from "@statewalker/indexer-mem-flexsearch";
11
+ // or
12
+ import { createMiniSearchIndexer } from "@statewalker/indexer-mem-minisearch";
13
+
14
+ const indexer = createFlexSearchIndexer();
15
+ const index = await indexer.createIndex({
16
+ name: "docs",
17
+ fulltext: { language: "en" },
18
+ vector: { dimensionality: 384, model: "all-MiniLM-L6-v2" },
19
+ });
20
+
21
+ await index.addDocument([
22
+ { path: "/docs/a", blockId: "1", content: "hello world", embedding: queryEmbedding },
23
+ ]);
24
+
25
+ for await (const hit of index.search({ queries: ["hello"], embeddings: [queryEmbedding], topK: 10 })) {
26
+ console.log(hit);
27
+ }
28
+ ```
29
+
30
+ ## Exports
31
+
32
+ - `MemVectorIndex` — in-memory vector sub-index with brute-force cosine search and Arrow IPC serialization (used as the vector side of both mem-flexsearch and mem-minisearch).
33
+
34
+ ## Related
35
+
36
+ - `@statewalker/indexer-api` — public types and contract
37
+ - `@statewalker/indexer-mem-flexsearch` / `@statewalker/indexer-mem-minisearch` — end-user factories combining `MemVectorIndex` with a full-text backend
@@ -0,0 +1,3 @@
1
+ import { MemVectorIndex } from "./mem-vector-index.js";
2
+ import { memVectorProvider } from "./mem-vector-provider.js";
3
+ export { MemVectorIndex, memVectorProvider };
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ import { MemVectorIndex } from "./mem-vector-index.js";
2
+ import { memVectorProvider } from "./mem-vector-provider.js";
3
+ export { MemVectorIndex, memVectorProvider };
@@ -0,0 +1,70 @@
1
+ import { VectorBlock, VectorIndex, VectorIndexInfo, VectorQuery, VectorResult } from "@statewalker/indexer-vector";
2
+ import { BlockReference, DocumentPath, PathSelector, PersistableSearchIndex, PersistenceEntry } from "@statewalker/indexer-api";
3
+ //#region src/mem-vector-index.d.ts
4
+ export declare class MemVectorIndex implements VectorIndex, PersistableSearchIndex<VectorBlock, VectorQuery, VectorResult> {
5
+ private readonly info;
6
+ private readonly entries;
7
+ private closed;
8
+ /**
9
+ * Dirty bit. `true` whenever `entries` has been mutated since the last
10
+ * `serializeToArrow()`. The serializer caches the last produced
11
+ * `Uint8Array` and returns it directly when `!dirty`, so a no-op re-sync
12
+ * skips the expensive Arrow IPC encoding for an unchanged index.
13
+ * Starts `true` so the first serialize after construction always runs.
14
+ */
15
+ private dirty;
16
+ private cachedSerialized?;
17
+ constructor(info: VectorIndexInfo);
18
+ /**
19
+ * `true` when the in-memory state has changed since the last serialize.
20
+ * Callers (e.g. the persistence layer) may use it to skip serialization
21
+ * and writes when the index has not been touched.
22
+ */
23
+ isDirty(): boolean;
24
+ private ensureOpen;
25
+ private filteredEntries;
26
+ getIndexInfo(): Promise<VectorIndexInfo>;
27
+ search(params: VectorQuery): AsyncGenerator<VectorResult>;
28
+ addDocument(blocks: VectorBlock[]): Promise<void>;
29
+ addDocuments(blocks: Iterable<VectorBlock[]> | AsyncIterable<VectorBlock[]>): Promise<void>;
30
+ deleteDocuments(pathSelectors: PathSelector[] | AsyncIterable<PathSelector>): Promise<void>;
31
+ getSize(pathPrefix?: DocumentPath): Promise<number>;
32
+ getDocumentPaths(pathPrefix?: DocumentPath): AsyncGenerator<DocumentPath>;
33
+ getDocumentBlocksRefs(pathPrefix?: DocumentPath): AsyncGenerator<BlockReference>;
34
+ getDocumentsBlocks(pathPrefix?: DocumentPath): AsyncGenerator<VectorBlock>;
35
+ close(_options?: {
36
+ force?: boolean;
37
+ }): Promise<void>;
38
+ flush(): Promise<void>;
39
+ deleteIndex(): Promise<void>;
40
+ /**
41
+ * Serialize embeddings to Arrow IPC format.
42
+ *
43
+ * Caches the result; while `dirty` is false (no mutations since the
44
+ * last call) returns the same `Uint8Array` instance, skipping the
45
+ * Arrow IPC encoding entirely. This makes no-op re-syncs cheap — the
46
+ * persistence layer still calls into us each flush, but the heavy
47
+ * encoding only runs when the index actually changed.
48
+ */
49
+ serializeToArrow(): Uint8Array;
50
+ /**
51
+ * Stream the vector sub-index's state as a single named entry. The composite
52
+ * prefixes this with `${indexName}/${subName}/` before it hits persistence.
53
+ */
54
+ serialise(): AsyncIterable<PersistenceEntry>;
55
+ /**
56
+ * Restore the vector sub-index from a previously-produced entry set. Expects
57
+ * one entry named `"arrow"`; ignores entries with other names so future
58
+ * additions remain forward-compatible.
59
+ */
60
+ loadFrom(entries: Iterable<PersistenceEntry>): Promise<void>;
61
+ /**
62
+ * Deserialize embeddings from Arrow IPC format. The reconstructed index
63
+ * starts clean (`dirty = false`) and primes its serialize cache with the
64
+ * input bytes — a subsequent `serializeToArrow()` on the just-loaded
65
+ * index returns the same bytes without re-encoding.
66
+ */
67
+ static deserializeFromArrow(info: VectorIndexInfo, data: Uint8Array): MemVectorIndex;
68
+ }
69
+ //#endregion
70
+ //# sourceMappingURL=mem-vector-index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mem-vector-index.d.ts","names":["EmbeddingBlock","EmbeddingSearchParams","EmbeddingSearchResult","EmbeddingIndexInfo","EmbeddingIndex"],"sources":["../src/mem-vector-index.ts"],"mappings":";;;qBAwCa,0BAETI,aACA,uBAAuBJ,aAAgBC,aAAuBC;mBAE/C;mBACA;UACT;;;;;;;;UAQA;UACA;EAER,YAAY,MAAMC;;;;;;EASlB;UAIQ;UAMC;EAYH,gBAAgB,QAAQA;EAKvB,OAAO,QAAQF,cAAwB,eAAeC;EA+BvD,YAAY,QAAQF,gBAAmB;EAgBvC,aACJ,QAAQ,SAASA,iBAAoB,cAAcA,iBAClD;EAOG,gBACJ,eAAe,iBAAiB,cAAc,gBAC7C;EAoBG,QAAQ,aAAa,eAAe;EAUnC,iBAAiB,aAAa,eAAe,eAAe;EAW5D,sBAAsB,aAAa,eAAe,eAAe;EASjE,mBAAmB,aAAa,eAAe,eAAeA;EAc/D,MAAM;IAAa;MAAoB;EAIvC,SAAS;EAKT,eAAe;;;;;;;;;;EAiBrB,oBAAoB;;;;;EAsCb,aAAa,cAAc;;;;;;EAS5B,SAAS,SAAS,SAAS,oBAAoB;;;;;;;SAiC9C,qBAAqB,MAAMG,iBAAoB,MAAM,aAAa"}
@@ -0,0 +1,252 @@
1
+ import { bruteForceSearch } from "./vector-search.js";
2
+ import { compositeKey, matchesPrefix, readEntryBytes, singleChunk, toAsyncIterable, validateDimensionality } from "@statewalker/indexer-core";
3
+ import { fixedSizeList, float32, tableFromArrays, tableFromIPC, tableToIPC, utf8 } from "@uwdata/flechette";
4
+ //#region src/mem-vector-index.ts
5
+ var MemVectorIndex = class MemVectorIndex {
6
+ info;
7
+ entries = /* @__PURE__ */ new Map();
8
+ closed = false;
9
+ /**
10
+ * Dirty bit. `true` whenever `entries` has been mutated since the last
11
+ * `serializeToArrow()`. The serializer caches the last produced
12
+ * `Uint8Array` and returns it directly when `!dirty`, so a no-op re-sync
13
+ * skips the expensive Arrow IPC encoding for an unchanged index.
14
+ * Starts `true` so the first serialize after construction always runs.
15
+ */
16
+ dirty = true;
17
+ cachedSerialized;
18
+ constructor(info) {
19
+ this.info = info;
20
+ }
21
+ /**
22
+ * `true` when the in-memory state has changed since the last serialize.
23
+ * Callers (e.g. the persistence layer) may use it to skip serialization
24
+ * and writes when the index has not been touched.
25
+ */
26
+ isDirty() {
27
+ return this.dirty;
28
+ }
29
+ ensureOpen() {
30
+ if (this.closed) throw new Error("EmbeddingIndex is closed");
31
+ }
32
+ *filteredEntries(pathPrefixes) {
33
+ if (!pathPrefixes || pathPrefixes.length === 0) {
34
+ yield* this.entries.values();
35
+ return;
36
+ }
37
+ for (const entry of this.entries.values()) if (pathPrefixes.some((p) => matchesPrefix(entry.path, p))) yield entry;
38
+ }
39
+ async getIndexInfo() {
40
+ this.ensureOpen();
41
+ return { ...this.info };
42
+ }
43
+ async *search(params) {
44
+ this.ensureOpen();
45
+ const { embeddings, paths } = params;
46
+ const topK = params.topK ?? 100;
47
+ if (!embeddings || embeddings.length === 0) return;
48
+ const bestScores = /* @__PURE__ */ new Map();
49
+ for (const queryEmb of embeddings) {
50
+ validateDimensionality(this.info, queryEmb);
51
+ const filtered = [...this.filteredEntries(paths)];
52
+ const results = bruteForceSearch(queryEmb, filtered, topK);
53
+ for (const r of results) {
54
+ const key = compositeKey(r.path, r.blockId);
55
+ const existing = bestScores.get(key);
56
+ if (!existing || r.score > existing.score) bestScores.set(key, r);
57
+ }
58
+ }
59
+ const sorted = [...bestScores.values()].sort((a, b) => b.score - a.score);
60
+ for (const r of sorted.slice(0, topK)) yield r;
61
+ }
62
+ async addDocument(blocks) {
63
+ this.ensureOpen();
64
+ if (blocks.length === 0) return;
65
+ for (const block of blocks) {
66
+ validateDimensionality(this.info, block.embedding);
67
+ const key = compositeKey(block.path, block.blockId);
68
+ this.entries.set(key, {
69
+ path: block.path,
70
+ blockId: block.blockId,
71
+ embedding: new Float32Array(block.embedding),
72
+ metadata: block.metadata
73
+ });
74
+ }
75
+ this.dirty = true;
76
+ }
77
+ async addDocuments(blocks) {
78
+ this.ensureOpen();
79
+ for await (const batch of blocks) await this.addDocument(batch);
80
+ }
81
+ async deleteDocuments(pathSelectors) {
82
+ this.ensureOpen();
83
+ let removed = 0;
84
+ for await (const sel of toAsyncIterable(pathSelectors)) if (sel.blockId !== void 0) {
85
+ if (this.entries.delete(compositeKey(sel.path, sel.blockId))) removed += 1;
86
+ } else for (const [key, entry] of this.entries) if (matchesPrefix(entry.path, sel.path)) {
87
+ this.entries.delete(key);
88
+ removed += 1;
89
+ }
90
+ if (removed > 0) this.dirty = true;
91
+ }
92
+ async getSize(pathPrefix) {
93
+ this.ensureOpen();
94
+ if (pathPrefix === void 0) return this.entries.size;
95
+ let count = 0;
96
+ for (const entry of this.entries.values()) if (matchesPrefix(entry.path, pathPrefix)) count++;
97
+ return count;
98
+ }
99
+ async *getDocumentPaths(pathPrefix) {
100
+ this.ensureOpen();
101
+ const paths = /* @__PURE__ */ new Set();
102
+ for (const entry of this.entries.values()) if (pathPrefix === void 0 || matchesPrefix(entry.path, pathPrefix)) paths.add(entry.path);
103
+ for (const p of paths) yield p;
104
+ }
105
+ async *getDocumentBlocksRefs(pathPrefix) {
106
+ this.ensureOpen();
107
+ for (const entry of this.entries.values()) if (pathPrefix === void 0 || matchesPrefix(entry.path, pathPrefix)) yield {
108
+ path: entry.path,
109
+ blockId: entry.blockId
110
+ };
111
+ }
112
+ async *getDocumentsBlocks(pathPrefix) {
113
+ this.ensureOpen();
114
+ for (const entry of this.entries.values()) if (pathPrefix === void 0 || matchesPrefix(entry.path, pathPrefix)) yield {
115
+ path: entry.path,
116
+ blockId: entry.blockId,
117
+ embedding: entry.embedding,
118
+ metadata: entry.metadata
119
+ };
120
+ }
121
+ async close(_options) {
122
+ this.closed = true;
123
+ }
124
+ async flush() {
125
+ this.ensureOpen();
126
+ }
127
+ async deleteIndex() {
128
+ this.ensureOpen();
129
+ this.entries.clear();
130
+ this.cachedSerialized = void 0;
131
+ this.dirty = true;
132
+ this.closed = true;
133
+ }
134
+ /**
135
+ * Serialize embeddings to Arrow IPC format.
136
+ *
137
+ * Caches the result; while `dirty` is false (no mutations since the
138
+ * last call) returns the same `Uint8Array` instance, skipping the
139
+ * Arrow IPC encoding entirely. This makes no-op re-syncs cheap — the
140
+ * persistence layer still calls into us each flush, but the heavy
141
+ * encoding only runs when the index actually changed.
142
+ */
143
+ serializeToArrow() {
144
+ if (!this.dirty && this.cachedSerialized !== void 0) return this.cachedSerialized;
145
+ const dim = this.info.dimensionality;
146
+ const paths = [];
147
+ const blockIds = [];
148
+ const embeddingArrays = [];
149
+ const metadata = [];
150
+ for (const entry of this.entries.values()) {
151
+ paths.push(entry.path);
152
+ blockIds.push(entry.blockId);
153
+ embeddingArrays.push(Array.from(entry.embedding));
154
+ metadata.push(entry.metadata === void 0 ? null : JSON.stringify(entry.metadata));
155
+ }
156
+ const table = tableFromArrays({
157
+ path: paths,
158
+ blockId: blockIds,
159
+ embedding: embeddingArrays,
160
+ metadata
161
+ }, { types: {
162
+ path: utf8(),
163
+ blockId: utf8(),
164
+ embedding: fixedSizeList(float32(), dim),
165
+ metadata: utf8()
166
+ } });
167
+ const bytes = tableToIPC(table, { format: "stream" });
168
+ this.cachedSerialized = bytes;
169
+ this.dirty = false;
170
+ return bytes;
171
+ }
172
+ /**
173
+ * Stream the vector sub-index's state as a single named entry. The composite
174
+ * prefixes this with `${indexName}/${subName}/` before it hits persistence.
175
+ */
176
+ async *serialise() {
177
+ yield {
178
+ name: "arrow",
179
+ content: singleChunk(this.serializeToArrow())
180
+ };
181
+ }
182
+ /**
183
+ * Restore the vector sub-index from a previously-produced entry set. Expects
184
+ * one entry named `"arrow"`; ignores entries with other names so future
185
+ * additions remain forward-compatible.
186
+ */
187
+ async loadFrom(entries) {
188
+ this.ensureOpen();
189
+ for (const e of entries) {
190
+ if (e.name !== "arrow") continue;
191
+ const bytes = await readEntryBytes(e);
192
+ const table = tableFromIPC(bytes);
193
+ const pathCol = table.getChild("path");
194
+ const blockIdCol = table.getChild("blockId");
195
+ const embCol = table.getChild("embedding");
196
+ const metaCol = table.getChild("metadata");
197
+ this.entries.clear();
198
+ for (let i = 0; i < table.numRows; i++) {
199
+ const path = pathCol.at(i);
200
+ const blockId = blockIdCol.at(i);
201
+ const embedding = new Float32Array(embCol.at(i));
202
+ const metaRaw = metaCol?.at(i);
203
+ const metadata = metaRaw == null ? void 0 : JSON.parse(metaRaw);
204
+ const key = compositeKey(path, blockId);
205
+ this.entries.set(key, {
206
+ path,
207
+ blockId,
208
+ embedding,
209
+ metadata
210
+ });
211
+ }
212
+ this.cachedSerialized = bytes;
213
+ this.dirty = false;
214
+ return;
215
+ }
216
+ }
217
+ /**
218
+ * Deserialize embeddings from Arrow IPC format. The reconstructed index
219
+ * starts clean (`dirty = false`) and primes its serialize cache with the
220
+ * input bytes — a subsequent `serializeToArrow()` on the just-loaded
221
+ * index returns the same bytes without re-encoding.
222
+ */
223
+ static deserializeFromArrow(info, data) {
224
+ const table = tableFromIPC(data);
225
+ const vec = new MemVectorIndex(info);
226
+ const pathCol = table.getChild("path");
227
+ const blockIdCol = table.getChild("blockId");
228
+ const embCol = table.getChild("embedding");
229
+ const metaCol = table.getChild("metadata");
230
+ for (let i = 0; i < table.numRows; i++) {
231
+ const path = pathCol.at(i);
232
+ const blockId = blockIdCol.at(i);
233
+ const embedding = new Float32Array(embCol.at(i));
234
+ const metaRaw = metaCol?.at(i);
235
+ const metadata = metaRaw == null ? void 0 : JSON.parse(metaRaw);
236
+ const key = compositeKey(path, blockId);
237
+ vec.entries.set(key, {
238
+ path,
239
+ blockId,
240
+ embedding,
241
+ metadata
242
+ });
243
+ }
244
+ vec.cachedSerialized = data;
245
+ vec.dirty = false;
246
+ return vec;
247
+ }
248
+ };
249
+ //#endregion
250
+ export { MemVectorIndex };
251
+
252
+ //# sourceMappingURL=mem-vector-index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mem-vector-index.js","names":[],"sources":["../src/mem-vector-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 validateDimensionality,\n} from \"@statewalker/indexer-core\";\nimport type {\n VectorBlock as EmbeddingBlock,\n VectorIndex as EmbeddingIndex,\n VectorIndexInfo as EmbeddingIndexInfo,\n VectorQuery as EmbeddingSearchParams,\n VectorResult as EmbeddingSearchResult,\n} from \"@statewalker/indexer-vector\";\nimport {\n fixedSizeList,\n float32,\n tableFromArrays,\n tableFromIPC,\n tableToIPC,\n utf8,\n} from \"@uwdata/flechette\";\nimport { bruteForceSearch } from \"./vector-search.js\";\n\ninterface StoredEntry {\n path: DocumentPath;\n blockId: string;\n embedding: Float32Array;\n metadata?: Metadata;\n}\n\nexport class MemVectorIndex\n implements\n EmbeddingIndex,\n PersistableSearchIndex<EmbeddingBlock, EmbeddingSearchParams, EmbeddingSearchResult>\n{\n private readonly info: EmbeddingIndexInfo;\n private readonly entries = new Map<string, StoredEntry>();\n private closed = false;\n /**\n * Dirty bit. `true` whenever `entries` has been mutated since the last\n * `serializeToArrow()`. The serializer caches the last produced\n * `Uint8Array` and returns it directly when `!dirty`, so a no-op re-sync\n * skips the expensive Arrow IPC encoding for an unchanged index.\n * Starts `true` so the first serialize after construction always runs.\n */\n private dirty = true;\n private cachedSerialized?: Uint8Array;\n\n constructor(info: EmbeddingIndexInfo) {\n this.info = info;\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(\"EmbeddingIndex is closed\");\n }\n }\n\n private *filteredEntries(pathPrefixes?: DocumentPath[]): Iterable<StoredEntry> {\n if (!pathPrefixes || pathPrefixes.length === 0) {\n yield* this.entries.values();\n return;\n }\n for (const entry of this.entries.values()) {\n if (pathPrefixes.some((p) => matchesPrefix(entry.path, p))) {\n yield entry;\n }\n }\n }\n\n async getIndexInfo(): Promise<EmbeddingIndexInfo> {\n this.ensureOpen();\n return { ...this.info };\n }\n\n async *search(params: EmbeddingSearchParams): AsyncGenerator<EmbeddingSearchResult> {\n this.ensureOpen();\n const { embeddings, paths } = params;\n // Sub-queries may omit `topK` to defer to the composite's top-level cutoff;\n // when unset we retrieve generously so RRF has material to fuse.\n const topK = params.topK ?? 100;\n\n if (!embeddings || embeddings.length === 0) return;\n\n // Multi-embedding: search for each, merge by best score\n const bestScores = new Map<string, EmbeddingSearchResult>();\n\n for (const queryEmb of embeddings) {\n validateDimensionality(this.info, queryEmb);\n const filtered = [...this.filteredEntries(paths)];\n const results = bruteForceSearch(queryEmb, filtered, topK);\n for (const r of results) {\n const key = compositeKey(r.path, r.blockId);\n const existing = bestScores.get(key);\n if (!existing || r.score > existing.score) {\n bestScores.set(key, r);\n }\n }\n }\n\n const sorted = [...bestScores.values()].sort((a, b) => b.score - a.score);\n for (const r of sorted.slice(0, topK)) {\n yield r;\n }\n }\n\n async addDocument(blocks: EmbeddingBlock[]): Promise<void> {\n this.ensureOpen();\n if (blocks.length === 0) return;\n for (const block of blocks) {\n validateDimensionality(this.info, block.embedding);\n const key = compositeKey(block.path, block.blockId);\n this.entries.set(key, {\n path: block.path,\n blockId: block.blockId,\n embedding: new Float32Array(block.embedding),\n metadata: block.metadata,\n });\n }\n this.dirty = true;\n }\n\n async addDocuments(\n blocks: Iterable<EmbeddingBlock[]> | AsyncIterable<EmbeddingBlock[]>,\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 if (this.entries.delete(compositeKey(sel.path, sel.blockId))) {\n removed += 1;\n }\n } else {\n for (const [key, entry] of this.entries) {\n if (matchesPrefix(entry.path, sel.path)) {\n this.entries.delete(key);\n removed += 1;\n }\n }\n }\n }\n if (removed > 0) this.dirty = true;\n }\n\n async getSize(pathPrefix?: DocumentPath): Promise<number> {\n this.ensureOpen();\n if (pathPrefix === undefined) return this.entries.size;\n let count = 0;\n for (const entry of this.entries.values()) {\n if (matchesPrefix(entry.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 entry of this.entries.values()) {\n if (pathPrefix === undefined || matchesPrefix(entry.path, pathPrefix)) {\n paths.add(entry.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 entry of this.entries.values()) {\n if (pathPrefix === undefined || matchesPrefix(entry.path, pathPrefix)) {\n yield { path: entry.path, blockId: entry.blockId };\n }\n }\n }\n\n async *getDocumentsBlocks(pathPrefix?: DocumentPath): AsyncGenerator<EmbeddingBlock> {\n this.ensureOpen();\n for (const entry of this.entries.values()) {\n if (pathPrefix === undefined || matchesPrefix(entry.path, pathPrefix)) {\n yield {\n path: entry.path,\n blockId: entry.blockId,\n embedding: entry.embedding,\n metadata: entry.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.entries.clear();\n this.cachedSerialized = undefined;\n this.dirty = true;\n this.closed = true;\n }\n\n /**\n * Serialize embeddings to Arrow IPC format.\n *\n * Caches the result; while `dirty` is false (no mutations since the\n * last call) returns the same `Uint8Array` instance, skipping the\n * Arrow IPC encoding entirely. This makes no-op re-syncs cheap — the\n * persistence layer still calls into us each flush, but the heavy\n * encoding only runs when the index actually changed.\n */\n serializeToArrow(): Uint8Array {\n if (!this.dirty && this.cachedSerialized !== undefined) {\n return this.cachedSerialized;\n }\n const dim = this.info.dimensionality;\n const paths: string[] = [];\n const blockIds: string[] = [];\n const embeddingArrays: number[][] = [];\n const metadata: Array<string | null> = [];\n for (const entry of this.entries.values()) {\n paths.push(entry.path);\n blockIds.push(entry.blockId);\n embeddingArrays.push(Array.from(entry.embedding));\n metadata.push(entry.metadata === undefined ? null : JSON.stringify(entry.metadata));\n }\n const table = tableFromArrays(\n { path: paths, blockId: blockIds, embedding: embeddingArrays, metadata },\n {\n types: {\n path: utf8(),\n blockId: utf8(),\n embedding: fixedSizeList(float32(), dim),\n metadata: utf8(),\n },\n },\n );\n const bytes = tableToIPC(table, { format: \"stream\" }) as Uint8Array;\n this.cachedSerialized = bytes;\n this.dirty = false;\n return bytes;\n }\n\n // --- PersistableSearchIndex --------------------------------------------------\n\n /**\n * Stream the vector 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 yield { name: \"arrow\", content: singleChunk(this.serializeToArrow()) };\n }\n\n /**\n * Restore the vector sub-index from a previously-produced entry set. Expects\n * one entry named `\"arrow\"`; ignores entries with other names so future\n * additions remain forward-compatible.\n */\n async loadFrom(entries: Iterable<PersistenceEntry>): Promise<void> {\n this.ensureOpen();\n for (const e of entries) {\n if (e.name !== \"arrow\") continue;\n const bytes = await readEntryBytes(e);\n const table = tableFromIPC(bytes);\n const pathCol = table.getChild(\"path\");\n const blockIdCol = table.getChild(\"blockId\");\n const embCol = table.getChild(\"embedding\");\n const metaCol = table.getChild(\"metadata\");\n this.entries.clear();\n for (let i = 0; i < table.numRows; i++) {\n const path = pathCol.at(i) as string as DocumentPath;\n const blockId = blockIdCol.at(i) as string;\n const embedding = new Float32Array(embCol.at(i) as ArrayLike<number>);\n const metaRaw = metaCol?.at(i) as string | null | undefined;\n const metadata =\n metaRaw == null ? undefined : (JSON.parse(metaRaw) as StoredEntry[\"metadata\"]);\n const key = compositeKey(path, blockId);\n this.entries.set(key, { path, blockId, embedding, metadata });\n }\n this.cachedSerialized = bytes;\n this.dirty = false;\n return;\n }\n }\n\n /**\n * Deserialize embeddings from Arrow IPC format. The reconstructed index\n * starts clean (`dirty = false`) and primes its serialize cache with the\n * input bytes — a subsequent `serializeToArrow()` on the just-loaded\n * index returns the same bytes without re-encoding.\n */\n static deserializeFromArrow(info: EmbeddingIndexInfo, data: Uint8Array): MemVectorIndex {\n const table = tableFromIPC(data);\n const vec = new MemVectorIndex(info);\n const pathCol = table.getChild(\"path\");\n const blockIdCol = table.getChild(\"blockId\");\n const embCol = table.getChild(\"embedding\");\n const metaCol = table.getChild(\"metadata\");\n for (let i = 0; i < table.numRows; i++) {\n const path = pathCol.at(i) as string as DocumentPath;\n const blockId = blockIdCol.at(i) as string;\n const embedding = new Float32Array(embCol.at(i) as ArrayLike<number>);\n const metaRaw = metaCol?.at(i) as string | null | undefined;\n const metadata =\n metaRaw == null ? undefined : (JSON.parse(metaRaw) as StoredEntry[\"metadata\"]);\n const key = compositeKey(path, blockId);\n vec.entries.set(key, { path, blockId, embedding, metadata });\n }\n vec.cachedSerialized = data;\n vec.dirty = false;\n return vec;\n }\n}\n"],"mappings":";;;;AAwCA,IAAa,iBAAb,MAAa,eAIb;CACE;CACA,0BAA2B,IAAI,IAAyB;CACxD,SAAiB;;;;;;;;CAQjB,QAAgB;CAChB;CAEA,YAAY,MAA0B;EACpC,KAAK,OAAO;CACd;;;;;;CAOA,UAAmB;EACjB,OAAO,KAAK;CACd;CAEA,aAA2B;EACzB,IAAI,KAAK,QACP,MAAM,IAAI,MAAM,0BAA0B;CAE9C;CAEA,CAAS,gBAAgB,cAAsD;EAC7E,IAAI,CAAC,gBAAgB,aAAa,WAAW,GAAG;GAC9C,OAAO,KAAK,QAAQ,OAAO;GAC3B;EACF;EACA,KAAK,MAAM,SAAS,KAAK,QAAQ,OAAO,GACtC,IAAI,aAAa,MAAM,MAAM,cAAc,MAAM,MAAM,CAAC,CAAC,GACvD,MAAM;CAGZ;CAEA,MAAM,eAA4C;EAChD,KAAK,WAAW;EAChB,OAAO,EAAE,GAAG,KAAK,KAAK;CACxB;CAEA,OAAO,OAAO,QAAsE;EAClF,KAAK,WAAW;EAChB,MAAM,EAAE,YAAY,UAAU;EAG9B,MAAM,OAAO,OAAO,QAAQ;EAE5B,IAAI,CAAC,cAAc,WAAW,WAAW,GAAG;EAG5C,MAAM,6BAAa,IAAI,IAAmC;EAE1D,KAAK,MAAM,YAAY,YAAY;GACjC,uBAAuB,KAAK,MAAM,QAAQ;GAC1C,MAAM,WAAW,CAAC,GAAG,KAAK,gBAAgB,KAAK,CAAC;GAChD,MAAM,UAAU,iBAAiB,UAAU,UAAU,IAAI;GACzD,KAAK,MAAM,KAAK,SAAS;IACvB,MAAM,MAAM,aAAa,EAAE,MAAM,EAAE,OAAO;IAC1C,MAAM,WAAW,WAAW,IAAI,GAAG;IACnC,IAAI,CAAC,YAAY,EAAE,QAAQ,SAAS,OAClC,WAAW,IAAI,KAAK,CAAC;GAEzB;EACF;EAEA,MAAM,SAAS,CAAC,GAAG,WAAW,OAAO,CAAC,CAAC,CAAC,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,KAAK;EACxE,KAAK,MAAM,KAAK,OAAO,MAAM,GAAG,IAAI,GAClC,MAAM;CAEV;CAEA,MAAM,YAAY,QAAyC;EACzD,KAAK,WAAW;EAChB,IAAI,OAAO,WAAW,GAAG;EACzB,KAAK,MAAM,SAAS,QAAQ;GAC1B,uBAAuB,KAAK,MAAM,MAAM,SAAS;GACjD,MAAM,MAAM,aAAa,MAAM,MAAM,MAAM,OAAO;GAClD,KAAK,QAAQ,IAAI,KAAK;IACpB,MAAM,MAAM;IACZ,SAAS,MAAM;IACf,WAAW,IAAI,aAAa,MAAM,SAAS;IAC3C,UAAU,MAAM;GAClB,CAAC;EACH;EACA,KAAK,QAAQ;CACf;CAEA,MAAM,aACJ,QACe;EACf,KAAK,WAAW;EAChB,WAAW,MAAM,SAAS,QACxB,MAAM,KAAK,YAAY,KAAK;CAEhC;CAEA,MAAM,gBACJ,eACe;EACf,KAAK,WAAW;EAChB,IAAI,UAAU;EACd,WAAW,MAAM,OAAO,gBAAgB,aAAa,GACnD,IAAI,IAAI,YAAY,KAAA,GACd;OAAA,KAAK,QAAQ,OAAO,aAAa,IAAI,MAAM,IAAI,OAAO,CAAC,GACzD,WAAW;EAAA,OAGb,KAAK,MAAM,CAAC,KAAK,UAAU,KAAK,SAC9B,IAAI,cAAc,MAAM,MAAM,IAAI,IAAI,GAAG;GACvC,KAAK,QAAQ,OAAO,GAAG;GACvB,WAAW;EACb;EAIN,IAAI,UAAU,GAAG,KAAK,QAAQ;CAChC;CAEA,MAAM,QAAQ,YAA4C;EACxD,KAAK,WAAW;EAChB,IAAI,eAAe,KAAA,GAAW,OAAO,KAAK,QAAQ;EAClD,IAAI,QAAQ;EACZ,KAAK,MAAM,SAAS,KAAK,QAAQ,OAAO,GACtC,IAAI,cAAc,MAAM,MAAM,UAAU,GAAG;EAE7C,OAAO;CACT;CAEA,OAAO,iBAAiB,YAAyD;EAC/E,KAAK,WAAW;EAChB,MAAM,wBAAQ,IAAI,IAAkB;EACpC,KAAK,MAAM,SAAS,KAAK,QAAQ,OAAO,GACtC,IAAI,eAAe,KAAA,KAAa,cAAc,MAAM,MAAM,UAAU,GAClE,MAAM,IAAI,MAAM,IAAI;EAGxB,KAAK,MAAM,KAAK,OAAO,MAAM;CAC/B;CAEA,OAAO,sBAAsB,YAA2D;EACtF,KAAK,WAAW;EAChB,KAAK,MAAM,SAAS,KAAK,QAAQ,OAAO,GACtC,IAAI,eAAe,KAAA,KAAa,cAAc,MAAM,MAAM,UAAU,GAClE,MAAM;GAAE,MAAM,MAAM;GAAM,SAAS,MAAM;EAAQ;CAGvD;CAEA,OAAO,mBAAmB,YAA2D;EACnF,KAAK,WAAW;EAChB,KAAK,MAAM,SAAS,KAAK,QAAQ,OAAO,GACtC,IAAI,eAAe,KAAA,KAAa,cAAc,MAAM,MAAM,UAAU,GAClE,MAAM;GACJ,MAAM,MAAM;GACZ,SAAS,MAAM;GACf,WAAW,MAAM;GACjB,UAAU,MAAM;EAClB;CAGN;CAEA,MAAM,MAAM,UAA+C;EACzD,KAAK,SAAS;CAChB;CAEA,MAAM,QAAuB;EAC3B,KAAK,WAAW;CAElB;CAEA,MAAM,cAA6B;EACjC,KAAK,WAAW;EAChB,KAAK,QAAQ,MAAM;EACnB,KAAK,mBAAmB,KAAA;EACxB,KAAK,QAAQ;EACb,KAAK,SAAS;CAChB;;;;;;;;;;CAWA,mBAA+B;EAC7B,IAAI,CAAC,KAAK,SAAS,KAAK,qBAAqB,KAAA,GAC3C,OAAO,KAAK;EAEd,MAAM,MAAM,KAAK,KAAK;EACtB,MAAM,QAAkB,CAAC;EACzB,MAAM,WAAqB,CAAC;EAC5B,MAAM,kBAA8B,CAAC;EACrC,MAAM,WAAiC,CAAC;EACxC,KAAK,MAAM,SAAS,KAAK,QAAQ,OAAO,GAAG;GACzC,MAAM,KAAK,MAAM,IAAI;GACrB,SAAS,KAAK,MAAM,OAAO;GAC3B,gBAAgB,KAAK,MAAM,KAAK,MAAM,SAAS,CAAC;GAChD,SAAS,KAAK,MAAM,aAAa,KAAA,IAAY,OAAO,KAAK,UAAU,MAAM,QAAQ,CAAC;EACpF;EACA,MAAM,QAAQ,gBACZ;GAAE,MAAM;GAAO,SAAS;GAAU,WAAW;GAAiB;EAAS,GACvE,EACE,OAAO;GACL,MAAM,KAAK;GACX,SAAS,KAAK;GACd,WAAW,cAAc,QAAQ,GAAG,GAAG;GACvC,UAAU,KAAK;EACjB,EACF,CACF;EACA,MAAM,QAAQ,WAAW,OAAO,EAAE,QAAQ,SAAS,CAAC;EACpD,KAAK,mBAAmB;EACxB,KAAK,QAAQ;EACb,OAAO;CACT;;;;;CAQA,OAAO,YAA6C;EAClD,MAAM;GAAE,MAAM;GAAS,SAAS,YAAY,KAAK,iBAAiB,CAAC;EAAE;CACvE;;;;;;CAOA,MAAM,SAAS,SAAoD;EACjE,KAAK,WAAW;EAChB,KAAK,MAAM,KAAK,SAAS;GACvB,IAAI,EAAE,SAAS,SAAS;GACxB,MAAM,QAAQ,MAAM,eAAe,CAAC;GACpC,MAAM,QAAQ,aAAa,KAAK;GAChC,MAAM,UAAU,MAAM,SAAS,MAAM;GACrC,MAAM,aAAa,MAAM,SAAS,SAAS;GAC3C,MAAM,SAAS,MAAM,SAAS,WAAW;GACzC,MAAM,UAAU,MAAM,SAAS,UAAU;GACzC,KAAK,QAAQ,MAAM;GACnB,KAAK,IAAI,IAAI,GAAG,IAAI,MAAM,SAAS,KAAK;IACtC,MAAM,OAAO,QAAQ,GAAG,CAAC;IACzB,MAAM,UAAU,WAAW,GAAG,CAAC;IAC/B,MAAM,YAAY,IAAI,aAAa,OAAO,GAAG,CAAC,CAAsB;IACpE,MAAM,UAAU,SAAS,GAAG,CAAC;IAC7B,MAAM,WACJ,WAAW,OAAO,KAAA,IAAa,KAAK,MAAM,OAAO;IACnD,MAAM,MAAM,aAAa,MAAM,OAAO;IACtC,KAAK,QAAQ,IAAI,KAAK;KAAE;KAAM;KAAS;KAAW;IAAS,CAAC;GAC9D;GACA,KAAK,mBAAmB;GACxB,KAAK,QAAQ;GACb;EACF;CACF;;;;;;;CAQA,OAAO,qBAAqB,MAA0B,MAAkC;EACtF,MAAM,QAAQ,aAAa,IAAI;EAC/B,MAAM,MAAM,IAAI,eAAe,IAAI;EACnC,MAAM,UAAU,MAAM,SAAS,MAAM;EACrC,MAAM,aAAa,MAAM,SAAS,SAAS;EAC3C,MAAM,SAAS,MAAM,SAAS,WAAW;EACzC,MAAM,UAAU,MAAM,SAAS,UAAU;EACzC,KAAK,IAAI,IAAI,GAAG,IAAI,MAAM,SAAS,KAAK;GACtC,MAAM,OAAO,QAAQ,GAAG,CAAC;GACzB,MAAM,UAAU,WAAW,GAAG,CAAC;GAC/B,MAAM,YAAY,IAAI,aAAa,OAAO,GAAG,CAAC,CAAsB;GACpE,MAAM,UAAU,SAAS,GAAG,CAAC;GAC7B,MAAM,WACJ,WAAW,OAAO,KAAA,IAAa,KAAK,MAAM,OAAO;GACnD,MAAM,MAAM,aAAa,MAAM,OAAO;GACtC,IAAI,QAAQ,IAAI,KAAK;IAAE;IAAM;IAAS;IAAW;GAAS,CAAC;EAC7D;EACA,IAAI,mBAAmB;EACvB,IAAI,QAAQ;EACZ,OAAO;CACT;AACF"}
@@ -0,0 +1,10 @@
1
+ import { VectorProvider } from "@statewalker/indexer-vector";
2
+ //#region src/mem-vector-provider.d.ts
3
+ /**
4
+ * `VectorProvider` backed by {@link MemVectorIndex} — the in-memory brute-force
5
+ * vector store. Wired into an `Indexer` at construction time via
6
+ * `createPersistenceBackedIndexer({ providers: [memVectorProvider] })`.
7
+ */
8
+ export declare const memVectorProvider: VectorProvider;
9
+ //#endregion
10
+ //# sourceMappingURL=mem-vector-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mem-vector-provider.d.ts","names":[],"sources":["../src/mem-vector-provider.ts"],"mappings":";;;;;;;qBAQa,mBAAmB"}
@@ -0,0 +1,22 @@
1
+ import { MemVectorIndex } from "./mem-vector-index.js";
2
+ import { VECTOR_TYPE } from "@statewalker/indexer-vector";
3
+ //#region src/mem-vector-provider.ts
4
+ /**
5
+ * `VectorProvider` backed by {@link MemVectorIndex} — the in-memory brute-force
6
+ * vector store. Wired into an `Indexer` at construction time via
7
+ * `createPersistenceBackedIndexer({ providers: [memVectorProvider] })`.
8
+ */
9
+ const memVectorProvider = {
10
+ type: VECTOR_TYPE,
11
+ create(config) {
12
+ return new MemVectorIndex({
13
+ dimensionality: config.dimensionality,
14
+ model: config.model,
15
+ metadata: config.metadata
16
+ });
17
+ }
18
+ };
19
+ //#endregion
20
+ export { memVectorProvider };
21
+
22
+ //# sourceMappingURL=mem-vector-provider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mem-vector-provider.js","names":[],"sources":["../src/mem-vector-provider.ts"],"sourcesContent":["import { VECTOR_TYPE, type VectorConfig, type VectorProvider } from \"@statewalker/indexer-vector\";\nimport { MemVectorIndex } from \"./mem-vector-index.js\";\n\n/**\n * `VectorProvider` backed by {@link MemVectorIndex} — the in-memory brute-force\n * vector store. Wired into an `Indexer` at construction time via\n * `createPersistenceBackedIndexer({ providers: [memVectorProvider] })`.\n */\nexport const memVectorProvider: VectorProvider = {\n type: VECTOR_TYPE,\n create(config: VectorConfig): MemVectorIndex {\n return new MemVectorIndex({\n dimensionality: config.dimensionality,\n model: config.model,\n metadata: config.metadata,\n });\n },\n};\n"],"mappings":";;;;;;;;AAQA,MAAa,oBAAoC;CAC/C,MAAM;CACN,OAAO,QAAsC;EAC3C,OAAO,IAAI,eAAe;GACxB,gBAAgB,OAAO;GACvB,OAAO,OAAO;GACd,UAAU,OAAO;EACnB,CAAC;CACH;AACF"}
@@ -0,0 +1,34 @@
1
+ //#region src/vector-search.ts
2
+ function cosineSimilarity(a, b) {
3
+ if (a.length !== b.length) throw new Error(`cosineSimilarity: dimensionality mismatch (${a.length} vs ${b.length})`);
4
+ let dot = 0;
5
+ let normA = 0;
6
+ let normB = 0;
7
+ for (let i = 0; i < a.length; i++) {
8
+ const ai = a[i] ?? 0;
9
+ const bi = b[i] ?? 0;
10
+ dot += ai * bi;
11
+ normA += ai * ai;
12
+ normB += bi * bi;
13
+ }
14
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
15
+ if (denom === 0) return 0;
16
+ return dot / denom;
17
+ }
18
+ function bruteForceSearch(query, entries, topK) {
19
+ const scored = [];
20
+ for (const entry of entries) {
21
+ const score = cosineSimilarity(query, entry.embedding);
22
+ scored.push({
23
+ path: entry.path,
24
+ blockId: entry.blockId,
25
+ score
26
+ });
27
+ }
28
+ scored.sort((a, b) => b.score - a.score);
29
+ return scored.slice(0, topK);
30
+ }
31
+ //#endregion
32
+ export { bruteForceSearch, cosineSimilarity };
33
+
34
+ //# sourceMappingURL=vector-search.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"vector-search.js","names":[],"sources":["../src/vector-search.ts"],"sourcesContent":["import type { DocumentPath } from \"@statewalker/indexer-api\";\nimport type { VectorResult as EmbeddingSearchResult } from \"@statewalker/indexer-vector\";\n\nexport function cosineSimilarity(a: Float32Array, b: Float32Array): number {\n if (a.length !== b.length) {\n throw new Error(`cosineSimilarity: dimensionality mismatch (${a.length} vs ${b.length})`);\n }\n let dot = 0;\n let normA = 0;\n let normB = 0;\n for (let i = 0; i < a.length; i++) {\n const ai = a[i] ?? 0;\n const bi = b[i] ?? 0;\n dot += ai * bi;\n normA += ai * ai;\n normB += bi * bi;\n }\n const denom = Math.sqrt(normA) * Math.sqrt(normB);\n if (denom === 0) return 0;\n return dot / denom;\n}\n\nexport function bruteForceSearch(\n query: Float32Array,\n entries: Iterable<{\n path: DocumentPath;\n blockId: string;\n embedding: Float32Array;\n }>,\n topK: number,\n): EmbeddingSearchResult[] {\n const scored: EmbeddingSearchResult[] = [];\n for (const entry of entries) {\n const score = cosineSimilarity(query, entry.embedding);\n scored.push({ path: entry.path, blockId: entry.blockId, score });\n }\n scored.sort((a, b) => b.score - a.score);\n return scored.slice(0, topK);\n}\n"],"mappings":";AAGA,SAAgB,iBAAiB,GAAiB,GAAyB;CACzE,IAAI,EAAE,WAAW,EAAE,QACjB,MAAM,IAAI,MAAM,8CAA8C,EAAE,OAAO,MAAM,EAAE,OAAO,EAAE;CAE1F,IAAI,MAAM;CACV,IAAI,QAAQ;CACZ,IAAI,QAAQ;CACZ,KAAK,IAAI,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK;EACjC,MAAM,KAAK,EAAE,MAAM;EACnB,MAAM,KAAK,EAAE,MAAM;EACnB,OAAO,KAAK;EACZ,SAAS,KAAK;EACd,SAAS,KAAK;CAChB;CACA,MAAM,QAAQ,KAAK,KAAK,KAAK,IAAI,KAAK,KAAK,KAAK;CAChD,IAAI,UAAU,GAAG,OAAO;CACxB,OAAO,MAAM;AACf;AAEA,SAAgB,iBACd,OACA,SAKA,MACyB;CACzB,MAAM,SAAkC,CAAC;CACzC,KAAK,MAAM,SAAS,SAAS;EAC3B,MAAM,QAAQ,iBAAiB,OAAO,MAAM,SAAS;EACrD,OAAO,KAAK;GAAE,MAAM,MAAM;GAAM,SAAS,MAAM;GAAS;EAAM,CAAC;CACjE;CACA,OAAO,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,KAAK;CACvC,OAAO,OAAO,MAAM,GAAG,IAAI;AAC7B"}
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@statewalker/indexer-mem",
3
+ "version": "0.1.2",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "In-memory base implementation of @statewalker/indexer-api. Reusable scaffold for full-text and vector in-memory indexers.",
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
+ "@uwdata/flechette": "^2.5.0",
31
+ "@statewalker/indexer-api": "^0.1.1",
32
+ "@statewalker/indexer-vector": "^0.1.1",
33
+ "@statewalker/indexer-core": "^0.1.1"
34
+ },
35
+ "devDependencies": {
36
+ "rimraf": "^6.1.3",
37
+ "tsdown": "^0.23.0",
38
+ "typescript": "^7.0.2",
39
+ "vitest": "^5.0.3"
40
+ },
41
+ "sideEffects": false,
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "scripts": {
46
+ "build": "tsdown",
47
+ "dev": "tsdown --watch",
48
+ "test": "vitest run --passWithNoTests",
49
+ "test:watch": "vitest",
50
+ "typecheck": "tsc --noEmit",
51
+ "clean": "rimraf dist",
52
+ "lint": "biome check --write .",
53
+ "format": "biome format --write ."
54
+ }
55
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { MemVectorIndex } from "./mem-vector-index.js";
2
+ export { memVectorProvider } from "./mem-vector-provider.js";
@@ -0,0 +1,344 @@
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
+ validateDimensionality,
16
+ } from "@statewalker/indexer-core";
17
+ import type {
18
+ VectorBlock as EmbeddingBlock,
19
+ VectorIndex as EmbeddingIndex,
20
+ VectorIndexInfo as EmbeddingIndexInfo,
21
+ VectorQuery as EmbeddingSearchParams,
22
+ VectorResult as EmbeddingSearchResult,
23
+ } from "@statewalker/indexer-vector";
24
+ import {
25
+ fixedSizeList,
26
+ float32,
27
+ tableFromArrays,
28
+ tableFromIPC,
29
+ tableToIPC,
30
+ utf8,
31
+ } from "@uwdata/flechette";
32
+ import { bruteForceSearch } from "./vector-search.js";
33
+
34
+ interface StoredEntry {
35
+ path: DocumentPath;
36
+ blockId: string;
37
+ embedding: Float32Array;
38
+ metadata?: Metadata;
39
+ }
40
+
41
+ export class MemVectorIndex
42
+ implements
43
+ EmbeddingIndex,
44
+ PersistableSearchIndex<EmbeddingBlock, EmbeddingSearchParams, EmbeddingSearchResult>
45
+ {
46
+ private readonly info: EmbeddingIndexInfo;
47
+ private readonly entries = new Map<string, StoredEntry>();
48
+ private closed = false;
49
+ /**
50
+ * Dirty bit. `true` whenever `entries` has been mutated since the last
51
+ * `serializeToArrow()`. The serializer caches the last produced
52
+ * `Uint8Array` and returns it directly when `!dirty`, so a no-op re-sync
53
+ * skips the expensive Arrow IPC encoding for an unchanged index.
54
+ * Starts `true` so the first serialize after construction always runs.
55
+ */
56
+ private dirty = true;
57
+ private cachedSerialized?: Uint8Array;
58
+
59
+ constructor(info: EmbeddingIndexInfo) {
60
+ this.info = info;
61
+ }
62
+
63
+ /**
64
+ * `true` when the in-memory state has changed since the last serialize.
65
+ * Callers (e.g. the persistence layer) may use it to skip serialization
66
+ * and writes when the index has not been touched.
67
+ */
68
+ isDirty(): boolean {
69
+ return this.dirty;
70
+ }
71
+
72
+ private ensureOpen(): void {
73
+ if (this.closed) {
74
+ throw new Error("EmbeddingIndex is closed");
75
+ }
76
+ }
77
+
78
+ private *filteredEntries(pathPrefixes?: DocumentPath[]): Iterable<StoredEntry> {
79
+ if (!pathPrefixes || pathPrefixes.length === 0) {
80
+ yield* this.entries.values();
81
+ return;
82
+ }
83
+ for (const entry of this.entries.values()) {
84
+ if (pathPrefixes.some((p) => matchesPrefix(entry.path, p))) {
85
+ yield entry;
86
+ }
87
+ }
88
+ }
89
+
90
+ async getIndexInfo(): Promise<EmbeddingIndexInfo> {
91
+ this.ensureOpen();
92
+ return { ...this.info };
93
+ }
94
+
95
+ async *search(params: EmbeddingSearchParams): AsyncGenerator<EmbeddingSearchResult> {
96
+ this.ensureOpen();
97
+ const { embeddings, paths } = params;
98
+ // Sub-queries may omit `topK` to defer to the composite's top-level cutoff;
99
+ // when unset we retrieve generously so RRF has material to fuse.
100
+ const topK = params.topK ?? 100;
101
+
102
+ if (!embeddings || embeddings.length === 0) return;
103
+
104
+ // Multi-embedding: search for each, merge by best score
105
+ const bestScores = new Map<string, EmbeddingSearchResult>();
106
+
107
+ for (const queryEmb of embeddings) {
108
+ validateDimensionality(this.info, queryEmb);
109
+ const filtered = [...this.filteredEntries(paths)];
110
+ const results = bruteForceSearch(queryEmb, filtered, topK);
111
+ for (const r of results) {
112
+ const key = compositeKey(r.path, r.blockId);
113
+ const existing = bestScores.get(key);
114
+ if (!existing || r.score > existing.score) {
115
+ bestScores.set(key, r);
116
+ }
117
+ }
118
+ }
119
+
120
+ const sorted = [...bestScores.values()].sort((a, b) => b.score - a.score);
121
+ for (const r of sorted.slice(0, topK)) {
122
+ yield r;
123
+ }
124
+ }
125
+
126
+ async addDocument(blocks: EmbeddingBlock[]): Promise<void> {
127
+ this.ensureOpen();
128
+ if (blocks.length === 0) return;
129
+ for (const block of blocks) {
130
+ validateDimensionality(this.info, block.embedding);
131
+ const key = compositeKey(block.path, block.blockId);
132
+ this.entries.set(key, {
133
+ path: block.path,
134
+ blockId: block.blockId,
135
+ embedding: new Float32Array(block.embedding),
136
+ metadata: block.metadata,
137
+ });
138
+ }
139
+ this.dirty = true;
140
+ }
141
+
142
+ async addDocuments(
143
+ blocks: Iterable<EmbeddingBlock[]> | AsyncIterable<EmbeddingBlock[]>,
144
+ ): Promise<void> {
145
+ this.ensureOpen();
146
+ for await (const batch of blocks) {
147
+ await this.addDocument(batch);
148
+ }
149
+ }
150
+
151
+ async deleteDocuments(
152
+ pathSelectors: PathSelector[] | AsyncIterable<PathSelector>,
153
+ ): Promise<void> {
154
+ this.ensureOpen();
155
+ let removed = 0;
156
+ for await (const sel of toAsyncIterable(pathSelectors)) {
157
+ if (sel.blockId !== undefined) {
158
+ if (this.entries.delete(compositeKey(sel.path, sel.blockId))) {
159
+ removed += 1;
160
+ }
161
+ } else {
162
+ for (const [key, entry] of this.entries) {
163
+ if (matchesPrefix(entry.path, sel.path)) {
164
+ this.entries.delete(key);
165
+ removed += 1;
166
+ }
167
+ }
168
+ }
169
+ }
170
+ if (removed > 0) this.dirty = true;
171
+ }
172
+
173
+ async getSize(pathPrefix?: DocumentPath): Promise<number> {
174
+ this.ensureOpen();
175
+ if (pathPrefix === undefined) return this.entries.size;
176
+ let count = 0;
177
+ for (const entry of this.entries.values()) {
178
+ if (matchesPrefix(entry.path, pathPrefix)) count++;
179
+ }
180
+ return count;
181
+ }
182
+
183
+ async *getDocumentPaths(pathPrefix?: DocumentPath): AsyncGenerator<DocumentPath> {
184
+ this.ensureOpen();
185
+ const paths = new Set<DocumentPath>();
186
+ for (const entry of this.entries.values()) {
187
+ if (pathPrefix === undefined || matchesPrefix(entry.path, pathPrefix)) {
188
+ paths.add(entry.path);
189
+ }
190
+ }
191
+ for (const p of paths) yield p;
192
+ }
193
+
194
+ async *getDocumentBlocksRefs(pathPrefix?: DocumentPath): AsyncGenerator<BlockReference> {
195
+ this.ensureOpen();
196
+ for (const entry of this.entries.values()) {
197
+ if (pathPrefix === undefined || matchesPrefix(entry.path, pathPrefix)) {
198
+ yield { path: entry.path, blockId: entry.blockId };
199
+ }
200
+ }
201
+ }
202
+
203
+ async *getDocumentsBlocks(pathPrefix?: DocumentPath): AsyncGenerator<EmbeddingBlock> {
204
+ this.ensureOpen();
205
+ for (const entry of this.entries.values()) {
206
+ if (pathPrefix === undefined || matchesPrefix(entry.path, pathPrefix)) {
207
+ yield {
208
+ path: entry.path,
209
+ blockId: entry.blockId,
210
+ embedding: entry.embedding,
211
+ metadata: entry.metadata,
212
+ };
213
+ }
214
+ }
215
+ }
216
+
217
+ async close(_options?: { force?: boolean }): Promise<void> {
218
+ this.closed = true;
219
+ }
220
+
221
+ async flush(): Promise<void> {
222
+ this.ensureOpen();
223
+ // no-op for in-memory
224
+ }
225
+
226
+ async deleteIndex(): Promise<void> {
227
+ this.ensureOpen();
228
+ this.entries.clear();
229
+ this.cachedSerialized = undefined;
230
+ this.dirty = true;
231
+ this.closed = true;
232
+ }
233
+
234
+ /**
235
+ * Serialize embeddings to Arrow IPC format.
236
+ *
237
+ * Caches the result; while `dirty` is false (no mutations since the
238
+ * last call) returns the same `Uint8Array` instance, skipping the
239
+ * Arrow IPC encoding entirely. This makes no-op re-syncs cheap — the
240
+ * persistence layer still calls into us each flush, but the heavy
241
+ * encoding only runs when the index actually changed.
242
+ */
243
+ serializeToArrow(): Uint8Array {
244
+ if (!this.dirty && this.cachedSerialized !== undefined) {
245
+ return this.cachedSerialized;
246
+ }
247
+ const dim = this.info.dimensionality;
248
+ const paths: string[] = [];
249
+ const blockIds: string[] = [];
250
+ const embeddingArrays: number[][] = [];
251
+ const metadata: Array<string | null> = [];
252
+ for (const entry of this.entries.values()) {
253
+ paths.push(entry.path);
254
+ blockIds.push(entry.blockId);
255
+ embeddingArrays.push(Array.from(entry.embedding));
256
+ metadata.push(entry.metadata === undefined ? null : JSON.stringify(entry.metadata));
257
+ }
258
+ const table = tableFromArrays(
259
+ { path: paths, blockId: blockIds, embedding: embeddingArrays, metadata },
260
+ {
261
+ types: {
262
+ path: utf8(),
263
+ blockId: utf8(),
264
+ embedding: fixedSizeList(float32(), dim),
265
+ metadata: utf8(),
266
+ },
267
+ },
268
+ );
269
+ const bytes = tableToIPC(table, { format: "stream" }) as Uint8Array;
270
+ this.cachedSerialized = bytes;
271
+ this.dirty = false;
272
+ return bytes;
273
+ }
274
+
275
+ // --- PersistableSearchIndex --------------------------------------------------
276
+
277
+ /**
278
+ * Stream the vector sub-index's state as a single named entry. The composite
279
+ * prefixes this with `${indexName}/${subName}/` before it hits persistence.
280
+ */
281
+ async *serialise(): AsyncIterable<PersistenceEntry> {
282
+ yield { name: "arrow", content: singleChunk(this.serializeToArrow()) };
283
+ }
284
+
285
+ /**
286
+ * Restore the vector sub-index from a previously-produced entry set. Expects
287
+ * one entry named `"arrow"`; ignores entries with other names so future
288
+ * additions remain forward-compatible.
289
+ */
290
+ async loadFrom(entries: Iterable<PersistenceEntry>): Promise<void> {
291
+ this.ensureOpen();
292
+ for (const e of entries) {
293
+ if (e.name !== "arrow") continue;
294
+ const bytes = await readEntryBytes(e);
295
+ const table = tableFromIPC(bytes);
296
+ const pathCol = table.getChild("path");
297
+ const blockIdCol = table.getChild("blockId");
298
+ const embCol = table.getChild("embedding");
299
+ const metaCol = table.getChild("metadata");
300
+ this.entries.clear();
301
+ for (let i = 0; i < table.numRows; i++) {
302
+ const path = pathCol.at(i) as string as DocumentPath;
303
+ const blockId = blockIdCol.at(i) as string;
304
+ const embedding = new Float32Array(embCol.at(i) as ArrayLike<number>);
305
+ const metaRaw = metaCol?.at(i) as string | null | undefined;
306
+ const metadata =
307
+ metaRaw == null ? undefined : (JSON.parse(metaRaw) as StoredEntry["metadata"]);
308
+ const key = compositeKey(path, blockId);
309
+ this.entries.set(key, { path, blockId, embedding, metadata });
310
+ }
311
+ this.cachedSerialized = bytes;
312
+ this.dirty = false;
313
+ return;
314
+ }
315
+ }
316
+
317
+ /**
318
+ * Deserialize embeddings from Arrow IPC format. The reconstructed index
319
+ * starts clean (`dirty = false`) and primes its serialize cache with the
320
+ * input bytes — a subsequent `serializeToArrow()` on the just-loaded
321
+ * index returns the same bytes without re-encoding.
322
+ */
323
+ static deserializeFromArrow(info: EmbeddingIndexInfo, data: Uint8Array): MemVectorIndex {
324
+ const table = tableFromIPC(data);
325
+ const vec = new MemVectorIndex(info);
326
+ const pathCol = table.getChild("path");
327
+ const blockIdCol = table.getChild("blockId");
328
+ const embCol = table.getChild("embedding");
329
+ const metaCol = table.getChild("metadata");
330
+ for (let i = 0; i < table.numRows; i++) {
331
+ const path = pathCol.at(i) as string as DocumentPath;
332
+ const blockId = blockIdCol.at(i) as string;
333
+ const embedding = new Float32Array(embCol.at(i) as ArrayLike<number>);
334
+ const metaRaw = metaCol?.at(i) as string | null | undefined;
335
+ const metadata =
336
+ metaRaw == null ? undefined : (JSON.parse(metaRaw) as StoredEntry["metadata"]);
337
+ const key = compositeKey(path, blockId);
338
+ vec.entries.set(key, { path, blockId, embedding, metadata });
339
+ }
340
+ vec.cachedSerialized = data;
341
+ vec.dirty = false;
342
+ return vec;
343
+ }
344
+ }
@@ -0,0 +1,18 @@
1
+ import { VECTOR_TYPE, type VectorConfig, type VectorProvider } from "@statewalker/indexer-vector";
2
+ import { MemVectorIndex } from "./mem-vector-index.js";
3
+
4
+ /**
5
+ * `VectorProvider` backed by {@link MemVectorIndex} — the in-memory brute-force
6
+ * vector store. Wired into an `Indexer` at construction time via
7
+ * `createPersistenceBackedIndexer({ providers: [memVectorProvider] })`.
8
+ */
9
+ export const memVectorProvider: VectorProvider = {
10
+ type: VECTOR_TYPE,
11
+ create(config: VectorConfig): MemVectorIndex {
12
+ return new MemVectorIndex({
13
+ dimensionality: config.dimensionality,
14
+ model: config.model,
15
+ metadata: config.metadata,
16
+ });
17
+ },
18
+ };
@@ -0,0 +1,14 @@
1
+ // See ../../indexer-core/src/text-codec.d.ts for rationale.
2
+ declare const TextEncoder: {
3
+ new (): {
4
+ encode(input: string): Uint8Array;
5
+ };
6
+ };
7
+
8
+ declare const TextDecoder: {
9
+ new (
10
+ label?: string,
11
+ ): {
12
+ decode(input?: Uint8Array): string;
13
+ };
14
+ };
@@ -0,0 +1,39 @@
1
+ import type { DocumentPath } from "@statewalker/indexer-api";
2
+ import type { VectorResult as EmbeddingSearchResult } from "@statewalker/indexer-vector";
3
+
4
+ export function cosineSimilarity(a: Float32Array, b: Float32Array): number {
5
+ if (a.length !== b.length) {
6
+ throw new Error(`cosineSimilarity: dimensionality mismatch (${a.length} vs ${b.length})`);
7
+ }
8
+ let dot = 0;
9
+ let normA = 0;
10
+ let normB = 0;
11
+ for (let i = 0; i < a.length; i++) {
12
+ const ai = a[i] ?? 0;
13
+ const bi = b[i] ?? 0;
14
+ dot += ai * bi;
15
+ normA += ai * ai;
16
+ normB += bi * bi;
17
+ }
18
+ const denom = Math.sqrt(normA) * Math.sqrt(normB);
19
+ if (denom === 0) return 0;
20
+ return dot / denom;
21
+ }
22
+
23
+ export function bruteForceSearch(
24
+ query: Float32Array,
25
+ entries: Iterable<{
26
+ path: DocumentPath;
27
+ blockId: string;
28
+ embedding: Float32Array;
29
+ }>,
30
+ topK: number,
31
+ ): EmbeddingSearchResult[] {
32
+ const scored: EmbeddingSearchResult[] = [];
33
+ for (const entry of entries) {
34
+ const score = cosineSimilarity(query, entry.embedding);
35
+ scored.push({ path: entry.path, blockId: entry.blockId, score });
36
+ }
37
+ scored.sort((a, b) => b.score - a.score);
38
+ return scored.slice(0, topK);
39
+ }