Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions src/selfhost/vectorize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,34 @@ export function createSqliteVectorize(driver: SqliteDriver): Vectorize {
const { rows } = opts.namespace
? driver.query(`SELECT id, embedding, metadata FROM ${TABLE} WHERE namespace=?`, [opts.namespace])
: driver.query(`SELECT id, embedding, metadata FROM ${TABLE}`, []);
const scored: Match[] = rows.map((r) => {
// #8766: a stored vector whose width differs from the query's (an operator swapped embedding models
// without a full reindex) is SKIPPED, never scored — cosineSimilarity's Math.min would otherwise
// silently compute a wrong similarity over the shorter prefix. Skipping degrades to fewer candidates,
// the same fail-safe posture the RAG pipeline already takes everywhere else; Qdrant hard-fails this at
// the collection level and pgvector raises, so this brings the SQLite adapter to parity. One WARN per
// query (not per row) so a mixed-width store is visible without flooding the log.
const mismatchedWidths = new Set<number>();
const scored: Match[] = [];
for (const r of rows) {
const values = JSON.parse(r.embedding as string) as number[];
if (values.length !== vector.length) {
mismatchedWidths.add(values.length);
continue;
}
const metadata = r.metadata ? (JSON.parse(r.metadata as string) as Record<string, unknown>) : undefined;
const score = cosineSimilarity(vector, values);
return metadata === undefined ? { id: r.id as string, score } : { id: r.id as string, score, metadata };
});
scored.push(metadata === undefined ? { id: r.id as string, score } : { id: r.id as string, score, metadata });
}
if (mismatchedWidths.size > 0) {
console.warn(
JSON.stringify({
event: "sqlite_vectorize_dimension_mismatch",
queryWidth: vector.length,
storedWidths: [...mismatchedWidths].sort((a, b) => a - b),
detail: "mismatched-width rows skipped — reindex after an embedding-model change",
}),
);
}
scored.sort((a, b) => b.score - a.score);
return { matches: scored.slice(0, opts.topK ?? 12) };
},
Expand Down
49 changes: 48 additions & 1 deletion test/unit/selfhost-vectorize.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DatabaseSync } from "node:sqlite";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { nodeSqliteDriver } from "../../src/selfhost/d1-adapter";
import { cosineSimilarity, createSqliteVectorize } from "../../src/selfhost/vectorize";

Expand Down Expand Up @@ -83,3 +83,50 @@ describe("createSqliteVectorize (#979 local RAG)", () => {
expect(res.matches.map((m) => m.id)).toContain("n2");
});
});

describe("dimension-mismatch guard (#8766)", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("SKIPS a stored vector whose width differs from the query's — never scores over a truncated prefix", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const v = makeVectorize();
await v.upsert([
{ id: "ok", values: [1, 0], namespace: "n" },
{ id: "wide", values: [1, 0, 0, 0], namespace: "n" }, // stale row from a different-dimension model
]);
const { matches } = await v.query([1, 0], { topK: 10, namespace: "n" });
expect(matches.map((m) => m.id)).toEqual(["ok"]);
// One WARN per query, naming both widths.
expect(warn).toHaveBeenCalledTimes(1);
const logged = JSON.parse(warn.mock.calls[0]![0] as string);
expect(logged).toMatchObject({ event: "sqlite_vectorize_dimension_mismatch", queryWidth: 2, storedWidths: [4] });
});

it("logs NOTHING when every stored width matches (the steady state stays silent)", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const v = makeVectorize();
await v.upsert([{ id: "a", values: [1, 0], namespace: "n" }]);
const { matches } = await v.query([1, 0], { topK: 10, namespace: "n" });
expect(matches).toHaveLength(1);
expect(warn).not.toHaveBeenCalled();
});

it("dedupes the warned widths and still returns matching rows sorted by score", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
const v = makeVectorize();
await v.upsert([
{ id: "best", values: [1, 0], namespace: "n" },
{ id: "worse", values: [0.5, 0.5], namespace: "n" },
{ id: "w4a", values: [1, 0, 0, 0], namespace: "n" },
{ id: "w4b", values: [0, 1, 0, 0], namespace: "n" },
{ id: "w3", values: [1, 0, 0], namespace: "n" },
]);
const { matches } = await v.query([1, 0], { topK: 10, namespace: "n" });
expect(matches.map((m) => m.id)).toEqual(["best", "worse"]);
const logged = JSON.parse((vi.mocked(console.warn).mock.calls[0]!)[0] as string);
expect(logged.storedWidths).toEqual([3, 4]);
expect(warn).toHaveBeenCalledTimes(1);
});
});