Local vector store
Hybrid vector + full-text search that runs on disk, in one SQLite file per tenant, nothing shipped to a vector cloud.
What it is
Local vector store is Caisson's on-disk hybrid retrieval engine: sqlite-vec (vec0) for KNN and SQLite FTS5 for text, fused by Reciprocal Rank Fusion (RRF_K=60). It runs FTS5-only with no embedder configured, the vector leg degrades cleanly on any backend fault. Tenant isolation is physical: one SQLite file per tenant, not a shared table with a filter.
What ships in the module
RRF hybrid search
LocalStore.hybridSearch runs the vec0 KNN leg and the FTS5 leg independently, then fuses them by Reciprocal Rank Fusion (RRF_K=60). Either leg can come up empty (a missing query vector, an empty query, or a vec backend fault) and the other still returns results.
File-per-tenant isolation
tenantDbPath and openTenantDb resolve one SQLite file per tenant under a root directory. The path is rejected fail-closed on traversal, null bytes, absolute paths, or path separators before anything is opened, a cross-tenant query is not expressible, because a connection only ever holds one tenant's file.
Pluggable embedder port, no bundled model
Embedder is an interface the bundle wires, this package never calls a model or opens a socket. embedOrSkip treats an absent embedder as a first-class mode: retrieval runs on the FTS5 floor alone, not an error, not a silent default model.
Cloud-egress secret scrub
When a buyer does wire a cloud embedder, scrubForEgress runs on every text before it leaves the box, stripping PEM key blocks, URL userinfo passwords, secret-named assignments, and bare token shapes. guardEmbedder and createCloudEmbedder apply it structurally, not as an opt-in step.
Dedup-on-write + retention GC
decideWrite hashes normalized content per scope and reinforces an existing duplicate (resets its recency, slides its TTL) instead of writing a second row. planGc then evicts in order (expired, decayed below a score floor, or over a per-scope cap) as a pure function of items, config, and now.
Validated memory-item boundary
MemoryItemSchema is a Zod .strict() boundary: UUID ids, bounded text (100k chars) and scope (256 chars), optional string-to-string metadata. Unknown keys are rejected, not silently dropped.
Browser-safe entry point
Import @caisson/local-store/browser inside a client bundle for fuseByRrf and RRF_K, the fusion arithmetic with no database attached, to merge leg rankings your server or worker already produced. Retrieval itself stays on the main entry: the vec0 KNN and FTS5 legs need bun:sqlite and the sqlite-vec native extension. Every browser-entry export is also on the main entry.
export function fuseByRrf(
legs: readonly RrfLeg[],
opts: RrfOptions = {},
): RrfRow[] {
const rrfK = opts.rrfK ?? RRF_K;
assertPositive(rrfK, "rrfK");
const fused = new Map<number, number>();
for (const leg of legs) {
assertPositive(leg.weight, "RRF leg weight");
for (const [key, rank] of leg.ranks) {
fused.set(key, (fused.get(key) ?? 0) + leg.weight / (rrfK + rank));
}
}
const ranked = [...fused.entries()].sort(
(a, b) => b[1] - a[1] || a[0] - b[0],
);
const rows = opts.limit === undefined ? ranked : ranked.slice(0, opts.limit);
return rows.map(([key, score]) => ({ key, score }));
}- fuseByRrf sums weight/(RRF_K + rank) across every leg a document appears in, a doc that only hits in the vector leg or only the FTS5 leg still scores, it isn't dropped for missing the other.
- The sort's tie-break is key ascending, which is rowid order for hybridSearch, deterministic ranking with no dependence on wall-clock time or run-to-run ordering.
- The fusion has no database attached, which is why it is also the whole of the browser entry point while the vec0 and FTS5 legs stay server-side.