Skip to content

Vector store

Semantic recall needs a vector index, but Engram treats that index as derived state: Postgres holds the truth (including the raw embedding float array), and the vector store can be dropped and rebuilt at any time. That framing is why a lost or corrupted index is an operational task, not a data-loss event.

There is exactly one backend: pgvector. Vectors live in a runtime-managed embedding_vec column on the memories table itself, so the vector index shares Postgres’ transactional boundary and a single pg_dump covers both the data and its index.

The store implements the VectorStore interface (packages/vector-store) and is injected through VECTOR_STORE_TOKEN:

interface VectorStore {
readonly backend: 'pgvector';
ensureReady(dimensions: number): Promise<void>;
upsert(records: VectorRecord[]): Promise<void>;
delete(ids: string[]): Promise<void>;
reset(): Promise<void>;
search(vector: number[], filter: VectorSearchFilter, limit?: number): Promise<VectorSearchResult[]>;
}

Every search carries a VectorSearchFilter with a required userId — tenant isolation is enforced inside the index as a WHERE clause, never bolted on after the fact. Optional filters (scope, tags, type, organizationId, created-date range) ride along the same way.

Dimensionality is observed, not assumed: ensureReady is called with the vector length of the first upserted vector, provisioning or validating the index at that size. VECTOR_DIMENSIONS is an optional strict pin (it does not default to 1536) — when set, the store verifies the index against it and throws an actionable error on mismatch instead of failing silently.

PgVectorStore (packages/vector-store/src/pgvector.vector-store.ts) uses an embedding_vec vector(n) column on the memories table itself, with an HNSW index and cosine distance (the pgvector <=> operator). Memory ids are the vector ids, so upserts are idempotent. No extra service; the vector index lives in the same transactional boundary as the data.

The column is runtime-managed, not schema-managed: it does not appear in schema.prisma, and ensureReady provisions the column and HNSW index at the observed dimensionality on the first vector write. On subsequent boots it verifies the live column’s dimensions (via the pg_attribute typmod) and throws an actionable error on mismatch. Until the first vector is written, searches simply return empty results. reset() drops the index and the column, so a full rebuild reprovisions them at the new dimensionality. Throughout, the raw embedding Float[] column in Postgres remains the source of truth; the pgvector column is a derived index. The health check reflects this split: ok tracks only the vector extension, while the lazily provisioned column is reported informationally (column, dimensions).

Deployments that predate runtime management (the old fixed vector(1536) column) should run one plain reindex after deploying — it reuses the stored Float[] embeddings, so no provider calls are made.

Tuning is exposed through three optional variables (see the configuration guide): PGVECTOR_HNSW_M and PGVECTOR_HNSW_EF_CONSTRUCTION at index build time, PGVECTOR_HNSW_EF_SEARCH per query (recall/latency trade-off).

The operational catch: the Postgres image must ship the extension — pgvector/pgvector:pg17 (pg16 or later). Plain postgres:*-alpine images will fail at ensureReady().

Switching embedding models with a different dimensionality (e.g. nomic-embed-text 768 → text-embedding-3-small 1536) is a reindex event: existing vectors are incompatible with the new index shape, so recreate the index and regenerate. The step-by-step is in Reindex embeddings.

The vector store returns raw similarity hits, deliberately over-fetched (3× the requested limit, capped at 100). MemoryLtmService.semanticSearch() then hydrates the rows from Postgres, drops superseded memories, and re-ranks by blended similarity + recency + importance before trimming to the requested limit. Keeping ranking in the service layer means the index only proposes candidates — final recall semantics live above it.