const DEFAULT_TABLE = 'memory_embeddings'; const DEFAULT_LIMIT = 8; const VECTOR_CANDIDATE_LIMIT_MAX = 150; const VECTOR_SCORE_MARGIN = Math.max( 0, Number(process.env.MEMIND_VECTOR_SCORE_MARGIN ?? 0.15) || 0.15, ); const VECTOR_EXPAND_CAP = Math.max( 50, Number(process.env.MEMIND_VECTOR_EXPAND_CAP ?? 200) || 200, ); function isSafeIdentifier(value) { return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(String(value ?? '')); } function resolveTableName(tableName) { const normalized = String(tableName ?? DEFAULT_TABLE).trim(); if (!isSafeIdentifier(normalized)) { throw new Error(`Invalid pgvector table name: ${normalized}`); } return normalized; } function normalizeEmbedding(value) { if (!Array.isArray(value)) return null; const numbers = value.map((item) => Number(item)); if (!numbers.length || numbers.some((item) => !Number.isFinite(item))) return null; return numbers; } function vectorLiteral(embedding) { return `[${embedding.join(',')}]`; } function normalizeSearchText(value) { return String(value ?? '') .normalize('NFKC') .toLowerCase() .replace(/[^a-z0-9\u4e00-\u9fff]+/gu, ''); } function buildCharacterNgrams(value, size = 2) { const text = normalizeSearchText(value); if (!text) return new Set(); if (text.length <= size) return new Set([text]); const grams = new Set(); for (let index = 0; index <= text.length - size; index += 1) { grams.add(text.slice(index, index + size)); } return grams; } function characterNgramCoverage(query, text) { const queryGrams = buildCharacterNgrams(query); if (queryGrams.size === 0) return 0; const textGrams = buildCharacterNgrams(text); let overlap = 0; for (const gram of queryGrams) { if (textGrams.has(gram)) overlap += 1; } return overlap / queryGrams.size; } function latinWordTokens(value) { return new Set( String(value ?? '') .normalize('NFKC') .toLowerCase() .match(/[a-z0-9]{2,}/g) ?? [], ); } const ENGLISH_KEYWORD_STOP_TERMS = new Set([ 'what', 'when', 'where', 'which', 'who', 'whom', 'whose', 'why', 'how', 'did', 'does', 'do', 'was', 'were', 'are', 'is', 'am', 'be', 'been', 'being', 'the', 'and', 'for', 'with', 'from', 'that', 'this', 'those', 'these', 'have', 'has', 'had', 'can', 'could', 'would', 'should', 'will', 'shall', 'about', 'after', 'before', 'during', 'into', 'onto', 'over', 'under', 'any', 'all', 'some', 'many', 'much', 'most', 'more', 'less', 'than', 'she', 'her', 'him', 'his', 'they', 'them', 'their', 'our', 'your', 'you', 'together', 'happened', 'piece', 'moment', 'until', 'timeline', 'events', 'everyone', 'doing', 'around', 'progress', 'based', 'want', 'know', 'full', 'please', 'tell', 'give', 'help', 'need', 'across', 'through', 'also', 'just', 'like', 'make', 'made', 'other', 'well', 'very', 'really', 'right', 'now', 'still', 'walk', 'currently', 'here', 'there', 'then', ]); function latinWordQueryCoverage(query, text) { const stop = ENGLISH_KEYWORD_STOP_TERMS; const { body, tagTokens } = parseSourceTagPrefix(text); let queryTokens = [...latinWordTokens(query)].filter( (token) => token.length >= 3 && !stop.has(token), ); if (queryTokens.length === 0) { queryTokens = [...latinWordTokens(query)].filter((token) => token.length >= 3); } if (queryTokens.length === 0) return 0; const textTokens = latinWordTokens(body); let overlap = 0; for (const token of queryTokens) { if (textTokens.has(token)) overlap += 1; } let coverage = overlap / queryTokens.length; if (tagTokens.size > 0) { let tagHits = 0; for (const token of queryTokens) { if (tagTokens.has(token)) tagHits += 1; } if (tagHits > 0) { coverage = Math.min(1, coverage + (tagHits / queryTokens.length) * 0.35); } } return coverage; } function tokenizeSourceTagPart(tagPart) { const tokens = new Set(); for (const piece of String(tagPart ?? '').toLowerCase().split(/[^a-z0-9]+/)) { if (!piece) continue; for (const sub of piece.split('_')) { if (sub.length >= 3 && !ENGLISH_KEYWORD_STOP_TERMS.has(sub)) tokens.add(sub); } if (piece.length >= 3 && !ENGLISH_KEYWORD_STOP_TERMS.has(piece)) tokens.add(piece); } return tokens; } function parseSourceTagPrefix(text) { const raw = String(text ?? ''); const match = raw.match(/^\[([^\]]+)\]\s*/); if (!match) return { body: raw, tagTokens: new Set() }; const tagTokens = tokenizeSourceTagPart(match[1]); return { body: raw.slice(match[0].length), tagTokens }; } export function extractRecallContextTerms(recallContext) { const terms = new Set(); const device = String(recallContext?.device ?? '').trim().toLowerCase(); if (device) { terms.add(device); for (const part of device.split('_')) { if (part.length >= 3 && !ENGLISH_KEYWORD_STOP_TERMS.has(part)) terms.add(part); } } const user = String(recallContext?.user ?? '').trim().toLowerCase(); if (user.length >= 3 && !ENGLISH_KEYWORD_STOP_TERMS.has(user)) terms.add(user); return [...terms]; } export function mergeKeywordTerms(query, recallContext, options = {}) { const contextTerms = extractRecallContextTerms(recallContext); const queryTerms = extractKeywordTerms(query, options); const properNouns = new Set(); for (const match of String(query ?? '').matchAll(/\b[A-Z][a-z]{2,}\b/g)) { properNouns.add(match[0].toLowerCase()); } const contextSet = new Set(contextTerms); return [...new Set([...contextTerms, ...queryTerms])] .sort((left, right) => { const leftContext = contextSet.has(left) ? 1 : 0; const rightContext = contextSet.has(right) ? 1 : 0; if (leftContext !== rightContext) return rightContext - leftContext; const leftProper = properNouns.has(left) ? 1 : 0; const rightProper = properNouns.has(right) ? 1 : 0; if (leftProper !== rightProper) return rightProper - leftProper; return right.length - left.length; }) .slice(0, options.maxTerms ?? 12); } export function buildEmbeddingQuery(query, recallContext) { const parts = [String(query ?? '').trim()]; const device = String(recallContext?.device ?? '').replace(/_/g, ' ').trim(); if (device) parts.push(`device context: ${device}`); return parts.filter(Boolean).join('\n'); } function queryScriptProfile(query) { const text = String(query ?? ''); const cjkChars = (text.match(/[\u4e00-\u9fff]/g) || []).length; const latinChars = (text.match(/[a-z]/gi) || []).length; if (latinChars > 0 && cjkChars === 0) return 'latin'; if (cjkChars > 0 && latinChars === 0) return 'cjk'; return 'mixed'; } function lexicalQueryCoverage(query, text) { const profile = queryScriptProfile(query); if (profile === 'latin') return latinWordQueryCoverage(query, text); if (profile === 'cjk') return characterNgramCoverage(query, text); return Math.max(latinWordQueryCoverage(query, text), characterNgramCoverage(query, text)); } 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 properNouns = new Set(); for (const match of normalized.matchAll(/\b[A-Z][a-z]{2,}\b/g)) { properNouns.add(match[0].toLowerCase()); } 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)) { const term = match[0].toLowerCase(); if (!ENGLISH_KEYWORD_STOP_TERMS.has(term)) { terms.add(term); } } return [...terms] .sort((left, right) => { const leftProper = properNouns.has(left) ? 1 : 0; const rightProper = properNouns.has(right) ? 1 : 0; if (leftProper !== rightProper) return rightProper - leftProper; return right.length - left.length; }) .slice(0, maxTerms); } function dedupeContentPrefix(text, length = 96) { return normalizeSearchText(String(text ?? '').slice(0, length)); } const KEYWORD_FETCH_CAP = 500; const KEYWORD_PRIORITY_TERM_CAP = 120; const KEYWORD_RETURN_CAP = 100; function splitPriorityKeywordTerms(query, terms) { const priority = new Set(); for (const match of String(query ?? '').matchAll(/\b[A-Z][a-z]{2,}\b/g)) { priority.add(match[0].toLowerCase()); } for (const match of String(query ?? '').matchAll(/\b[A-Z]{2,}\b/g)) { priority.add(match[0].toLowerCase()); } const priorityTerms = terms.filter((term) => priority.has(term)); const generalTerms = terms.filter((term) => !priority.has(term)); return { priorityTerms, generalTerms }; } function rankKeywordCandidateRows(rows, query, returnLimit) { const safeLimit = Math.max(1, Number(returnLimit) || 20); return rows .map((row) => ({ row, lexicalScore: lexicalQueryCoverage(query, row.content ?? row.text ?? ''), updatedAt: timestampValue(row.updated_at ?? row.updatedAt ?? row.created_at ?? row.createdAt), })) .sort((left, right) => { if (left.lexicalScore !== right.lexicalScore) { return right.lexicalScore - left.lexicalScore; } return right.updatedAt - left.updatedAt; }) .slice(0, safeLimit) .map(({ row }) => ({ ...row, score: null })); } function capRowsByLexical(rows, query, cap) { if (rows.length <= cap) return rows; return rows .map((row) => ({ row, lexicalScore: lexicalQueryCoverage(query, row.content ?? row.text ?? ''), })) .sort((left, right) => right.lexicalScore - left.lexicalScore) .slice(0, cap) .map(({ row }) => row); } /** * Offline / in-memory keyword candidate selection. Priority terms (proper nouns) * are fetched in separate buckets so a broad OR query cannot truncate them out * before lexical ranking — the root cause of MemFuseBench candidate misses. */ export function selectKeywordCandidateRows(rows, query, terms, { fetchCap = KEYWORD_FETCH_CAP, returnLimit = 100, priorityTermCap = 120, } = {}) { if (!terms.length) return []; const { priorityTerms, generalTerms } = splitPriorityKeywordTerms(query, terms); const byId = new Map(); const haystacks = rows.map((row) => ({ row, text: String(row.content ?? row.text ?? '').toLowerCase(), })); function addTermMatches(termSubset, cap) { if (!termSubset.length) return; const matched = haystacks .filter(({ text }) => termSubset.some((term) => text.includes(term))) .map(({ row }) => row); for (const row of capRowsByLexical(matched, query, cap)) { byId.set(String(row.id), row); } } for (const term of priorityTerms.slice(0, 6)) { addTermMatches([term], priorityTermCap); } if (generalTerms.length > 0) { addTermMatches(generalTerms, fetchCap); } else if (priorityTerms.length > 0) { addTermMatches(priorityTerms, fetchCap); } return rankKeywordCandidateRows([...byId.values()], query, returnLimit); } async function queryKeywordTermSet(pool, { userId, tableName, terms, cap, }) { if (!terms.length) return []; const clauses = terms.map((_term, index) => `content ILIKE $${index + 2}`); const params = [userId, ...terms.map((term) => `%${term}%`), cap]; const sql = ` SELECT id, content, type, created_at, updated_at, 1.0 AS score FROM ${tableName} WHERE user_id = $1 AND (${clauses.join(' OR ')}) LIMIT $${params.length} `; const result = await pool.query(sql, params); return result?.rows ?? []; } async function fetchKeywordCandidates(pool, { userId, query, tableName, limit = 20, maxTerms = 12, recallContext = null, } = {}) { const terms = mergeKeywordTerms(query, recallContext, { maxTerms }); if (!terms.length) return []; const safeLimit = Math.max(1, Math.min(KEYWORD_RETURN_CAP, Number(limit) || 20)); const fetchCap = Math.min(KEYWORD_FETCH_CAP, Math.max(safeLimit, safeLimit * 10)); const { priorityTerms, generalTerms } = splitPriorityKeywordTerms(query, terms); const byId = new Map(); for (const term of priorityTerms.slice(0, 6)) { const rows = await queryKeywordTermSet(pool, { userId, tableName, terms: [term], cap: KEYWORD_PRIORITY_TERM_CAP, }); for (const row of capRowsByLexical(rows, query, KEYWORD_PRIORITY_TERM_CAP)) { byId.set(String(row.id), row); } } const generalQueryTerms = generalTerms.length > 0 ? generalTerms : priorityTerms; if (generalQueryTerms.length > 0) { const rows = await queryKeywordTermSet(pool, { userId, tableName, terms: generalQueryTerms, cap: fetchCap, }); for (const row of capRowsByLexical(rows, query, fetchCap)) { byId.set(String(row.id), row); } } return rankKeywordCandidateRows([...byId.values()], query, safeLimit); } /** * Vector candidate union: top-N by cosine plus any row within `margin` of the * best score (capped at expandCap), then optional recency rows for cold-start. */ export function selectVectorCandidateRows(scoredEntries, { baseLimit = 100, recentRows = [], margin = VECTOR_SCORE_MARGIN, expandCap = VECTOR_EXPAND_CAP, } = {}) { const sorted = [...scoredEntries].sort((left, right) => right.score - left.score); const topScore = sorted[0]?.score ?? 0; const scoreFloor = topScore - margin; const merged = new Map(); for (const entry of sorted.slice(0, baseLimit)) { merged.set(String(entry.row.id), entry); } for (const entry of sorted) { if (merged.size >= expandCap) break; if (entry.score < scoreFloor) break; merged.set(String(entry.row.id), entry); } for (const row of recentRows.slice(0, baseLimit)) { const key = String(row.id); if (!merged.has(key)) { const existing = sorted.find((entry) => String(entry.row.id) === key); merged.set(key, existing ?? { row, score: 0 }); } } return [...merged.values()]; } function timestampValue(value) { if (value == null) return 0; const numeric = Number(value); if (Number.isFinite(numeric)) return numeric; const parsed = Date.parse(String(value)); return Number.isFinite(parsed) ? parsed : 0; } function normalizeRow(row) { const text = String(row?.content ?? row?.memory_text ?? row?.text ?? '').trim(); if (!text) return null; return { id: row?.id == null ? null : String(row.id), label: row?.type ?? row?.label ?? 'semantic', text, score: row?.score == null ? null : Number(row.score), createdAt: row?.created_at ?? row?.createdAt ?? null, updatedAt: row?.updated_at ?? row?.updatedAt ?? row?.created_at ?? row?.createdAt ?? null, }; } const RRF_RANK_CONSTANT = 60; function rankEntries(entries, compareFn) { const order = [...entries].sort(compareFn); const ranks = new Map(); for (let index = 0; index < order.length; index += 1) { ranks.set(order[index].key, index + 1); } return ranks; } function recallVectorSpread(entries) { const vectorScores = entries .map((entry) => entry.vectorScore) .filter((score) => score >= 0); if (vectorScores.length < 2) return null; const sorted = [...vectorScores].sort((left, right) => right - left); const max = sorted[0]; const median = sorted[Math.floor(sorted.length / 2)]; const spread = max - median; return { max, median, spread, semantic: spread >= 0.20 && max >= 0.50, }; } function recallRankingWeights(query, entries) { if (queryScriptProfile(query) !== 'latin') { return { lexical: 1, vector: 1 }; } const spreadInfo = recallVectorSpread(entries); if (spreadInfo?.semantic) { return { lexical: 0.45, vector: 1.55 }; } return { lexical: 1, vector: 1 }; } function recallVectorTermWeight(query, entry, spreadInfo, vectorWeight) { if (!spreadInfo?.semantic || queryScriptProfile(query) !== 'latin') { return vectorWeight; } if (entry.vectorScore < 0) return vectorWeight; // Pure vector matches with zero lexical overlap often beat weak-overlap gold in RRF. if (entry.lexicalScore <= 0) return vectorWeight * 0.6; return vectorWeight; } 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); } const entries = [...byId.values()].map((memory) => ({ memory, key: memory.id ?? `${memory.label}:${memory.text}`, lexicalScore: lexicalQueryCoverage(query, memory.text), vectorScore: Number.isFinite(memory.score) ? memory.score : -1, updatedAt: timestampValue(memory.updatedAt), })); if (entries.length === 0) return []; const lexicalRanks = rankEntries(entries, (left, right) => { if (left.lexicalScore !== right.lexicalScore) { return right.lexicalScore - left.lexicalScore; } if (left.lexicalScore > 0 && left.updatedAt !== right.updatedAt) { return right.updatedAt - left.updatedAt; } return right.vectorScore - left.vectorScore; }); const vectorEligible = entries.filter((entry) => entry.vectorScore >= 0); const vectorRanks = rankEntries(vectorEligible, (left, right) => { if (left.vectorScore !== right.vectorScore) { return right.vectorScore - left.vectorScore; } if (left.lexicalScore !== right.lexicalScore) { return right.lexicalScore - left.lexicalScore; } return right.updatedAt - left.updatedAt; }); const spreadInfo = recallVectorSpread(entries); const { lexical: lexicalWeight, vector: vectorWeight } = recallRankingWeights(query, entries); return entries .map((entry) => { const lexicalRank = lexicalRanks.get(entry.key); const lexicalTerm = lexicalWeight / (RRF_RANK_CONSTANT + lexicalRank); const effectiveVectorWeight = recallVectorTermWeight(query, entry, spreadInfo, vectorWeight); const vectorTerm = entry.vectorScore >= 0 ? effectiveVectorWeight / (RRF_RANK_CONSTANT + vectorRanks.get(entry.key)) : vectorWeight / (RRF_RANK_CONSTANT + lexicalRank); return { entry, fusedScore: lexicalTerm + vectorTerm, }; }) .sort((left, right) => { if (left.fusedScore !== right.fusedScore) { return right.fusedScore - left.fusedScore; } const leftEntry = left.entry; const rightEntry = right.entry; if (leftEntry.lexicalScore !== rightEntry.lexicalScore) { return rightEntry.lexicalScore - leftEntry.lexicalScore; } if (leftEntry.vectorScore !== rightEntry.vectorScore) { return rightEntry.vectorScore - leftEntry.vectorScore; } return rightEntry.updatedAt - leftEntry.updatedAt; }) .slice(0, limit) .map(({ entry }) => entry.memory); } export function createPgvectorMemoryBackend({ pool = null, enabled = false, tableName = DEFAULT_TABLE, embedQuery = null, defaultLimit = DEFAULT_LIMIT, unavailableReason = 'not_configured', } = {}) { const resolvedTableName = resolveTableName(tableName); async function resolveEmbedding(input) { const explicit = normalizeEmbedding(input?.embedding); if (explicit) return explicit; if (typeof embedQuery !== 'function' || !input?.query) return null; const embeddingQuery = buildEmbeddingQuery(input.query, input.recallContext); return normalizeEmbedding(await embedQuery(embeddingQuery, input)); } return { name: 'pgvector', category: 'semantic', role: 'primary-vector-store', flag: 'MEMORY_VECTOR_ENABLED', unavailableReason, isAvailable() { return Boolean(enabled && pool?.query); }, getUnavailableReason() { return this.isAvailable() ? null : unavailableReason; }, async resolve(input = {}) { if (!this.isAvailable()) return { memories: [], semanticMemories: [] }; const userId = String(input.userId ?? '').trim(); if (!userId) return { memories: [], semanticMemories: [] }; const embedding = await resolveEmbedding(input); if (!embedding) return { memories: [], semanticMemories: [] }; const limit = Math.max(1, Math.min(50, Number(input.limit ?? defaultLimit) || defaultLimit)); const candidateLimit = Math.max( limit, Math.min(VECTOR_CANDIDATE_LIMIT_MAX, Number(input.candidateLimit ?? 50) || 50), ); const sql = ` WITH top_score AS ( SELECT (1 - (embedding <=> $2::vector))::float8 AS best_score FROM ${resolvedTableName} WHERE user_id = $1 ORDER BY embedding <=> $2::vector LIMIT 1 ), vector_candidates AS ( SELECT m.id, m.content, m.type, m.created_at, m.updated_at, (1 - (m.embedding <=> $2::vector))::float8 AS score, 0 AS source_priority FROM ${resolvedTableName} m CROSS JOIN top_score t WHERE m.user_id = $1 AND (1 - (m.embedding <=> $2::vector)) >= (t.best_score - $4::float8) ORDER BY m.embedding <=> $2::vector LIMIT $5 ), recent_candidates AS ( SELECT id, content, type, created_at, updated_at, 1 - (embedding <=> $2::vector) AS score, 1 AS source_priority FROM ${resolvedTableName} WHERE user_id = $1 ORDER BY updated_at DESC LIMIT $3 ) SELECT DISTINCT ON (id) id, content, type, created_at, updated_at, score FROM ( SELECT * FROM vector_candidates UNION ALL SELECT * FROM recent_candidates ) AS candidates ORDER BY id, source_priority `; const [result, keywordRows] = await Promise.all([ pool.query(sql, [ userId, vectorLiteral(embedding), candidateLimit, VECTOR_SCORE_MARGIN, VECTOR_EXPAND_CAP, ]), fetchKeywordCandidates(pool, { userId, query: input.query, tableName: resolvedTableName, limit: Math.max(limit, candidateLimit), recallContext: input.recallContext ?? null, }).catch(() => []), ]); const memories = rankHybridCandidates( [...(result?.rows ?? []), ...keywordRows], input.query, limit, ); return { semanticMemories: memories.map((item) => item.text), memories, }; }, }; } export const pgvectorMemoryBackendInternals = { lexicalQueryCoverage, latinWordQueryCoverage, queryScriptProfile, recallRankingWeights, rankHybridCandidates, extractKeywordTerms, extractRecallContextTerms, mergeKeywordTerms, buildEmbeddingQuery, parseSourceTagPrefix, selectKeywordCandidateRows, selectVectorCandidateRows, dedupeContentPrefix, };