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:
john
2026-09-02 10:01:42 +08:00
parent 0b6f79ae16
commit 2c0d903ecf
3 changed files with 89 additions and 6 deletions
+26 -3
View File
@@ -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
View File
@@ -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) {
+43
View File
@@ -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({