/** * adapters/memory/pgVector — the target our own port has named since 2.x. * * `MemoryStore`'s docstring has listed the backends it was designed for from * the beginning — *"Every storage backend (InMemory, Redis, DynamoDB, * **Postgres**, Bedrock AgentCore) implements this interface"* — and named the * query, twice, in the places an implementer would look: *"**Postgres**: * multi-row INSERT … ON CONFLICT DO UPDATE"* for `putMany`, *"**pgvector**: * `ORDER BY embedding <=> query LIMIT k`"* for `search`. Every one of those * sentences was true about the design and false about the shipped package. * This is the adapter that makes them the same sentence. * * It matters more than one row in a table of backends. Postgres is the database * most teams already run, `pgvector` is an extension away, and a corpus that * lives beside the application's own data inherits its backups, its failover, * its access control and its migrations. `sqliteVectorStore` is one machine; * `s3VectorsStore` is serverless and eventually consistent; this is the one for * a fleet that already has a database. * * ── The table, and why this does not create it ────────────────────────────── * A `vector(N)` column fixes N at creation, and N is a fact about your * embedder. Creating the table implicitly would pick that number — and the * index type, and the operator class — on your behalf, in a migration you never * reviewed, in a database whose DDL is usually somebody's job. So the schema is * yours to run, and this store REFUSES a table that is missing rather than * silently answering "no matches" against nothing: * * ```sql * CREATE EXTENSION IF NOT EXISTS vector; * * -- 1024 = your embedder's dimensions. bedrockEmbedder() default: 1024. * -- openaiEmbedder() default: 1536. staticEmbedder(): 256. * CREATE TABLE af_vectors ( * namespace TEXT NOT NULL, * id TEXT NOT NULL, * value JSONB NOT NULL, * metadata JSONB, * embedding vector(1024), * embedder_fp TEXT, * version INTEGER NOT NULL, * created_at BIGINT NOT NULL, * updated_at BIGINT NOT NULL, * last_accessed_at BIGINT NOT NULL, * access_count INTEGER NOT NULL, * ttl BIGINT, * tier TEXT, * source JSONB, * embedding_model TEXT, * PRIMARY KEY (namespace, id) * ); * * -- Cosine, because that is the score this port reports and every threshold * -- in this library is calibrated on. Match the operator class to the metric. * CREATE INDEX af_vectors_hnsw ON af_vectors * USING hnsw (embedding vector_cosine_ops); * CREATE INDEX af_vectors_ns ON af_vectors (namespace); * * -- Recognition (`seen`/`recordSignature`), usefulness feedback, and the * -- per-namespace embedder fingerprint. Small, and each one is a port method * -- that would otherwise have to be refused. * CREATE TABLE af_signatures ( * namespace TEXT NOT NULL, signature TEXT NOT NULL, * PRIMARY KEY (namespace, signature) * ); * CREATE TABLE af_feedback ( * namespace TEXT NOT NULL, id TEXT NOT NULL, * total DOUBLE PRECISION NOT NULL, count INTEGER NOT NULL, * PRIMARY KEY (namespace, id) * ); * CREATE TABLE af_index_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); * ``` * * Every table and column name above is an OPTION with that value as its * default, so this drops into a schema that already has naming conventions — * see {@link PgVectorStoreOptions}. Identifiers are validated and quoted; a * name that is not a plain SQL identifier is refused rather than interpolated. * * ── Cosine, and only cosine ───────────────────────────────────────────────── * `search` is `1 - (embedding <=> $query::vector)` — pgvector's cosine * DISTANCE, converted to the cosine SIMILARITY the port reports and * `defineRAG`'s 0.7 default is calibrated on. `<->` (L2) and `<#>` (inner * product) are deliberately not options: their ranges are not that range, and a * number that reads like a cosine and is not one is the failure mode the whole * fingerprint machinery exists to prevent. Build the HNSW index with * `vector_cosine_ops` so the operator and the index agree — with the wrong * operator class the query still returns the right answer, slowly, by scanning. * * ── One statement at a time, on purpose ───────────────────────────────────── * The client here is anything with `query()` — a `pg.Pool` is the expected one, * and a Pool hands each `query()` its own connection. `BEGIN` on one and the * next statement on another is a transaction that silently is not one, so this * adapter never writes multi-statement transactions. Everything that must be * atomic is ONE statement: `putMany` is one multi-row upsert, `putIfVersion` is * one conditional upsert, `forget` is one statement with CTEs across all four * tables. That is a constraint that made the code better. * * ── Lazy peer dependency ──────────────────────────────────────────────────── * `pg` is an OPTIONAL peer dependency, required at construction time. Importing * `agentfootprint/memory` costs nothing for consumers who never build one of * these. Pass `client` to reuse the pool your app already has — which is the * recommended shape, because a second pool to the same database is a second set * of connections nobody counted. */ import type { MemoryIdentity } from '../../memory/identity/index.js'; import type { MemoryStore } from '../../memory/store/types.js'; /** One result set, as this adapter reads it. */ export interface PgQueryResult { readonly rows: readonly Record[]; } /** * The slice of a `pg` client this adapter calls. * * Structural, so a `Pool`, a `Client`, a pgBouncer-fronted wrapper or a test * double all satisfy it without this package taking a hard type dependency on * the optional peer. */ export interface PgLikeClient { query(text: string, params?: readonly unknown[]): Promise; /** Optional — awaited by {@link PgVectorStore.close} when this store built the pool. */ end?(): Promise; } /** The one constructor this adapter needs out of `pg`. */ export interface PgSdkModule { readonly Pool?: new (config: { connectionString?: string; }) => PgLikeClient; } /** * Column names, so this store fits a schema that already has conventions. * Every one defaults to the name in the `CREATE TABLE` above. */ export interface PgVectorColumns { readonly namespace?: string; readonly id?: string; readonly value?: string; readonly metadata?: string; readonly embedding?: string; readonly embedderFp?: string; readonly version?: string; readonly createdAt?: string; readonly updatedAt?: string; readonly lastAccessedAt?: string; readonly accessCount?: string; readonly ttl?: string; readonly tier?: string; readonly source?: string; readonly embeddingModel?: string; } export interface PgVectorStoreOptions { /** * Postgres connection string, used only when this store builds its own pool. * Prefer `client` — a second pool to one database is a second set of * connections nobody counted. */ readonly connectionString?: string; /** A pre-built `pg.Pool` (or anything with `query`). Recommended. */ readonly client?: PgLikeClient; /** Schema the tables live in. Default `'public'`. */ readonly schema?: string; /** The vectors table. Default `'af_vectors'`. */ readonly table?: string; /** The recognition-set table (`seen`/`recordSignature`). Default `'af_signatures'`. */ readonly signaturesTable?: string; /** The usefulness-aggregate table. Default `'af_feedback'`. */ readonly feedbackTable?: string; /** The key/value table holding one embedder fingerprint per namespace. Default `'af_index_meta'`. */ readonly metaTable?: string; /** Column names inside {@link table}. Each defaults to the documented one. */ readonly columns?: PgVectorColumns; /** * Rows per multi-row upsert. Default 500. * * Postgres caps a statement at 65,535 bound parameters and this store binds * 15 per row, so 500 leaves an order of magnitude of headroom. Raising it * trades round-trips for a statement that fails all-or-nothing on a bigger * unit. */ readonly batchSize?: number; /** @internal Test injection — skips the `pg` require entirely. */ readonly _client?: PgLikeClient; /** @internal Test injection — the `pg` module (exercises the real shim with a mock module). */ readonly _pg?: PgSdkModule; } /** A Postgres-backed vector store, plus the two things it owns beyond the port. */ export interface PgVectorStore extends MemoryStore { /** * 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): Promise; /** * Release the pool, if this store built one. Idempotent. A client you passed * in is yours and is left alone. */ close(): Promise; } /** * Raised when the database is reachable but its schema is not this store's. * * The law `sqliteVectorStore` states for a file, one backend 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 treated a missing table * as an empty corpus would answer every question from the model's own weights * and log nothing. */ export declare class PgVectorSchemaError extends Error { readonly code: "ERR_PGVECTOR_SCHEMA"; /** The schema-qualified table that could not be used. */ readonly table: string; /** Columns this store needs and did not find. Empty when the table is absent entirely. */ readonly missingColumns: readonly string[]; constructor(table: string, missingColumns: readonly string[], detail: string); } /** * Open a `MemoryStore` over an existing Postgres + pgvector table. * * @throws when `pg` is absent and no `client` was passed. * @throws PgVectorSchemaError on the first call, when the table or a column it * needs is not there. * @throws EmbedderMismatchError from `put`/`putMany`/`search` when a vector * meets a namespace built by a different embedder. * * @example A corpus beside the application's own data * ```ts * import { Pool } from 'pg'; * import { defineRAG, indexDocuments } from 'agentfootprint'; * import { pgVectorStore } from 'agentfootprint/memory'; * import { openaiEmbedder } from 'agentfootprint/providers'; * * const store = pgVectorStore({ client: new Pool({ connectionString: process.env.DATABASE_URL }) }); * const embedder = openaiEmbedder(); * * 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 pgVectorStore(options?: PgVectorStoreOptions): PgVectorStore;