A persistent vector store: PostgreSQL with the pgvector extension, reached through the C FFI.
The ai package's built-in store is a slice held in memory. It is rebuilt on every start, so a service re-embeds its whole corpus each time it boots and pays a provider for text that has not changed. It also scans every vector on every query.
This keeps the vectors in a table. An index survives a restart, grows a document at a time, and is searched by the database with a real index rather than by a loop in your program.
apt install postgresql-17 postgresql-17-pgvector libpq-dev # Debian, Ubuntu
brew install postgresql@17 pgvector # macOS
createdb lumenvec
psql lumenvec -c 'CREATE EXTENSION vector'
sh packages/pgvector/build.sh
build.sh finds libpq's headers through pg_config and produces pgvector_shim.o. Because // @link paths resolve against the working directory, compile programs that import this from packages/pgvector.
import { pgConnect, pgCreateStore, pgUpsert, pgSearch, pgClose } from "./pgvector.ts";
pgConnect("host=127.0.0.1 user=lumen password=lumen dbname=lumenvec");
// The width must match the embedding model: mistral-embed is 1024,
// text-embedding-3-small is 1536.
pgCreateStore("docs", 1024);
pgUpsert("docs", "notes.txt#0", chunkText, "notes.txt", "chunk=0", vector);
let hits = pgSearch("docs", queryVector, 5);
for (const h of hits) {
console.log(`${h.score} ${h.source} ${h.text}`);
}
pgClose();
| function | does |
|---|---|
pgConnect(conninfo) | open a connection from a libpq conninfo string |
pgConnected() / pgServerVersion() / pgClose() | connection state |
pgCreateStore(table, dims) | create the extension and table if absent |
pgCreateIndex(table, lists) | build an ivfflat cosine index |
pgDropStore(table) | drop the table |
pgUpsert(table, id, text, source, metadata, embedding) | insert or replace |
pgDeleteById(table, id) / pgDeleteBySource(table, source) | remove |
pgCount(table) / pgHas(table, id) / pgGet(table, id) | inspect |
pgSearch(table, query, k) | the k nearest chunks |
pgSearchInSource(table, query, source, k) | the same, within one document |
pgSearchWithin(table, query, k, maxDistance) | nearest, no further than a bound |
pgVectorLiteral(v) / pgParseVector(s) | pgvector's text form |
pgSafeIdentifier(name) | whether a name is usable as a table |
A PgHit carries id, text, source, metadata, distance and score. distance is what the database returned, so smaller is nearer; score is 1 - distance, the cosine similarity that reads the familiar way.
Calls that change something return a PgResult — { ok, rows, error } — with the database's own message rather than one this package invented.
The prefix names the store rather than dodging a clash. pgVectorLiteral and pgParseVector are pgvector's own wire format, and pgSearch says which store is being searched when a program has more than one.
It began as a workaround — two modules exporting search used to be a compile error — and that reason is gone: the compiler now renames internally and importers resolve through the exporting module's table (spec 476). The names stay because they read well, not because they must.
The point of persisting vectors is not to embed the same text twice:
let pending: string[] = [];
for (const part of parts) {
if (!pgHas("docs", part.id)) { pending = [...pending, part.text]; }
}
if (pending.length > 0) {
let vectors = embedBatchWithConfig(cfg, pending);
// ... upsert them
}
examples/index-and-search.ts is a runnable version. Run it twice: the second run reports all 3 chunks already stored — no embedding calls.
pgSearch always returns k rows, however unrelated they are — cosine similarity has no notion of "no match". When "nothing matched" must be a possible answer, use pgSearchWithin with a distance ceiling.
Build an index once a table holds enough rows that a scan is slow. Below a few thousand, a sequential scan is faster than an ivfflat index and exact besides; an approximate index trades recall for speed, which is a bad trade on a small table.
Every value is sent as a bound parameter through PQexecParams, never pasted into statement text, so a document containing a quote is data rather than syntax. A test stores the string it's a test'); DROP TABLE lumen_test_vectors; -- and then checks the table is still there.
Table names cannot be bound — SQL has no placeholder for an identifier — so they are checked against pgSafeIdentifier (letters, digits, underscores, not starting with a digit) and refused otherwise.
COPY bulk load: rows go in one at a time, which is fine for thousands and slow for millions.// @link paths resolve against it rather than against the source file. examples/rag-pdf.ts links this and pdf, and says so in its header.