fix(memory-v2): improve pgvector recall and WeChat memory injection

Add keyword fallback and dedupe for pgvector resolve, score personal vs episodic memories by relevance, record WeChat recall product events, and disable broken chatrecall on Postgres session storage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-01 22:26:47 +08:00
parent dac06753ce
commit dcf83e9b6f
8 changed files with 286 additions and 16 deletions
+76 -2
View File
@@ -53,6 +53,62 @@ function lexicalQueryCoverage(query, text) {
return overlap / queryGrams.size;
}
const KEYWORD_STOP_TERMS = new Set([
'记得', '忘记', '之前', '我们', '聊过', '讨论', '继续', '聊聊', '什么', '吗', '呢',
'有没有', '是否', '告诉', '提到', '说过', '以前', '上次', '对话', '会话', '回忆',
'搜索', '帮助', '可以', '一下', '还是', '然后', '现在', '今天', '晚上', '你好',
]);
export function extractKeywordTerms(query, { maxTerms = 8, minLength = 2 } = {}) {
const normalized = String(query ?? '').normalize('NFKC').trim();
if (!normalized) return [];
const terms = new Set();
const cjkOnly = normalized.replace(/[^\u4e00-\u9fff]/gu, '');
for (let index = 0; index < cjkOnly.length; index += 1) {
for (const size of [4, 3, 2]) {
if (index + size > cjkOnly.length) continue;
const term = cjkOnly.slice(index, index + size);
if (term.length >= minLength && !KEYWORD_STOP_TERMS.has(term)) {
terms.add(term);
}
}
}
for (const match of normalized.matchAll(/[a-z0-9]{3,}/gi)) {
terms.add(match[0].toLowerCase());
}
return [...terms]
.sort((left, right) => right.length - left.length)
.slice(0, maxTerms);
}
function dedupeContentPrefix(text, length = 96) {
return normalizeSearchText(String(text ?? '').slice(0, length));
}
async function fetchKeywordCandidates(pool, {
userId,
query,
tableName,
limit = 20,
maxTerms = 8,
} = {}) {
const terms = extractKeywordTerms(query, { maxTerms });
if (!terms.length) return [];
const clauses = terms.map((_term, index) => `content ILIKE $${index + 2}`);
const params = [userId, ...terms.map((term) => `%${term}%`)];
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)));
const result = await pool.query(sql, params);
return result?.rows ?? [];
}
function timestampValue(value) {
if (value == null) return 0;
const numeric = Number(value);
@@ -76,9 +132,13 @@ function normalizeRow(row) {
function rankHybridCandidates(rows, query, limit) {
const byId = new Map();
const byPrefix = new Set();
for (const row of rows ?? []) {
const memory = normalizeRow(row);
if (!memory) continue;
const prefixKey = dedupeContentPrefix(memory.text);
if (prefixKey && byPrefix.has(prefixKey)) continue;
if (prefixKey) byPrefix.add(prefixKey);
const key = memory.id ?? `${memory.label}:${memory.text}`;
if (!byId.has(key)) byId.set(key, memory);
}
@@ -171,8 +231,20 @@ export function createPgvectorMemoryBackend({
) AS candidates
ORDER BY id, source_priority
`;
const result = await pool.query(sql, [userId, vectorLiteral(embedding), candidateLimit]);
const memories = rankHybridCandidates(result?.rows ?? [], input.query, limit);
const [result, keywordRows] = await Promise.all([
pool.query(sql, [userId, vectorLiteral(embedding), candidateLimit]),
fetchKeywordCandidates(pool, {
userId,
query: input.query,
tableName: resolvedTableName,
limit: Math.max(limit, candidateLimit),
}).catch(() => []),
]);
const memories = rankHybridCandidates(
[...(result?.rows ?? []), ...keywordRows],
input.query,
limit,
);
return {
semanticMemories: memories.map((item) => item.text),
memories,
@@ -184,4 +256,6 @@ export function createPgvectorMemoryBackend({
export const pgvectorMemoryBackendInternals = {
lexicalQueryCoverage,
rankHybridCandidates,
extractKeywordTerms,
dedupeContentPrefix,
};