Files
crm-ai/bridge/src/llm-cache.js
T
2026-10-23 10:35:28 +08:00

101 lines
3.7 KiB
JavaScript

'use strict';
const crypto = require('crypto');
const { createEmbeddingClient } = require('./embedding');
const SIM_THRESHOLD = 0.85;
// 语义缓存:只缓存确定性政策问答,避免订单/库存等实时数据被缓存。
function createLlmCache(cfg, deps = {}) {
if (!cfg.pgUrl || !cfg.embApiKey || !cfg.embBaseUrl) {
return { enabled: false, get: async () => null, store: async () => false };
}
const emb = deps.embedding || createEmbeddingClient(cfg, { pgUrl: cfg.pgUrl });
const pool = deps.pool || emb.pool;
let ready = null;
let failed = false;
function ensureTable() {
if (failed) return Promise.reject(new Error('cache unavailable'));
if (!ready) {
ready = (async () => {
await pool.query('CREATE EXTENSION IF NOT EXISTS vector');
await pool.query(`
CREATE TABLE IF NOT EXISTS bridge_llm_cache (
id SERIAL PRIMARY KEY,
query_vec vector(${Number(cfg.embDim) || 1024}),
query_text TEXT NOT NULL,
intent TEXT NOT NULL,
verified_scope TEXT NOT NULL,
lang TEXT NOT NULL,
kb_fingerprint TEXT,
system_hash TEXT,
reply TEXT NOT NULL,
hit_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (query_text, intent, verified_scope, lang)
)
`);
await pool.query('CREATE INDEX IF NOT EXISTS bridge_llm_cache_vec_idx ON bridge_llm_cache USING hnsw (query_vec vector_cosine_ops)');
})().catch((err) => {
failed = true;
console.error(JSON.stringify({ ts: new Date().toISOString(), warn: `llm cache init failed: ${err.message}` }));
throw err;
});
}
return ready;
}
async function get({ query, intent, verifiedScope, lang, kbFingerprint, systemHash }) {
try {
await ensureTable();
const vec = await emb.embedQuery(query);
const r = await pool.query(
`SELECT id, reply, kb_fingerprint, system_hash,
1 - (query_vec <=> $1::vector) AS sim
FROM bridge_llm_cache
WHERE intent = $2 AND verified_scope = $3
AND 1 - (query_vec <=> $1::vector) > ${SIM_THRESHOLD}
ORDER BY query_vec <=> $1::vector
LIMIT 1`,
[emb.vecStr(vec), intent, verifiedScope || 'anonymous']
);
const hit = r.rows && r.rows[0];
if (!hit) return null;
if (hit.kb_fingerprint && hit.kb_fingerprint !== (kbFingerprint || '')) return null;
if (hit.system_hash && hit.system_hash !== (systemHash || '')) return null;
pool.query('UPDATE bridge_llm_cache SET hit_count = hit_count + 1 WHERE id = $1', [hit.id]).catch(() => {});
return hit.reply;
} catch {
return null;
}
}
async function store({ query, intent, verifiedScope, lang, kbFingerprint, systemHash, reply }) {
try {
if (!reply) return false;
await ensureTable();
const vec = await emb.embedQuery(query);
await pool.query(
`INSERT INTO bridge_llm_cache
(query_vec, query_text, intent, verified_scope, lang, kb_fingerprint, system_hash, reply)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
ON CONFLICT (query_text, intent, verified_scope, lang) DO UPDATE SET
query_vec = EXCLUDED.query_vec,
kb_fingerprint = EXCLUDED.kb_fingerprint,
system_hash = EXCLUDED.system_hash,
reply = EXCLUDED.reply,
created_at = NOW()`,
[emb.vecStr(vec), String(query).slice(0, 500), intent, verifiedScope || 'anonymous', lang || 'en', kbFingerprint || '', systemHash || '', reply]
);
return true;
} catch {
return false;
}
}
return { enabled: true, get, store };
}
module.exports = { createLlmCache, SIM_THRESHOLD };