/** * 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 (`'@'`) 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;