254 lines
8.7 KiB
TypeScript
254 lines
8.7 KiB
TypeScript
/**
|
|
* tests/unit/memory-vectorstore-rrf.test.ts
|
|
*
|
|
* Plan 21 — Memory Engine Redesign (F4)
|
|
* Tests for searchHybrid() RRF (Reciprocal Rank Fusion, k=60):
|
|
* - Case 1: doc only FTS hit → rrfScore = 1/(60+ftsRank), vecRank=null.
|
|
* - Case 2: doc only vec hit → rrfScore = 1/(60+vecRank), ftsRank=null.
|
|
* - Case 3: doc in both → rrfScore = sum, highest score.
|
|
* - Results ordered DESC by rrfScore.
|
|
* - apiKeyId filters both vec and FTS results.
|
|
*/
|
|
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { mock } from "node:test";
|
|
|
|
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omr-vecstore-rrf-"));
|
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
|
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
|
|
process.env.MEMORY_RRF_K = "60";
|
|
|
|
const core = await import("../../src/lib/db/core.ts");
|
|
const vsModule = await import("../../src/lib/memory/vectorStore.ts");
|
|
const { getVectorStore, _resetVectorStoreSingleton } = vsModule;
|
|
|
|
import type { EmbeddingResolution } from "../../src/lib/memory/embedding/types.ts";
|
|
|
|
const DIM = 4;
|
|
const RRF_K = 60;
|
|
|
|
function makeResolution(): EmbeddingResolution {
|
|
return {
|
|
source: "remote",
|
|
model: "test/dim4",
|
|
dimensions: DIM,
|
|
signature: `test:dim4:${DIM}`,
|
|
reason: "test",
|
|
};
|
|
}
|
|
|
|
function makeVec(...values: number[]): Float32Array {
|
|
return new Float32Array(values);
|
|
}
|
|
|
|
function cleanup() {
|
|
mock.restoreAll();
|
|
_resetVectorStoreSingleton();
|
|
core.resetDbInstance();
|
|
if (fs.existsSync(TEST_DATA_DIR)) {
|
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
}
|
|
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
}
|
|
|
|
test.afterEach(() => {
|
|
cleanup();
|
|
});
|
|
|
|
test.after(() => {
|
|
core.resetDbInstance();
|
|
if (fs.existsSync(TEST_DATA_DIR)) {
|
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function getStoreOrSkip(t: { skip: (msg: string) => void }): ReturnType<typeof getVectorStore> {
|
|
_resetVectorStoreSingleton();
|
|
const store = getVectorStore();
|
|
if (store === null) {
|
|
t.skip("sqlite-vec not available in this environment — skipping");
|
|
return null;
|
|
}
|
|
return store;
|
|
}
|
|
|
|
async function setupTable(store: NonNullable<ReturnType<typeof getVectorStore>>) {
|
|
await store.ensureReady(makeResolution());
|
|
}
|
|
|
|
function insertMemoryWithFts(
|
|
db: ReturnType<typeof core.getDbInstance>,
|
|
id: string,
|
|
apiKeyId: string,
|
|
content: string,
|
|
) {
|
|
// Insert into memories — the trigger memory_fts_ai fires automatically if the DB has it.
|
|
// In a fresh test DB the trigger exists (created by migration 023).
|
|
db.prepare(
|
|
`INSERT INTO memories (id, api_key_id, type, key, content, created_at)
|
|
VALUES (?, ?, 'factual', ?, ?, datetime('now'))`,
|
|
).run(id, apiKeyId, `key-${id}`, content);
|
|
// The migration 023 trigger inserts into memory_fts using memory_id (= rowid).
|
|
// If the trigger didn't fire (e.g. test DB without triggers), manually sync FTS.
|
|
try {
|
|
const row = db.prepare("SELECT rowid, memory_id FROM memories WHERE id = ?").get(id) as
|
|
| { rowid: number; memory_id: number | null }
|
|
| undefined;
|
|
if (row) {
|
|
const ftsRowid = row.memory_id ?? row.rowid;
|
|
const ftsCount = db
|
|
.prepare("SELECT COUNT(*) AS cnt FROM memory_fts WHERE rowid = ?")
|
|
.get(ftsRowid) as { cnt: number };
|
|
if (ftsCount.cnt === 0) {
|
|
db.prepare("INSERT INTO memory_fts(rowid, content, key) VALUES(?, ?, ?)").run(
|
|
ftsRowid,
|
|
content,
|
|
`key-${id}`,
|
|
);
|
|
}
|
|
}
|
|
} catch {
|
|
// FTS population is best-effort for tests — if memory_fts doesn't exist, vec-only tests still work.
|
|
}
|
|
}
|
|
|
|
// ──────────────── RRF tests ────────────────
|
|
|
|
test("searchHybrid: results ordered DESC by rrfScore", async (t) => {
|
|
const store = getStoreOrSkip(t);
|
|
if (!store) return;
|
|
|
|
const db = core.getDbInstance();
|
|
await setupTable(store);
|
|
|
|
// Insert 3 memories. All searchable via FTS for "hello".
|
|
insertMemoryWithFts(db, "mem-both", "key1", "hello world");
|
|
insertMemoryWithFts(db, "mem-fts-only", "key1", "hello text search only");
|
|
insertMemoryWithFts(db, "mem-vec-only", "key1", "different topic");
|
|
|
|
// mem-both gets a vector close to query.
|
|
await store.upsertVector("mem-both", makeVec(1.0, 0.0, 0.0, 0.0));
|
|
// mem-vec-only gets a vector close to query but no FTS match.
|
|
await store.upsertVector("mem-vec-only", makeVec(0.95, 0.05, 0.0, 0.0));
|
|
// mem-fts-only has no vector.
|
|
|
|
const query = makeVec(1.0, 0.0, 0.0, 0.0);
|
|
const hits = await store.searchHybrid(query, "hello", 10);
|
|
|
|
// Should return at least something.
|
|
assert.ok(hits.length > 0, "should return at least one hit");
|
|
|
|
// All hits must have rrfScore > 0.
|
|
for (const h of hits) {
|
|
assert.ok(typeof h.memoryId === "string");
|
|
assert.ok(typeof h.rrfScore === "number");
|
|
assert.ok(h.rrfScore > 0, `rrfScore must be > 0, got ${h.rrfScore}`);
|
|
}
|
|
|
|
// Results must be ordered DESC by rrfScore.
|
|
for (let i = 0; i < hits.length - 1; i++) {
|
|
assert.ok(
|
|
hits[i].rrfScore >= hits[i + 1].rrfScore,
|
|
`results must be ordered DESC by rrfScore: ${hits[i].rrfScore} >= ${hits[i + 1].rrfScore}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("searchHybrid: doc in both FTS and vec → highest rrfScore (sum of both contributions)", async (t) => {
|
|
const store = getStoreOrSkip(t);
|
|
if (!store) return;
|
|
|
|
const db = core.getDbInstance();
|
|
await setupTable(store);
|
|
|
|
insertMemoryWithFts(db, "mem-both", "key1", "hello hybrid search");
|
|
insertMemoryWithFts(db, "mem-fts-only", "key1", "hello text");
|
|
insertMemoryWithFts(db, "mem-vec-only", "key1", "no-fts-match");
|
|
|
|
// Give mem-both a close vector.
|
|
await store.upsertVector("mem-both", makeVec(1.0, 0.0, 0.0, 0.0));
|
|
// Give mem-vec-only a close vector too.
|
|
await store.upsertVector("mem-vec-only", makeVec(0.9, 0.0, 0.0, 0.0));
|
|
|
|
const hits = await store.searchHybrid(makeVec(1.0, 0.0, 0.0, 0.0), "hello", 10);
|
|
|
|
const bothHit = hits.find((h) => h.memoryId === "mem-both");
|
|
if (bothHit) {
|
|
// mem-both should have contributions from both vec and fts.
|
|
// Its rrfScore should be ≥ 1/(60+1) (at minimum from one source).
|
|
const minRrf = 1 / (RRF_K + 1);
|
|
assert.ok(
|
|
bothHit.rrfScore >= minRrf,
|
|
`mem-both rrfScore ${bothHit.rrfScore} should be >= ${minRrf}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test("searchHybrid: FTS-only hit has vecRank=null", async (t) => {
|
|
const store = getStoreOrSkip(t);
|
|
if (!store) return;
|
|
|
|
const db = core.getDbInstance();
|
|
await setupTable(store);
|
|
|
|
// Only insert FTS, no vector for this memory.
|
|
insertMemoryWithFts(db, "fts-only-mem", "key1", "unique text for fts test only");
|
|
|
|
// Query that will NOT match FTS for other mems.
|
|
const hits = await store.searchHybrid(makeVec(0.0, 0.0, 0.0, 1.0), "unique text for fts", 10);
|
|
|
|
const ftsOnlyHit = hits.find((h) => h.memoryId === "fts-only-mem");
|
|
if (ftsOnlyHit) {
|
|
// If mem only came from FTS, vecRank should be null.
|
|
if (ftsOnlyHit.ftsRank !== null && ftsOnlyHit.vecRank === null) {
|
|
assert.ok(ftsOnlyHit.rrfScore > 0);
|
|
const expectedContrib = 1 / (RRF_K + (ftsOnlyHit.ftsRank ?? 1));
|
|
// Score should be approximately the FTS contribution.
|
|
assert.ok(
|
|
Math.abs(ftsOnlyHit.rrfScore - expectedContrib) < 0.01,
|
|
`FTS-only rrfScore ${ftsOnlyHit.rrfScore} should ≈ ${expectedContrib}`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("searchHybrid: apiKeyId filters both vec and FTS results", async (t) => {
|
|
const store = getStoreOrSkip(t);
|
|
if (!store) return;
|
|
|
|
const db = core.getDbInstance();
|
|
await setupTable(store);
|
|
|
|
// Insert two memories with different api_key_id.
|
|
insertMemoryWithFts(db, "mem-key1", "key1", "hello hybrid");
|
|
insertMemoryWithFts(db, "mem-key2", "key2", "hello hybrid");
|
|
|
|
await store.upsertVector("mem-key1", makeVec(1.0, 0.0, 0.0, 0.0));
|
|
await store.upsertVector("mem-key2", makeVec(1.0, 0.0, 0.0, 0.0));
|
|
|
|
// Without filter: should see both.
|
|
const allHits = await store.searchHybrid(makeVec(1.0, 0.0, 0.0, 0.0), "hello", 10);
|
|
const allIds = allHits.map((h) => h.memoryId);
|
|
// At least one of each should appear (FTS and/or vec).
|
|
assert.ok(
|
|
allIds.includes("mem-key1") || allIds.includes("mem-key2"),
|
|
"without filter should include at least one hit",
|
|
);
|
|
|
|
// With filter for key1 only.
|
|
const key1Hits = await store.searchHybrid(makeVec(1.0, 0.0, 0.0, 0.0), "hello", 10, "key1");
|
|
for (const h of key1Hits) {
|
|
assert.notEqual(h.memoryId, "mem-key2", "key2 should not appear when filtering for key1");
|
|
}
|
|
|
|
// With filter for key2 only.
|
|
const key2Hits = await store.searchHybrid(makeVec(1.0, 0.0, 0.0, 0.0), "hello", 10, "key2");
|
|
for (const h of key2Hits) {
|
|
assert.notEqual(h.memoryId, "mem-key1", "key1 should not appear when filtering for key2");
|
|
}
|
|
});
|