Improve pgvector keyword recall ranking without changing hybrid sort.
Rank ILIKE matches by lexical coverage instead of recency and attach lexical scores to keyword rows so MemFuseBench candidate recall rises sharply while Chinese hybrid ordering stays unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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: [] };
|
||||
},
|
||||
|
||||
+20
-3
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user