diff --git a/memory-v2-memfuse-bench.mjs b/memory-v2-memfuse-bench.mjs index 29039c8..95a7d8b 100644 --- a/memory-v2-memfuse-bench.mjs +++ b/memory-v2-memfuse-bench.mjs @@ -2,7 +2,9 @@ import os from 'node:os'; import path from 'node:path'; import { readFile as fsReadFile } from 'node:fs/promises'; -import { createPgvectorMemoryBackend } from './memory-v2-pgvector.mjs'; +import { createPgvectorMemoryBackend, pgvectorMemoryBackendInternals } from './memory-v2-pgvector.mjs'; + +const { lexicalQueryCoverage } = pgvectorMemoryBackendInternals; /** * MemFuseBench retrieval harness for Memory V2. @@ -400,11 +402,32 @@ export function createCorpusPool({ rows, embedText, embeddingCache = new Map() } .map(likeParamToTerm) .filter(Boolean); if (terms.length === 0) return { rows: [] }; - const matched = byRecency.filter((row) => { + const fetchCap = Math.min(500, Math.max(limit, limit * 10)); + const matched = corpus.filter((row) => { const haystack = row.content.toLowerCase(); return terms.some((term) => haystack.includes(term)); }); - return { rows: trackRows(matched.slice(0, limit).map((row) => toResultRow(row, 1))) }; + let pool = matched; + if (pool.length > fetchCap) { + pool = [...matched].sort((left, right) => String(left.id).localeCompare(String(right.id))) + .slice(0, fetchCap); + } + const ranked = pool + .map((row) => ({ + row, + lexicalScore: lexicalQueryCoverage(terms.join(' '), row.content), + timestampMs: row.timestampMs, + })) + .sort((left, right) => { + if (left.lexicalScore !== right.lexicalScore) { + return right.lexicalScore - left.lexicalScore; + } + return right.timestampMs - left.timestampMs; + }) + .slice(0, limit); + return { + rows: trackRows(ranked.map(({ row, lexicalScore }) => toResultRow(row, lexicalScore))), + }; } return { rows: [] }; }, diff --git a/memory-v2-pgvector.mjs b/memory-v2-pgvector.mjs index 966e1a0..0407a3b 100644 --- a/memory-v2-pgvector.mjs +++ b/memory-v2-pgvector.mjs @@ -85,6 +85,8 @@ function dedupeContentPrefix(text, length = 96) { return normalizeSearchText(String(text ?? '').slice(0, length)); } +const KEYWORD_FETCH_CAP = 500; + async function fetchKeywordCandidates(pool, { userId, query, @@ -96,17 +98,32 @@ async function fetchKeywordCandidates(pool, { if (!terms.length) return []; const clauses = terms.map((_term, index) => `content ILIKE $${index + 2}`); const params = [userId, ...terms.map((term) => `%${term}%`)]; + const safeLimit = Math.max(1, Math.min(50, Number(limit) || 20)); + const fetchCap = Math.min(KEYWORD_FETCH_CAP, Math.max(safeLimit, safeLimit * 10)); const sql = ` SELECT id, content, type, created_at, updated_at, 1.0 AS score FROM ${tableName} WHERE user_id = $1 AND (${clauses.join(' OR ')}) - ORDER BY updated_at DESC LIMIT $${params.length + 1} `; - params.push(Math.max(1, Math.min(50, Number(limit) || 20))); + params.push(fetchCap); const result = await pool.query(sql, params); - return result?.rows ?? []; + const rows = result?.rows ?? []; + return rows + .map((row) => ({ + row, + lexicalScore: lexicalQueryCoverage(query, row.content ?? ''), + updatedAt: timestampValue(row.updated_at ?? row.created_at), + })) + .sort((left, right) => { + if (left.lexicalScore !== right.lexicalScore) { + return right.lexicalScore - left.lexicalScore; + } + return right.updatedAt - left.updatedAt; + }) + .slice(0, safeLimit) + .map(({ row, lexicalScore }) => ({ ...row, score: lexicalScore })); } function timestampValue(value) { diff --git a/memory-v2-pgvector.test.mjs b/memory-v2-pgvector.test.mjs index 7b4c94a..aef9651 100644 --- a/memory-v2-pgvector.test.mjs +++ b/memory-v2-pgvector.test.mjs @@ -162,6 +162,49 @@ test('pgvector hybrid ranking keeps vector order when query has no lexical overl assert.equal(ranked[1].id, '1'); }); +test('pgvector keyword fallback ranks ILIKE matches by lexical coverage, not recency', async () => { + const backend = createPgvectorMemoryBackend({ + enabled: true, + pool: { + async query(sql) { + if (String(sql).includes('ILIKE')) { + assert.doesNotMatch(String(sql), /ORDER BY updated_at DESC/i); + return { + rows: [ + { + id: 902, + content: 'Recent but weak match for curtains only', + type: 'noise', + score: 1, + created_at: '2026-09-01T00:00:00.000Z', + updated_at: '2026-09-01T00:00:00.000Z', + }, + { + id: 901, + content: 'Sarah closed the smart curtains to reduce pollen entry', + type: 'fact', + score: 1, + created_at: '2026-05-01T00:00:00.000Z', + updated_at: '2026-05-01T00:00:00.000Z', + }, + ], + }; + } + return { rows: [] }; + }, + }, + embedQuery: async () => [0.25, 0.5, 0.75], + }); + + const result = await backend.resolve({ + userId: 'user-1', + query: 'Why did Sarah close the curtains?', + limit: 1, + }); + + assert.match(result.memories[0].text, /Sarah closed the smart curtains/); +}); + test('pgvector keyword fallback retrieves topic memories missed by vector top-k', async () => { const queries = []; const backend = createPgvectorMemoryBackend({