Skip to content

Commit defeee6

Browse files
committed
perf: add embedding cache with FTS pre-filtering and scoped session read cache
#1: In-memory TTL embedding cache eliminates repeated blob deserialization. FTS pre-filtering narrows semantic candidates from O(N) to O(k) before cosine scoring. Cache invalidated on memory write/delete/update. #7: Scoped raw-message cache (withRawSessionMessageCache) ensures compartment trigger evaluation loads OpenCode's message DB once instead of twice per evaluation. Cache is synchronous, try/finally scoped, and supports safe nesting.
1 parent 7760537 commit defeee6

File tree

10 files changed

+422
-78
lines changed

10 files changed

+422
-78
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/// <reference types="bun-types" />
2+
3+
import { Database } from "bun:sqlite";
4+
import { afterEach, describe, expect, it } from "bun:test";
5+
import { initializeDatabase } from "../storage-db";
6+
import {
7+
getProjectEmbeddings,
8+
resetEmbeddingCacheForTests,
9+
saveEmbedding,
10+
setEmbeddingCacheTtlForTests,
11+
} from "./index";
12+
import { insertMemory } from "./storage-memory";
13+
14+
let db: Database | null = null;
15+
16+
function createTestDb(): Database {
17+
const database = new Database(":memory:");
18+
initializeDatabase(database);
19+
return database;
20+
}
21+
22+
function toNumbers(embedding: Float32Array | undefined): number[] {
23+
return embedding ? Array.from(embedding) : [];
24+
}
25+
26+
afterEach(() => {
27+
resetEmbeddingCacheForTests();
28+
db?.close(false);
29+
db = null;
30+
});
31+
32+
describe("embedding-cache", () => {
33+
it("reloads project embeddings after TTL expiry", async () => {
34+
db = createTestDb();
35+
setEmbeddingCacheTtlForTests(10);
36+
37+
const memory = insertMemory(db, {
38+
projectPath: "/repo/project",
39+
category: "ARCHITECTURE_DECISIONS",
40+
content: "Cache embeddings for memory search.",
41+
});
42+
43+
saveEmbedding(db, memory.id, new Float32Array([1, 0]), "mock:model");
44+
45+
const initial = getProjectEmbeddings(db, "/repo/project");
46+
expect(toNumbers(initial.get(memory.id))).toEqual([1, 0]);
47+
48+
saveEmbedding(db, memory.id, new Float32Array([0, 1]), "mock:model");
49+
50+
const beforeExpiry = getProjectEmbeddings(db, "/repo/project");
51+
expect(toNumbers(beforeExpiry.get(memory.id))).toEqual([1, 0]);
52+
53+
await Bun.sleep(20);
54+
55+
const afterExpiry = getProjectEmbeddings(db, "/repo/project");
56+
expect(toNumbers(afterExpiry.get(memory.id))).toEqual([0, 1]);
57+
});
58+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { Database } from "bun:sqlite";
2+
import { loadAllEmbeddings } from "./storage-memory-embeddings";
3+
4+
interface ProjectEmbeddingCacheEntry {
5+
embeddings: Map<number, Float32Array>;
6+
expiresAt: number;
7+
}
8+
9+
const DEFAULT_EMBEDDING_CACHE_TTL_MS = 60_000;
10+
11+
const projectEmbeddingCache = new Map<string, ProjectEmbeddingCacheEntry>();
12+
13+
let embeddingCacheTtlMs = DEFAULT_EMBEDDING_CACHE_TTL_MS;
14+
15+
function getValidCacheEntry(projectPath: string): ProjectEmbeddingCacheEntry | null {
16+
const entry = projectEmbeddingCache.get(projectPath);
17+
if (!entry) {
18+
return null;
19+
}
20+
21+
if (entry.expiresAt <= Date.now()) {
22+
projectEmbeddingCache.delete(projectPath);
23+
return null;
24+
}
25+
26+
return entry;
27+
}
28+
29+
export function getProjectEmbeddings(db: Database, projectPath: string): Map<number, Float32Array> {
30+
const cached = getValidCacheEntry(projectPath);
31+
if (cached) {
32+
return cached.embeddings;
33+
}
34+
35+
const embeddings = loadAllEmbeddings(db, projectPath);
36+
projectEmbeddingCache.set(projectPath, {
37+
embeddings,
38+
expiresAt: Date.now() + embeddingCacheTtlMs,
39+
});
40+
return embeddings;
41+
}
42+
43+
export function peekProjectEmbeddings(projectPath: string): Map<number, Float32Array> | null {
44+
return getValidCacheEntry(projectPath)?.embeddings ?? null;
45+
}
46+
47+
export function invalidateProject(projectPath: string): void {
48+
projectEmbeddingCache.delete(projectPath);
49+
}
50+
51+
export function invalidateMemory(projectPath: string, memoryId: number): void {
52+
const cached = getValidCacheEntry(projectPath);
53+
cached?.embeddings.delete(memoryId);
54+
}
55+
56+
export function resetEmbeddingCacheForTests(): void {
57+
projectEmbeddingCache.clear();
58+
embeddingCacheTtlMs = DEFAULT_EMBEDDING_CACHE_TTL_MS;
59+
}
60+
61+
export function setEmbeddingCacheTtlForTests(ttlMs: number): void {
62+
embeddingCacheTtlMs = Math.max(0, Math.floor(ttlMs));
63+
}

src/features/magic-context/memory/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from "./constants";
22
export * from "./embedding";
33
export * from "./embedding-backfill";
4+
export * from "./embedding-cache";
45
export * from "./normalize-hash";
56
export { resolveProjectIdentity } from "./project-identity";
67
export { promoteSessionFactsToMemory } from "./promotion";

src/features/magic-context/memory/storage-memory.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ import {
1111
getMemoryByHash,
1212
getMemoryById,
1313
getMemoryCount,
14+
getProjectEmbeddings,
1415
getStoredModelId,
1516
insertMemory,
1617
loadAllEmbeddings,
18+
resetEmbeddingCacheForTests,
1719
saveEmbedding,
1820
searchMemoriesFTS,
1921
updateMemoryContent,
@@ -85,6 +87,7 @@ function makeMemoryDatabase(): Database {
8587
}
8688

8789
afterEach(() => {
90+
resetEmbeddingCacheForTests();
8891
if (db) {
8992
db.close(false);
9093
}
@@ -233,6 +236,46 @@ describe("storage-memory", () => {
233236
expect(updated?.normalizedHash).toBe(computeNormalizedHash("cache_ttl=10m"));
234237
expect(loadAllEmbeddings(db, "/repo/project")).toEqual(new Map());
235238
});
239+
240+
it("#when cache-sensitive writes occur #then embedding cache invalidates updated project entries", () => {
241+
db = makeMemoryDatabase();
242+
243+
const memory = insertMemory(db, {
244+
projectPath: "/repo/project",
245+
category: "CONFIG_DEFAULTS",
246+
content: "cache_ttl=5m",
247+
});
248+
saveEmbedding(db, memory.id, new Float32Array([0.1, 0.2]), "local:model-a");
249+
250+
const initialCache = getProjectEmbeddings(db, "/repo/project");
251+
expect(Array.from(initialCache.get(memory.id) ?? [])).toEqual(
252+
Array.from(new Float32Array([0.1, 0.2])),
253+
);
254+
255+
updateMemoryContent(
256+
db,
257+
memory.id,
258+
"cache_ttl=10m",
259+
computeNormalizedHash("cache_ttl=10m"),
260+
);
261+
262+
const cacheAfterUpdate = getProjectEmbeddings(db, "/repo/project");
263+
expect(cacheAfterUpdate.has(memory.id)).toBeFalse();
264+
265+
const secondMemory = insertMemory(db, {
266+
projectPath: "/repo/project",
267+
category: "CONFIG_DEFAULTS",
268+
content: "cache_ttl=15m",
269+
});
270+
saveEmbedding(db, secondMemory.id, new Float32Array([0.3, 0.4]), "local:model-a");
271+
272+
const cacheAfterInsert = getProjectEmbeddings(db, "/repo/project");
273+
expect(Array.from(cacheAfterInsert.keys())).toEqual([secondMemory.id]);
274+
275+
deleteMemory(db, secondMemory.id);
276+
277+
expect(getProjectEmbeddings(db, "/repo/project")).toEqual(new Map());
278+
});
236279
});
237280

238281
describe("#given FTS search", () => {

src/features/magic-context/memory/storage-memory.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Database } from "bun:sqlite";
2+
import { invalidateMemory, invalidateProject } from "./embedding-cache";
23
import { computeNormalizedHash } from "./normalize-hash";
34
import type {
45
Memory,
@@ -373,6 +374,8 @@ export function insertMemory(db: Database, input: MemoryInput): Memory {
373374
if (!inserted) {
374375
throw new Error("Failed to load inserted memory row");
375376
}
377+
378+
invalidateProject(input.projectPath);
376379
return inserted;
377380
}
378381

@@ -467,6 +470,8 @@ export function updateMemoryContent(
467470
content: string,
468471
normalizedHash: string,
469472
): void {
473+
const memory = getMemoryById(db, id);
474+
470475
db.transaction(() => {
471476
getUpdateMemoryContentStatement(db).run(content, normalizedHash, Date.now(), id);
472477

@@ -480,6 +485,10 @@ export function updateMemoryContent(
480485
}
481486
stmt.run(id);
482487
})();
488+
489+
if (memory) {
490+
invalidateMemory(memory.projectPath, id);
491+
}
483492
}
484493

485494
export function supersededMemory(db: Database, id: number, supersededById: number): void {
@@ -524,10 +533,16 @@ export function archiveMemory(db: Database, id: number, reason?: string): void {
524533
}
525534

526535
export function deleteMemory(db: Database, id: number): void {
536+
const memory = getMemoryById(db, id);
537+
527538
db.transaction(() => {
528539
getDeleteMemoryEmbeddingStatement(db).run(id);
529540
getDeleteMemoryStatement(db).run(id);
530541
})();
542+
543+
if (memory) {
544+
invalidateProject(memory.projectPath);
545+
}
531546
}
532547

533548
export function getMemoryCount(db: Database, projectPath?: string): number {

src/features/magic-context/search.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const rawMessagesBySession = new Map<
1111
>();
1212

1313
import { replaceSessionFacts } from "./compartment-storage";
14-
import { getMemoryById, insertMemory, saveEmbedding } from "./memory";
14+
import { getMemoryById, insertMemory, resetEmbeddingCacheForTests, saveEmbedding } from "./memory";
1515
import { unifiedSearch } from "./search";
1616
import { initializeDatabase } from "./storage-db";
1717

@@ -32,6 +32,7 @@ afterEach(() => {
3232
queryEmbedding = null;
3333
embeddingQueries.length = 0;
3434
rawMessagesBySession.clear();
35+
resetEmbeddingCacheForTests();
3536
});
3637

3738
describe("unifiedSearch", () => {
@@ -187,4 +188,38 @@ describe("unifiedSearch", () => {
187188
}),
188189
).toEqual([]);
189190
});
191+
192+
it("falls back to full semantic search when FTS finds no matches", async () => {
193+
const memory = insertMemory(db, {
194+
projectPath: "/repo/project",
195+
category: "ARCHITECTURE_DECISIONS",
196+
content: "alpha beta gamma",
197+
});
198+
saveEmbedding(db, memory.id, new Float32Array([0, 1]), "mock:model");
199+
queryEmbedding = new Float32Array([0, 1]);
200+
201+
const results = await unifiedSearch(
202+
db,
203+
"ses-semantic",
204+
"/repo/project",
205+
"vector-only query",
206+
{
207+
limit: 5,
208+
memoryEnabled: true,
209+
embeddingEnabled: true,
210+
readMessages,
211+
embedQuery,
212+
isEmbeddingRuntimeEnabled,
213+
},
214+
);
215+
216+
const memoryResults = results.filter(
217+
(result): result is Extract<(typeof results)[number], { source: "memory" }> =>
218+
result.source === "memory",
219+
);
220+
221+
expect(memoryResults).toHaveLength(1);
222+
expect(memoryResults[0]?.memoryId).toBe(memory.id);
223+
expect(memoryResults[0]?.matchType).toBe("semantic");
224+
});
190225
});

0 commit comments

Comments
 (0)