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:
+42
-43
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user