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 VectorStore interface
Section titled “The VectorStore interface”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.
pgvector: the index lives in Postgres
Section titled “pgvector: the index lives in Postgres”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.
HNSW tuning
Section titled “HNSW tuning”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.
Where ranking actually happens
Section titled “Where ranking actually happens”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.