Files
memind/memory-v2-memfuse-bench.mjs
T
john 2c0d903ecf 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>
2026-09-02 10:01:42 +08:00

624 lines
22 KiB
JavaScript

import os from 'node:os';
import path from 'node:path';
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.
*
* MemFuseBench (Mi-Memory / Darwin Agent Team, MIT) ships 357 evidence-grounded
* questions over 7,823 source-tagged events. This module turns those scenarios
* into a corpus + case list and runs them through the *production* pgvector
* backend so the measured numbers reflect `rankHybridCandidates` and
* `extractKeywordTerms`, not a parallel scoring implementation.
*
* The dataset itself is never vendored into this repository. It is read from an
* external checkout and every entry point degrades to
* `{ available: false, reason }` when the file is absent, so CI stays green
* without it.
*/
export const MEMFUSE_DATASET_ENV = 'MEMFUSE_BENCH_DATASET';
export const MEMFUSE_DIMENSIONS = Object.freeze([
'cross_device_causal_reasoning',
'cross_device_information_fusion',
'multi_source_conflict_arbitration',
'cross_user_information_synthesis',
'cross_user_query',
'perspective_difference',
]);
// Events tagged with these sources are adversarial by construction: they look
// topical but are never part of any answer's evidence set.
export const MEMFUSE_DISTRACTOR_SOURCES = Object.freeze(['noise', 'adversarial']);
const DEFAULT_RELATIVE_DATASET_PATH = path.join(
'mi-memory',
'MemFuse',
'MemFuseBench',
'memfusebench_dataset.json',
);
export function resolveMemFuseDatasetPath(env = process.env) {
const explicit = String(env?.[MEMFUSE_DATASET_ENV] ?? '').trim();
if (explicit) return path.resolve(explicit);
const projectRoot = String(env?.MEMFUSE_PROJECT_ROOT ?? '').trim();
const base = projectRoot || path.join(os.homedir(), 'Project');
return path.join(base, DEFAULT_RELATIVE_DATASET_PATH);
}
function asArray(value) {
return Array.isArray(value) ? value : [];
}
function nonEmptyString(value) {
const text = String(value ?? '').trim();
return text.length > 0 ? text : null;
}
export function normalizeMemFuseDataset(raw) {
const scenarios = asArray(raw?.scenarios)
.map((scenario) => {
const scenarioId = nonEmptyString(scenario?.scenario_id);
if (!scenarioId) return null;
const episodes = asArray(scenario?.episodes)
.map((episode) => {
const episodeId = nonEmptyString(episode?.episode_id);
const events = asArray(episode?.events)
.map((event) => {
const eventId = nonEmptyString(event?.event_id);
const description = nonEmptyString(event?.description);
if (!eventId || !description) return null;
return {
eventId,
description,
device: nonEmptyString(event?.device),
modality: nonEmptyString(event?.modality) ?? 'event',
location: nonEmptyString(event?.location),
source: nonEmptyString(event?.source) ?? 'unknown',
timestamp: nonEmptyString(event?.timestamp),
characters: asArray(event?.characters).map(String),
};
})
.filter(Boolean);
if (!episodeId || events.length === 0) return null;
return { episodeId, events };
})
.filter(Boolean);
const questions = asArray(scenario?.questions)
.map((question) => {
const questionId = nonEmptyString(question?.question_id);
const text = nonEmptyString(question?.question);
const evidenceEventIds = [
...new Set(asArray(question?.evidence_event_ids).map(String).filter(Boolean)),
];
if (!questionId || !text || evidenceEventIds.length === 0) return null;
const checklist = asArray(question?.answer_checklist)
.map((entry) => ({
point: nonEmptyString(entry?.point) ?? '',
sourceEvents: [
...new Set(asArray(entry?.source_events).map(String).filter(Boolean)),
],
}))
.filter((entry) => entry.sourceEvents.length > 0);
return {
questionId,
scenarioId,
question: text,
evidenceEventIds,
checklist,
dimension: nonEmptyString(question?.dimension) ?? 'unknown',
questionUser: nonEmptyString(question?.question_user),
questionTime: nonEmptyString(question?.question_time),
questionDevice: nonEmptyString(question?.question_device),
};
})
.filter(Boolean);
if (episodes.length === 0 || questions.length === 0) return null;
return {
scenarioId,
description: nonEmptyString(scenario?.description),
timeSpan: nonEmptyString(scenario?.time_span),
episodes,
questions,
};
})
.filter(Boolean);
if (scenarios.length === 0) {
return { ok: false, reason: 'dataset_has_no_usable_scenarios' };
}
const eventCount = scenarios.reduce(
(total, scenario) =>
total + scenario.episodes.reduce((sum, episode) => sum + episode.events.length, 0),
0,
);
const questionCount = scenarios.reduce(
(total, scenario) => total + scenario.questions.length,
0,
);
return {
ok: true,
dataset: {
scenarios,
stats: { scenarioCount: scenarios.length, eventCount, questionCount },
},
};
}
export async function loadMemFuseDataset({
datasetPath = null,
env = process.env,
readFile = fsReadFile,
} = {}) {
const resolvedPath = datasetPath ? path.resolve(datasetPath) : resolveMemFuseDatasetPath(env);
let payload;
try {
payload = await readFile(resolvedPath, 'utf8');
} catch (err) {
return {
available: false,
path: resolvedPath,
reason: err?.code === 'ENOENT' ? 'dataset_not_found' : 'dataset_read_failed',
error: err instanceof Error ? err.message : String(err),
};
}
let raw;
try {
raw = JSON.parse(payload);
} catch (err) {
return {
available: false,
path: resolvedPath,
reason: 'dataset_parse_failed',
error: err instanceof Error ? err.message : String(err),
};
}
const normalized = normalizeMemFuseDataset(raw);
if (!normalized.ok) {
return { available: false, path: resolvedPath, reason: normalized.reason };
}
return { available: true, path: resolvedPath, ...normalized.dataset };
}
/**
* Flatten a scenario into rows shaped like the `memory_embeddings` table so the
* pgvector backend can consume them unchanged.
*
* `includeSourceTags` prepends `[device · location]`. It defaults to false so the
* baseline measures retrieval over raw event text rather than over a prefix
* format chosen here.
*/
export function buildScenarioCorpus(scenario, { includeSourceTags = false } = {}) {
const rows = [];
const byId = new Map();
for (const episode of asArray(scenario?.episodes)) {
for (const event of asArray(episode?.events)) {
const tags = [event.device, event.location].filter(Boolean).join(' · ');
const content =
includeSourceTags && tags ? `[${tags}] ${event.description}` : event.description;
const timestampMs = event.timestamp ? Date.parse(event.timestamp) : NaN;
const isoTimestamp = Number.isFinite(timestampMs)
? new Date(timestampMs).toISOString()
: null;
const row = {
id: event.eventId,
content,
type: event.modality,
created_at: isoTimestamp,
updated_at: isoTimestamp,
episodeId: episode.episodeId,
source: event.source,
device: event.device,
location: event.location,
timestampMs: Number.isFinite(timestampMs) ? timestampMs : 0,
};
rows.push(row);
byId.set(row.id, row);
}
}
return { rows, byId };
}
export function buildScenarioCases(
scenario,
{ dimensions = null, maxQuestions = null, questionIds = null } = {},
) {
const dimensionFilter = dimensions?.length ? new Set(dimensions) : null;
const idFilter = questionIds?.length ? new Set(questionIds) : null;
const cases = asArray(scenario?.questions).filter((question) => {
if (dimensionFilter && !dimensionFilter.has(question.dimension)) return false;
if (idFilter && !idFilter.has(question.questionId)) return false;
return true;
});
const capped = Number(maxQuestions);
return Number.isFinite(capped) && capped > 0 ? cases.slice(0, capped) : cases;
}
const CJK_RANGE = /[\u4e00-\u9fff]/u;
function tokenizeForEmbedding(text) {
const normalized = String(text ?? '')
.normalize('NFKC')
.toLowerCase();
const tokens = [];
for (const match of normalized.matchAll(/[a-z0-9]{2,}/g)) tokens.push(match[0]);
if (CJK_RANGE.test(normalized)) {
const cjk = normalized.replace(/[^\u4e00-\u9fff]/gu, '');
for (let index = 0; index + 2 <= cjk.length; index += 1) {
tokens.push(cjk.slice(index, index + 2));
}
}
return tokens;
}
function hashToken(token, dimensions) {
let hash = 2166136261;
for (let index = 0; index < token.length; index += 1) {
hash ^= token.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return Math.abs(hash) % dimensions;
}
/**
* Deterministic offline stand-in for a sentence embedder: hashed bag-of-tokens,
* L2 normalized. Cosine over these vectors approximates lexical overlap, NOT
* semantic similarity, so reports built on it are labelled `lexical-hash`. Pass
* a real `embedText` to measure semantic recall.
*/
export function createLexicalHashEmbedder({ dimensions = 256 } = {}) {
const size = Math.max(16, Math.min(4096, Number(dimensions) || 256));
return function embedText(text) {
const vector = new Array(size).fill(0);
const tokens = tokenizeForEmbedding(text);
if (tokens.length === 0) return vector;
for (const token of tokens) {
vector[hashToken(token, size)] += 1;
}
let norm = 0;
for (const value of vector) norm += value * value;
norm = Math.sqrt(norm);
if (norm === 0) return vector;
for (let index = 0; index < size; index += 1) vector[index] /= norm;
return vector;
};
}
function cosineSimilarity(left, right) {
const length = Math.min(left.length, right.length);
let dot = 0;
let leftNorm = 0;
let rightNorm = 0;
for (let index = 0; index < length; index += 1) {
dot += left[index] * right[index];
leftNorm += left[index] * left[index];
rightNorm += right[index] * right[index];
}
if (leftNorm === 0 || rightNorm === 0) return 0;
return dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm));
}
function parseVectorLiteral(literal) {
const text = String(literal ?? '').trim();
if (!text.startsWith('[') || !text.endsWith(']')) return null;
const inner = text.slice(1, -1);
if (!inner) return [];
const parts = inner.split(',').map((part) => Number(part));
return parts.some((part) => !Number.isFinite(part)) ? null : parts;
}
function likeParamToTerm(param) {
return String(param ?? '')
.replace(/^%+/, '')
.replace(/%+$/, '')
.toLowerCase();
}
/**
* Fake pg pool that emulates the two query shapes issued by
* `createPgvectorMemoryBackend.resolve`: the vector/recent candidate union and
* 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() }) {
const corpus = asArray(rows);
const byRecency = [...corpus].sort((left, right) => right.timestampMs - left.timestampMs);
function embeddingFor(row) {
if (!embeddingCache.has(row.id)) {
embeddingCache.set(row.id, embedText(row.content));
}
return embeddingCache.get(row.id);
}
function toResultRow(row, score) {
return {
id: row.id,
content: row.content,
type: row.type,
created_at: row.created_at,
updated_at: row.updated_at,
score,
};
}
let vectorQueryCount = 0;
let keywordQueryCount = 0;
// Every id handed back to the backend, i.e. the candidate pool that ranking
// then narrows to top-k. Tracking it separates "candidate generation lost the
// evidence" from "ranking lost the evidence".
const returnedIds = new Set();
function trackRows(rows) {
for (const row of rows) returnedIds.add(row.id);
return rows;
}
return {
stats: () => ({
vectorQueryCount,
keywordQueryCount,
corpusSize: corpus.length,
candidateIds: new Set(returnedIds),
}),
async query(sql, params = []) {
const text = String(sql ?? '');
if (text.includes('vector_candidates')) {
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);
}
return {
rows: trackRows(
[...merged.values()].map((row) => toResultRow(row, scoreById.get(row.id) ?? 0)),
),
};
}
if (text.includes('ILIKE')) {
keywordQueryCount += 1;
const limit = Math.max(1, Number(params[params.length - 1]) || 20);
const terms = params
.slice(1, params.length - 1)
.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);
return {
rows: trackRows(ranked.map(({ row, lexicalScore }) => toResultRow(row, lexicalScore))),
};
}
return { rows: [] };
},
};
}
function checklistCoverage(checklist, retrievedIds) {
if (!checklist?.length) return null;
let covered = 0;
for (const entry of checklist) {
if (entry.sourceEvents.some((eventId) => retrievedIds.has(eventId))) covered += 1;
}
return covered / checklist.length;
}
export async function runMemFuseBenchCase({
testCase,
corpus,
embedText,
limit = 20,
candidateLimit = 100,
embeddingCache = new Map(),
}) {
const pool = createCorpusPool({ rows: corpus.rows, embedText, embeddingCache });
const backend = createPgvectorMemoryBackend({ enabled: true, embedQuery: embedText, pool });
const result = await backend.resolve({
userId: 'memfuse-bench-user',
query: testCase.question,
limit,
candidateLimit,
});
const retrieved = result.memories.map((memory) => memory.id).filter(Boolean);
const retrievedIds = new Set(retrieved);
const gold = new Set(testCase.evidenceEventIds);
const hits = retrieved.filter((id) => gold.has(id));
const firstHitIndex = retrieved.findIndex((id) => gold.has(id));
const distractors = retrieved.filter((id) =>
MEMFUSE_DISTRACTOR_SOURCES.includes(corpus.byId.get(id)?.source),
);
const { candidateIds } = pool.stats();
const candidateHits = [...gold].filter((id) => candidateIds.has(id));
const candidateRecall = gold.size > 0 ? candidateHits.length / gold.size : 0;
const recall = gold.size > 0 ? hits.length / gold.size : 0;
return {
questionId: testCase.questionId,
scenarioId: testCase.scenarioId,
dimension: testCase.dimension,
goldCount: gold.size,
returned: retrieved.length,
hitCount: hits.length,
recall,
precision: retrieved.length > 0 ? hits.length / retrieved.length : 0,
hitAny: hits.length > 0,
candidateCount: candidateIds.size,
candidateHitCount: candidateHits.length,
candidateRecall,
// Positive value = evidence reached the candidate pool but ranking dropped
// it. This is the split that decides whether to fix retrieval or ranking.
rankingLoss: Math.max(0, candidateRecall - recall),
firstHitRank: firstHitIndex >= 0 ? firstHitIndex + 1 : null,
reciprocalRank: firstHitIndex >= 0 ? 1 / (firstHitIndex + 1) : 0,
checklistCoverage: checklistCoverage(testCase.checklist, retrievedIds),
distractorCount: distractors.length,
};
}
function mean(values) {
const usable = values.filter((value) => Number.isFinite(value));
if (usable.length === 0) return 0;
return usable.reduce((total, value) => total + value, 0) / usable.length;
}
function aggregate(results) {
const coverage = results
.map((item) => item.checklistCoverage)
.filter((value) => Number.isFinite(value));
return {
caseCount: results.length,
recallAtK: mean(results.map((item) => item.recall)),
precisionAtK: mean(results.map((item) => item.precision)),
candidateRecall: mean(results.map((item) => item.candidateRecall)),
rankingLoss: mean(results.map((item) => item.rankingLoss)),
hitAnyRate: results.length
? results.filter((item) => item.hitAny).length / results.length
: 0,
checklistCoverage: coverage.length ? mean(coverage) : null,
mrr: mean(results.map((item) => item.reciprocalRank)),
distractorRate: results.length
? mean(results.map((item) => (item.returned ? item.distractorCount / item.returned : 0)))
: 0,
};
}
export async function runMemFuseBench({
dataset,
scenarioIds = null,
dimensions = null,
maxQuestionsPerScenario = null,
questionIds = null,
limit = 20,
candidateLimit = 100,
embedText = null,
includeSourceTags = false,
onProgress = null,
} = {}) {
const scenarios = asArray(dataset?.scenarios);
if (scenarios.length === 0) throw new Error('runMemFuseBench requires a loaded dataset');
const embedder = typeof embedText === 'function' ? embedText : createLexicalHashEmbedder();
const embeddingMode = typeof embedText === 'function' ? 'external' : 'lexical-hash';
const scenarioFilter = scenarioIds?.length ? new Set(scenarioIds) : null;
// The pgvector backend clamps its own limit to 50; mirror it so the reported
// `limit` never overstates how many rows were actually considered.
const effectiveLimit = Math.max(1, Math.min(50, Number(limit) || 20));
const results = [];
const scenarioReports = [];
for (const scenario of scenarios) {
if (scenarioFilter && !scenarioFilter.has(scenario.scenarioId)) continue;
const cases = buildScenarioCases(scenario, {
dimensions,
maxQuestions: maxQuestionsPerScenario,
questionIds,
});
if (cases.length === 0) continue;
const corpus = buildScenarioCorpus(scenario, { includeSourceTags });
// 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();
const scenarioResults = [];
for (const testCase of cases) {
const caseResult = await runMemFuseBenchCase({
testCase,
corpus,
embedText: embedder,
limit: effectiveLimit,
candidateLimit,
embeddingCache,
});
scenarioResults.push(caseResult);
results.push(caseResult);
if (typeof onProgress === 'function') {
onProgress({ scenarioId: scenario.scenarioId, completed: results.length, ...caseResult });
}
}
scenarioReports.push({
scenarioId: scenario.scenarioId,
corpusSize: corpus.rows.length,
...aggregate(scenarioResults),
});
}
if (results.length === 0) throw new Error('runMemFuseBench selected zero questions');
const byDimension = {};
for (const dimension of new Set(results.map((item) => item.dimension))) {
byDimension[dimension] = aggregate(results.filter((item) => item.dimension === dimension));
}
return {
limit: effectiveLimit,
candidateLimit,
embeddingMode,
includeSourceTags,
overall: aggregate(results),
byScenario: scenarioReports,
byDimension,
results,
};
}
export function summarizeMemFuseBench(report) {
const round = (value) => (Number.isFinite(value) ? Number(value.toFixed(4)) : null);
return {
limit: report.limit,
embeddingMode: report.embeddingMode,
caseCount: report.overall.caseCount,
recallAtK: round(report.overall.recallAtK),
precisionAtK: round(report.overall.precisionAtK),
candidateRecall: round(report.overall.candidateRecall),
rankingLoss: round(report.overall.rankingLoss),
hitAnyRate: round(report.overall.hitAnyRate),
checklistCoverage: round(report.overall.checklistCoverage),
mrr: round(report.overall.mrr),
distractorRate: round(report.overall.distractorRate),
weakestDimension:
Object.entries(report.byDimension)
.sort((left, right) => left[1].recallAtK - right[1].recallAtK)
.map(([dimension]) => dimension)[0] ?? null,
};
}