const DEFAULT_TABLE = 'memory_embeddings'; const DEFAULT_LIMIT = 8; 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 lexicalQueryCoverage(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 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, }; } function rankHybridCandidates(rows, query, limit) { const byId = new Map(); for (const row of rows ?? []) { const memory = normalizeRow(row); if (!memory) continue; const key = memory.id ?? `${memory.label}:${memory.text}`; if (!byId.has(key)) byId.set(key, memory); } return [...byId.values()] .map((memory) => ({ memory, lexicalScore: lexicalQueryCoverage(query, memory.text), vectorScore: Number.isFinite(memory.score) ? memory.score : -1, updatedAt: timestampValue(memory.updatedAt), })) .sort((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; }) .slice(0, limit) .map(({ memory }) => 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; return normalizeEmbedding(await embedQuery(input.query, 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(100, Number(input.candidateLimit ?? 50) || 50), ); const sql = ` WITH vector_candidates AS ( SELECT id, content, type, created_at, updated_at, 1 - (embedding <=> $2::vector) AS score, 0 AS source_priority FROM ${resolvedTableName} WHERE user_id = $1 ORDER BY embedding <=> $2::vector LIMIT $3 ), 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 = await pool.query(sql, [userId, vectorLiteral(embedding), candidateLimit]); const memories = rankHybridCandidates(result?.rows ?? [], input.query, limit); return { semanticMemories: memories.map((item) => item.text), memories, }; }, }; } export const pgvectorMemoryBackendInternals = { lexicalQueryCoverage, rankHybridCandidates, };