Skip to content

Commit 64991bd

Browse files
committed
perf(memory): optimize addMessage session upsert and add coverage
1 parent d953bd9 commit 64991bd

2 files changed

Lines changed: 159 additions & 21 deletions

File tree

src/memory.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { afterEach, beforeEach, expect, test } from "bun:test";
2+
import { Database } from "bun:sqlite";
3+
import { addMessage, initializeMemoryTables } from "./memory";
4+
5+
let db: Database;
6+
7+
beforeEach(() => {
8+
db = new Database(":memory:");
9+
db.exec("PRAGMA foreign_keys = ON");
10+
initializeMemoryTables(db);
11+
});
12+
13+
afterEach(() => {
14+
db.close();
15+
});
16+
17+
test("addMessage creates session once and does not overwrite existing title", async () => {
18+
await addMessage(db, "s1", "user", "first", { title: "Initial Title" });
19+
await addMessage(db, "s1", "assistant", "second", { title: "New Title" });
20+
21+
const session = db
22+
.prepare(`SELECT id, title FROM memory_sessions WHERE id = ?`)
23+
.get("s1") as { id: string; title: string } | null;
24+
const sessionCount = (
25+
db.prepare(`SELECT COUNT(*) AS count FROM memory_sessions WHERE id = ?`).get("s1") as {
26+
count: number;
27+
}
28+
).count;
29+
const messageCount = (
30+
db.prepare(`SELECT COUNT(*) AS count FROM memory_messages WHERE session_id = ?`).get("s1") as {
31+
count: number;
32+
}
33+
).count;
34+
35+
expect(session).not.toBeNull();
36+
expect(session?.title).toBe("Initial Title");
37+
expect(sessionCount).toBe(1);
38+
expect(messageCount).toBe(2);
39+
});
40+
41+
test("addMessage backfills empty title when provided later", async () => {
42+
const now = new Date().toISOString();
43+
db.prepare(
44+
`INSERT INTO memory_sessions (id, title, created_at, updated_at, active) VALUES (?, ?, ?, ?, 1)`
45+
).run("s-empty", "", now, now);
46+
47+
await addMessage(db, "s-empty", "user", "hello", { title: "Backfilled Title" });
48+
49+
const session = db
50+
.prepare(`SELECT title FROM memory_sessions WHERE id = ?`)
51+
.get("s-empty") as { title: string } | null;
52+
expect(session?.title).toBe("Backfilled Title");
53+
});
54+
55+
test("addMessage with sessionId 'new' generates a concrete session id", async () => {
56+
const message = await addMessage(db, "new", "user", "hello");
57+
expect(message.session_id).not.toBe("new");
58+
expect(message.session_id.length).toBe(8);
59+
60+
const session = db
61+
.prepare(`SELECT id FROM memory_sessions WHERE id = ?`)
62+
.get(message.session_id) as { id: string } | null;
63+
expect(session?.id).toBe(message.session_id);
64+
});

src/memory.ts

Lines changed: 95 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ export type MemorySearchResult = {
6464
source: "fts" | "vec";
6565
};
6666

