@fgv/ts-agent-memory-sqlite-vec 5.1.0-44 → 5.1.0-46
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/.rush/temp/{e0139967d914fcbb502d556358d7bfdfe7ff91d6.tar.log → b39f85377e1ce7a5b4a0aad208a6f48e4fba124b.tar.log} +2 -2
- package/.rush/temp/chunked-rush-logs/ts-agent-memory-sqlite-vec.build.chunks.jsonl +2 -2
- package/.rush/temp/operation/build/all.log +2 -2
- package/.rush/temp/operation/build/log-chunks.jsonl +2 -2
- package/.rush/temp/operation/build/state.json +1 -1
- package/README.md +16 -2
- package/dist/packlets/sqlite-vec-index/sqliteVecFragmentIndex.js +144 -26
- package/dist/packlets/sqlite-vec-index/sqliteVecFragmentIndex.js.map +1 -1
- package/dist/test/unit/sqliteVecFragmentIndex.test.js +168 -7
- package/dist/test/unit/sqliteVecFragmentIndex.test.js.map +1 -1
- package/dist/ts-agent-memory-sqlite-vec.d.ts +68 -11
- package/lib/packlets/sqlite-vec-index/sqliteVecFragmentIndex.d.ts +68 -11
- package/lib/packlets/sqlite-vec-index/sqliteVecFragmentIndex.d.ts.map +1 -1
- package/lib/packlets/sqlite-vec-index/sqliteVecFragmentIndex.js +144 -26
- package/lib/packlets/sqlite-vec-index/sqliteVecFragmentIndex.js.map +1 -1
- package/lib/test/unit/sqliteVecFragmentIndex.test.js +168 -7
- package/lib/test/unit/sqliteVecFragmentIndex.test.js.map +1 -1
- package/package.json +7 -7
- package/rush-logs/ts-agent-memory-sqlite-vec.build.cache.log +1 -1
- package/rush-logs/ts-agent-memory-sqlite-vec.build.log +2 -2
- package/src/packlets/sqlite-vec-index/sqliteVecFragmentIndex.ts +184 -25
- package/src/test/unit/sqliteVecFragmentIndex.test.ts +236 -11
- package/temp/build/lint/_eslint-5eVG3S6w.json +2 -2
- package/temp/build/typescript/ts_8nwakTlr.json +1 -1
- package/temp/ts-agent-memory-sqlite-vec.api.json +2 -2
|
@@ -9,6 +9,7 @@ import { Result, captureResult, fail, succeed } from '@fgv/ts-utils';
|
|
|
9
9
|
import {
|
|
10
10
|
IEdgeTarget,
|
|
11
11
|
IEmbeddedFragment,
|
|
12
|
+
IFragmentLocator,
|
|
12
13
|
IFragmentVectorIndex,
|
|
13
14
|
IVectorQueryHit,
|
|
14
15
|
MemoryId,
|
|
@@ -23,20 +24,49 @@ const DEFAULT_TABLE_NAME: string = 'memory_fragments';
|
|
|
23
24
|
/** A simple SQL identifier — the only shape allowed for the table name (it is interpolated into DDL). */
|
|
24
25
|
const IDENTIFIER_RE: RegExp = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* The auxiliary (`+`-prefixed) columns this version of the index writes. A table
|
|
29
|
+
* created by an earlier version carries a different set; see
|
|
30
|
+
* {@link SqliteVecFragmentIndex._readExistingDimension} for why that has to be
|
|
31
|
+
* detected explicitly rather than migrated.
|
|
32
|
+
*/
|
|
33
|
+
const AUXILIARY_COLUMNS: ReadonlyArray<string> = ['start_off', 'end_off', 'fragment_id'];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Matches one `+name` auxiliary-column declaration in a `vec0` `CREATE VIRTUAL TABLE`
|
|
37
|
+
* statement. Only ever consumed via `String.matchAll`, which iterates a clone rather
|
|
38
|
+
* than advancing this instance's `lastIndex`, so the shared `/g` regex is reusable.
|
|
39
|
+
*/
|
|
40
|
+
const AUXILIARY_COLUMN_RE: RegExp = /\+\s*([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
41
|
+
|
|
26
42
|
/**
|
|
27
43
|
* One KNN row as returned by the fragment `vec0` MATCH query. The offset columns are
|
|
28
44
|
* typed `number | bigint` because `better-sqlite3` returns integer columns as
|
|
29
45
|
* `bigint` when a consumer enables its safe-integer mode (`defaultSafeIntegers`);
|
|
30
46
|
* {@link SqliteVecFragmentIndex._toOffset} coerces them to a plain `number` (and
|
|
31
47
|
* fails loudly on an out-of-safe-range value) before they reach the public locator.
|
|
48
|
+
* All three identity columns are nullable: a fragment stored without a locator has
|
|
49
|
+
* `NULL` offsets, and one stored without a `fragmentId` has a `NULL` `fragment_id`.
|
|
32
50
|
*/
|
|
33
51
|
interface IKnnRow {
|
|
34
52
|
readonly target_key: string;
|
|
35
|
-
|
|
36
|
-
readonly
|
|
53
|
+
// eslint-disable-next-line @rushstack/no-new-null -- SQLite returns NULL (not undefined) for an absent locator offset
|
|
54
|
+
readonly start_off: number | bigint | null;
|
|
55
|
+
// eslint-disable-next-line @rushstack/no-new-null -- SQLite returns NULL (not undefined) for an absent locator offset
|
|
56
|
+
readonly end_off: number | bigint | null;
|
|
57
|
+
// eslint-disable-next-line @rushstack/no-new-null -- SQLite returns NULL (not undefined) for an absent fragment id
|
|
58
|
+
readonly fragment_id: string | null;
|
|
37
59
|
readonly distance: number;
|
|
38
60
|
}
|
|
39
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The identity fields of a fragment hit, in `IVectorQueryHit` shape: a field the
|
|
64
|
+
* stored fragment did not carry is *absent*, never present-but-`undefined`, so a hit
|
|
65
|
+
* for a fragment stored without a `fragmentId` is structurally identical to one this
|
|
66
|
+
* index produced before `fragment_id` existed.
|
|
67
|
+
*/
|
|
68
|
+
type FragmentIdentity = Pick<IVectorQueryHit, 'locator' | 'fragmentId'>;
|
|
69
|
+
|
|
40
70
|
/**
|
|
41
71
|
* A persistent, `sqlite-vec`-backed `IFragmentVectorIndex` (from
|
|
42
72
|
* `@fgv/ts-agent-memory`) — the fragment-granular sibling of
|
|
@@ -46,12 +76,25 @@ interface IKnnRow {
|
|
|
46
76
|
* @remarks
|
|
47
77
|
* Where {@link SqliteVecVectorIndex} keys one vector per record on a
|
|
48
78
|
* `target_key` primary key, this index holds **many** vectors per record — one per
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
79
|
+
* fragment — so it keys the `vec0` table on `target_key` as a **`PARTITION KEY`**
|
|
80
|
+
* (many rows may share it) and stores each fragment's identity in three auxiliary
|
|
81
|
+
* columns (`+start_off`, `+end_off`, `+fragment_id`) that ride alongside the vector
|
|
82
|
+
* and are returned on query but never filtered — in particular `fragment_id` is
|
|
83
|
+
* stored and returned verbatim, never parsed and never part of the query path. A
|
|
84
|
+
* query is a brute-force `vec0` KNN scan across all partitions returning per-fragment
|
|
85
|
+
* hits, each carrying its record `target` plus whichever identity fields the stored
|
|
86
|
+
* fragment was added with (a fragment must carry at least one).
|
|
87
|
+
*
|
|
88
|
+
* **`vec0` schema changes require a drop-and-re-index.** A
|
|
89
|
+
* `CREATE VIRTUAL TABLE IF NOT EXISTS` is a no-op against an existing table (SQLite
|
|
90
|
+
* does not compare schemas) and `vec0` has no `ALTER TABLE ADD COLUMN`, so a database written by an
|
|
91
|
+
* earlier version of this package keeps its old auxiliary columns. `create` detects
|
|
92
|
+
* that by parsing the stored `CREATE VIRTUAL TABLE` SQL and fails with an actionable
|
|
93
|
+
* message naming the expected and found columns, rather than letting a widened
|
|
94
|
+
* `INSERT` surface an opaque `no such column` at statement-prepare time. There are no
|
|
95
|
+
* in-place migrations: drop the table (or use a fresh `tableName`) and re-index.
|
|
96
|
+
* Fragment vectors are re-derivable from the records, so this costs embedding time,
|
|
97
|
+
* never data.
|
|
55
98
|
*
|
|
56
99
|
* Semantics match `InMemoryFragmentCosineIndex` exactly: `addFragments` is
|
|
57
100
|
* whole-record-replace (a single transaction deletes every prior fragment of the
|
|
@@ -104,12 +147,15 @@ export class SqliteVecFragmentIndex implements IFragmentVectorIndex {
|
|
|
104
147
|
/**
|
|
105
148
|
* Family-convention factory. Loads the `sqlite-vec` extension onto the supplied
|
|
106
149
|
* `better-sqlite3` connection and, if the fragment table already exists (a
|
|
107
|
-
* reopened persistent file),
|
|
108
|
-
* re-embedding is needed on
|
|
150
|
+
* reopened persistent file), verifies its auxiliary-column set matches this
|
|
151
|
+
* version's and recovers its established dimension so no re-embedding is needed on
|
|
152
|
+
* open.
|
|
109
153
|
*
|
|
110
154
|
* @param params - See {@link ISqliteVecFragmentIndexCreateParams}.
|
|
111
155
|
* @returns `Success` with the index, or `Failure` if the table name is not a
|
|
112
|
-
* simple identifier
|
|
156
|
+
* simple identifier, the extension fails to load, or the existing table was
|
|
157
|
+
* written by a version with a different auxiliary-column set (which requires a
|
|
158
|
+
* drop-and-re-index — `vec0` cannot be altered in place).
|
|
113
159
|
*/
|
|
114
160
|
public static create(params: ISqliteVecFragmentIndexCreateParams): Promise<Result<SqliteVecFragmentIndex>> {
|
|
115
161
|
const table: string = params.tableName ?? DEFAULT_TABLE_NAME;
|
|
@@ -146,11 +192,25 @@ export class SqliteVecFragmentIndex implements IFragmentVectorIndex {
|
|
|
146
192
|
if (fragment.vector.length === 0) {
|
|
147
193
|
return Promise.resolve(fail(`fragment index: cannot add '${key}': empty fragment vector`));
|
|
148
194
|
}
|
|
195
|
+
// A fragment carrying neither identity cannot be resolved back to anything by a
|
|
196
|
+
// consumer holding the hit — the same invariant `embeddedFragmentConverter`
|
|
197
|
+
// enforces at the untyped boundary, re-checked here at the index seam.
|
|
198
|
+
if (fragment.locator === undefined && fragment.fragmentId === undefined) {
|
|
199
|
+
return Promise.resolve(
|
|
200
|
+
fail(
|
|
201
|
+
`fragment index: cannot add '${key}': fragment requires at least one of 'locator' or 'fragmentId'`
|
|
202
|
+
)
|
|
203
|
+
);
|
|
204
|
+
}
|
|
149
205
|
// Locator offsets are persisted as SQLite integers (bound via BigInt). Reject a
|
|
150
206
|
// non-safe-integer offset up front with a clear message, rather than letting
|
|
151
207
|
// `BigInt(nonInteger)` throw cryptically inside the write transaction OR storing
|
|
152
208
|
// a value the read-side `_toOffset` guard would later reject on every query.
|
|
153
|
-
|
|
209
|
+
// An absent locator persists as a NULL offset pair and skips the check.
|
|
210
|
+
if (
|
|
211
|
+
fragment.locator !== undefined &&
|
|
212
|
+
(!Number.isSafeInteger(fragment.locator.start) || !Number.isSafeInteger(fragment.locator.end))
|
|
213
|
+
) {
|
|
154
214
|
return Promise.resolve(
|
|
155
215
|
fail(
|
|
156
216
|
`fragment index: cannot add '${key}': locator [${fragment.locator.start}, ${fragment.locator.end}) offsets must be safe integers`
|
|
@@ -256,10 +316,7 @@ export class SqliteVecFragmentIndex implements IFragmentVectorIndex {
|
|
|
256
316
|
hits.push({
|
|
257
317
|
target: SqliteVecFragmentIndex._parseKey(key),
|
|
258
318
|
score: 1 - row.distance,
|
|
259
|
-
|
|
260
|
-
start: SqliteVecFragmentIndex._toOffset(row.start_off, key),
|
|
261
|
-
end: SqliteVecFragmentIndex._toOffset(row.end_off, key)
|
|
262
|
-
}
|
|
319
|
+
...SqliteVecFragmentIndex._toIdentity(row, key)
|
|
263
320
|
});
|
|
264
321
|
}
|
|
265
322
|
return hits;
|
|
@@ -267,12 +324,16 @@ export class SqliteVecFragmentIndex implements IFragmentVectorIndex {
|
|
|
267
324
|
);
|
|
268
325
|
}
|
|
269
326
|
|
|
270
|
-
/**
|
|
327
|
+
/**
|
|
328
|
+
* Create the fragment `vec0` virtual table with the established dimension. The
|
|
329
|
+
* auxiliary columns must stay in sync with `AUXILIARY_COLUMNS`, which
|
|
330
|
+
* `create` compares against an existing table's stored DDL.
|
|
331
|
+
*/
|
|
271
332
|
private _createTable(dimension: number): void {
|
|
272
333
|
this._db.exec(
|
|
273
334
|
`CREATE VIRTUAL TABLE IF NOT EXISTS "${this._table}" USING vec0(` +
|
|
274
335
|
`target_key TEXT PARTITION KEY, embedding float[${dimension}] distance_metric=cosine, ` +
|
|
275
|
-
`+start_off integer, +end_off integer)`
|
|
336
|
+
`+start_off integer, +end_off integer, +fragment_id text)`
|
|
276
337
|
);
|
|
277
338
|
}
|
|
278
339
|
|
|
@@ -282,7 +343,8 @@ export class SqliteVecFragmentIndex implements IFragmentVectorIndex {
|
|
|
282
343
|
`DELETE FROM "${this._table}" WHERE target_key = ?`
|
|
283
344
|
);
|
|
284
345
|
const ins: BetterSqlite3.Statement = this._db.prepare(
|
|
285
|
-
`INSERT INTO "${this._table}"(target_key, embedding, start_off, end_off)
|
|
346
|
+
`INSERT INTO "${this._table}"(target_key, embedding, start_off, end_off, fragment_id) ` +
|
|
347
|
+
`VALUES (?, ?, ?, ?, ?)`
|
|
286
348
|
);
|
|
287
349
|
// Whole-record replace: drop every prior fragment of the target, then insert the
|
|
288
350
|
// new set, atomically. An empty set collapses to a pure delete.
|
|
@@ -294,9 +356,13 @@ export class SqliteVecFragmentIndex implements IFragmentVectorIndex {
|
|
|
294
356
|
ins.run(
|
|
295
357
|
key,
|
|
296
358
|
SqliteVecFragmentIndex._toBlob(fragment.vector),
|
|
297
|
-
// vec0 typed columns reject a JS float; bind the offsets as integers.
|
|
298
|
-
|
|
299
|
-
|
|
359
|
+
// vec0 typed columns reject a JS float; bind the offsets as integers. An
|
|
360
|
+
// absent locator binds the pair as NULL — never a partial pair, so the read
|
|
361
|
+
// side can treat a half-NULL pair as corruption rather than a legal shape.
|
|
362
|
+
fragment.locator === undefined ? null : BigInt(fragment.locator.start),
|
|
363
|
+
fragment.locator === undefined ? null : BigInt(fragment.locator.end),
|
|
364
|
+
// Stored verbatim and never parsed; absent binds as NULL.
|
|
365
|
+
fragment.fragmentId ?? null
|
|
300
366
|
);
|
|
301
367
|
}
|
|
302
368
|
});
|
|
@@ -306,7 +372,8 @@ export class SqliteVecFragmentIndex implements IFragmentVectorIndex {
|
|
|
306
372
|
replaceTxn(key, fragments);
|
|
307
373
|
},
|
|
308
374
|
query: this._db.prepare(
|
|
309
|
-
`SELECT target_key, start_off, end_off, distance FROM "${this._table}"
|
|
375
|
+
`SELECT target_key, start_off, end_off, fragment_id, distance FROM "${this._table}" ` +
|
|
376
|
+
`WHERE embedding MATCH ? AND k = ?`
|
|
310
377
|
),
|
|
311
378
|
fragmentCount: this._db.prepare(`SELECT count(*) AS c FROM "${this._table}"`),
|
|
312
379
|
recordCount: this._db.prepare(`SELECT count(DISTINCT target_key) AS c FROM "${this._table}"`)
|
|
@@ -315,8 +382,15 @@ export class SqliteVecFragmentIndex implements IFragmentVectorIndex {
|
|
|
315
382
|
|
|
316
383
|
/**
|
|
317
384
|
* Recover the established dimension of an existing fragment `vec0` table from its
|
|
318
|
-
* stored `CREATE VIRTUAL TABLE` SQL (`float[<n>]`)
|
|
385
|
+
* stored `CREATE VIRTUAL TABLE` SQL (`float[<n>]`), after checking that the table's
|
|
386
|
+
* auxiliary columns match `AUXILIARY_COLUMNS`. Returns `undefined` when the
|
|
319
387
|
* table does not exist yet (a fresh database — dimension is set by the first add).
|
|
388
|
+
*
|
|
389
|
+
* Throws when a table of that name exists but is not a usable fragment index (a
|
|
390
|
+
* mismatched auxiliary-column set, or no `vec0` embedding column); the caller runs
|
|
391
|
+
* this inside `captureResult`, so it surfaces as a loud `Failure` from `create`.
|
|
392
|
+
* The same stored DDL answers every one of those questions, so the checks cost
|
|
393
|
+
* nothing extra.
|
|
320
394
|
*/
|
|
321
395
|
private static _readExistingDimension(db: BetterSqlite3.Database, table: string): number | undefined {
|
|
322
396
|
const row: { sql: string } | undefined = db
|
|
@@ -325,8 +399,93 @@ export class SqliteVecFragmentIndex implements IFragmentVectorIndex {
|
|
|
325
399
|
if (row === undefined) {
|
|
326
400
|
return undefined;
|
|
327
401
|
}
|
|
402
|
+
SqliteVecFragmentIndex._verifyAuxiliaryColumns(row.sql, table);
|
|
328
403
|
const match: RegExpMatchArray | null = row.sql.match(/float\[(\d+)\]/);
|
|
329
|
-
|
|
404
|
+
if (match === null) {
|
|
405
|
+
// The auxiliary columns matched but there is no `float[<n>]` embedding column,
|
|
406
|
+
// so this is not a usable fragment index table. Same remedy as a column
|
|
407
|
+
// mismatch — and failing here beats handing back a dimensionless index whose
|
|
408
|
+
// first add would `CREATE VIRTUAL TABLE IF NOT EXISTS` into a no-op.
|
|
409
|
+
throw new Error(
|
|
410
|
+
`existing table '${table}' has no vec0 embedding column, so it is not a usable fragment ` +
|
|
411
|
+
`index table. Drop it (or pass a fresh tableName) and re-add every fragment.`
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
return Number(match[1]);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Compare an existing table's auxiliary columns against `AUXILIARY_COLUMNS`.
|
|
419
|
+
*
|
|
420
|
+
* `CREATE VIRTUAL TABLE IF NOT EXISTS` is a no-op against an existing table (SQLite
|
|
421
|
+
* never compares schemas) and `vec0` has no `ALTER TABLE ADD COLUMN`, so a table
|
|
422
|
+
* written by an earlier version of this package silently keeps its old columns and
|
|
423
|
+
* only fails later — as an opaque `no such column` when the widened `INSERT` is
|
|
424
|
+
* prepared. Detect it here instead and say what to do about it. Order is not
|
|
425
|
+
* compared: every statement names its columns explicitly, so only the set matters.
|
|
426
|
+
*/
|
|
427
|
+
private static _verifyAuxiliaryColumns(sql: string, table: string): void {
|
|
428
|
+
const found: string[] = Array.from(sql.matchAll(AUXILIARY_COLUMN_RE), (m) => m[1]);
|
|
429
|
+
const expected: ReadonlyArray<string> = AUXILIARY_COLUMNS;
|
|
430
|
+
const matches: boolean =
|
|
431
|
+
found.length === expected.length && expected.every((column) => found.includes(column));
|
|
432
|
+
if (!matches) {
|
|
433
|
+
throw new Error(
|
|
434
|
+
`existing table '${table}' has auxiliary columns [${found.join(', ')}] but this index ` +
|
|
435
|
+
`requires [${expected.join(', ')}] — it was written by a different version of ` +
|
|
436
|
+
`@fgv/ts-agent-memory-sqlite-vec, or it is not a fragment index table at all. vec0 virtual ` +
|
|
437
|
+
`tables cannot be altered in place, so this requires a drop-and-re-index: DROP TABLE ` +
|
|
438
|
+
`"${table}" (or pass a fresh tableName) and re-add every fragment. Fragment vectors are ` +
|
|
439
|
+
`re-derivable from the records, so this costs embedding time, never data.`
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Rebuild the identity fields of a hit from a persisted row, omitting each field
|
|
446
|
+
* the stored fragment did not carry (so a hit is structurally identical to one this
|
|
447
|
+
* index produced before `fragment_id` existed).
|
|
448
|
+
*
|
|
449
|
+
* A row carrying neither identity violates the write-side invariant and could not
|
|
450
|
+
* be resolved by the caller, so it fails loudly instead of yielding an anonymous
|
|
451
|
+
* hit.
|
|
452
|
+
*/
|
|
453
|
+
private static _toIdentity(row: IKnnRow, key: string): FragmentIdentity {
|
|
454
|
+
const locator: IFragmentLocator | undefined = SqliteVecFragmentIndex._toLocator(row, key);
|
|
455
|
+
if (locator === undefined && row.fragment_id === null) {
|
|
456
|
+
throw new Error(
|
|
457
|
+
`fragment '${key}': row carries neither a locator nor a fragment id (corrupt persisted data)`
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
...(locator !== undefined ? { locator } : {}),
|
|
462
|
+
...(row.fragment_id !== null ? { fragmentId: row.fragment_id } : {})
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Rebuild a fragment's locator from its persisted offsets, or `undefined` when the
|
|
468
|
+
* fragment was stored without one (both offsets `NULL`).
|
|
469
|
+
*
|
|
470
|
+
* The pair is written all-or-nothing, so a half-`NULL` pair can only come from
|
|
471
|
+
* corrupt / externally-edited data. Throw rather than coerce — `Number(null)` is
|
|
472
|
+
* `0`, which would silently fabricate a span starting at the top of the body.
|
|
473
|
+
*/
|
|
474
|
+
private static _toLocator(row: IKnnRow, key: string): IFragmentLocator | undefined {
|
|
475
|
+
const start: number | bigint | null = row.start_off;
|
|
476
|
+
const end: number | bigint | null = row.end_off;
|
|
477
|
+
if (start === null && end === null) {
|
|
478
|
+
return undefined;
|
|
479
|
+
}
|
|
480
|
+
if (start === null || end === null) {
|
|
481
|
+
throw new Error(
|
|
482
|
+
`fragment '${key}': locator has only one of its start/end offsets (corrupt persisted data)`
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
return {
|
|
486
|
+
start: SqliteVecFragmentIndex._toOffset(start, key),
|
|
487
|
+
end: SqliteVecFragmentIndex._toOffset(end, key)
|
|
488
|
+
};
|
|
330
489
|
}
|
|
331
490
|
|
|
332
491
|
/** Pack a `Float32Array` as the little-endian byte blob `vec0` stores. Copies, so the caller may reuse its buffer. */
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import '@fgv/ts-utils-jest';
|
|
7
7
|
|
|
8
8
|
import BetterSqlite3 from 'better-sqlite3';
|
|
9
|
+
import { load as loadSqliteVec } from 'sqlite-vec';
|
|
9
10
|
import * as fs from 'fs';
|
|
10
11
|
import * as os from 'os';
|
|
11
12
|
import * as path from 'path';
|
|
@@ -68,14 +69,77 @@ describe('SqliteVecFragmentIndex', () => {
|
|
|
68
69
|
expect(await SqliteVecFragmentIndex.create({ database: closed })).toFailWith(/failed to initialize/i);
|
|
69
70
|
});
|
|
70
71
|
|
|
71
|
-
test('
|
|
72
|
+
test('rejects a pre-existing non-vec0 table of the same name rather than adopting it', async () => {
|
|
72
73
|
db.exec('CREATE TABLE memory_fragments (foo TEXT)');
|
|
73
|
-
expect(await SqliteVecFragmentIndex.create({ database: db })).
|
|
74
|
-
|
|
75
|
-
expect(index.recordCount).toBe(0);
|
|
76
|
-
}
|
|
74
|
+
expect(await SqliteVecFragmentIndex.create({ database: db })).toFailWith(
|
|
75
|
+
/auxiliary columns \[\] but this index requires \[start_off, end_off, fragment_id\]/i
|
|
77
76
|
);
|
|
78
77
|
});
|
|
78
|
+
|
|
79
|
+
describe('auxiliary-column schema detection', () => {
|
|
80
|
+
// `CREATE VIRTUAL TABLE IF NOT EXISTS` is a no-op against an existing table and
|
|
81
|
+
// vec0 has no `ALTER TABLE ADD COLUMN`, so a database written by an earlier
|
|
82
|
+
// version keeps its narrower column set. Without detection the widened INSERT
|
|
83
|
+
// surfaces an opaque `no such column: fragment_id` at statement-prepare time.
|
|
84
|
+
function createLegacyTable(database: BetterSqlite3.Database): void {
|
|
85
|
+
loadSqliteVec(database);
|
|
86
|
+
database.exec(
|
|
87
|
+
'CREATE VIRTUAL TABLE memory_fragments USING vec0(' +
|
|
88
|
+
'target_key TEXT PARTITION KEY, embedding float[2] distance_metric=cosine, ' +
|
|
89
|
+
'+start_off integer, +end_off integer)'
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
test('fails with an actionable message naming expected and found columns', async () => {
|
|
94
|
+
createLegacyTable(db);
|
|
95
|
+
expect(await SqliteVecFragmentIndex.create({ database: db })).toFailWith(
|
|
96
|
+
/auxiliary columns \[start_off, end_off\] but this index requires \[start_off, end_off, fragment_id\]/i
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('states that a drop-and-re-index is required and that no data is lost', async () => {
|
|
101
|
+
createLegacyTable(db);
|
|
102
|
+
expect(await SqliteVecFragmentIndex.create({ database: db })).toFailWith(
|
|
103
|
+
/cannot be altered in place[\s\S]*drop-and-re-index[\s\S]*DROP TABLE "memory_fragments"[\s\S]*costs embedding time, never data/i
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('does not surface the opaque sqlite "no such column" error', async () => {
|
|
108
|
+
createLegacyTable(db);
|
|
109
|
+
const created = await SqliteVecFragmentIndex.create({ database: db });
|
|
110
|
+
expect(created).toFail();
|
|
111
|
+
expect(created.isFailure() && created.message).not.toMatch(/no such column/i);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('accepts a table whose auxiliary columns match, regardless of declaration order', async () => {
|
|
115
|
+
loadSqliteVec(db);
|
|
116
|
+
db.exec(
|
|
117
|
+
'CREATE VIRTUAL TABLE memory_fragments USING vec0(' +
|
|
118
|
+
'target_key TEXT PARTITION KEY, embedding float[3] distance_metric=cosine, ' +
|
|
119
|
+
'+fragment_id text, +end_off integer, +start_off integer)'
|
|
120
|
+
);
|
|
121
|
+
expect(await SqliteVecFragmentIndex.create({ database: db })).toSucceed();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('rejects a table that mimics the auxiliary columns but has no vec0 embedding column', async () => {
|
|
125
|
+
db.exec(
|
|
126
|
+
'CREATE TABLE memory_fragments ("+start_off" integer, "+end_off" integer, "+fragment_id" text)'
|
|
127
|
+
);
|
|
128
|
+
expect(await SqliteVecFragmentIndex.create({ database: db })).toFailWith(/no vec0 embedding column/i);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('rejects a table with the right number of columns but a different one', async () => {
|
|
132
|
+
loadSqliteVec(db);
|
|
133
|
+
db.exec(
|
|
134
|
+
'CREATE VIRTUAL TABLE memory_fragments USING vec0(' +
|
|
135
|
+
'target_key TEXT PARTITION KEY, embedding float[2] distance_metric=cosine, ' +
|
|
136
|
+
'+start_off integer, +end_off integer, +frag_id text)'
|
|
137
|
+
);
|
|
138
|
+
expect(await SqliteVecFragmentIndex.create({ database: db })).toFailWith(
|
|
139
|
+
/auxiliary columns \[start_off, end_off, frag_id\]/i
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
79
143
|
});
|
|
80
144
|
|
|
81
145
|
describe('addFragments', () => {
|
|
@@ -179,13 +243,11 @@ describe('SqliteVecFragmentIndex', () => {
|
|
|
179
243
|
|
|
180
244
|
test('fails loudly (never silently corrupts) when the table name collides with a non-vec0 table', async () => {
|
|
181
245
|
// A plain table already occupies the default name. `CREATE VIRTUAL TABLE IF NOT
|
|
182
|
-
// EXISTS` no-ops against it, so
|
|
183
|
-
//
|
|
246
|
+
// EXISTS` no-ops against it, so the collision must fail loudly rather than
|
|
247
|
+
// corrupt state — `create` now catches it up front via the auxiliary-column
|
|
248
|
+
// check, so the index is never handed out at all.
|
|
184
249
|
db.exec('CREATE TABLE memory_fragments (foo TEXT)');
|
|
185
|
-
|
|
186
|
-
expect(await index.addFragments(target('knowledge', 'doc-a'), [frag(0, 5, 1, 0)])).toFailWith(
|
|
187
|
-
/cannot add 'knowledge\0doc-a'/i
|
|
188
|
-
);
|
|
250
|
+
expect(await SqliteVecFragmentIndex.create({ database: db })).toFailWith(/auxiliary columns/i);
|
|
189
251
|
});
|
|
190
252
|
});
|
|
191
253
|
|
|
@@ -366,6 +428,46 @@ describe('SqliteVecFragmentIndex', () => {
|
|
|
366
428
|
).run(key, toBlob(...vec), start, end);
|
|
367
429
|
}
|
|
368
430
|
|
|
431
|
+
/** Insert a row with arbitrary (possibly NULL) identity columns, bypassing the write-side guards. */
|
|
432
|
+
function insertRawIdentity(
|
|
433
|
+
key: string,
|
|
434
|
+
start: bigint | null,
|
|
435
|
+
end: bigint | null,
|
|
436
|
+
fragmentId: string | null,
|
|
437
|
+
...vec: number[]
|
|
438
|
+
): void {
|
|
439
|
+
db.prepare(
|
|
440
|
+
'INSERT INTO memory_fragments(target_key, embedding, start_off, end_off, fragment_id) ' +
|
|
441
|
+
'VALUES (?, ?, ?, ?, ?)'
|
|
442
|
+
).run(key, toBlob(...vec), start, end, fragmentId);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
test('fails loudly when a stored locator has a start offset but no end offset', async () => {
|
|
446
|
+
// `Number(null)` is 0, so coercing a half-NULL pair would silently fabricate a
|
|
447
|
+
// span rather than surface the corruption.
|
|
448
|
+
const index = await seededWithRow();
|
|
449
|
+
insertRawIdentity('knowledge\0doc-b', BigInt(3), null, 'frag-1', 0, 1);
|
|
450
|
+
expect(await index.query(Float32Array.from([0, 1]), 5)).toFailWith(
|
|
451
|
+
/locator has only one of its start\/end offsets/i
|
|
452
|
+
);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test('fails loudly when a stored locator has an end offset but no start offset', async () => {
|
|
456
|
+
const index = await seededWithRow();
|
|
457
|
+
insertRawIdentity('knowledge\0doc-b', null, BigInt(9), 'frag-1', 0, 1);
|
|
458
|
+
expect(await index.query(Float32Array.from([0, 1]), 5)).toFailWith(
|
|
459
|
+
/locator has only one of its start\/end offsets/i
|
|
460
|
+
);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test('fails loudly when a stored row carries neither a locator nor a fragment id', async () => {
|
|
464
|
+
const index = await seededWithRow();
|
|
465
|
+
insertRawIdentity('knowledge\0doc-b', null, null, null, 0, 1);
|
|
466
|
+
expect(await index.query(Float32Array.from([0, 1]), 5)).toFailWith(
|
|
467
|
+
/carries neither a locator nor a fragment id/i
|
|
468
|
+
);
|
|
469
|
+
});
|
|
470
|
+
|
|
369
471
|
test('coerces bigint offsets (better-sqlite3 safe-integer mode) to number locators', async () => {
|
|
370
472
|
const index = await seededWithRow();
|
|
371
473
|
// Under safe-integer mode every integer column comes back as a bigint.
|
|
@@ -398,6 +500,129 @@ describe('SqliteVecFragmentIndex', () => {
|
|
|
398
500
|
});
|
|
399
501
|
});
|
|
400
502
|
|
|
503
|
+
describe('fragment identity', () => {
|
|
504
|
+
/** A fragment identified only by an opaque id — no honest body span. */
|
|
505
|
+
function idFrag(fragmentId: string, ...values: number[]): IEmbeddedFragment {
|
|
506
|
+
return { fragmentId, vector: Float32Array.from(values) };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
test('rejects a fragment carrying neither a locator nor a fragmentId', async () => {
|
|
510
|
+
const index = await makeIndex();
|
|
511
|
+
expect(
|
|
512
|
+
await index.addFragments(target('knowledge', 'doc-a'), [{ vector: Float32Array.from([1, 0]) }])
|
|
513
|
+
).toFailWith(/at least one of 'locator' or 'fragmentId'/i);
|
|
514
|
+
expect(index.fragmentCount).toBe(0);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
test('carries an opaque fragmentId through to the query hit verbatim', async () => {
|
|
518
|
+
const index = await makeIndex();
|
|
519
|
+
const opaque = 'urn:frag:9f8e::{not-parsed}';
|
|
520
|
+
(await index.addFragments(target('knowledge', 'doc-a'), [idFrag(opaque, 1, 0)])).orThrow();
|
|
521
|
+
expect(await index.query(Float32Array.from([1, 0]), 1)).toSucceedAndSatisfy(
|
|
522
|
+
(hits: ReadonlyArray<IVectorQueryHit>) => {
|
|
523
|
+
expect(hits[0].fragmentId).toBe(opaque);
|
|
524
|
+
expect(hits[0].locator).toBeUndefined();
|
|
525
|
+
}
|
|
526
|
+
);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test('carries both identities when a fragment supplies both', async () => {
|
|
530
|
+
const index = await makeIndex();
|
|
531
|
+
(
|
|
532
|
+
await index.addFragments(target('knowledge', 'doc-a'), [
|
|
533
|
+
{ ...frag(2, 8, 1, 0), fragmentId: 'frag-1' }
|
|
534
|
+
])
|
|
535
|
+
).orThrow();
|
|
536
|
+
expect(await index.query(Float32Array.from([1, 0]), 1)).toSucceedAndSatisfy(
|
|
537
|
+
(hits: ReadonlyArray<IVectorQueryHit>) => {
|
|
538
|
+
expect(hits[0].locator).toEqual(loc(2, 8));
|
|
539
|
+
expect(hits[0].fragmentId).toBe('frag-1');
|
|
540
|
+
}
|
|
541
|
+
);
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test('a locator-only fragment produces a hit with no fragmentId key at all', async () => {
|
|
545
|
+
// Byte-identical to what this index produced before `fragment_id` existed: the
|
|
546
|
+
// key is absent, not present-and-undefined, so an existing caller's structural
|
|
547
|
+
// comparisons are unaffected by the addition.
|
|
548
|
+
const index = await makeIndex();
|
|
549
|
+
(await index.addFragments(target('knowledge', 'doc-a'), [frag(0, 5, 1, 0)])).orThrow();
|
|
550
|
+
expect(await index.query(Float32Array.from([1, 0]), 1)).toSucceedAndSatisfy(
|
|
551
|
+
(hits: ReadonlyArray<IVectorQueryHit>) => {
|
|
552
|
+
expect(Object.keys(hits[0]).sort()).toEqual(['locator', 'score', 'target']);
|
|
553
|
+
expect(hits[0]).toStrictEqual({
|
|
554
|
+
target: target('knowledge', 'doc-a'),
|
|
555
|
+
score: hits[0].score,
|
|
556
|
+
locator: loc(0, 5)
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test('an id-only fragment produces a hit with no locator key at all', async () => {
|
|
563
|
+
const index = await makeIndex();
|
|
564
|
+
(await index.addFragments(target('knowledge', 'doc-a'), [idFrag('frag-1', 1, 0)])).orThrow();
|
|
565
|
+
expect(await index.query(Float32Array.from([1, 0]), 1)).toSucceedAndSatisfy(
|
|
566
|
+
(hits: ReadonlyArray<IVectorQueryHit>) => {
|
|
567
|
+
expect(Object.keys(hits[0]).sort()).toEqual(['fragmentId', 'score', 'target']);
|
|
568
|
+
}
|
|
569
|
+
);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test('skips the safe-integer offset check for a fragment with no locator', async () => {
|
|
573
|
+
// The write-side offset validation must not fire (or throw on `BigInt(undefined)`)
|
|
574
|
+
// when there is no locator to validate.
|
|
575
|
+
const index = await makeIndex();
|
|
576
|
+
expect(await index.addFragments(target('knowledge', 'doc-a'), [idFrag('frag-1', 1, 0)])).toSucceedWith(
|
|
577
|
+
1
|
|
578
|
+
);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
test('still rejects a non-safe-integer offset when a locator IS supplied', async () => {
|
|
582
|
+
const index = await makeIndex();
|
|
583
|
+
expect(
|
|
584
|
+
await index.addFragments(target('knowledge', 'doc-a'), [
|
|
585
|
+
{ locator: loc(0.5, 5), fragmentId: 'frag-1', vector: Float32Array.from([1, 0]) }
|
|
586
|
+
])
|
|
587
|
+
).toFailWith(/offsets must be safe integers/i);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test('round-trips every identity shape across a close + reopen', async () => {
|
|
591
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'svfragid-'));
|
|
592
|
+
const dbPath = path.join(dir, 'fragments.db');
|
|
593
|
+
const first = new BetterSqlite3(dbPath);
|
|
594
|
+
try {
|
|
595
|
+
const writeIndex = (await SqliteVecFragmentIndex.create({ database: first })).orThrow();
|
|
596
|
+
(
|
|
597
|
+
await writeIndex.addFragments(target('knowledge', 'doc-a'), [
|
|
598
|
+
frag(0, 5, 1, 0),
|
|
599
|
+
idFrag('frag-rewritten', 0, 1),
|
|
600
|
+
{ ...frag(5, 9, 0.9, 0.1), fragmentId: 'frag-both' }
|
|
601
|
+
])
|
|
602
|
+
).orThrow();
|
|
603
|
+
} finally {
|
|
604
|
+
first.close();
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const second = new BetterSqlite3(dbPath);
|
|
608
|
+
try {
|
|
609
|
+
const reopened = (await SqliteVecFragmentIndex.create({ database: second })).orThrow();
|
|
610
|
+
expect(reopened.fragmentCount).toBe(3);
|
|
611
|
+
expect(await reopened.query(Float32Array.from([1, 0]), 3)).toSucceedAndSatisfy(
|
|
612
|
+
(hits: ReadonlyArray<IVectorQueryHit>) => {
|
|
613
|
+
const byShape = new Map(hits.map((h) => [h.fragmentId ?? '<none>', h]));
|
|
614
|
+
expect(byShape.get('<none>')?.locator).toEqual(loc(0, 5));
|
|
615
|
+
expect(byShape.get('frag-rewritten')?.locator).toBeUndefined();
|
|
616
|
+
expect(byShape.get('frag-both')?.locator).toEqual(loc(5, 9));
|
|
617
|
+
}
|
|
618
|
+
);
|
|
619
|
+
} finally {
|
|
620
|
+
second.close();
|
|
621
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
|
|
401
626
|
describe('custom table name', () => {
|
|
402
627
|
test('two fragment indexes on distinct tables in one database are independent', async () => {
|
|
403
628
|
const a = (await SqliteVecFragmentIndex.create({ database: db, tableName: 'frag_a' })).orThrow();
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
],
|
|
12
12
|
[
|
|
13
13
|
"packlets/sqlite-vec-index/sqliteVecFragmentIndex.ts",
|
|
14
|
-
"
|
|
14
|
+
"033c31d058e5ab9a24ba21bcd70971fe3d757071fd5019e030c9be1313110cd4_B592XbdjJPfwmjGGtzeNtHae18Y="
|
|
15
15
|
],
|
|
16
16
|
[
|
|
17
17
|
"packlets/sqlite-vec-index/index.ts",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
],
|
|
24
24
|
[
|
|
25
25
|
"test/unit/sqliteVecFragmentIndex.test.ts",
|
|
26
|
-
"
|
|
26
|
+
"f99104ddbd95b2673f19035335ee47a4117727d60cfb7a7578e2207f001e6a84_tjT+aXFBH4Gc6UgYzDdGTpLJ+SY="
|
|
27
27
|
],
|
|
28
28
|
[
|
|
29
29
|
"test/unit/sqliteVecVectorIndex.test.ts",
|