6d48480b1c
Auto-enable device/location prefixes for cross_device scenarios, thread question_device into keyword and embedding paths, and boost lexical scores from source-tag tokens. Co-authored-by: Cursor <cursoragent@cursor.com>
691 lines
23 KiB
JavaScript
691 lines
23 KiB
JavaScript
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,
|
|
};
|