agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
240 lines (239 loc) • 12.2 kB
TypeScript
/**
* adapters/memory/sqliteVector — a corpus in a file, so you embed it once.
*
* `InMemoryStore` is a `Map`, and its cost is the one nobody notices until the
* bill arrives: **restart the process and the whole corpus is re-embedded.**
* That is fine for three documents and absurd for ten thousand. The step up was
* "bring a vector database", and the step between those two — *one machine, one
* file, nothing to install* — was a store every consumer had to write for
* themselves. This is that store.
*
* ── The two-phase cost model this exists to fix ─────────────────────────────
* **Index time** embeds the corpus. It happens once, in its own process, and
* its cost scales with the size of your documents. **Query time** embeds one
* thing — the user's question — per retrieval, and its cost scales with
* traffic. A 10,000-chunk corpus is 10,000 embeddings *once* and one embedding
* per question thereafter. With a `Map` it is 10,000 embeddings *per restart*.
* That is the whole argument for a file.
*
* ── Exact search, and the ceiling said out loud ─────────────────────────────
* Vectors live in SQLite as `Float32Array` blobs. On the first search of a
* namespace they are hydrated into ONE resident `Float32Array` matrix,
* normalised, and every later query is an exact dot product across it. There is
* **no approximate index and no pretence of one**: this returns the true top-K,
* or it does not answer.
*
* Measured against THIS implementation on Node 22.16, Apple silicon, one
* namespace, median of five queries:
*
* | corpus | query | resident matrix | file | first search (hydration) |
* |---|---|---|---|---|
* | 10,000 × 384-d | 6 ms | 15 MB | 21 MB | 45 ms |
* | 50,000 × 384-d | 31 ms | 77 MB | 105 MB | 251 ms |
* | 100,000 × 384-d | 65 ms | 154 MB | 211 MB | 939 ms |
* | 10,000 × 1536-d | 16 ms | 61 MB | 83 MB | 122 ms |
* | 50,000 × 1536-d | 89 ms | 307 MB | 413 MB | **5.7 s** |
*
* **The documented ceiling is 50,000 chunks.** Below it, every query is under
* 100 ms at every embedder this library ships and the resident matrix is under
* ~300 MB. It degrades linearly and predictably to about 100,000. Above that,
* or when the process cannot hold the matrix, move to a managed vector database
* — `MemoryStore` is the seam, and nothing else in your code changes. For scale
* intuition: 50,000 chunks at ~1,000 characters is roughly 50 MB of text, on
* the order of 25,000 pages.
*
* **It is a RECOMMENDATION, not an enforced limit, and the difference matters
* when you are planning.** Nothing in this store counts chunks, refuses a write
* at 50,000, or degrades on purpose past it: chunk 50,001 is stored and searched
* exactly like chunk 3. The number is the point on the measured curve where this
* implementation stops being obviously the right tool — published so the
* decision is yours and dated, rather than discovered in production. The refusals
* this store DOES enforce are named on the constructor: a missing `node:sqlite`,
* an unreadable file, a schema-identity or schema-version mismatch, `':memory:'`,
* and an embedder-fingerprint conflict.
*
* **Hydration is the number to plan around, not the query.** Steady-state
* search is fast everywhere in that table; reading the vectors off disk the
* FIRST time is what costs, and at 50,000 × 1536 it is 5.7 seconds. Paid
* lazily, that lands on whoever asks the first question after a deploy. Call
* {@link SqliteVectorStore.warm} at boot to pay it somewhere you chose — see
* that method. Smaller vectors are dramatically cheaper here: 384 dimensions
* hydrates 100,000 chunks in under a second, which is one more reason a
* 384-dimension embedder is the better default for a corpus this size.
*
* A loadable extension (sqlite-vec) was measured against this and deliberately
* NOT taken: it is roughly 2× faster at these sizes, and costs a native binary
* on a five-platform matrix (no musl, no Windows/arm64) plus a pre-1.0
* dependency. Two times, at sizes where we are already under 100 ms, does not
* buy that.
*
* ── One process on one machine ──────────────────────────────────────────────
* The same ceiling `sqliteSessions` states, for the same reason. It survives a
* restart, a crash, a deploy. It is NOT distributed. WAL gives one writer and
* many readers at once; a second writer waits up to `busyTimeoutMs` and then
* fails loudly rather than queueing forever.
*
* ── Zero dependencies, and the version floor that buys ──────────────────────
* SQLite is *inside Node* — no install, no native build, no peer dependency.
* The price is a version floor this package does not otherwise have, so the
* module is loaded when you actually construct a store and its absence is
* refused by name. There is deliberately **no fallback to memory**: a corpus
* that silently forgot every document on restart looks, from the outside,
* exactly like a corpus that was never built.
*/
import type { MemoryIdentity } from '../../memory/identity/index.js';
import type { MemoryStore } from '../../memory/store/types.js';
/** One prepared statement, as this adapter calls it. */
export interface SqliteVectorStatementLike {
run(...params: readonly unknown[]): unknown;
get(...params: readonly unknown[]): unknown;
all(...params: readonly unknown[]): unknown[];
}
/** One open database, as this adapter calls it. */
export interface SqliteVectorDatabaseLike {
exec(sql: string): void;
prepare(sql: string): SqliteVectorStatementLike;
close(): void;
}
/** The shape of `node:sqlite` this adapter needs, declared locally. */
export interface SqliteVectorModuleLike {
new (path: string): SqliteVectorDatabaseLike;
}
export interface SqliteVectorStoreOptions {
/**
* The database file. Created if it does not exist, along with its parent
* directory — "no infrastructure" would be a thin promise if you still had
* to `mkdir` first.
*
* `':memory:'` is refused: it looks like a file, keeps nothing across a
* restart, and re-embedding the corpus on every boot is the exact cost this
* store exists to remove. Use `InMemoryStore` when that is what you want —
* it says so in its name.
*/
readonly file: string;
/**
* How long a write waits for another writer's lock before failing, in
* milliseconds. Default 5000. It fails rather than waiting forever on
* purpose: a request hung on a lock looks exactly like a slow model, and the
* two need different fixes.
*/
readonly busyTimeoutMs?: number;
/**
* @internal Test seam only — the `node:sqlite` module, injected. Not public
* API, not supported, and not a place to plug in another SQLite driver.
*/
readonly _sqlite?: SqliteVectorModuleLike;
}
/**
* Raised when the file exists but this runtime cannot use it as a vector index.
*
* The law `sqliteSessions` states for a session file, one domain over: **an
* unreadable index and an empty one are different facts, and only one of them
* is safe to answer with "no matches".** A store that opened a corrupt file as
* an empty database would answer every question from the model's own weights
* and log nothing.
*
* `problem` is the fact to branch on:
*
* - `'cannot-open'` — not a SQLite database, or not readable.
* - `'not-our-schema'` — a database whose `af_vectors` table is somebody
* else's table of that name. Point the store at its own file.
* - `'newer-schema'` — written by a newer agentfootprint than this one.
*/
export declare class UnreadableIndexFileError extends Error {
readonly code: "ERR_UNREADABLE_INDEX_FILE";
/** The file that was refused. */
readonly file: string;
/** Which of the three cases this is. */
readonly problem: 'cannot-open' | 'not-our-schema' | 'newer-schema';
constructor(file: string, problem: UnreadableIndexFileError['problem'], detail: string);
}
/**
* Raised when a vector meets an index built by a different embedder.
*
* Since 9.3.0 this is ONE class shared by every store that can make the check
* (`lib/embedderMismatch.ts`), re-exported here so the import path that has
* worked since 8.9.0 keeps working. A second class of the same name would make
* `instanceof` depend on which store threw.
*/
export { EmbedderMismatchError } from '../../lib/embedderMismatch.js';
/** A durable vector store, plus the three things a file owns beyond the port. */
export interface SqliteVectorStore extends MemoryStore {
/**
* The journalling mode the file **actually has**, read back from SQLite
* rather than assumed from what was asked for.
*
* Normally `'wal'`. Something else when the file lives where WAL cannot work
* — a network filesystem is the usual reason — and that is a fact worth being
* able to read: the store still works, with one writer *or* one reader at a
* time instead of both. A silent downgrade is only ever discovered under load.
*/
readonly journalMode: string;
/** The file this store is backed by. */
readonly file: string;
/**
* The embedder fingerprint (`'<id>@<dims>'`) a namespace was built with, or
* `undefined` when nothing with a vector has been written to it yet.
*
* Read this before an embedder swap: it is the fact `EmbedderMismatchError`
* refuses on, available up front instead of at the first failed write.
*/
fingerprintOf(identity: MemoryIdentity): string | undefined;
/**
* Read a namespace's vectors into memory NOW, and report how many and how
* long it took.
*
* The first search of a namespace pays for hydration — 251 ms for 50,000
* 384-dimension vectors, 5.7 s for 50,000 at 1536. Left alone that bill
* lands on whoever asks the first question after a deploy, which is the
* worst possible person to hand it to and the hardest latency to explain
* afterwards.
*
* Calling this at boot moves the cost somewhere you chose. It is optional,
* idempotent, and changes no result: a warmed store and a cold one answer
* identically, one of them just answers the first question faster.
*
* ```ts
* const store = sqliteVectorStore({ file: './corpus.db' });
* const { count, durationMs } = await store.warm({ conversationId: '_global' });
* console.log(`corpus ready: ${count} vectors in ${durationMs}ms`);
* ```
*/
warm(identity: MemoryIdentity): Promise<{
count: number;
durationMs: number;
}>;
/**
* Close the file. Idempotent — a shutdown hook and an explicit close can
* coexist. Reading or writing afterwards refuses by name rather than
* reopening behind your back.
*/
close(): void;
}
/**
* Open (or create) a vector index in one SQLite file.
*
* @throws SqliteUnavailableError when the running Node has no `node:sqlite`.
* @throws UnreadableIndexFileError when the file exists but cannot be used —
* never answered with an empty index.
* @throws EmbedderMismatchError from `put`/`putMany`/`search` when a vector
* meets a namespace built by a different embedder.
*
* @example Embed the corpus once, ever
* ```ts
* import { indexDocuments, defineRAG } from 'agentfootprint';
* import { sqliteVectorStore } from 'agentfootprint/memory';
* import { staticEmbedder } from 'agentfootprint/providers';
*
* const store = sqliteVectorStore({ file: './corpus.db' });
* const embedder = staticEmbedder();
*
* // First boot indexes; every boot after this one finds the vectors already there.
* await indexDocuments(store, embedder, docs, { embedderId: embedder.id });
*
* const agent = Agent.create({ provider })
* .rag(defineRAG({ id: 'docs', store, embedder, embedderId: embedder.id }))
* .build();
* ```
*/
export declare function sqliteVectorStore(options: SqliteVectorStoreOptions): SqliteVectorStore;