@monlite/vector — vector / semantic search
Nearest-neighbour search over embeddings. The vector() plugin adds a single method —
collection.findSimilar({ vector, topK, where }) — and runs the same API on either engine:
- SQLite (
@monlite/core, opened with{ allowExtensions: true }): asqlite-vec(vec0) index, with a brute-force JS fallback when the extension can't load (e.g. the SQLite-WASM build in the browser). Kept current on writes. - Postgres (
@monlite/postgres): a native generatedvector(dim)column + HNSW index (pgvector), maintained by Postgres itself — no indexer, no catch-up.
npm install @monlite/vector
Plugin (document collections)
Pass vector() to createDb with a map of collection → { field, dimensions, distance }.
On SQLite the database must be opened with allowExtensions: true so sqlite-vec can load.
import { createDb } from "@monlite/core";
import { vector } from "@monlite/vector";
const db = createDb("app.db", {
allowExtensions: true,
plugins: [vector({ docs: { field: "embedding", dimensions: 384, distance: "cosine" } })],
});
await db.collection("docs").create({
data: { text: "the cat sat", embedding: [/* 384 floats */], status: "live" },
});
const hits = await db.collection("docs").findSimilar({
vector: queryEmbedding, // length must equal `dimensions`
topK: 5,
where: { status: "live" },
});
// hits: Array<Doc & { _id: string; _distance: number }> (smaller _distance = closer)
Each result is the full document plus a _distance (raw metric distance — smaller is closer).
VectorField — per-collection config
interface VectorField {
/** Document field holding the embedding (a number[]). Dot-notation allowed. */
field: string;
/** Embedding dimensionality (must match your model). */
dimensions: number;
/** Distance metric. Default "l2". */
distance?: "l2" | "cosine";
}
Documents without a valid embedding on field (wrong length, non-numeric, or missing) are
simply not indexed — you can write the embedding later and it gets picked up on the next write.
findSimilar(opts)
interface FindSimilarOptions<T> {
/** Query embedding (length must equal the configured `dimensions`). */
vector: number[];
/** Number of nearest neighbours to return. Default 10. */
topK?: number;
/** Additionally constrain matches with a normal monlite where clause. */
where?: WhereInput<T>;
/**
* When `where` is set, how many neighbours to pull before filtering
* (then trimmed to `topK`). Default `max(topK * 10, 200)`.
*/
candidates?: number;
}
type SimilarResult<T> = T & { _id: string; _distance: number };
// Plain KNN.
await db.collection("docs").findSimilar({ vector: q, topK: 10 });
// KNN + structured filter.
await db.collection("docs").findSimilar({
vector: q,
topK: 10,
where: { status: "live", lang: "en" },
});
// Widen the candidate pool for a very selective filter.
await db.collection("docs").findSimilar({
vector: q,
topK: 5,
where: { tenantId: "rare" },
candidates: 2000,
});
findSimilar rejects if vector.length isn't exactly the configured dimensions. With a
where, it over-fetches neighbours then filters and trims to topK. The neighbour count is
capped at 4096 (sqlite-vec's hard k limit), so a topK/candidates above that returns
at most 4096 — for exact recall over a larger pre-filtered set, use the
dynamic store below (the filter runs inside the KNN).
Indexing is linear at scale (the vec0 table is keyed on doc_id).
Brute-force fallback
When sqlite-vec can't load (browser / SQLite-WASM), embeddings are stored as JSON and the
nearest neighbours are computed in JS — exact (not approximate), O(n) per query, which is
fine for the thousands-of-vectors scale a local/edge store typically holds. The findSimilar()
API and results are identical; only the engine underneath changes.
catchUp() — cross-process freshness
If another process writes documents, call catchUp() on a reader to incrementally index
new/changed documents and drop entries for cross-process deletes:
const { indexed, removed } = db.collection("docs").catchUp();
On the Postgres engine this is a no-op — Postgres maintains the column itself.
On Postgres — same API, native pgvector
Swap @monlite/core for @monlite/postgres and the same vector()
plugin and findSimilar() call run on a native, generated vector(dim) column with an HNSW
index. The first findSimilar() lazily and idempotently runs:
CREATE EXTENSION IF NOT EXISTS vector;
ALTER TABLE "docs" ADD COLUMN IF NOT EXISTS _vec vector(384)
GENERATED ALWAYS AS ((data->>'embedding')::vector(384)) STORED;
CREATE INDEX IF NOT EXISTS "docs_vec_idx" ON "docs" USING hnsw (_vec vector_cosine_ops);
The distance operator follows distance: <-> for L2 (vector_l2_ops) and <=> for
cosine (vector_cosine_ops), surfaced as the same _distance field. Postgres keeps the column
current on every write from any connection, so there is no indexer and no catchUp. (HNSW caps
dimensions at ~2000; above that the index is skipped and KNN falls back to an exact scan — still
correct.) The only change to move from SQLite to Postgres is the engine import.
Dynamic store — createVectorStore(db)
The vector() plugin indexes an embedding field of an existing document collection with a
static spec. When you instead need a programmatic store over collections created at
runtime — RAG corpora, per-tenant indexes, "give me a vector table for this id" — use
createVectorStore(db). Each collection is its own vec0 table; indexedFields become
filterable metadata columns so a where on one is pushed inside the KNN (exact pre-filtered
recall, e.g. scoped to one case/tenant even over a large corpus), with full metadata kept in an
auxiliary payload column. Synchronous; requires { allowExtensions: true }.
import { createDb } from "@monlite/core";
import { createVectorStore } from "@monlite/vector";
const db = createDb("rag.db", { allowExtensions: true });
const store = createVectorStore(db);
store.ensureCollection("docs", {
dimensions: 384,
metric: "cosine", // default "cosine"
indexedFields: ["docId"], // pushed into the KNN as a pre-filter
});
store.upsert("docs", [
{ id: "c1", vector: emb1, metadata: { docId: "d1", text: "...", page: 1 } },
{ id: "c2", vector: emb2, metadata: { docId: "d1", text: "...", page: 2 } },
]);
const hits = store.search("docs", { vector: q, topK: 5, where: { docId: "d1" } });
// hits: Array<{ id: string; distance: number; metadata: Record<string, unknown> }>
store.delete("docs", { id: "c1" }); // delete one point
store.delete("docs", { where: { docId: "d1" } }); // delete a whole scope
Surface
interface VectorStoreCollectionOptions {
/** Embedding dimensionality (must match your model). */
dimensions: number;
/** Distance metric. Default "cosine". */
metric?: "cosine" | "l2";
/** Metadata fields indexed as filterable columns (exact pre-filtered KNN). Default []. */
indexedFields?: string[];
}
interface VectorStorePoint {
id: string;
vector: number[];
/** Arbitrary JSON metadata, stored alongside the vector and returned on search. */
metadata?: Record<string, unknown>;
}
interface VectorStoreHit {
id: string;
/** Raw metric distance (smaller = closer): cosine-distance or L2. */
distance: number;
metadata: Record<string, unknown>;
}
interface VectorSearchOptions {
vector: number[];
/** Nearest neighbours to return. Default 10. */
topK?: number;
/**
* Exact metadata filter ({ field: value }). Fields in `indexedFields` are pushed
* into the KNN (pre-filtered); others are matched after the KNN (post-filtered).
*/
where?: Record<string, unknown>;
}
interface VectorStore {
ensureCollection(name: string, opts: VectorStoreCollectionOptions): void;
upsert(name: string, points: VectorStorePoint[]): void;
search(name: string, opts: VectorSearchOptions): VectorStoreHit[];
delete(name: string, opts: { id?: string; where?: Record<string, unknown> }): void;
}
Notes:
- A
whereon a declaredindexedFieldruns inside the KNN (exact, fast). Awhereon a non-indexed metadata key is applied after the KNN over an over-fetched candidate pool, so declare the fields you filter on asindexedFieldsfor exact recall at scale. - Indexed metadata is stored as text, so filter values are compared as text
(
{ year: 2021 }matches the stored"2021"). - Cosine search with an all-zero query vector throws — its distance is undefined (
0/0). upsertis delete-then-insert byid, batched in a transaction.searchreturns[]for unknown collections or unparseable queries; thekis clamped tosqlite-vec's 4096 limit.- A reopened store recovers its real schema (indexed columns + metric) from the
vec0table definition, soensureCollectionis optional after the first run.
Great to ~1M vectors locally; beyond that keep a dedicated vector DB (e.g. Qdrant) behind the same interface for scale.
Hybrid search — hybridSearch()
Run keyword (FTS) and semantic (vector) retrieval and fuse the two rankings with Reciprocal
Rank Fusion. The collection should have both @monlite/fts and
@monlite/vector configured; if FTS isn't active it falls back to vector-only.
import { hybridSearch } from "@monlite/vector";
const hits = await hybridSearch(db.collection("docs"), {
text: "black holes",
vector: queryEmbedding,
topK: 10,
where: { status: "live" },
});
// hits: Array<Doc & { _id: string; _rrf: number }> (higher _rrf = better)
interface HybridOptions<T> {
/** Keyword query (uses @monlite/fts's collection.search, if active). */
text: string;
/** Semantic query embedding. */
vector: number[];
/** Final number of results. Default 10. */
topK?: number;
/** Constrain both arms with a normal monlite where clause. */
where?: WhereInput<T>;
/** Candidates pulled from each arm before fusing. Default topK * 4. */
candidates?: number;
/** Reciprocal-rank-fusion constant. Default 60. */
k?: number;
}
type HybridResult<T> = T & { _id: string; _rrf: number };
Both arms run in parallel over the same where, each contributing 1 / (k + rank) to a
document's fused score; the top topK by fused score are returned with their _rrf weight.