@fgv/ts-agent-memory-sqlite-vec 5.1.0-49 → 5.1.0-51
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/README.md +26 -1
- package/dist/packlets/sqlite-vec-index/connection.js +54 -0
- package/dist/packlets/sqlite-vec-index/connection.js.map +1 -0
- package/dist/packlets/sqlite-vec-index/model.js.map +1 -1
- package/dist/packlets/sqlite-vec-index/rebuildHelpers.js +48 -0
- package/dist/packlets/sqlite-vec-index/rebuildHelpers.js.map +1 -0
- package/dist/packlets/sqlite-vec-index/sqliteVecFragmentIndex.js +189 -8
- package/dist/packlets/sqlite-vec-index/sqliteVecFragmentIndex.js.map +1 -1
- package/dist/packlets/sqlite-vec-index/sqliteVecVectorIndex.js +107 -50
- package/dist/packlets/sqlite-vec-index/sqliteVecVectorIndex.js.map +1 -1
- package/dist/ts-agent-memory-sqlite-vec.d.ts +188 -13
- package/lib/packlets/sqlite-vec-index/connection.d.ts +42 -0
- package/lib/packlets/sqlite-vec-index/connection.d.ts.map +1 -0
- package/lib/packlets/sqlite-vec-index/connection.js +91 -0
- package/lib/packlets/sqlite-vec-index/connection.js.map +1 -0
- package/lib/packlets/sqlite-vec-index/model.d.ts +92 -0
- package/lib/packlets/sqlite-vec-index/model.d.ts.map +1 -1
- package/lib/packlets/sqlite-vec-index/model.js.map +1 -1
- package/lib/packlets/sqlite-vec-index/rebuildHelpers.d.ts +38 -0
- package/lib/packlets/sqlite-vec-index/rebuildHelpers.d.ts.map +1 -0
- package/lib/packlets/sqlite-vec-index/rebuildHelpers.js +53 -0
- package/lib/packlets/sqlite-vec-index/rebuildHelpers.js.map +1 -0
- package/lib/packlets/sqlite-vec-index/sqliteVecFragmentIndex.d.ts +52 -7
- package/lib/packlets/sqlite-vec-index/sqliteVecFragmentIndex.d.ts.map +1 -1
- package/lib/packlets/sqlite-vec-index/sqliteVecFragmentIndex.js +188 -7
- package/lib/packlets/sqlite-vec-index/sqliteVecFragmentIndex.js.map +1 -1
- package/lib/packlets/sqlite-vec-index/sqliteVecVectorIndex.d.ts +44 -11
- package/lib/packlets/sqlite-vec-index/sqliteVecVectorIndex.d.ts.map +1 -1
- package/lib/packlets/sqlite-vec-index/sqliteVecVectorIndex.js +108 -51
- package/lib/packlets/sqlite-vec-index/sqliteVecVectorIndex.js.map +1 -1
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -25,6 +25,31 @@ rush add -p @fgv/ts-agent-memory-sqlite-vec # or npm/pnpm add
|
|
|
25
25
|
|
|
26
26
|
## Quick start
|
|
27
27
|
|
|
28
|
+
Two factories, differing only in **who owns the connection**. If this index is the only
|
|
29
|
+
thing on the file, `open` is the shorter path and needs no `better-sqlite3` import of
|
|
30
|
+
your own:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { SqliteVecVectorIndex } from '@fgv/ts-agent-memory-sqlite-vec';
|
|
34
|
+
import { FileTreeMemoryStore } from '@fgv/ts-agent-memory';
|
|
35
|
+
|
|
36
|
+
// We open the file and hand back a disposer for the connection we created.
|
|
37
|
+
const handle = (await SqliteVecVectorIndex.open({ path: '/path/to/vault/vectors.db' })).orThrow();
|
|
38
|
+
|
|
39
|
+
const store = (
|
|
40
|
+
await FileTreeMemoryStore.create({ root, registry, vectorIndex: handle.index, embed })
|
|
41
|
+
).orThrow();
|
|
42
|
+
|
|
43
|
+
// ...use the store; embeddings are written to vectors.db on every put.
|
|
44
|
+
handle.close(); // closes the connection THIS open() created; idempotent.
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`SqliteVecFragmentIndex.open({ path })` is the identical shape for the fragment lane.
|
|
48
|
+
|
|
49
|
+
**Two `open` calls on one path give two independent connections, not a shared one.** To
|
|
50
|
+
back a record index *and* a fragment index with a single connection, own it yourself and
|
|
51
|
+
pass it to both `create` methods:
|
|
52
|
+
|
|
28
53
|
```ts
|
|
29
54
|
import Database from 'better-sqlite3';
|
|
30
55
|
import { SqliteVecVectorIndex } from '@fgv/ts-agent-memory-sqlite-vec';
|
|
@@ -108,7 +133,7 @@ Known instance: the release that added `IEmbeddedFragment.fragmentId` added a `+
|
|
|
108
133
|
Deliberately excluded — reach for the upstream libraries (or a different backend) directly if you need these:
|
|
109
134
|
|
|
110
135
|
- **ANN / large-N indexing.** Query is a brute-force `vec0` KNN scan — correct and durable for the same "thousands of records" regime the in-memory index targets. An approximate-nearest-neighbor structure for very large N is a different backend behind the same `IVectorIndex` / `IFragmentVectorIndex` seam. (This applies to both indexes, including `SqliteVecFragmentIndex`, whose capped query fetches the full ranked set.)
|
|
111
|
-
- **Connection lifecycle.**
|
|
136
|
+
- **Connection lifecycle beyond plain open/close.** With `create({ database })` you open and close the `better-sqlite3` `Database` and this index never does; with `open({ path })` this package opens the file and the returned handle's `close()` disposes of exactly what it opened. Either way, pooling, WAL/pragma tuning, backups, and multi-process coordination are yours.
|
|
112
137
|
- **Embedding.** This is a vector *index*, not an embedder — the store's consumer-wired `MemoryEmbedder` produces the vectors (`@fgv/ts-extras/ai-assist` `callProviderEmbedding`, `@fgv/ts-extras-transformers`, etc.).
|
|
113
138
|
- **A browser sibling.** `better-sqlite3` is Node-only. A WASM-SQLite browser variant, if ever needed, is a separate package.
|
|
114
139
|
- **Schema migration of any kind.** Re-embedding with a different-dimension model against an existing table fails loudly, and a package release that changes a `vec0` table's columns requires a drop-and-re-index — see [Upgrading](#upgrading-vec0-schema-changes-require-a-drop-and-re-index). Drop the table (or use a new `tableName`) to re-index; `vec0` cannot be altered in place.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Erik Fortune
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
import { captureAsyncResult, captureResult } from '@fgv/ts-utils';
|
|
6
|
+
/**
|
|
7
|
+
* Opens a `better-sqlite3` connection this package owns.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* **The value import of `better-sqlite3` lives here, and it is deliberately
|
|
11
|
+
* lazy.** Every other module in this package imports the driver as `import type`
|
|
12
|
+
* only, so merely importing `@fgv/ts-agent-memory-sqlite-vec` has never loaded the
|
|
13
|
+
* native binding — a property the path-based factories must not cost consumers who
|
|
14
|
+
* only ever call `create()`. A static import would move the native load (and its
|
|
15
|
+
* failure mode) to package-load time for everyone. The dynamic `import()` keeps it
|
|
16
|
+
* at the one call that actually needs a connection.
|
|
17
|
+
*
|
|
18
|
+
* Taking this import is the entire point of the path-based factories: it is the
|
|
19
|
+
* only place this wrapper leaked its own dependency into consumer source.
|
|
20
|
+
*
|
|
21
|
+
* @param path - Filesystem path to the database file. `':memory:'` is accepted and
|
|
22
|
+
* yields an owned ephemeral connection.
|
|
23
|
+
* @param label - Package-facing prefix for the failure message.
|
|
24
|
+
* @returns `Success` with a connection this package owns, or `Failure` if the
|
|
25
|
+
* driver could not be loaded or the file could not be opened.
|
|
26
|
+
* @internal
|
|
27
|
+
*/
|
|
28
|
+
export async function openOwnedConnection(path, label) {
|
|
29
|
+
return (await captureAsyncResult(async () => (await import('better-sqlite3')).default))
|
|
30
|
+
.withErrorFormat((m) => `${label}: failed to load the 'better-sqlite3' driver: ${m}`)
|
|
31
|
+
.onSuccess((driver) => captureResult(() => new driver(path)).withErrorFormat((m) => `${label}: failed to open '${path}': ${m}`));
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Closes a connection this package opened.
|
|
35
|
+
*
|
|
36
|
+
* @remarks
|
|
37
|
+
* Only ever called on a connection produced by {@link openOwnedConnection}. A
|
|
38
|
+
* connection supplied through a `create()` param is the consumer's and is never
|
|
39
|
+
* closed here — that asymmetry is the reason `close` travels on the handle
|
|
40
|
+
* returned by `open` rather than sitting on the index class.
|
|
41
|
+
*
|
|
42
|
+
* `better-sqlite3`'s own `close()` is safe to call on an already-closed
|
|
43
|
+
* connection, so a second `close()` on the same handle succeeds rather than
|
|
44
|
+
* failing.
|
|
45
|
+
*
|
|
46
|
+
* @internal
|
|
47
|
+
*/
|
|
48
|
+
export function closeOwnedConnection(db, label) {
|
|
49
|
+
return captureResult(() => {
|
|
50
|
+
db.close();
|
|
51
|
+
return true;
|
|
52
|
+
}).withErrorFormat((m) => `${label}: failed to close the connection: ${m}`);
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=connection.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"connection.js","sourceRoot":"","sources":["../../../src/packlets/sqlite-vec-index/connection.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,EAAU,kBAAkB,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAE1E;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,CAAC,KAAK,UAAU,mBAAmB,CACvC,IAAY,EACZ,KAAa;IAEb,OAAO,CAAC,MAAM,kBAAkB,CAAC,KAAK,IAAI,EAAE,CAAC,CAAC,MAAM,MAAM,CAAC,gBAAgB,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;SACpF,eAAe,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,KAAK,iDAAiD,CAAC,EAAE,CAAC;SACpF,SAAS,CAAC,CAAC,MAAM,EAAE,EAAE,CACpB,aAAa,CAAC,GAAG,EAAE,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,KAAK,qBAAqB,IAAI,MAAM,CAAC,EAAE,CAAC,CACzG,CAAC;AACN,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,oBAAoB,CAAC,EAA0B,EAAE,KAAa;IAC5E,OAAO,aAAa,CAAO,GAAG,EAAE;QAC9B,EAAE,CAAC,KAAK,EAAE,CAAC;QACX,OAAO,IAAI,CAAC;IACd,CAAC,CAAC,CAAC,eAAe,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,GAAG,KAAK,qCAAqC,CAAC,EAAE,CAAC,CAAC;AAC9E,CAAC","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport type BetterSqlite3 from 'better-sqlite3';\nimport { Result, captureAsyncResult, captureResult } from '@fgv/ts-utils';\n\n/**\n * Opens a `better-sqlite3` connection this package owns.\n *\n * @remarks\n * **The value import of `better-sqlite3` lives here, and it is deliberately\n * lazy.** Every other module in this package imports the driver as `import type`\n * only, so merely importing `@fgv/ts-agent-memory-sqlite-vec` has never loaded the\n * native binding — a property the path-based factories must not cost consumers who\n * only ever call `create()`. A static import would move the native load (and its\n * failure mode) to package-load time for everyone. The dynamic `import()` keeps it\n * at the one call that actually needs a connection.\n *\n * Taking this import is the entire point of the path-based factories: it is the\n * only place this wrapper leaked its own dependency into consumer source.\n *\n * @param path - Filesystem path to the database file. `':memory:'` is accepted and\n * yields an owned ephemeral connection.\n * @param label - Package-facing prefix for the failure message.\n * @returns `Success` with a connection this package owns, or `Failure` if the\n * driver could not be loaded or the file could not be opened.\n * @internal\n */\nexport async function openOwnedConnection(\n path: string,\n label: string\n): Promise<Result<BetterSqlite3.Database>> {\n return (await captureAsyncResult(async () => (await import('better-sqlite3')).default))\n .withErrorFormat((m) => `${label}: failed to load the 'better-sqlite3' driver: ${m}`)\n .onSuccess((driver) =>\n captureResult(() => new driver(path)).withErrorFormat((m) => `${label}: failed to open '${path}': ${m}`)\n );\n}\n\n/**\n * Closes a connection this package opened.\n *\n * @remarks\n * Only ever called on a connection produced by {@link openOwnedConnection}. A\n * connection supplied through a `create()` param is the consumer's and is never\n * closed here — that asymmetry is the reason `close` travels on the handle\n * returned by `open` rather than sitting on the index class.\n *\n * `better-sqlite3`'s own `close()` is safe to call on an already-closed\n * connection, so a second `close()` on the same handle succeeds rather than\n * failing.\n *\n * @internal\n */\nexport function closeOwnedConnection(db: BetterSqlite3.Database, label: string): Result<true> {\n return captureResult<true>(() => {\n db.close();\n return true;\n }).withErrorFormat((m) => `${label}: failed to close the connection: ${m}`);\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"model.js","sourceRoot":"","sources":["../../../src/packlets/sqlite-vec-index/model.ts"],"names":[],"mappings":"AAAA;;;GAGG","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport type BetterSqlite3 from 'better-sqlite3';\n\n/**\n * Parameters for {@link SqliteVecVectorIndex.create}.\n * @public\n */\nexport interface ISqliteVecVectorIndexCreateParams {\n /**\n * A `better-sqlite3` `Database` the consumer owns (bring-your-own, mirroring\n * the boundary-package convention). The consumer opens it (`new Database(path)`\n * for a persistent file, or `new Database(':memory:')` for an ephemeral index)\n * and owns its lifecycle — this index never closes it. `create` loads the\n * `sqlite-vec` extension onto the connection and, if the vector table already\n * exists (a reopened persistent file), recovers its established dimension so no\n * re-embedding is required on open.\n */\n readonly database: BetterSqlite3.Database;\n\n /**\n * Name of the `vec0` virtual table that holds the embeddings. Must be a simple\n * SQL identifier (`[A-Za-z_][A-Za-z0-9_]*`). Defaults to `'memory_vectors'`.\n * Supply a distinct name to hold more than one independent index in a single\n * database file.\n */\n readonly tableName?: string;\n}\n\n/**\n * Parameters for {@link SqliteVecFragmentIndex.create}.\n * @public\n */\nexport interface ISqliteVecFragmentIndexCreateParams {\n /**\n * A `better-sqlite3` `Database` the consumer owns (bring-your-own, mirroring\n * {@link ISqliteVecVectorIndexCreateParams.database}). The consumer opens it and\n * owns its lifecycle — this index never closes it. `create` loads the\n * `sqlite-vec` extension onto the connection and, if the fragment table already\n * exists (a reopened persistent file), recovers its established dimension so no\n * re-embedding is required on open.\n */\n readonly database: BetterSqlite3.Database;\n\n /**\n * Name of the `vec0` virtual table that holds the fragment embeddings. Must be a\n * simple SQL identifier (`[A-Za-z_][A-Za-z0-9_]*`). Defaults to\n * `'memory_fragments'`. Supply a distinct name (distinct from any record-level\n * {@link SqliteVecVectorIndex} table) to hold more than one independent index in\n * a single database file.\n */\n readonly tableName?: string;\n}\n"]}
|
|
1
|
+
{"version":3,"file":"model.js","sourceRoot":"","sources":["../../../src/packlets/sqlite-vec-index/model.ts"],"names":[],"mappings":"AAAA;;;GAGG","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport type BetterSqlite3 from 'better-sqlite3';\nimport type { Result } from '@fgv/ts-utils';\nimport type { SqliteVecFragmentIndex } from './sqliteVecFragmentIndex';\nimport type { SqliteVecVectorIndex } from './sqliteVecVectorIndex';\n\n/**\n * Parameters for {@link SqliteVecVectorIndex.create}.\n * @public\n */\nexport interface ISqliteVecVectorIndexCreateParams {\n /**\n * A `better-sqlite3` `Database` the consumer owns (bring-your-own, mirroring\n * the boundary-package convention). The consumer opens it (`new Database(path)`\n * for a persistent file, or `new Database(':memory:')` for an ephemeral index)\n * and owns its lifecycle — this index never closes it. `create` loads the\n * `sqlite-vec` extension onto the connection and, if the vector table already\n * exists (a reopened persistent file), recovers its established dimension so no\n * re-embedding is required on open.\n */\n readonly database: BetterSqlite3.Database;\n\n /**\n * Name of the `vec0` virtual table that holds the embeddings. Must be a simple\n * SQL identifier (`[A-Za-z_][A-Za-z0-9_]*`). Defaults to `'memory_vectors'`.\n * Supply a distinct name to hold more than one independent index in a single\n * database file.\n */\n readonly tableName?: string;\n}\n\n/**\n * Parameters for {@link SqliteVecVectorIndex.open}.\n * @public\n */\nexport interface ISqliteVecVectorIndexOpenParams {\n /**\n * Filesystem path to the database file, opened by this package rather than by\n * the consumer. Created if it does not exist, exactly as `better-sqlite3` would.\n * `':memory:'` is accepted and yields an owned ephemeral connection.\n *\n * **Two `open` calls on one path produce two independent connections, not a\n * shared one.** That is legal in SQLite and has a different locking story than\n * the single-connection case — writes contend, and a reader can see a\n * `SQLITE_BUSY`. To put a record index and a fragment index on one connection\n * (the intended shared-handle case), open the connection yourself and pass it to\n * both `create` methods.\n */\n readonly path: string;\n\n /**\n * Name of the `vec0` virtual table that holds the embeddings. Must be a simple\n * SQL identifier (`[A-Za-z_][A-Za-z0-9_]*`). Defaults to `'memory_vectors'`.\n * Supply a distinct name to hold more than one independent index in a single\n * database file.\n */\n readonly tableName?: string;\n}\n\n/**\n * An index plus the connection {@link SqliteVecVectorIndex.open} opened for it.\n *\n * @public\n */\nexport interface ISqliteVecVectorIndexHandle {\n /** The index, ready to use. */\n readonly index: SqliteVecVectorIndex;\n\n /**\n * Closes the connection **this `open` call created**. Idempotent — a second\n * `close` succeeds rather than failing.\n *\n * @remarks\n * The disposer travels on this handle rather than on the index class because an\n * index built by `create` holds a connection the **consumer** owns, and must stay\n * incapable of closing it. A `close()` method meaningful on some instances and\n * forbidden on others would be a lie in the type; here, only the caller that\n * caused the connection to exist is handed the means to end it.\n *\n * The index is unusable afterwards — every operation on it will fail against a\n * closed connection.\n */\n close(): Result<true>;\n}\n\n/**\n * Parameters for {@link SqliteVecFragmentIndex.open}.\n * @public\n */\nexport interface ISqliteVecFragmentIndexOpenParams {\n /**\n * Filesystem path to the database file, opened by this package rather than by\n * the consumer. Created if it does not exist, exactly as `better-sqlite3` would.\n * `':memory:'` is accepted and yields an owned ephemeral connection.\n *\n * **Two `open` calls on one path produce two independent connections, not a\n * shared one** — see {@link ISqliteVecVectorIndexOpenParams.path}. To put a\n * fragment index and a record index on one connection, open it yourself and pass\n * it to both `create` methods.\n */\n readonly path: string;\n\n /**\n * Name of the `vec0` virtual table that holds the fragment embeddings. Must be a\n * simple SQL identifier (`[A-Za-z_][A-Za-z0-9_]*`). Defaults to\n * `'memory_fragments'`.\n */\n readonly tableName?: string;\n}\n\n/**\n * An index plus the connection {@link SqliteVecFragmentIndex.open} opened for it.\n *\n * @public\n */\nexport interface ISqliteVecFragmentIndexHandle {\n /** The index, ready to use. */\n readonly index: SqliteVecFragmentIndex;\n\n /**\n * Closes the connection **this `open` call created**. Idempotent — a second\n * `close` succeeds rather than failing. See\n * {@link ISqliteVecVectorIndexHandle.close} for why the disposer lives here\n * rather than on the index class.\n */\n close(): Result<true>;\n}\n\n/**\n * Parameters for {@link SqliteVecFragmentIndex.create}.\n * @public\n */\nexport interface ISqliteVecFragmentIndexCreateParams {\n /**\n * A `better-sqlite3` `Database` the consumer owns (bring-your-own, mirroring\n * {@link ISqliteVecVectorIndexCreateParams.database}). The consumer opens it and\n * owns its lifecycle — this index never closes it. `create` loads the\n * `sqlite-vec` extension onto the connection and, if the fragment table already\n * exists (a reopened persistent file), recovers its established dimension so no\n * re-embedding is required on open.\n */\n readonly database: BetterSqlite3.Database;\n\n /**\n * Name of the `vec0` virtual table that holds the fragment embeddings. Must be a\n * simple SQL identifier (`[A-Za-z_][A-Za-z0-9_]*`). Defaults to\n * `'memory_fragments'`. Supply a distinct name (distinct from any record-level\n * {@link SqliteVecVectorIndex} table) to hold more than one independent index in\n * a single database file.\n */\n readonly tableName?: string;\n}\n"]}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright (c) 2026 Erik Fortune
|
|
3
|
+
* SPDX-License-Identifier: MIT
|
|
4
|
+
*/
|
|
5
|
+
import { captureAsyncResult } from '@fgv/ts-utils';
|
|
6
|
+
/**
|
|
7
|
+
* Invoke a consumer-supplied hook that already returns a `Result`, converting a
|
|
8
|
+
* synchronous throw or a promise rejection into a `Failure` rather than letting
|
|
9
|
+
* it escape.
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* Package-internal. `@fgv/ts-agent-memory` carries an identical private copy for
|
|
13
|
+
* its in-memory indexes. Exporting a single `AsyncDeferredResult`-invoking
|
|
14
|
+
* primitive from `ts-utils` is the right home and is recorded in
|
|
15
|
+
* `docs/TECH_DEBT.md`; this module exists because *both* index classes in *this*
|
|
16
|
+
* package now need it, which is the point at which a second in-package copy stops
|
|
17
|
+
* being the cheaper thing.
|
|
18
|
+
*/
|
|
19
|
+
export async function invokeHook(hook) {
|
|
20
|
+
return (await captureAsyncResult(hook)).onSuccess((inner) => inner);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Compose the failure that aborted a rebuild with the outcome of the rollback
|
|
24
|
+
* that followed it.
|
|
25
|
+
*
|
|
26
|
+
* @remarks
|
|
27
|
+
* A rollback that ALSO fails is worth saying out loud: the `'fail'` path promises
|
|
28
|
+
* an empty index, and a caller that retries against a table which is neither the
|
|
29
|
+
* old index nor empty is working from a state the contract never described. This
|
|
30
|
+
* matters more here than in the in-memory package — these tables are **durable**,
|
|
31
|
+
* so a botched rollback survives the process.
|
|
32
|
+
*/
|
|
33
|
+
export function withRollbackNote(error, rollback) {
|
|
34
|
+
return rollback.isFailure() ? `${error} (rollback also failed: ${rollback.message})` : error;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Increment `kind`'s tally by `by` (default one).
|
|
38
|
+
*
|
|
39
|
+
* @remarks
|
|
40
|
+
* The `by` parameter exists for the fragment lane, whose `fragments` count
|
|
41
|
+
* accumulates a fan-out rather than a record count — the one place a rebuild adds
|
|
42
|
+
* more than one per record.
|
|
43
|
+
*/
|
|
44
|
+
export function tally(counts, kind, by = 1) {
|
|
45
|
+
var _a;
|
|
46
|
+
counts.set(kind, ((_a = counts.get(kind)) !== null && _a !== void 0 ? _a : 0) + by);
|
|
47
|
+
}
|
|
48
|
+
//# sourceMappingURL=rebuildHelpers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rebuildHelpers.js","sourceRoot":"","sources":["../../../src/packlets/sqlite-vec-index/rebuildHelpers.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,EAAU,kBAAkB,EAAE,MAAM,eAAe,CAAC;AAG3D;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,CAAI,IAA8B;IAChE,OAAO,CAAC,MAAM,kBAAkB,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,CAAC;AACtE,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAa,EAAE,QAAsB;IACpE,OAAO,QAAQ,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,GAAG,KAAK,2BAA2B,QAAQ,CAAC,OAAO,GAAG,CAAC,CAAC,CAAC,KAAK,CAAC;AAC/F,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,KAAK,CAAC,MAAyB,EAAE,IAAU,EAAE,KAAa,CAAC;;IACzE,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,MAAA,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,mCAAI,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC;AACjD,CAAC","sourcesContent":["/*\n * Copyright (c) 2026 Erik Fortune\n * SPDX-License-Identifier: MIT\n */\n\nimport { Result, captureAsyncResult } from '@fgv/ts-utils';\nimport { Kind } from '@fgv/ts-agent-memory';\n\n/**\n * Invoke a consumer-supplied hook that already returns a `Result`, converting a\n * synchronous throw or a promise rejection into a `Failure` rather than letting\n * it escape.\n *\n * @remarks\n * Package-internal. `@fgv/ts-agent-memory` carries an identical private copy for\n * its in-memory indexes. Exporting a single `AsyncDeferredResult`-invoking\n * primitive from `ts-utils` is the right home and is recorded in\n * `docs/TECH_DEBT.md`; this module exists because *both* index classes in *this*\n * package now need it, which is the point at which a second in-package copy stops\n * being the cheaper thing.\n */\nexport async function invokeHook<T>(hook: () => Promise<Result<T>>): Promise<Result<T>> {\n return (await captureAsyncResult(hook)).onSuccess((inner) => inner);\n}\n\n/**\n * Compose the failure that aborted a rebuild with the outcome of the rollback\n * that followed it.\n *\n * @remarks\n * A rollback that ALSO fails is worth saying out loud: the `'fail'` path promises\n * an empty index, and a caller that retries against a table which is neither the\n * old index nor empty is working from a state the contract never described. This\n * matters more here than in the in-memory package — these tables are **durable**,\n * so a botched rollback survives the process.\n */\nexport function withRollbackNote(error: string, rollback: Result<true>): string {\n return rollback.isFailure() ? `${error} (rollback also failed: ${rollback.message})` : error;\n}\n\n/**\n * Increment `kind`'s tally by `by` (default one).\n *\n * @remarks\n * The `by` parameter exists for the fragment lane, whose `fragments` count\n * accumulates a fan-out rather than a record count — the one place a rebuild adds\n * more than one per record.\n */\nexport function tally(counts: Map<Kind, number>, kind: Kind, by: number = 1): void {\n counts.set(kind, (counts.get(kind) ?? 0) + by);\n}\n"]}
|
|
@@ -3,10 +3,14 @@
|
|
|
3
3
|
* SPDX-License-Identifier: MIT
|
|
4
4
|
*/
|
|
5
5
|
import { load as loadSqliteVec } from 'sqlite-vec';
|
|
6
|
-
import { captureResult, fail, succeed } from '@fgv/ts-utils';
|
|
6
|
+
import { captureResult, fail, failWithDetail, succeed, succeedWithDetail } from '@fgv/ts-utils';
|
|
7
7
|
import { edgeTargetKey } from '@fgv/ts-agent-memory';
|
|
8
|
+
import { invokeHook, tally, withRollbackNote } from './rebuildHelpers';
|
|
9
|
+
import { closeOwnedConnection, openOwnedConnection } from './connection';
|
|
8
10
|
/** Default name for the fragment `vec0` virtual table. */
|
|
9
11
|
const DEFAULT_TABLE_NAME = 'memory_fragments';
|
|
12
|
+
/** Package-facing prefix for this class's failure messages. */
|
|
13
|
+
const LABEL = 'sqlite-vec fragment index';
|
|
10
14
|
/** A simple SQL identifier — the only shape allowed for the table name (it is interpolated into DDL). */
|
|
11
15
|
const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
12
16
|
/**
|
|
@@ -61,9 +65,13 @@ const AUXILIARY_COLUMN_RE = /\+\s*([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
|
61
65
|
* cosine (`score = 1 - cosineDistance`), byte-identical to the in-memory index.
|
|
62
66
|
* Large-N ANN indexing is explicitly out of scope, same regime as the record index.
|
|
63
67
|
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
68
|
+
* **Connection ownership depends on which factory you use.** With
|
|
69
|
+
* {@link SqliteVecFragmentIndex.create} the `Database` is consumer-owned
|
|
70
|
+
* (bring-your-own): this index loads the `sqlite-vec` extension onto it and
|
|
71
|
+
* reads/writes the table, but never opens or closes the connection — and that is
|
|
72
|
+
* the seam for backing this index and a record index with one connection. With
|
|
73
|
+
* {@link SqliteVecFragmentIndex.open} this package opens the file itself and hands
|
|
74
|
+
* back a handle carrying the disposer for the connection it created.
|
|
67
75
|
* @public
|
|
68
76
|
*/
|
|
69
77
|
export class SqliteVecFragmentIndex {
|
|
@@ -114,6 +122,48 @@ export class SqliteVecFragmentIndex {
|
|
|
114
122
|
return new SqliteVecFragmentIndex(params.database, table, dimension);
|
|
115
123
|
}).withErrorFormat((e) => `sqlite-vec fragment index: failed to initialize: ${e}`));
|
|
116
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Path-based factory. Opens the database file itself and returns the index
|
|
127
|
+
* together with a disposer for the connection it created.
|
|
128
|
+
*
|
|
129
|
+
* @remarks
|
|
130
|
+
* The fragment-granular sibling of {@link SqliteVecVectorIndex.open}, and present
|
|
131
|
+
* for the same reason: a consumer doing sub-document retrieval only would
|
|
132
|
+
* otherwise still value-import `better-sqlite3` and hand-roll a `captureResult`
|
|
133
|
+
* around a constructor that throws.
|
|
134
|
+
*
|
|
135
|
+
* **Use `create` instead when one connection must back both a fragment index and
|
|
136
|
+
* a record index** — the intended shared-handle case. Two `open` calls on one path
|
|
137
|
+
* give two independent connections, not a shared one.
|
|
138
|
+
*
|
|
139
|
+
* If initialization fails after the file is opened, the connection is closed
|
|
140
|
+
* before returning, so a failed `open` does not leak the descriptor it created.
|
|
141
|
+
* Should that close *itself* fail — the connection is then genuinely leaked — the
|
|
142
|
+
* returned message says so rather than hiding it. That includes the
|
|
143
|
+
* auxiliary-column mismatch failure, which is reported by `create` only after the
|
|
144
|
+
* file is open.
|
|
145
|
+
*
|
|
146
|
+
* @param params - See {@link ISqliteVecFragmentIndexOpenParams}.
|
|
147
|
+
* @returns `Success` with a {@link ISqliteVecFragmentIndexHandle}, or `Failure` if
|
|
148
|
+
* the driver could not be loaded, the file could not be opened, the table name is
|
|
149
|
+
* not a simple identifier, the extension fails to load, or the existing table was
|
|
150
|
+
* written by a version with a different auxiliary-column set.
|
|
151
|
+
*/
|
|
152
|
+
static async open(params) {
|
|
153
|
+
return (await openOwnedConnection(params.path, LABEL)).thenOnSuccess(async (database) => (await SqliteVecFragmentIndex.create({ database, tableName: params.tableName }))
|
|
154
|
+
.onFailure((message) =>
|
|
155
|
+
// This call opened the connection, so a failure to initialize on top of it
|
|
156
|
+
// must not leave the file handle behind. A close that ALSO fails is said out
|
|
157
|
+
// loud rather than swallowed — the same reasoning, and the same helper, as
|
|
158
|
+
// `withRollbackNote`: silently discarding it would make the "a failed open
|
|
159
|
+
// leaks nothing" guarantee untrue exactly when it stopped holding, with no
|
|
160
|
+
// way for a caller to detect it.
|
|
161
|
+
fail(withRollbackNote(message, closeOwnedConnection(database, LABEL))))
|
|
162
|
+
.onSuccess((index) => succeed({
|
|
163
|
+
index,
|
|
164
|
+
close: () => closeOwnedConnection(database, LABEL)
|
|
165
|
+
})));
|
|
166
|
+
}
|
|
117
167
|
/** {@inheritDoc IFragmentVectorIndex.addFragments} */
|
|
118
168
|
addFragments(target, fragments) {
|
|
119
169
|
const key = edgeTargetKey(target);
|
|
@@ -181,8 +231,106 @@ export class SqliteVecFragmentIndex {
|
|
|
181
231
|
return target;
|
|
182
232
|
}).withErrorFormat((e) => `fragment index: cannot remove '${edgeTargetKey(target)}': ${e}`));
|
|
183
233
|
}
|
|
234
|
+
/** {@inheritDoc IFragmentVectorIndex.has} */
|
|
235
|
+
has(target) {
|
|
236
|
+
return Promise.resolve(captureResult(() => {
|
|
237
|
+
// Before any add has created the table there is nothing held — a truthful
|
|
238
|
+
// `false`, matching `remove`'s idempotence and the zero counts.
|
|
239
|
+
if (this._stmts === undefined) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
return this._stmts.has.get(edgeTargetKey(target)) !== undefined;
|
|
243
|
+
}).withErrorFormat((e) => `fragment index: cannot check '${edgeTargetKey(target)}': ${e}`));
|
|
244
|
+
}
|
|
245
|
+
/** {@inheritDoc IFragmentVectorIndex.rebuild} */
|
|
246
|
+
async rebuild(source, embed, options) {
|
|
247
|
+
var _a;
|
|
248
|
+
const lenient = ((_a = options === null || options === void 0 ? void 0 : options.onRecordError) !== null && _a !== void 0 ? _a : 'fail') === 'skip';
|
|
249
|
+
// `source` is consumer-supplied, so a throw or rejection becomes a `Failure`
|
|
250
|
+
// here rather than escaping as an exception.
|
|
251
|
+
const listed = await invokeHook(() => source.list());
|
|
252
|
+
if (listed.isFailure()) {
|
|
253
|
+
// Deliberately BEFORE any clear, matching both siblings: a failed list is no
|
|
254
|
+
// evidence about the fragments already held, and clearing here would destroy
|
|
255
|
+
// a healthy PERSISTED index over a transient read error. No detail — there is
|
|
256
|
+
// nothing this call disturbed to describe.
|
|
257
|
+
return failWithDetail(`fragment index rebuild: failed to list records: ${listed.message}`);
|
|
258
|
+
}
|
|
259
|
+
const cleared = this._clear();
|
|
260
|
+
if (cleared.isFailure()) {
|
|
261
|
+
// Also nothing established: the table still holds whatever it held.
|
|
262
|
+
return failWithDetail(`fragment index rebuild: failed to clear the index: ${cleared.message}`);
|
|
263
|
+
}
|
|
264
|
+
const indexed = new Map();
|
|
265
|
+
const fragments = new Map();
|
|
266
|
+
const declined = new Map();
|
|
267
|
+
const skipped = [];
|
|
268
|
+
// Absent stays absent — only the source knows whether it filtered anything.
|
|
269
|
+
const report = () => ({
|
|
270
|
+
indexed,
|
|
271
|
+
fragments,
|
|
272
|
+
declined,
|
|
273
|
+
excluded: listed.value.excluded,
|
|
274
|
+
skipped
|
|
275
|
+
});
|
|
276
|
+
for (const scoped of listed.value.records) {
|
|
277
|
+
const kind = scoped.record.envelope.kind;
|
|
278
|
+
// Capture-wrapped: an embedder that throws mid-loop would otherwise escape
|
|
279
|
+
// past the `'fail'` rollback below, leaving this DURABLE table holding a
|
|
280
|
+
// partial index that survives the process.
|
|
281
|
+
const embedded = await invokeHook(() => embed(scoped.record));
|
|
282
|
+
if (embedded.isFailure()) {
|
|
283
|
+
const error = `fragment index rebuild: embedding '${edgeTargetKey(scoped.target)}' failed: ${embedded.message}`;
|
|
284
|
+
if (!lenient) {
|
|
285
|
+
// A rollback that also fails is said out loud: the `'fail'` path
|
|
286
|
+
// promises an empty index, and on a DURABLE table a botched rollback
|
|
287
|
+
// survives the process.
|
|
288
|
+
return failWithDetail(withRollbackNote(error, this._clear()), report());
|
|
289
|
+
}
|
|
290
|
+
skipped.push({ target: scoped.target, error });
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
// An empty array is this lane's decline, and it is still WRITTEN — the
|
|
294
|
+
// whole-record-replace is what clears any stale fragments.
|
|
295
|
+
const added = await this.addFragments(scoped.target, embedded.value);
|
|
296
|
+
if (added.isFailure()) {
|
|
297
|
+
const error = `fragment index rebuild: ${added.message}`;
|
|
298
|
+
if (!lenient) {
|
|
299
|
+
return failWithDetail(withRollbackNote(error, this._clear()), report());
|
|
300
|
+
}
|
|
301
|
+
skipped.push({ target: scoped.target, error });
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (added.value === 0) {
|
|
305
|
+
tally(declined, kind);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
tally(indexed, kind);
|
|
309
|
+
tally(fragments, kind, added.value);
|
|
310
|
+
}
|
|
311
|
+
return succeedWithDetail(report());
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* **Empties the rows; does NOT release the table's declared dimension.** That
|
|
315
|
+
* is a `vec0` constraint rather than a choice — the dimension is schema, and
|
|
316
|
+
* there is no `ALTER TABLE` for it — so a rebuild at a new dimension fails
|
|
317
|
+
* here where it would succeed on the in-memory sibling, which forgets its
|
|
318
|
+
* dimension on reset. Changing dimension needs a drop-and-re-index; see the
|
|
319
|
+
* note on `IVectorIndex.rebuild`. Tolerates a table that does not exist yet.
|
|
320
|
+
*/
|
|
321
|
+
_clear() {
|
|
322
|
+
if (this._stmts === undefined) {
|
|
323
|
+
return succeed(true);
|
|
324
|
+
}
|
|
325
|
+
// Capture-wrapped like every other statement path: a closed connection or an
|
|
326
|
+
// I/O error is a `Failure`, not an exception out of a `Result`-returning method.
|
|
327
|
+
return captureResult(() => this._db.prepare(`DELETE FROM "${this._table}"`).run()).onSuccess(() => succeed(true));
|
|
328
|
+
}
|
|
184
329
|
/** {@inheritDoc IFragmentVectorIndex.query} */
|
|
185
|
-
query(vector, topK,
|
|
330
|
+
query(vector, topK, options) {
|
|
331
|
+
const maxPerRecord = options === null || options === void 0 ? void 0 : options.maxPerRecord;
|
|
332
|
+
const scope = options === null || options === void 0 ? void 0 : options.scope;
|
|
333
|
+
const id = options === null || options === void 0 ? void 0 : options.id;
|
|
186
334
|
if (topK <= 0 || this._stmts === undefined) {
|
|
187
335
|
return Promise.resolve(succeed([]));
|
|
188
336
|
}
|
|
@@ -196,11 +344,33 @@ export class SqliteVecFragmentIndex {
|
|
|
196
344
|
// capped record's later fragments are skipped), so fetch the full ranked set
|
|
197
345
|
// and apply the cap + topK cut here — exactly as the in-memory index does.
|
|
198
346
|
// Uncapped, KNN's own `k = topK` is already the answer.
|
|
199
|
-
|
|
347
|
+
// A scope-only narrowing (a versioned kind's per-entity subtree) spans several
|
|
348
|
+
// records, and `target_key` equality cannot express a prefix, so it is applied
|
|
349
|
+
// over the full ranked set below. Correct either way — the caller's `topK` is
|
|
350
|
+
// applied to the NARROWED set, which is the property that matters — but only
|
|
351
|
+
// the single-record case gets the partition push-down.
|
|
352
|
+
const recordKey = scope !== undefined && id !== undefined ? edgeTargetKey({ scope, id }) : undefined;
|
|
353
|
+
// The cap forces the full ranked set ONLY when other records can fill from
|
|
354
|
+
// behind a capped one. Under a single-record narrowing every row belongs to
|
|
355
|
+
// that record, so the result is exactly `min(topK, maxPerRecord, fragments)`
|
|
356
|
+
// and those are the first rows KNN returns — `k = topK` suffices, and
|
|
357
|
+
// expanding to the table-wide `fragmentCount` would ask an
|
|
358
|
+
// already-partition-restricted query for far more rows than it can use.
|
|
359
|
+
const wholeSet = recordKey === undefined && (maxPerRecord !== undefined || scope !== undefined);
|
|
360
|
+
const fetchK = wholeSet
|
|
361
|
+
? Number(stmts.fragmentCount.get().c)
|
|
362
|
+
: topK;
|
|
200
363
|
if (fetchK <= 0) {
|
|
201
364
|
return [];
|
|
202
365
|
}
|
|
203
|
-
const
|
|
366
|
+
const blob = SqliteVecFragmentIndex._toBlob(vector);
|
|
367
|
+
const rows = (recordKey !== undefined
|
|
368
|
+
? stmts.queryScopedToRecord.all(blob, fetchK, recordKey)
|
|
369
|
+
: stmts.query.all(blob, fetchK));
|
|
370
|
+
// The scope prefix every record in `scope` shares. `edgeTargetKey` joins with
|
|
371
|
+
// a NUL, so this cannot collide with a longer scope that merely starts the
|
|
372
|
+
// same way.
|
|
373
|
+
const scopePrefix = scope !== undefined && recordKey === undefined ? `${scope}\0` : undefined;
|
|
204
374
|
// sqlite-vec returns rows ascending by distance (nearest first); score is
|
|
205
375
|
// `1 - cosineDistance`, so this order is already descending score.
|
|
206
376
|
const hits = [];
|
|
@@ -209,6 +379,9 @@ export class SqliteVecFragmentIndex {
|
|
|
209
379
|
if (hits.length >= topK) {
|
|
210
380
|
break;
|
|
211
381
|
}
|
|
382
|
+
if (scopePrefix !== undefined && !row.target_key.startsWith(scopePrefix)) {
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
212
385
|
if (maxPerRecord !== undefined) {
|
|
213
386
|
const used = (_a = perRecord.get(row.target_key)) !== null && _a !== void 0 ? _a : 0;
|
|
214
387
|
if (used >= maxPerRecord) {
|
|
@@ -259,8 +432,16 @@ export class SqliteVecFragmentIndex {
|
|
|
259
432
|
},
|
|
260
433
|
query: this._db.prepare(`SELECT target_key, start_off, end_off, fragment_id, distance FROM "${this._table}" ` +
|
|
261
434
|
`WHERE embedding MATCH ? AND k = ?`),
|
|
435
|
+
// The single-record narrowing constrains `target_key`, which is the table's
|
|
436
|
+
// PARTITION KEY — so this is a partition-restricted KNN rather than a scan
|
|
437
|
+
// plus a filter. That is the performance reason this narrowing belongs in the
|
|
438
|
+
// library instead of in a bigger over-fetch on the caller's side.
|
|
439
|
+
queryScopedToRecord: this._db.prepare(`SELECT target_key, start_off, end_off, fragment_id, distance FROM "${this._table}" ` +
|
|
440
|
+
`WHERE embedding MATCH ? AND k = ? AND target_key = ?`),
|
|
262
441
|
fragmentCount: this._db.prepare(`SELECT count(*) AS c FROM "${this._table}"`),
|
|
263
|
-
recordCount: this._db.prepare(`SELECT count(DISTINCT target_key) AS c FROM "${this._table}"`)
|
|
442
|
+
recordCount: this._db.prepare(`SELECT count(DISTINCT target_key) AS c FROM "${this._table}"`),
|
|
443
|
+
// `LIMIT 1`: membership needs existence, not cardinality.
|
|
444
|
+
has: this._db.prepare(`SELECT 1 FROM "${this._table}" WHERE target_key = ? LIMIT 1`)
|
|
264
445
|
};
|
|
265
446
|
}
|
|
266
447
|
/**
|