Improve MemFuse recall via hybrid ranking and candidate generation.

Add RRF fusion with English word-level lexical scoring, tiered keyword fetch, and vector margin expansion (0.15/200) to fix pre-rank truncation; wire DashScope embedding bench path and update baseline to 28.8% recall@20.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-02 13:41:12 +08:00
parent b923e54eff
commit fb3a442e73
11 changed files with 1436 additions and 97 deletions
+42 -43
View File
@@ -4,8 +4,6 @@ import { readFile as fsReadFile } from 'node:fs/promises';
import { createPgvectorMemoryBackend, pgvectorMemoryBackendInternals } from './memory-v2-pgvector.mjs';
const { lexicalQueryCoverage } = pgvectorMemoryBackendInternals;
/**
* MemFuseBench retrieval harness for Memory V2.
*
@@ -327,17 +325,24 @@ function likeParamToTerm(param) {
* the `content ILIKE` keyword fallback. Emulating the SQL rather than bypassing
* it keeps the production ranking path under test.
*/
export function createCorpusPool({ rows, embedText, embeddingCache = new Map() }) {
export function createCorpusPool({ rows, embedText, embeddingCache = new Map(), benchQuery = '' } = {}) {
const corpus = asArray(rows);
const byRecency = [...corpus].sort((left, right) => right.timestampMs - left.timestampMs);
const activeQuery = String(benchQuery ?? '');
function embeddingFor(row) {
if (!embeddingCache.has(row.id)) {
embeddingCache.set(row.id, embedText(row.content));
const embedded = embedText(row.content);
embeddingCache.set(row.id, embedded);
}
return embeddingCache.get(row.id);
}
async function resolvedEmbeddingFor(row) {
const cached = embeddingFor(row);
return cached instanceof Promise ? cached : cached;
}
function toResultRow(row, score) {
return {
id: row.id,
@@ -374,23 +379,21 @@ export function createCorpusPool({ rows, embedText, embeddingCache = new Map() }
vectorQueryCount += 1;
const queryVector = parseVectorLiteral(params[1]);
const limit = Math.max(1, Number(params[2]) || 50);
const scored = corpus.map((row) => ({
row,
score: queryVector ? cosineSimilarity(queryVector, embeddingFor(row)) : 0,
}));
const scoreById = new Map(scored.map((entry) => [entry.row.id, entry.score]));
const vectorTop = [...scored]
.sort((left, right) => right.score - left.score)
.slice(0, limit)
.map((entry) => entry.row);
const recentTop = byRecency.slice(0, limit);
const merged = new Map();
for (const row of [...vectorTop, ...recentTop]) {
if (!merged.has(row.id)) merged.set(row.id, row);
const scored = [];
for (const row of corpus) {
const embedding = await resolvedEmbeddingFor(row);
scored.push({
row,
score: queryVector ? cosineSimilarity(queryVector, embedding) : 0,
});
}
const selected = pgvectorMemoryBackendInternals.selectVectorCandidateRows(scored, {
baseLimit: limit,
recentRows: byRecency,
});
return {
rows: trackRows(
[...merged.values()].map((row) => toResultRow(row, scoreById.get(row.id) ?? 0)),
selected.map(({ row, score }) => toResultRow(row, score)),
),
};
}
@@ -402,31 +405,15 @@ export function createCorpusPool({ rows, embedText, embeddingCache = new Map() }
.map(likeParamToTerm)
.filter(Boolean);
if (terms.length === 0) return { rows: [] };
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));
});
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);
const queryText = activeQuery || terms.join(' ');
const selected = pgvectorMemoryBackendInternals.selectKeywordCandidateRows(
corpus,
queryText,
terms,
{ returnLimit: limit },
);
return {
rows: trackRows(ranked.map(({ row, lexicalScore }) => toResultRow(row, lexicalScore))),
rows: trackRows(selected.map((row) => toResultRow(row, null))),
};
}
return { rows: [] };
@@ -451,7 +438,12 @@ export async function runMemFuseBenchCase({
candidateLimit = 100,
embeddingCache = new Map(),
}) {
const pool = createCorpusPool({ rows: corpus.rows, embedText, embeddingCache });
const pool = createCorpusPool({
rows: corpus.rows,
embedText,
embeddingCache,
benchQuery: testCase.question,
});
const backend = createPgvectorMemoryBackend({ enabled: true, embedQuery: embedText, pool });
const result = await backend.resolve({
userId: 'memfuse-bench-user',
@@ -533,6 +525,7 @@ export async function runMemFuseBench({
limit = 20,
candidateLimit = 100,
embedText = null,
prefetchEmbedTexts = null,
includeSourceTags = false,
onProgress = null,
} = {}) {
@@ -556,6 +549,12 @@ export async function runMemFuseBench({
});
if (cases.length === 0) continue;
const corpus = buildScenarioCorpus(scenario, { includeSourceTags });
if (typeof prefetchEmbedTexts === 'function') {
await prefetchEmbedTexts([
...corpus.rows.map((row) => row.content),
...cases.map((testCase) => testCase.question),
]);
}
// One cache per scenario: corpus embeddings are reused across that
// scenario's questions, which is where nearly all the cost sits.
const embeddingCache = new Map();