On-device vector search
On-device vector search runs nearest-neighbor embedding lookups locally, inside the application's own SQLite file, with no network round-trip and no vectors leaving the machine. Caisson's local-store package pairs the sqlite-vec vec0 extension with FTS5 in that same file, fusing both rankings by Reciprocal Rank Fusion, so retrieval keeps working with zero embedder configured.
In code
hybridSearch(opts: HybridSearchOptions): SearchHit[] {
const limit = opts.limit ?? 10;
const legLimit = Math.max(limit * 8, 50);
const ftsWeight = opts.ftsWeight ?? 1;
if (!Number.isFinite(ftsWeight) || ftsWeight <= 0) {
throw new ValidationError("ftsWeight must be a positive finite number", {
received: ftsWeight,
});
}
const vecRanks = this.vecLeg(opts.queryVector, legLimit);
const ftsRanks = this.ftsLeg(opts.queryText, legLimit);
// RRF fusion: every leg a doc appears in contributes 1/(RRF_K + rank); sum across legs. The
// FTS contribution is scaled by `ftsWeight` (default 1 — the symmetric classic form).
const fused = new Map<number, number>();
for (const [rowid, rank] of vecRanks)
fused.set(rowid, (fused.get(rowid) ?? 0) + 1 / (RRF_K + rank));
for (const [rowid, rank] of ftsRanks)
fused.set(rowid, (fused.get(rowid) ?? 0) + ftsWeight / (RRF_K + rank));
const ranked = [...fused.entries()]
// score descending; deterministic tie-break by rowid ascending (stable, env-free).
.sort((a, b) => b[1] - a[1] || a[0] - b[0])
.slice(0, limit);
if (ranked.length === 0) return [];
return ranked.map(([rowid, score]) => ({ id: this.docId(rowid), score }));
}How it holds
FTS5 is the always-available floor
hybridSearch always runs the FTS5 leg; the vector leg runs only when a queryVector is supplied, and a vec backend fault is caught and skipped rather than thrown. No embedder configured, no live vector index, no server down: retrieval degrades to FTS5-only and keeps answering.
One fixed formula, not a tunable blend
Fusion is Reciprocal Rank Fusion at the standard RRF_K=60: every leg a document appears in contributes 1/(60+rank), summed across legs, then ranked descending with a deterministic rowid tie-break. One lever exists, ftsWeight (default 1, the symmetric classic form) scales the FTS leg when exact-term evidence should outrank semantic neighborhood; a non-positive value throws rather than guesses. Ordering never depends on the environment.
The embedder is a port, never a bundled model
local-store depends on nothing that opens a socket or loads a model; embed() is an injected Embedder interface the consuming bundle wires. An undefined embedder is a first-class, documented mode, not a fallback failure: the FTS5 floor alone runs fully offline.
vec0's dimension is fixed at table creation
CREATE VIRTUAL TABLE docs_vec USING vec0(...FLOAT[dim]) locks the embedding width when the store opens. upsert() and every query vector are checked against it, and a mismatch throws instead of silently padding or truncating a vector.