67+
type RecallTimings = {
68+
ftsMs: number;
69+
vecMs: number;
70+
fuseMs: number;
71+
dedupeMs: number;
72+
totalMs: number;
73+
};
74+
6775
// =============================================================================
6876
// Schema Initialization
6977
// =============================================================================
@@ -102,6 +110,7 @@ export function initializeMemoryTables(db: Database): void {
102110

103111
db.exec(`CREATE INDEX IF NOT EXISTS idx_memory_messages_session ON memory_messages(session_id)`);
104112
db.exec(`CREATE INDEX IF NOT EXISTS idx_memory_messages_hash ON memory_messages(hash)`);
113+
db.exec(`CREATE INDEX IF NOT EXISTS idx_memory_sessions_active ON memory_sessions(active, id)`);
105114

106115
// FTS5 for memory search
107116
db.exec(`
@@ -263,19 +272,26 @@ export async function addMessage(
263272
const now = new Date().toISOString();
264273
const hash = await hashContent(content);
265274

266-
// Ensure session exists (createSession may generate a new ID for "new")
267-
let session = getSession(db, sessionId);
268-
if (!session) {
269-
session = createSession(db, sessionId, options.title || "");
270-
} else if (options.title && !session.title) {
271-
db.prepare(`UPDATE memory_sessions SET title = ? WHERE id = ?`).run(
272-
options.title,
273-
sessionId
274-
);
275+
// Preserve "new" behavior, which generates an ID.
276+
let resolvedSessionId = sessionId;
277+
if (sessionId === "new") {
278+
resolvedSessionId = crypto.randomUUID().slice(0, 8);
275279
}
276280

277-
// Use the resolved session ID (may differ from input if "new" was passed)
278-
const resolvedSessionId = session.id;
281+
// Ensure session exists without a pre-read on every message.
282+
db.prepare(
283+
`INSERT OR IGNORE INTO memory_sessions (id, title, created_at, updated_at, active)
284+
VALUES (?, ?, ?, ?, 1)`
285+
).run(resolvedSessionId, options.title || "", now, now);
286+
287+
// If title is provided later, fill it only when current title is empty.
288+
if (options.title) {
289+
db.prepare(
290+
`UPDATE memory_sessions
291+
SET title = ?
292+
WHERE id = ? AND (title = '' OR title IS NULL)`
293+
).run(options.title, resolvedSessionId);
294+
}
279295

280296
const metadataStr = options.metadata
281297
? JSON.stringify(options.metadata)
@@ -347,24 +363,37 @@ export function searchMemoryFTS(
347363
): MemorySearchResult[] {
348364
const ftsQuery = buildMemoryFTS5Query(query);
349365
if (!ftsQuery) return [];
366+
const candidateLimit = Math.max(limit * 3, limit);
350367

368+
// Rank candidate rowids in FTS first, then join to payload tables.
369+
// This keeps the expensive bm25 ordering on the smallest possible row shape.
351370
const sql = `
371+
WITH ranked AS (
372+
SELECT
373+
rowid,
374+
bm25(memory_fts, 5.0, 1.0, 1.0) as bm25_score
375+
FROM memory_fts
376+
WHERE memory_fts MATCH ?
377+
ORDER BY bm25_score ASC
378+
LIMIT ?
379+
)
352380
SELECT
353381
m.session_id,
354382
s.title as session_title,
355383
m.id as message_id,
356384
m.role,
357385
m.content,
358-
bm25(memory_fts, 5.0, 1.0, 1.0) as bm25_score
359-
FROM memory_fts f
360-
JOIN memory_messages m ON m.id = f.rowid
386+
r.bm25_score
387+
FROM ranked r
388+
JOIN memory_messages m ON m.id = r.rowid
361389
JOIN memory_sessions s ON s.id = m.session_id
362-
WHERE memory_fts MATCH ? AND s.active = 1
363-
ORDER BY bm25_score ASC
390+
WHERE s.active = 1
391+
ORDER BY r.bm25_score ASC
364392
LIMIT ?
365393
`;
366394

367-
const rows = db.prepare(sql).all(ftsQuery, limit) as {
395+
const stmt = getMemoryFtsStmt(db, sql);
396+
const rows = stmt.all(ftsQuery, candidateLimit, limit) as {
368397
session_id: string;
369398
session_title: string;
370399
message_id: number;
@@ -385,6 +414,17 @@ export function searchMemoryFTS(
385414
}));
386415
}
387416

417+
const memoryFtsStmtCache = new WeakMap<Database, ReturnType<Database["prepare"]>>();
418+
419+
function getMemoryFtsStmt(db: Database, sql: string) {
420+
let stmt = memoryFtsStmtCache.get(db);
421+
if (!stmt) {
422+
stmt = db.prepare(sql);
423+
memoryFtsStmtCache.set(db, stmt);
424+
}
425+
return stmt;
426+
}
427+
388428
/**
389429
* Search memory using vector similarity.
390430
* Two-step pattern: query vectors_vec first, then JOIN separately.
@@ -654,16 +694,22 @@ export async function recallMemories(
654694
maxTokens?: number;
655695
} = {}
656696
): Promise<{ results: MemorySearchResult[]; synthesis?: string }> {
697+
const startedAt = performance.now();
698+
const shouldTraceRecall = process.env.SMRITI_BENCH_TRACE === "1";
657699
const limit = options.limit ?? 10;
658700

659701
// Run FTS and vector search
702+
const ftsStartedAt = performance.now();
660703
const ftsResults = searchMemoryFTS(db, query, limit);
704+
const ftsMs = performance.now() - ftsStartedAt;
661705
let vecResults: MemorySearchResult[] = [];
706+
const vecStartedAt = performance.now();
662707
try {
663708
vecResults = await searchMemoryVec(db, query, limit);
664709
} catch {
665710
// Vector search may fail if no embeddings exist
666711
}
712+
const vecMs = performance.now() - vecStartedAt;
667713

668714
// Convert to RankedResult format for RRF
669715
const toRanked = (results: MemorySearchResult[]): RankedResult[] =>
@@ -676,24 +722,33 @@ export async function recallMemories(
676722
}));
677723

678724
// Fuse results with RRF
725+
const fuseStartedAt = performance.now();
679726
const fused = reciprocalRankFusion(
680727
[toRanked(ftsResults), toRanked(vecResults)],
681728
[1.0, 1.0]
682729
);
730+
const fuseMs = performance.now() - fuseStartedAt;
683731

684732
// Deduplicate by session, keeping best score per session
733+
const dedupeStartedAt = performance.now();
685734
const sessionSeen = new Map<string, boolean>();
686735
const dedupedResults: MemorySearchResult[] = [];
736+
const originalByKey = new Map<string, MemorySearchResult>();
737+
for (const result of ftsResults) {
738+
originalByKey.set(`${result.session_id}:${result.message_id}`, result);
739+
}
740+
for (const result of vecResults) {
741+
const key = `${result.session_id}:${result.message_id}`;
742+
// Prefer vector entry if both are present because it typically carries the better semantic score.
743+
originalByKey.set(key, result);
744+
}
687745

688746
for (const r of fused) {
689747
const [sessionId] = r.file.split(":");
690748
if (!sessionId) continue;
691749

692750
// Find the original result to preserve all fields
693-
const original =
694-
[...ftsResults, ...vecResults].find(
695-
(o) => `${o.session_id}:${o.message_id}` === r.file
696-
) || null;
751+
const original = originalByKey.get(r.file) ?? null;
697752

698753
if (original && !sessionSeen.has(sessionId)) {
699754
sessionSeen.set(sessionId, true);
@@ -711,6 +766,7 @@ export async function recallMemories(
711766
});
712767
}
713768
}
769+
const dedupeMs = performance.now() - dedupeStartedAt;
714770

715771
const results = dedupedResults.slice(0, limit);
716772

@@ -730,6 +786,24 @@ export async function recallMemories(
730786
});
731787
}
732788

789+
if (shouldTraceRecall) {
790+
const timings: RecallTimings = {
791+
ftsMs,
792+
vecMs,
793+
fuseMs,
794+
dedupeMs,
795+
totalMs: performance.now() - startedAt,
796+
};
797+
console.error(
798+
`[recall.trace] q="${query.slice(0, 64)}" ` +
799+
`fts=${timings.ftsMs.toFixed(3)}ms ` +
800+
`vec=${timings.vecMs.toFixed(3)}ms ` +
801+
`fuse=${timings.fuseMs.toFixed(3)}ms ` +
802+
`dedupe=${timings.dedupeMs.toFixed(3)}ms ` +
803+
`total=${timings.totalMs.toFixed(3)}ms`
804+
);
805+
}
806+
733807
return { results, synthesis };
734808
}
735809

0 commit comments

Comments
 (0)