/**
* In-database vector storage + brute-force search.
*
* Design:
* - Vectors are stored as BLOB columns holding a little-endian Float32 buffer.
* Encoding: `encodeVector(Float32Array) -> Buffer`.
* - Each row additionally stores its squared L2 norm² (cached) so cosine
* similarity can be computed without recomputing sqrt on every query.
* - Search is brute-force: `SELECT id, vec, norm2 FROM
WHERE vec IS NOT NULL`
* then we compute cosine(q, v) in JS and keep top-K with a small heap.
* - We *intentionally* don't rely on sqlite-wasm or vss. Pure JS brute is
* ~1 M × 384 in <50ms on a laptop, which is plenty for local plugin use.
*
* When usage grows past, say, 100K rows per table, this module is the single
* place to swap in an ANN index (e.g. hnswlib-node or faiss).
*/
import { rootLogger } from "../logger/index.js";
import type { EmbeddingVector } from "../types.js";
import type { StorageDb } from "./types.js";
const log = rootLogger.child({ channel: "storage.vector" });
// ─── Encoding ────────────────────────────────────────────────────────────────
const FLOAT32_BYTES = 4;
/** Float32Array → Buffer (little-endian, zero-copy when possible). */
export function encodeVector(vec: EmbeddingVector): Buffer {
if (!(vec instanceof Float32Array)) {
throw new Error("[storage.vector] encodeVector expects Float32Array");
}
// Node Buffers are always little-endian on our supported platforms.
return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
}
/** Buffer → Float32Array. Copies so callers can't mutate the underlying DB blob. */
export function decodeVector(buf: Buffer | Uint8Array | null | undefined): EmbeddingVector | null {
if (!buf) return null;
if (buf.byteLength === 0) return new Float32Array(0);
if (buf.byteLength % FLOAT32_BYTES !== 0) {
throw new Error(
`[storage.vector] decoded buffer is not aligned to float32 (${buf.byteLength} bytes)`,
);
}
const view = new Uint8Array(buf);
const copy = new Uint8Array(view.byteLength);
copy.set(view);
return new Float32Array(copy.buffer, 0, view.byteLength / FLOAT32_BYTES);
}
// ─── Math ────────────────────────────────────────────────────────────────────
export function dot(a: EmbeddingVector, b: EmbeddingVector): number {
if (a.length !== b.length) {
throw new Error(`[storage.vector] dimension mismatch: ${a.length} vs ${b.length}`);
}
let s = 0;
for (let i = 0; i < a.length; i++) s += a[i]! * b[i]!;
return s;
}
export function norm2(a: EmbeddingVector): number {
let s = 0;
for (let i = 0; i < a.length; i++) s += a[i]! * a[i]!;
return s;
}
export function cosine(a: EmbeddingVector, b: EmbeddingVector): number {
const d = dot(a, b);
const na = Math.sqrt(norm2(a));
const nb = Math.sqrt(norm2(b));
if (na === 0 || nb === 0) return 0;
return d / (na * nb);
}
/**
* Cosine similarity using pre-computed norm² of `b`. Saves one sqrt + one pass
* per candidate when the query side is fixed.
*/
export function cosinePrenormed(
a: EmbeddingVector,
aNorm: number,
b: EmbeddingVector,
bNorm2: number,
): number {
if (aNorm === 0 || bNorm2 === 0) return 0;
return dot(a, b) / (aNorm * Math.sqrt(bNorm2));
}
// ─── Top-K brute search ──────────────────────────────────────────────────────
export interface VectorRow {
id: TId;
vec: EmbeddingVector;
/** Pre-computed L2 norm². If absent we compute + cache. */
norm2?: number;
meta?: TMeta;
}
export interface VectorHit {
id: TId;
score: number; // cosine in [-1, 1]
meta?: TMeta;
}
/**
* Brute-force top-K cosine search over an in-memory array of rows. Stable
* (ties ordered by input order). Mutates `rows[i].norm2` if it was missing.
*/
export function topKCosine(
query: EmbeddingVector,
rows: Array>,
k: number,
): Array> {
if (k <= 0 || rows.length === 0) return [];
const qNorm = Math.sqrt(norm2(query));
if (qNorm === 0) return [];
// Simple n*log(k) approach: maintain a bounded min-heap on score.
const heap: Array> = [];
for (const row of rows) {
if (row.vec.length === 0) continue;
if (row.vec.length !== query.length) {
log.warn("search.dim_mismatch", {
expected: query.length,
got: row.vec.length,
rowId: String(row.id),
});
continue;
}
if (row.norm2 === undefined) row.norm2 = norm2(row.vec);
const score = cosinePrenormed(query, qNorm, row.vec, row.norm2);
pushBounded(heap, { id: row.id, score, meta: row.meta }, k);
}
// `heap` is a min-heap on score; caller wants DESC.
heap.sort((a, b) => b.score - a.score);
return heap;
}
function pushBounded(heap: T[], item: T, k: number): void {
if (heap.length < k) {
heap.push(item);
siftUp(heap, heap.length - 1);
return;
}
if (item.score <= heap[0]!.score) return; // can't make top-K
heap[0] = item;
siftDown(heap, 0);
}
function siftUp(heap: T[], i: number): void {
while (i > 0) {
const p = (i - 1) >> 1;
if (heap[p]!.score <= heap[i]!.score) break;
[heap[p], heap[i]] = [heap[i]!, heap[p]!];
i = p;
}
}
function siftDown(heap: T[], i: number): void {
const n = heap.length;
for (;;) {
const l = 2 * i + 1;
const r = 2 * i + 2;
let smallest = i;
if (l < n && heap[l]!.score < heap[smallest]!.score) smallest = l;
if (r < n && heap[r]!.score < heap[smallest]!.score) smallest = r;
if (smallest === i) break;
[heap[i], heap[smallest]] = [heap[smallest]!, heap[i]!];
i = smallest;
}
}
// ─── Convenience: scan a column and run top-K ────────────────────────────────
export interface VectorScanOptions {
/** Name of the BLOB column holding the vector. */
vecColumn: string;
/** Name of the REAL column caching norm². If absent we compute per-row. */
norm2Column?: string;
/** Optional WHERE clause (without the "WHERE"). */
where?: string;
/** Parameters for the WHERE clause. */
params?: Record;
/** Optional LIMIT to cap candidates fetched from SQLite. */
hardCap?: number;
}
export interface ScanRow {
id: string;
vec: Buffer | null;
norm2?: number | null;
[k: string]: unknown;
}
/**
* Default LIMIT applied when the caller doesn't pass an explicit
* `hardCap`. Historically 100_000, which — combined with the old
* `.all()` materialisation — meant one accidental "no cap" call
* could pull 100k multi-KB vector BLOBs into JS memory in a single
* synchronous step. On the reporter's DB in issue #2076 that
* translated to 4.2 GB RSS and a 100 % CPU main-thread stall.
*
* 5_000 is a much safer default: it still covers realistic per-agent
* corpora and it forces callers that legitimately need more to opt
* in explicitly.
*/
export const DEFAULT_SCAN_HARD_CAP = 5_000;
/**
* Stream rows from `table`, decode vectors, and run top-K cosine against
* `query`. `selectExtra` lets callers bring along columns that will surface in
* `VectorHit.meta`.
*
* Streaming: we use `.iterate()` (not `.all()`) so at most one row's
* BLOB is decoded at a time. The top-K min-heap keeps only `k`
* vectors of state, so peak RSS is O(k * dim) regardless of how many
* rows the LIMIT allows. Fixes the "load 100k BLOBs synchronously"
* pathology in #2076.
*/
export function scanAndTopK(
db: StorageDb,
table: string,
selectExtra: string[],
query: EmbeddingVector,
k: number,
opts: VectorScanOptions,
): Array> {
if (k <= 0 || query.length === 0) return [];
const { vecColumn, norm2Column, where, params, hardCap } = opts;
const cap = hardCap ?? DEFAULT_SCAN_HARD_CAP;
const cols = ["id", vecColumn, ...(norm2Column ? [norm2Column] : []), ...selectExtra];
const sql = [
`SELECT ${cols.join(", ")} FROM ${table}`,
where ? `WHERE ${where}` : "",
`LIMIT ${cap}`,
]
.filter(Boolean)
.join(" ");
const qNorm = Math.sqrt(norm2(query));
if (qNorm === 0) return [];
// Build meta once per row — hoisted out of the loop so the meta
// projection is *not* re-allocated as a closure per iteration. The
// two heap-push branches below both need this when the row is kept,
// and rebuilding the closure in each iteration was pure GC pressure
// for hot searches. Only invoked for the heap-push branches, so
// filtered-out rows pay nothing.
const buildMeta =
selectExtra.length > 0
? (row: ScanRow): TMeta =>
Object.fromEntries(selectExtra.map((c) => [c, row[c]])) as TMeta
: (_row: ScanRow): TMeta => undefined as TMeta;
// Streaming top-K: min-heap of size k. We *never* build the full
// candidate array — one BLOB lives on the JS heap at a time (plus
// the k already-selected vectors we're comparing against).
const heap: Array> = [];
const stmt = db.prepare(sql);
const iter = params === undefined ? stmt.iterate() : stmt.iterate(params);
for (const r of iter) {
const vec = decodeVector(r[vecColumn] as Buffer | null);
if (!vec) continue;
if (vec.length === 0 || vec.length !== query.length) {
// Non-empty vectors whose length disagrees with the query are the
// canonical "schema drift" signal — usually a re-embedding pass
// switched model dimensions and stale rows lingered. The old
// `topKCosine` path logged this exact case (see `topKCosine`
// above); the streaming rewrite must preserve the signal so
// operators can still detect silent under-recall in production.
if (vec.length !== 0) {
log.warn("search.dim_mismatch", {
expected: query.length,
got: vec.length,
rowId: String(r["id"]),
});
}
continue;
}
const rowNorm2 = norm2Column
? (r[norm2Column] as number | null | undefined) ?? norm2(vec)
: norm2(vec);
const score = cosinePrenormed(query, qNorm, vec, rowNorm2);
if (heap.length < k) {
heap.push({ id: String(r["id"]), score, meta: buildMeta(r) });
siftUp(heap, heap.length - 1);
} else if (score > heap[0]!.score) {
heap[0] = { id: String(r["id"]), score, meta: buildMeta(r) };
siftDown(heap, 0);
}
}
// `heap` is a min-heap on score; caller wants DESC.
heap.sort((a, b) => b.score - a.score);
return heap;
}