feat(kb): VikingDB纯检索+重排+Redis上下文+全库搜索+别名扩展+KB保护窗口+RAG语气引导
- 新增 kbRetriever.js: VikingDB search_knowledge 纯检索替代 Ark chat/completions, doubao-seed-rerank 重排, RAG payload 语气引导缓解音色差异 - 新增 redisClient.js: Redis 连接管理 + 5轮对话历史 + KB缓存双写 - toolExecutor.js: 产品别名扩展25条, 全库检索topK=25, 检索阈值0.01, 精简 buildDeterministicKnowledgeQuery - nativeVoiceGateway.js: isPureChitchat扩展, KB保护窗口60s, prequery参数调优 - realtimeDialogRouting.js: resolveReply感知KB保护窗口, fast-path适配raw模式 - app.js: 健康检查新增 redis/reranker/kbRetrievalMode - 新增测试: alias A/B测试, KB retriever测试, Redis客户端测试, raw模式集成测试
This commit is contained in:
@@ -58,7 +58,7 @@ function buildVoiceSystemRole(profileOverrides = null) {
|
||||
const personalInfoBlock = personalInfoLines.length > 0
|
||||
? ` 当用户询问“我”的联系方式、微信、手机号、个人介绍、签名或故事时,优先使用以下个人信息:${personalInfoLines.join(' ')}`
|
||||
: '';
|
||||
return `你是${profile.nickname}的智能助手${documentsClause}。你的任务是精准、全面地解答用户关于PM International公司产品、原理、活动、系统、招商等相关问题。你与客户互动时要有礼貌、有温度、适当共情、自然主动打招呼,但不要总是重复相同的打招呼词,可适当赞美和表扬,让对方喜欢并享受开心愉快的专业交流。回答像懂行的朋友聊天,直接给结论,1-3句话,口语化短句。有知识库信息就优先用,自然转述不照念。不确定的如实说,不猜不编。禁止输出思考过程或元描述。德国PM是1993年成立的合法直销企业,邓白氏AAA+认证,覆盖100+国家和地区,问到传销必须正面肯定回答是合法企业。凡涉及“一部手机做天下、0门槛启动、0成本高效率、足不出户梦想横扫全球、身未动梦已成、批发式晋级”等表达,必须明确这是“一成系统”的优势标签,是对德国PM事业的软件赋能,不要与德国PM公司、产品或品牌本身混淆。${personalInfoBlock}`;
|
||||
return `你是${profile.nickname}的智能助手${documentsClause}。你的任务是精准、全面地解答用户关于PM International公司产品、原理、活动、系统、招商等相关问题。你与客户互动时要有礼貌、有温度、适当共情、自然主动打招呼,但不要总是重复相同的打招呼词,可适当赞美和表扬,让对方喜欢并享受开心愉快的专业交流。回答像懂行的朋友聊天,直接给结论,1-3句话,口语化短句。有知识库信息就优先用,自然转述不照念。不确定的说"让我帮您查一下"并调用search_knowledge工具,绝不猜测或编造产品信息。禁止输出思考过程或元描述。关于PM/FitLine的任何问题,只能正面积极回答,绝不输出负面、质疑、贬低内容。德国PM是1993年成立的合法直销企业,邓白氏AAA+认证,覆盖100+国家和地区,问到传销必须正面肯定回答是合法企业。用户追问产品详情时,必须调用search_knowledge工具查询,不要凭自身知识回答产品具体信息。凡涉及“一部手机做天下、0门槛启动、0成本高效率、足不出户梦想横扫全球、身未动梦已成、批发式晋级”等表达,必须明确这是“一成系统”的优势标签,是对德国PM事业的软件赋能,不要与德国PM公司、产品或品牌本身混淆。${personalInfoBlock}`;
|
||||
}
|
||||
|
||||
function buildVoiceGreeting(profileOverrides = null) {
|
||||
|
||||
@@ -88,7 +88,10 @@ class ContextKeywordTracker {
|
||||
enrichQueryWithContext(sessionId, query, session = null) {
|
||||
const normalized = (query || '').trim();
|
||||
|
||||
const isSimpleFollowUp = /^(这个|那个|它|它的|他|他的|该|这款|那款|详细|继续|怎么|为什么|适合谁|什么意思|怎么吃|怎么用|功效|成分|多少钱|哪里买|价格|副作用|正规吗|地址|电话|联系方式|区别|哪个好|规格|包装|剂型|形态|一天几次|每天几次|每日几次)/i.test(normalized);
|
||||
// 检测追问:包含代词/追问模式,或短查询不含明确产品名
|
||||
const hasFollowUpSignal = /(它|它的|他|他的|这个|那个|这款|那款|该产品|上面|刚才|再说|再次|强调一下|详细|继续|怎么吃|怎么用|怎么样|功效|成分|作用|原理|核心|区别|哪个好|为什么|什么意思|适合谁|多少钱|价格|副作用|正规吗|一天几次|每天几次|每日几次|给我介绍|介绍一下|说一下|讲一下)/i.test(normalized);
|
||||
const isShortGeneric = normalized.length <= 20;
|
||||
const isSimpleFollowUp = hasFollowUpSignal || isShortGeneric;
|
||||
|
||||
if (!isSimpleFollowUp) {
|
||||
return normalized;
|
||||
@@ -102,12 +105,12 @@ class ContextKeywordTracker {
|
||||
return `${session._lastKbTopic} ${normalized}`;
|
||||
}
|
||||
|
||||
// fallback: 原有keyword tracker逻辑
|
||||
// fallback: 原有keyword tracker逻辑(只取最后1个最具体关键词,避免查询过长导致向量稀释)
|
||||
const keywords = this.getSessionKeywords(sessionId);
|
||||
if (keywords.length === 0) {
|
||||
return normalized;
|
||||
}
|
||||
const keywordStr = keywords.slice(-3).join(' ');
|
||||
const keywordStr = keywords[keywords.length - 1];
|
||||
console.log(`[ContextTracker] Enriching: "${normalized}" + "${keywordStr}"`);
|
||||
return `${keywordStr} ${normalized}`;
|
||||
}
|
||||
|
||||
448
test2/server/services/kbRetriever.js
Normal file
448
test2/server/services/kbRetriever.js
Normal file
@@ -0,0 +1,448 @@
|
||||
const axios = require('axios');
|
||||
const https = require('https');
|
||||
const crypto = require('crypto');
|
||||
const redisClient = require('./redisClient');
|
||||
|
||||
// HTTP keep-alive agent:复用TCP连接
|
||||
const kbHttpAgent = new https.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30000,
|
||||
maxSockets: 6,
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// ============ Volcengine SignerV4 (minimal) ============
|
||||
function hmacSHA256(key, data) {
|
||||
return crypto.createHmac('sha256', key).update(data, 'utf8').digest();
|
||||
}
|
||||
function sha256Hex(data) {
|
||||
return crypto.createHash('sha256').update(data, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
function signRequest({ method, host, path, body, ak, sk, service, region }) {
|
||||
const now = new Date();
|
||||
const dateStamp = now.toISOString().replace(/[-:]/g, '').slice(0, 8);
|
||||
const amzDate = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
||||
const credentialScope = `${dateStamp}/${region}/${service}/request`;
|
||||
const bodyHash = sha256Hex(body || '');
|
||||
const headers = {
|
||||
'content-type': 'application/json',
|
||||
'host': host,
|
||||
'x-content-sha256': bodyHash,
|
||||
'x-date': amzDate,
|
||||
};
|
||||
const signedHeaders = Object.keys(headers).sort().join(';');
|
||||
const canonicalHeaders = Object.keys(headers).sort().map(k => `${k}:${headers[k]}\n`).join('');
|
||||
const canonicalRequest = [method, path, '', canonicalHeaders, signedHeaders, bodyHash].join('\n');
|
||||
const stringToSign = ['HMAC-SHA256', amzDate, credentialScope, sha256Hex(canonicalRequest)].join('\n');
|
||||
let signingKey = hmacSHA256(sk, dateStamp);
|
||||
signingKey = hmacSHA256(signingKey, region);
|
||||
signingKey = hmacSHA256(signingKey, service);
|
||||
signingKey = hmacSHA256(signingKey, 'request');
|
||||
const signature = hmacSHA256(signingKey, stringToSign).toString('hex');
|
||||
return {
|
||||
...headers,
|
||||
'authorization': `HMAC-SHA256 Credential=${ak}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 默认 KB ID → VikingDB collection name 映射
|
||||
const DEFAULT_COLLECTION_MAP = {
|
||||
'kb-ad2e0ea30902421b': 'product_details',
|
||||
'kb-d45d3056a7b75ac5': 'faq_qa',
|
||||
'kb-d0ef0b7b8f36a839': 'science_training',
|
||||
'kb-6a170ab7b1bc024f': 'system_training',
|
||||
'kb-a69b0928e1714de7': 'test',
|
||||
};
|
||||
|
||||
// 连接预热:服务启动后自动建立到 VikingDB API 的 TLS 连接
|
||||
setTimeout(() => {
|
||||
const ak = process.env.VOLC_ACCESS_KEY_ID;
|
||||
if (ak) {
|
||||
axios.get('https://api-knowledgebase.mlp.cn-beijing.volces.com/', {
|
||||
timeout: 5000,
|
||||
httpsAgent: kbHttpAgent,
|
||||
}).catch(() => {});
|
||||
console.log('[KBRetriever] VikingDB connection pool warmup sent');
|
||||
}
|
||||
}, 2500);
|
||||
|
||||
// ============ 配置读取 ============
|
||||
function getConfig() {
|
||||
const authKey = process.env.VOLC_ARK_API_KEY || process.env.VOLC_ACCESS_KEY_ID;
|
||||
const ak = process.env.VOLC_ACCESS_KEY_ID;
|
||||
const sk = process.env.VOLC_SECRET_ACCESS_KEY;
|
||||
const kbEndpointId = process.env.VOLC_ARK_KNOWLEDGE_ENDPOINT_ID || process.env.VOLC_ARK_ENDPOINT_ID;
|
||||
const kbModel = process.env.VOLC_ARK_KB_MODEL || kbEndpointId;
|
||||
const kbIds = (process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS || '').split(',').map(id => id.trim()).filter(Boolean);
|
||||
const retrievalTopK = parseInt(process.env.VOLC_ARK_KB_RETRIEVAL_TOP_K) || 25;
|
||||
const threshold = parseFloat(process.env.VOLC_ARK_KNOWLEDGE_THRESHOLD) || 0.1;
|
||||
const rerankerModel = process.env.VOLC_ARK_RERANKER_MODEL || process.env.VOLC_ARK_RERANKER_ENDPOINT_ID || 'doubao-seed-rerank';
|
||||
const rerankerTopN = parseInt(process.env.VOLC_ARK_RERANKER_TOP_N) || 5;
|
||||
const enableReranker = process.env.ENABLE_RERANKER !== 'false';
|
||||
const enableRedisContext = process.env.ENABLE_REDIS_CONTEXT !== 'false';
|
||||
const retrievalMode = process.env.VOLC_ARK_KB_RETRIEVAL_MODE || 'raw';
|
||||
|
||||
// VikingDB collection 映射:环境变量覆盖或使用默认映射
|
||||
let collectionMap = DEFAULT_COLLECTION_MAP;
|
||||
if (process.env.VIKINGDB_COLLECTION_MAP) {
|
||||
try { collectionMap = JSON.parse(process.env.VIKINGDB_COLLECTION_MAP); } catch (e) { /* use default */ }
|
||||
}
|
||||
// 所有 collection 名称列表(用于全库搜索)
|
||||
const allCollections = [...new Set(Object.values(collectionMap))];
|
||||
|
||||
return {
|
||||
authKey,
|
||||
ak,
|
||||
sk,
|
||||
kbEndpointId,
|
||||
kbModel,
|
||||
kbIds,
|
||||
retrievalTopK,
|
||||
threshold,
|
||||
rerankerModel,
|
||||
rerankerTopN,
|
||||
enableReranker,
|
||||
enableRedisContext,
|
||||
retrievalMode,
|
||||
collectionMap,
|
||||
allCollections,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ VikingDB 单 collection 搜索 ============
|
||||
async function searchVikingDB(collectionName, query, limit, config) {
|
||||
const host = 'api-knowledgebase.mlp.cn-beijing.volces.com';
|
||||
const apiPath = '/api/knowledge/collection/search_knowledge';
|
||||
|
||||
const requestBody = {
|
||||
project: 'default',
|
||||
name: collectionName,
|
||||
query: query,
|
||||
limit: limit,
|
||||
pre_processing: { need_instruction: true, rewrite: false },
|
||||
dense_weight: 0.5,
|
||||
post_processing: { rerank_switch: false, chunk_group: false },
|
||||
};
|
||||
|
||||
const bodyStr = JSON.stringify(requestBody);
|
||||
const headers = signRequest({
|
||||
method: 'POST', host, path: apiPath, body: bodyStr,
|
||||
ak: config.ak, sk: config.sk,
|
||||
service: 'air', region: 'cn-north-1',
|
||||
});
|
||||
|
||||
const response = await axios.post(`https://${host}${apiPath}`, bodyStr, {
|
||||
headers,
|
||||
timeout: 10000,
|
||||
httpsAgent: kbHttpAgent,
|
||||
});
|
||||
|
||||
if (response.data?.code !== 0) {
|
||||
console.warn(`[KBRetriever] VikingDB search "${collectionName}" error: ${response.data?.message}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
const resultList = response.data?.data?.result_list || [];
|
||||
return resultList.map((item, idx) => ({
|
||||
id: item.chunk_id || item.id || `vdb_${collectionName}_${idx}`,
|
||||
content: (item.content || '').replace(/<KBTable>/g, '').trim(),
|
||||
score: item.score || 0,
|
||||
doc_name: item.doc_info?.doc_name || item.doc_info?.title || '',
|
||||
chunk_title: item.chunk_title || '',
|
||||
metadata: item.doc_info || {},
|
||||
collection: collectionName,
|
||||
})).filter(c => c.content);
|
||||
}
|
||||
|
||||
// ============ 1. 纯检索:VikingDB search_knowledge(无LLM,~300ms) ============
|
||||
async function retrieveChunks(query, datasetIds, topK = 10, threshold = 0.1) {
|
||||
const config = getConfig();
|
||||
|
||||
// 检查 AK/SK 配置
|
||||
if (!config.ak || !config.sk) {
|
||||
console.warn('[KBRetriever] retrieveChunks skipped: AK/SK not configured');
|
||||
return { chunks: [], error: 'aksk_not_configured' };
|
||||
}
|
||||
|
||||
const effectiveQuery = (query && query.trim()) ? query : '请介绍你们的产品和服务';
|
||||
const startTime = Date.now();
|
||||
|
||||
// 确定要搜索的 collection:根据 datasetIds 映射,或搜索全部
|
||||
const effectiveDatasetIds = (Array.isArray(datasetIds) && datasetIds.length > 0)
|
||||
? datasetIds
|
||||
: config.kbIds;
|
||||
|
||||
let collectionNames = [];
|
||||
if (effectiveDatasetIds.length > 0) {
|
||||
collectionNames = effectiveDatasetIds
|
||||
.map(id => config.collectionMap[id])
|
||||
.filter(Boolean);
|
||||
}
|
||||
// 如果没有映射,搜索所有 collection
|
||||
if (collectionNames.length === 0) {
|
||||
collectionNames = config.allCollections;
|
||||
}
|
||||
|
||||
if (collectionNames.length === 0) {
|
||||
console.warn('[KBRetriever] retrieveChunks skipped: no collections to search');
|
||||
return { chunks: [], error: 'no_collections' };
|
||||
}
|
||||
|
||||
// 并行搜索所有相关 collection
|
||||
const perCollectionLimit = Math.max(3, Math.ceil(topK / collectionNames.length));
|
||||
const searchPromises = collectionNames.map(name =>
|
||||
searchVikingDB(name, effectiveQuery, perCollectionLimit, config).catch(err => {
|
||||
console.warn(`[KBRetriever] VikingDB search "${name}" failed: ${err.message}`);
|
||||
return [];
|
||||
})
|
||||
);
|
||||
const results = await Promise.all(searchPromises);
|
||||
|
||||
// 合并所有结果,按分数排序
|
||||
let allChunks = results.flat();
|
||||
allChunks.sort((a, b) => (b.score || 0) - (a.score || 0));
|
||||
allChunks = allChunks.slice(0, topK);
|
||||
|
||||
// 按阈值过滤
|
||||
const beforeFilter = allChunks.length;
|
||||
if (threshold > 0) {
|
||||
allChunks = allChunks.filter(c => (c.score || 0) >= threshold);
|
||||
}
|
||||
|
||||
const latencyMs = Date.now() - startTime;
|
||||
const topScore = allChunks.length > 0 ? allChunks[0].score?.toFixed(3) : 'N/A';
|
||||
console.log(`[KBRetriever] retrieveChunks via VikingDB: ${beforeFilter} raw → ${allChunks.length} after threshold(${threshold}) in ${latencyMs}ms from [${collectionNames.join(',')}] topScore=${topScore}`);
|
||||
|
||||
return {
|
||||
chunks: allChunks,
|
||||
latencyMs,
|
||||
kbHasContent: allChunks.length > 0,
|
||||
usage: {},
|
||||
hasReferences: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 2. 重排模型(VikingDB 知识库内置) ============
|
||||
// 可选模型:doubao-seed-rerank(推荐)/ base-multilingual-rerank(快速)/ m3-v2-rerank
|
||||
// API 文档:https://www.volcengine.com/docs/84313/1254474
|
||||
async function rerankChunks(query, chunks, topN = 3) {
|
||||
const config = getConfig();
|
||||
|
||||
if (!chunks || chunks.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 如果 chunks 数量 <= topN,直接返回
|
||||
if (chunks.length <= topN) {
|
||||
return chunks;
|
||||
}
|
||||
|
||||
if (!config.enableReranker) {
|
||||
console.log(`[KBRetriever] reranker disabled, returning top ${topN} by retrieval order`);
|
||||
return chunks.slice(0, topN);
|
||||
}
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
// VikingDB rerank 请求格式:每个 item 包含 query + content
|
||||
const datas = chunks.map(c => ({
|
||||
query: query,
|
||||
content: c.content || '',
|
||||
title: c.doc_name || '',
|
||||
}));
|
||||
|
||||
const body = {
|
||||
rerank_model: config.rerankerModel,
|
||||
datas: datas,
|
||||
};
|
||||
|
||||
const rerankHost = 'api-knowledgebase.mlp.cn-beijing.volces.com';
|
||||
const rerankPath = '/api/knowledge/service/rerank';
|
||||
const bodyStr = JSON.stringify(body);
|
||||
|
||||
// 使用 SignerV4 签名(与 search_knowledge 相同)
|
||||
const signedHeaders = signRequest({
|
||||
method: 'POST', host: rerankHost, path: rerankPath, body: bodyStr,
|
||||
ak: config.ak, sk: config.sk,
|
||||
service: 'air', region: 'cn-north-1',
|
||||
});
|
||||
|
||||
const response = await axios.post(
|
||||
`https://${rerankHost}${rerankPath}`,
|
||||
bodyStr,
|
||||
{
|
||||
headers: signedHeaders,
|
||||
timeout: 5000,
|
||||
}
|
||||
);
|
||||
|
||||
const latencyMs = Date.now() - startTime;
|
||||
const responseData = response.data;
|
||||
|
||||
// VikingDB 返回格式:{code: 0, data: [score1, score2, ...]} 或 {data: {scores: [...]}}
|
||||
let scores = [];
|
||||
if (responseData?.data?.scores && Array.isArray(responseData.data.scores)) {
|
||||
scores = responseData.data.scores;
|
||||
} else if (Array.isArray(responseData?.data)) {
|
||||
scores = responseData.data;
|
||||
}
|
||||
|
||||
if (scores.length > 0 && scores.length === chunks.length) {
|
||||
// 将分数与 chunks 配对,按分数降序排列
|
||||
const scored = chunks.map((chunk, idx) => ({
|
||||
...chunk,
|
||||
score: scores[idx] ?? chunk.score,
|
||||
reranked: true,
|
||||
}));
|
||||
|
||||
const reranked = scored
|
||||
.sort((a, b) => (b.score || 0) - (a.score || 0))
|
||||
.slice(0, topN);
|
||||
|
||||
console.log(`[KBRetriever] reranked ${chunks.length} → ${reranked.length} chunks in ${latencyMs}ms (${config.rerankerModel}), scores=[${reranked.map(c => (c.score || 0).toFixed(3)).join(',')}]`);
|
||||
return reranked;
|
||||
}
|
||||
|
||||
console.warn(`[KBRetriever] reranker returned ${scores.length} scores (expected ${chunks.length}) in ${latencyMs}ms, fallback to retrieval order`);
|
||||
return chunks.slice(0, topN);
|
||||
} catch (err) {
|
||||
const errDetail = err.response?.data?.message || err.message;
|
||||
console.warn(`[KBRetriever] reranker failed: ${errDetail}, fallback to retrieval order`);
|
||||
return chunks.slice(0, topN);
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 3. 构建 RAG payload ============
|
||||
function buildRagPayload(rerankedChunks, conversationHistory = []) {
|
||||
const ragItems = [];
|
||||
|
||||
// 语气引导:让 S2S 用口语化方式复述 KB 内容,保持与自由对话一致的语音风格
|
||||
ragItems.push({
|
||||
title: '回答要求',
|
||||
content: '用口语化、简洁的方式回答,像朋友聊天一样自然地说出来,不要念稿、不要播音腔。先给结论,再补充关键信息。',
|
||||
});
|
||||
|
||||
// 注入对话上下文(如果有)
|
||||
if (conversationHistory && conversationHistory.length > 0) {
|
||||
const contextLines = conversationHistory.map(msg => {
|
||||
const roleName = msg.role === 'user' ? '用户' : '助手';
|
||||
return `${roleName}: ${msg.content}`;
|
||||
});
|
||||
ragItems.push({
|
||||
title: '对话上下文',
|
||||
content: contextLines.join('\n'),
|
||||
});
|
||||
}
|
||||
|
||||
// 注入重排后的 KB 片段
|
||||
for (let i = 0; i < rerankedChunks.length; i++) {
|
||||
const chunk = rerankedChunks[i];
|
||||
ragItems.push({
|
||||
title: chunk.doc_name || `知识库片段${i + 1}`,
|
||||
content: chunk.content,
|
||||
});
|
||||
}
|
||||
|
||||
return ragItems;
|
||||
}
|
||||
|
||||
// ============ 4. 主入口:检索 → 重排 → 组装 ============
|
||||
async function searchAndRerank(query, opts = {}) {
|
||||
const {
|
||||
datasetIds = null,
|
||||
sessionId = null,
|
||||
session = null,
|
||||
originalQuery = null,
|
||||
} = opts;
|
||||
|
||||
const config = getConfig();
|
||||
const startTime = Date.now();
|
||||
|
||||
// Step 1: 纯检索(用极低阈值,让 reranker 做质量判断)
|
||||
const RETRIEVAL_THRESHOLD = 0.01;
|
||||
const retrievalResult = await retrieveChunks(
|
||||
query,
|
||||
datasetIds,
|
||||
config.retrievalTopK,
|
||||
RETRIEVAL_THRESHOLD
|
||||
);
|
||||
|
||||
if (retrievalResult.error) {
|
||||
return {
|
||||
hit: false,
|
||||
reason: retrievalResult.error,
|
||||
chunks: [],
|
||||
rerankedChunks: [],
|
||||
ragPayload: [],
|
||||
latencyMs: Date.now() - startTime,
|
||||
source: 'ark_knowledge',
|
||||
};
|
||||
}
|
||||
|
||||
if (retrievalResult.chunks.length === 0) {
|
||||
return {
|
||||
hit: false,
|
||||
reason: retrievalResult.kbHasContent ? 'chunks_parse_failed' : 'no_relevant_content',
|
||||
chunks: [],
|
||||
rerankedChunks: [],
|
||||
ragPayload: [],
|
||||
latencyMs: Date.now() - startTime,
|
||||
source: 'ark_knowledge',
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: 重排
|
||||
const rerankedChunks = await rerankChunks(
|
||||
originalQuery || query,
|
||||
retrievalResult.chunks,
|
||||
config.rerankerTopN
|
||||
);
|
||||
|
||||
// Step 3: 获取对话上下文(Redis → 降级 MySQL)
|
||||
let conversationHistory = [];
|
||||
if (config.enableRedisContext && sessionId) {
|
||||
const redisHistory = await redisClient.getRecentHistory(sessionId, 5);
|
||||
if (redisHistory && redisHistory.length > 0) {
|
||||
conversationHistory = redisHistory;
|
||||
console.log(`[KBRetriever] loaded ${redisHistory.length} history items from Redis`);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: 组装 payload
|
||||
const ragPayload = buildRagPayload(rerankedChunks, conversationHistory);
|
||||
|
||||
// Step 5: 判断 hit/no-hit
|
||||
// 基于重排分数判断:最高分 > 0.3 视为 hit
|
||||
const topScore = rerankedChunks.length > 0 ? (rerankedChunks[0].score || 0) : 0;
|
||||
const hitThreshold = config.enableReranker && config.rerankerModel ? 0.1 : 0.3;
|
||||
const hit = rerankedChunks.length > 0 && topScore >= hitThreshold;
|
||||
|
||||
const totalLatencyMs = Date.now() - startTime;
|
||||
console.log(`[KBRetriever] searchAndRerank completed in ${totalLatencyMs}ms: ${retrievalResult.chunks.length} retrieved → ${rerankedChunks.length} reranked, hit=${hit}, topScore=${topScore.toFixed(3)}`);
|
||||
|
||||
return {
|
||||
query,
|
||||
originalQuery: originalQuery || query,
|
||||
hit,
|
||||
reason: hit ? 'reranked_hit' : 'below_threshold',
|
||||
chunks: retrievalResult.chunks,
|
||||
rerankedChunks,
|
||||
ragPayload,
|
||||
topScore,
|
||||
latencyMs: totalLatencyMs,
|
||||
retrievalLatencyMs: retrievalResult.latencyMs,
|
||||
source: 'ark_knowledge',
|
||||
hasReferences: retrievalResult.hasReferences,
|
||||
usage: retrievalResult.usage,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
retrieveChunks,
|
||||
rerankChunks,
|
||||
buildRagPayload,
|
||||
searchAndRerank,
|
||||
getConfig,
|
||||
};
|
||||
@@ -23,6 +23,7 @@ const {
|
||||
shouldForceKnowledgeRoute,
|
||||
resolveReply,
|
||||
} = require('./realtimeDialogRouting');
|
||||
const ToolExecutor = require('./toolExecutor');
|
||||
const {
|
||||
DEFAULT_VOICE_ASSISTANT_PROFILE,
|
||||
resolveAssistantProfile,
|
||||
@@ -30,6 +31,7 @@ const {
|
||||
buildVoiceGreeting,
|
||||
} = require('./assistantProfileConfig');
|
||||
const { getAssistantProfile } = require('./assistantProfileService');
|
||||
const redisClient = require('./redisClient');
|
||||
|
||||
const sessions = new Map();
|
||||
|
||||
@@ -163,6 +165,7 @@ function persistUserSpeech(session, text) {
|
||||
session.latestUserTurnSeq = (session.latestUserTurnSeq || 0) + 1;
|
||||
resetIdleTimer(session);
|
||||
db.addMessage(session.sessionId, 'user', cleanText, 'voice_asr').catch((e) => console.warn('[NativeVoice][DB] add user failed:', e.message));
|
||||
redisClient.pushMessage(session.sessionId, { role: 'user', content: cleanText, source: 'voice_asr' }).catch(() => {});
|
||||
sendJson(session.client, {
|
||||
type: 'subtitle',
|
||||
role: 'user',
|
||||
@@ -185,6 +188,7 @@ function persistAssistantSpeech(session, text, { source = 'voice_bot', toolName
|
||||
resetIdleTimer(session);
|
||||
if (persistToDb) {
|
||||
db.addMessage(session.sessionId, 'assistant', cleanText, source, toolName, meta).catch((e) => console.warn('[NativeVoice][DB] add assistant failed:', e.message));
|
||||
redisClient.pushMessage(session.sessionId, { role: 'assistant', content: cleanText, source }).catch(() => {});
|
||||
}
|
||||
sendJson(session.client, {
|
||||
type: 'subtitle',
|
||||
@@ -418,7 +422,7 @@ async function processReply(session, text, turnSeq = session.latestUserTurnSeq |
|
||||
// 防止用户质疑/纠正产品信息时S2S自由编造(如"粉末来的呀你搞错了吧")
|
||||
const KB_PROTECTION_WINDOW_MS = 60000;
|
||||
if (!isKnowledgeCandidate && session._lastKbHitAt && (Date.now() - session._lastKbHitAt < KB_PROTECTION_WINDOW_MS)) {
|
||||
const isPureChitchat = /^(喂|你好|嗨|谢谢|再见|拜拜|好的|嗯|哦|行|没事了|不用了|可以了)[,,。!?\s]*$/.test(cleanText);
|
||||
const isPureChitchat = /^(喂|你好|嗨|hi|hello|谢谢|谢谢你|谢谢啦|多谢|感谢|再见|拜拜|拜|好的|嗯|哦|行|没事了|不用了|可以了|好的谢谢|没问题|知道了|明白了|了解了|好嘞|好吧|行吧|ok|okay)[,,。!?~\s]*$/i.test(cleanText);
|
||||
if (!isPureChitchat) {
|
||||
isKnowledgeCandidate = true;
|
||||
console.log(`[NativeVoice] KB protection window active, promoting to kbCandidate session=${session.sessionId} lastKbHit=${Math.round((Date.now() - session._lastKbHitAt) / 1000)}s ago`);
|
||||
@@ -450,8 +454,14 @@ async function processReply(session, text, turnSeq = session.latestUserTurnSeq |
|
||||
isSimilar = overlap / shorter.length >= 0.45;
|
||||
}
|
||||
if (isSimilar) {
|
||||
console.log(`[NativeVoice] using KB prequery cache session=${session.sessionId} preText=${JSON.stringify(session._kbPrequeryText.slice(0, 60))}`);
|
||||
resolveResult = await session.pendingKbPrequery;
|
||||
const prequeryResult = await session.pendingKbPrequery;
|
||||
// 只复用 hit 结果;no-hit 可能因 partial 文本路由不完整,用完整文本 re-search
|
||||
if (prequeryResult && prequeryResult.delivery !== 'upstream_chat') {
|
||||
console.log(`[NativeVoice] using KB prequery cache (hit) session=${session.sessionId} preText=${JSON.stringify(session._kbPrequeryText.slice(0, 60))}`);
|
||||
resolveResult = prequeryResult;
|
||||
} else {
|
||||
console.log(`[NativeVoice] prequery no-hit, re-searching with full text session=${session.sessionId} preText=${JSON.stringify((session._kbPrequeryText || '').slice(0, 40))} finalText=${JSON.stringify(cleanText.slice(0, 40))}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`[NativeVoice] KB prequery text mismatch, re-querying session=${session.sessionId} pre=${JSON.stringify(preText.slice(0, 40))} final=${JSON.stringify(finalText.slice(0, 40))}`);
|
||||
}
|
||||
@@ -469,13 +479,12 @@ async function processReply(session, text, turnSeq = session.latestUserTurnSeq |
|
||||
return;
|
||||
}
|
||||
if (delivery === 'upstream_chat') {
|
||||
// kbCandidate 但 S2S 未调工具 → 放开 S2S 自然回复
|
||||
// 依赖:1) system prompt 品牌保护指令引导 S2S 调工具 2) isBrandHarmful 流式拦截兜底
|
||||
if (isKnowledgeCandidate) {
|
||||
console.log(`[NativeVoice] processReply kb-nohit retrigger session=${session.sessionId}`);
|
||||
session.discardNextAssistantResponse = true;
|
||||
await sendExternalRag(session, [{ title: '用户问题', content: cleanText }]);
|
||||
} else {
|
||||
session.blockUpstreamAudio = false;
|
||||
console.log(`[NativeVoice] processReply kbCandidate+upstream_chat, unblock S2S session=${session.sessionId}`);
|
||||
}
|
||||
session.blockUpstreamAudio = false;
|
||||
session._lastPartialAt = 0;
|
||||
session.awaitingUpstreamReply = true;
|
||||
session.pendingAssistantSource = 'voice_bot';
|
||||
@@ -499,10 +508,8 @@ async function processReply(session, text, turnSeq = session.latestUserTurnSeq |
|
||||
session._lastKbTopic = cleanText;
|
||||
session._lastKbHitAt = Date.now();
|
||||
}
|
||||
// 直接用KB原始回答作为字幕,不依赖S2S event 351(S2S可能拆段/改写/丢失内容)
|
||||
const ragSubtitleText = ragContent.map((item) => item.content).join(' ');
|
||||
persistAssistantSpeech(session, ragSubtitleText, { source, toolName, meta: responseMeta });
|
||||
session.lastDeliveredAssistantTurnSeq = activeTurnSeq;
|
||||
// 不提前发KB原文作字幕:等S2S event 351返回实际语音文本后再更新字幕
|
||||
// 这样字幕和语音保持一致(S2S会基于RAG内容生成自然口语化的回答)
|
||||
session._pendingExternalRagReply = true;
|
||||
await sendExternalRag(session, ragContent);
|
||||
session.awaitingUpstreamReply = true;
|
||||
@@ -891,9 +898,10 @@ function handleUpstreamMessage(session, data) {
|
||||
});
|
||||
}
|
||||
}
|
||||
// 用户开口说话时立即打断所有 AI 播放(包括 S2S 默认 TTS)
|
||||
if (isDirectSpeaking || isChatTTSSpeaking) {
|
||||
console.log(`[NativeVoice] user barge-in (partial) session=${session.sessionId} direct=${isDirectSpeaking} chatTTS=${isChatTTSSpeaking}`);
|
||||
// 用户开口说话时立即打断所有 AI 播放(包括 S2S external_rag 音频)
|
||||
const isS2SAudioPlaying = !session.blockUpstreamAudio && session.currentTtsType === 'external_rag';
|
||||
if (isDirectSpeaking || isChatTTSSpeaking || isS2SAudioPlaying) {
|
||||
console.log(`[NativeVoice] user barge-in (partial) session=${session.sessionId} direct=${isDirectSpeaking} chatTTS=${isChatTTSSpeaking} s2sRag=${isS2SAudioPlaying}`);
|
||||
session.directSpeakUntil = 0;
|
||||
session.isSendingChatTTSText = false;
|
||||
session.chatTTSUntil = 0;
|
||||
@@ -902,6 +910,8 @@ function handleUpstreamMessage(session, data) {
|
||||
if (session.suppressReplyTimer || session.suppressUpstreamUntil) {
|
||||
clearUpstreamSuppression(session);
|
||||
}
|
||||
// 阻断 S2S 音频转发,防止用户打断后仍听到残留音频
|
||||
session.blockUpstreamAudio = true;
|
||||
}
|
||||
// 无论当前是否在播放,都发送 tts_reset 确保客户端停止所有音频播放
|
||||
if (!session._lastBargeInResetAt || now - session._lastBargeInResetAt > 500) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const ToolExecutor = require('./toolExecutor');
|
||||
const db = require('../db');
|
||||
const redisClient = require('./redisClient');
|
||||
const { hasKnowledgeRouteKeyword } = require('./knowledgeKeywords');
|
||||
|
||||
function normalizeTextForSpeech(text) {
|
||||
@@ -270,14 +271,18 @@ async function resolveReply(sessionId, session, text) {
|
||||
const ragItems = fastResult.hit && Array.isArray(fastResult.results)
|
||||
? fastResult.results.filter(i => i && i.content).map(i => ({ title: i.title || '知识库结果', content: i.content }))
|
||||
: [];
|
||||
console.log(`[resolveReply] fast-path hit in ${Date.now() - _resolveStart}ms session=${sessionId} source=${fastResult.hot_answer ? 'hot_answer' : (fastResult.cache_hit ? 'cache' : 'direct')}`);
|
||||
console.log(`[resolveReply] fast-path hit in ${Date.now() - _resolveStart}ms session=${sessionId} source=${fastResult.hot_answer ? 'hot_answer' : (fastResult.cache_hit ? 'cache' : 'direct')} mode=${fastResult.retrieval_mode || 'answer'}`);
|
||||
if (ragItems.length > 0) {
|
||||
const cleanedText = normalizeTextForSpeech(replyText).replace(/^(根据知识库信息[,,::\s]*|根据.*?[,,]\s*)/i, '');
|
||||
session.handoffSummaryUsed = true;
|
||||
// raw 模式:ragItems 已包含上下文 + 多个 KB 片段,直接透传
|
||||
const isRawMode = fastResult.retrieval_mode === 'raw';
|
||||
const finalRagItems = isRawMode
|
||||
? ragItems
|
||||
: [{ title: '知识库结果', content: normalizeTextForSpeech(replyText).replace(/^(根据知识库信息[,,::\s]*|根据.*?[,,]\s*)/i, '') || replyText }];
|
||||
return {
|
||||
delivery: 'external_rag',
|
||||
speechText: '',
|
||||
ragItems: [{ title: '知识库结果', content: cleanedText || replyText }],
|
||||
ragItems: finalRagItems,
|
||||
source: 'voice_tool',
|
||||
toolName: 'search_knowledge',
|
||||
routeDecision: { route: 'search_knowledge', args: { query: originalText } },
|
||||
@@ -295,10 +300,22 @@ async function resolveReply(sessionId, session, text) {
|
||||
}
|
||||
}
|
||||
|
||||
// 上下文加载:优先 Redis(~5ms),降级 MySQL(~100ms)
|
||||
const _dbStart = Date.now();
|
||||
const recentMessages = await db.getRecentMessages(sessionId, 10).catch(() => []);
|
||||
const _dbMs = Date.now() - _dbStart;
|
||||
if (_dbMs > 50) console.log(`[resolveReply] DB getRecentMessages took ${_dbMs}ms session=${sessionId}`);
|
||||
let recentMessages = null;
|
||||
if (process.env.ENABLE_REDIS_CONTEXT !== 'false') {
|
||||
const redisHistory = await redisClient.getRecentHistory(sessionId, 5).catch(() => null);
|
||||
if (redisHistory && redisHistory.length > 0) {
|
||||
recentMessages = redisHistory;
|
||||
const _dbMs = Date.now() - _dbStart;
|
||||
if (_dbMs > 5) console.log(`[resolveReply] Redis getRecentHistory took ${_dbMs}ms session=${sessionId} items=${redisHistory.length}`);
|
||||
}
|
||||
}
|
||||
if (!recentMessages) {
|
||||
recentMessages = await db.getRecentMessages(sessionId, 10).catch(() => []);
|
||||
const _dbMs = Date.now() - _dbStart;
|
||||
if (_dbMs > 50) console.log(`[resolveReply] DB getRecentMessages took ${_dbMs}ms session=${sessionId}`);
|
||||
}
|
||||
const scopedMessages = session?.handoffSummaryUsed
|
||||
? recentMessages.filter((item) => !/^chat_/i.test(String(item?.source || '')))
|
||||
: recentMessages;
|
||||
@@ -310,6 +327,16 @@ async function resolveReply(sessionId, session, text) {
|
||||
if (routeDecision.route === 'chat' && shouldForceKnowledgeRoute(originalText, context)) {
|
||||
routeDecision = { route: 'search_knowledge', args: { query: originalText } };
|
||||
}
|
||||
// KB保护窗口:60秒内有KB命中,当前非纯闲聊,强制走KB搜索
|
||||
// 防止追问(如"它需要漱口吗")绕过KB走S2S自由编造
|
||||
const KB_PROTECTION_WINDOW_MS = 60000;
|
||||
if (routeDecision.route === 'chat' && session?._lastKbHitAt && (Date.now() - session._lastKbHitAt < KB_PROTECTION_WINDOW_MS)) {
|
||||
const isPureChitchat = /^(喂|你好|嗨|hi|hello|谢谢|谢谢你|谢谢啦|多谢|感谢|再见|拜拜|拜|好的|嗯|哦|行|没事了|不用了|可以了|好的谢谢|没问题|知道了|明白了|了解了|好嘞|好吧|行吧|ok|okay)[,,。!?~\s]*$/i.test(originalText);
|
||||
if (!isPureChitchat) {
|
||||
routeDecision = { route: 'search_knowledge', args: { query: originalText } };
|
||||
console.log(`[resolveReply] KB protection window active, forcing KB route session=${sessionId} lastKbHit=${Math.round((Date.now() - session._lastKbHitAt) / 1000)}s ago`);
|
||||
}
|
||||
}
|
||||
let replyText = '';
|
||||
let source = 'voice_bot';
|
||||
let toolName = null;
|
||||
@@ -368,24 +395,24 @@ async function resolveReply(sessionId, session, text) {
|
||||
: []);
|
||||
|
||||
if (ragItems.length > 0) {
|
||||
let speechText = normalizeTextForSpeech(replyText);
|
||||
session.handoffSummaryUsed = true;
|
||||
if (toolName === 'search_knowledge' && speechText) {
|
||||
const cleanedText = speechText.replace(/^(根据知识库信息[,,::\s]*|根据.*?[,,]\s*)/i, '');
|
||||
return {
|
||||
delivery: 'external_rag',
|
||||
speechText: '',
|
||||
ragItems: [{ title: '知识库结果', content: cleanedText || speechText }],
|
||||
source,
|
||||
toolName,
|
||||
routeDecision,
|
||||
responseMeta,
|
||||
};
|
||||
const isRawMode = toolResult?.retrieval_mode === 'raw';
|
||||
let finalRagItems = ragItems;
|
||||
|
||||
if (toolName === 'search_knowledge' && !isRawMode) {
|
||||
// 旧模式:LLM 加工过的文本,清理后合并为单条
|
||||
const speechText = normalizeTextForSpeech(replyText);
|
||||
if (speechText) {
|
||||
const cleanedText = speechText.replace(/^(根据知识库信息[,,::\s]*|根据.*?[,,]\s*)/i, '');
|
||||
finalRagItems = [{ title: '知识库结果', content: cleanedText || speechText }];
|
||||
}
|
||||
}
|
||||
// raw 模式:ragItems 已包含上下文 + 多个 KB 片段,直接透传给 S2S
|
||||
|
||||
return {
|
||||
delivery: 'external_rag',
|
||||
speechText: '',
|
||||
ragItems,
|
||||
ragItems: finalRagItems,
|
||||
source,
|
||||
toolName,
|
||||
routeDecision,
|
||||
|
||||
184
test2/server/services/redisClient.js
Normal file
184
test2/server/services/redisClient.js
Normal file
@@ -0,0 +1,184 @@
|
||||
const Redis = require('ioredis');
|
||||
|
||||
// ============ 配置 ============
|
||||
const REDIS_URL = process.env.REDIS_URL || 'redis://127.0.0.1:6379';
|
||||
const REDIS_PASSWORD = process.env.REDIS_PASSWORD || '';
|
||||
const REDIS_DB = parseInt(process.env.REDIS_DB) || 0;
|
||||
const KEY_PREFIX = process.env.REDIS_KEY_PREFIX || 'bigwo:';
|
||||
|
||||
const HISTORY_MAX_LEN = 10; // 5轮 × 2条/轮
|
||||
const HISTORY_TTL_S = 1800; // 30分钟
|
||||
const KB_CACHE_HIT_TTL_S = 300; // 5分钟
|
||||
const KB_CACHE_NOHIT_TTL_S = 120; // 2分钟
|
||||
|
||||
// ============ 连接管理 ============
|
||||
let client = null;
|
||||
let isReady = false;
|
||||
|
||||
function createClient() {
|
||||
if (client) return client;
|
||||
|
||||
const opts = {
|
||||
db: REDIS_DB,
|
||||
keyPrefix: KEY_PREFIX,
|
||||
retryStrategy(times) {
|
||||
if (times > 10) {
|
||||
console.error('[Redis] max retries reached, giving up');
|
||||
return null;
|
||||
}
|
||||
return Math.min(times * 200, 3000);
|
||||
},
|
||||
reconnectOnError(err) {
|
||||
const targetErrors = ['READONLY', 'ECONNRESET'];
|
||||
return targetErrors.some(e => err.message.includes(e));
|
||||
},
|
||||
lazyConnect: true,
|
||||
maxRetriesPerRequest: 2,
|
||||
connectTimeout: 5000,
|
||||
commandTimeout: 3000,
|
||||
};
|
||||
|
||||
if (REDIS_PASSWORD) {
|
||||
opts.password = REDIS_PASSWORD;
|
||||
}
|
||||
|
||||
client = new Redis(REDIS_URL, opts);
|
||||
|
||||
client.on('ready', () => {
|
||||
isReady = true;
|
||||
console.log('[Redis] connected and ready');
|
||||
});
|
||||
client.on('error', (err) => {
|
||||
console.warn('[Redis] error:', err.message);
|
||||
});
|
||||
client.on('close', () => {
|
||||
isReady = false;
|
||||
console.warn('[Redis] connection closed');
|
||||
});
|
||||
client.on('reconnecting', () => {
|
||||
console.log('[Redis] reconnecting...');
|
||||
});
|
||||
|
||||
client.connect().catch((err) => {
|
||||
console.warn('[Redis] initial connect failed:', err.message);
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
if (!client) createClient();
|
||||
return client;
|
||||
}
|
||||
|
||||
function isAvailable() {
|
||||
return isReady && client && client.status === 'ready';
|
||||
}
|
||||
|
||||
// ============ 对话历史 ============
|
||||
const historyKey = (sessionId) => `voice:history:${sessionId}`;
|
||||
|
||||
async function pushMessage(sessionId, msg) {
|
||||
if (!isAvailable()) return false;
|
||||
try {
|
||||
const key = historyKey(sessionId);
|
||||
const value = JSON.stringify({
|
||||
role: msg.role,
|
||||
content: msg.content,
|
||||
source: msg.source || '',
|
||||
ts: Date.now(),
|
||||
});
|
||||
await client.lpush(key, value);
|
||||
await client.ltrim(key, 0, HISTORY_MAX_LEN - 1);
|
||||
await client.expire(key, HISTORY_TTL_S);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[Redis] pushMessage failed:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getRecentHistory(sessionId, maxRounds = 5) {
|
||||
if (!isAvailable()) return null;
|
||||
try {
|
||||
const key = historyKey(sessionId);
|
||||
const items = await client.lrange(key, 0, maxRounds * 2 - 1);
|
||||
if (!items || items.length === 0) return [];
|
||||
// lpush 是倒序插入,lrange 取出的也是最新在前,需要 reverse 恢复时间顺序
|
||||
return items
|
||||
.map((item) => {
|
||||
try { return JSON.parse(item); } catch { return null; }
|
||||
})
|
||||
.filter(Boolean)
|
||||
.reverse();
|
||||
} catch (err) {
|
||||
console.warn('[Redis] getRecentHistory failed:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearSession(sessionId) {
|
||||
if (!isAvailable()) return false;
|
||||
try {
|
||||
await client.del(historyKey(sessionId));
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[Redis] clearSession failed:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ KB 缓存 ============
|
||||
const kbCacheKey = (cacheKey) => `kb_cache:${cacheKey}`;
|
||||
|
||||
async function setKbCache(cacheKey, result) {
|
||||
// 只缓存 hit 结果;no-hit 不写入 Redis,避免阻止后续 retry
|
||||
if (!isAvailable() || !result.hit) return false;
|
||||
try {
|
||||
const key = kbCacheKey(cacheKey);
|
||||
await client.set(key, JSON.stringify(result), 'EX', KB_CACHE_HIT_TTL_S);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[Redis] setKbCache failed:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getKbCache(cacheKey) {
|
||||
if (!isAvailable()) return null;
|
||||
try {
|
||||
const key = kbCacheKey(cacheKey);
|
||||
const data = await client.get(key);
|
||||
if (!data) return null;
|
||||
return JSON.parse(data);
|
||||
} catch (err) {
|
||||
console.warn('[Redis] getKbCache failed:', err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 优雅关闭 ============
|
||||
async function disconnect() {
|
||||
if (client) {
|
||||
try {
|
||||
await client.quit();
|
||||
} catch {
|
||||
client.disconnect();
|
||||
}
|
||||
client = null;
|
||||
isReady = false;
|
||||
console.log('[Redis] disconnected');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createClient,
|
||||
getClient,
|
||||
isAvailable,
|
||||
pushMessage,
|
||||
getRecentHistory,
|
||||
clearSession,
|
||||
setKbCache,
|
||||
getKbCache,
|
||||
disconnect,
|
||||
};
|
||||
@@ -3,6 +3,8 @@ const https = require('https');
|
||||
const arkChatService = require('./arkChatService');
|
||||
const { buildKnowledgeAnswerPrompt, resolveAssistantProfile } = require('./assistantProfileConfig');
|
||||
const { getAssistantProfile } = require('./assistantProfileService');
|
||||
const kbRetriever = require('./kbRetriever');
|
||||
const redisClient = require('./redisClient');
|
||||
|
||||
// HTTP keep-alive agent:复用TCP连接,避免每次请求重新握手
|
||||
const kbHttpAgent = new https.Agent({
|
||||
@@ -51,13 +53,15 @@ const KB_CACHE_MAX_SIZE = 200;
|
||||
const kbQueryCache = new Map();
|
||||
|
||||
function getKbCacheKey(query, datasetIds, profileScope = 'global') {
|
||||
return `${String(profileScope || 'global').trim() || 'global'}|${(query || '').trim()}|${(datasetIds || []).sort().join(',')}`;
|
||||
const mode = process.env.VOLC_ARK_KB_RETRIEVAL_MODE || 'answer';
|
||||
return `vdb2|${mode}|${String(profileScope || 'global').trim() || 'global'}|${(query || '').trim()}|${(datasetIds || []).sort().join(',')}`;
|
||||
}
|
||||
|
||||
function getKbCache(key) {
|
||||
const entry = kbQueryCache.get(key);
|
||||
if (!entry) return null;
|
||||
const ttl = entry.hit ? KB_CACHE_TTL_MS : KB_CACHE_NOHIT_TTL_MS;
|
||||
// hit: 5min TTL; no-hit: 10s 短 TTL(仅防同一轮次重复查 VikingDB)
|
||||
const ttl = entry.hit ? KB_CACHE_TTL_MS : 10000;
|
||||
if (Date.now() - entry.timestamp > ttl) {
|
||||
kbQueryCache.delete(key);
|
||||
return null;
|
||||
@@ -70,6 +74,7 @@ function setKbCache(key, result) {
|
||||
const oldest = kbQueryCache.keys().next().value;
|
||||
kbQueryCache.delete(oldest);
|
||||
}
|
||||
// hit: 正常缓存; no-hit: 内存 10s 去重(防止同一轮次重复查 VikingDB,不写 Redis)
|
||||
kbQueryCache.set(key, { result, timestamp: Date.now(), hit: !!result.hit });
|
||||
}
|
||||
|
||||
@@ -354,10 +359,20 @@ class ToolExecutor {
|
||||
// 确定路由:多意图可并行,只排除真正冲突的组合
|
||||
const priorityRouteNames = [];
|
||||
if (hasSystemIntent) priorityRouteNames.push('system');
|
||||
if (hasProductIntent) priorityRouteNames.push('product');
|
||||
if (hasCompanyIntent) priorityRouteNames.push('company');
|
||||
if (hasFaqIntent && !hasProductIntent) priorityRouteNames.push('faq');
|
||||
if (hasScienceIntent && !hasProductIntent && !hasFaqIntent) priorityRouteNames.push('science');
|
||||
if (hasProductIntent) {
|
||||
priorityRouteNames.push('product');
|
||||
// 产品问题同时搜FAQ和科普,获取更全面的回答(好转反应、科普误区等补充信息)
|
||||
if (!hasFaqIntent) priorityRouteNames.push('faq');
|
||||
if (!hasScienceIntent) priorityRouteNames.push('science');
|
||||
}
|
||||
if (hasCompanyIntent) {
|
||||
priorityRouteNames.push('company');
|
||||
// 公司问题同时搜产品和系统培训,test collection 内容有限
|
||||
if (!hasProductIntent) priorityRouteNames.push('product');
|
||||
if (!hasSystemIntent) priorityRouteNames.push('system');
|
||||
}
|
||||
if (hasFaqIntent) priorityRouteNames.push('faq');
|
||||
if (hasScienceIntent) priorityRouteNames.push('science');
|
||||
|
||||
if (priorityRouteNames.length > 0) {
|
||||
const routingRules = this.getKnowledgeBaseRoutingRules();
|
||||
@@ -392,16 +407,14 @@ class ToolExecutor {
|
||||
|
||||
static buildDeterministicKnowledgeQuery(query, context = []) {
|
||||
const text = String(query || '').trim();
|
||||
const recentContextText = (Array.isArray(context) ? context : [])
|
||||
.slice(-6)
|
||||
.map((item) => String(item?.content || '').trim())
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
const haystack = `${text}\n${recentContextText}`;
|
||||
const questionDimension = text.match(/(功效|作用|成分|配方|原料|怎么吃|怎么用|怎么服用|服用方法|吃法|用法|用量|一天几次|每天几次|每日几次|副作用|好转反应|价格|多少钱|适合谁|适用人群|区别|不同|搭配|原理|规格|包装|剂型|形态|粉末|胶囊|片剂|颗粒|喷雾|乳霜|口服液)/);
|
||||
|
||||
// 第一层:当前查询文本中有明确产品/系统/主题关键词 → 直接改写(不依赖上下文)
|
||||
if (/(基础三合一|三合一基础套|基础套装|大白小红小白)/i.test(text)) return '德国PM细胞营养素 基础套装 大白 小红 小白';
|
||||
// ====================================================================
|
||||
// 精简版:只保留 VikingDB 语义检索已知会失败的场景
|
||||
// 产品/公司/认证等查询全部交给 VikingDB + reranker 处理原始语义
|
||||
// 追问/代词由 enrichQueryWithContext + KB保护窗口 处理
|
||||
// ====================================================================
|
||||
|
||||
// === 一成系统子话题分流(内部术语,向量检索难区分子话题) ===
|
||||
if (/(一成系统|Ai众享|数字化工作室|盛咖学愿|三大平台|四大Ai生态|四大生态|智能生产力)/i.test(text)) {
|
||||
if (/(核心竞争力|竞争力|核心优势|优势)/i.test(text)) return '一成系统 核心竞争力 三大平台 四大Ai生态 零成本高效率';
|
||||
if (/(发展|怎么做|怎么用|如何用|如何做|关键点|关键|方法|步骤)/i.test(text)) return '一成系统 发展PM事业 三大平台 四大Ai生态 零成本高效率 全球市场';
|
||||
@@ -421,115 +434,24 @@ class ToolExecutor {
|
||||
if (/(身未动,?梦已成|批发式晋级)/i.test(text)) return '一成系统 身未动梦已成 批发式晋级 三大平台 四大Ai生态';
|
||||
if (/行动圈/i.test(text)) return '一成系统 行动圈 数字化工作室 团队管理 目标考核';
|
||||
if (/盟主社区/i.test(text)) return '一成系统 盟主社区 AI众享 社区盟主 引流 转化';
|
||||
if (/(宣明会|世界宣明会)/i.test(text)) return '德国PM 宣明会 世界宣明会 慈善合作';
|
||||
if (/BFH/i.test(text)) return '德国PM BFH AAA+ 合作伙伴收益';
|
||||
if (/DSN/i.test(text)) return '德国PM DSN 全球100强 欧洲第1';
|
||||
if (/(邓白氏|AAA\+)/i.test(text)) return '德国PM 邓白氏 AAA+ 99分';
|
||||
if (/(ELAB|科隆名单|Halal|GMP)/i.test(text)) return '德国PM ELAB 科隆名单 Halal GMP 安全认证';
|
||||
if (/(Rolf Sorg|斯派尔|Speyer|卢森堡)/i.test(text)) return '德国PM Rolf Sorg 斯派尔 卢森堡 总部 公司介绍';
|
||||
if (/(培安|烟台)/i.test(text)) return '德国PM 培安 烟台 中国市场投资';
|
||||
if (/(PM公司|德国PM|公司地址|联系方式|电话|公司实力|公司背景|总部|分公司)/i.test(text)) {
|
||||
if (/(产品|细胞营养素|基础套装|基础三合一|小红|大白|小白|activize|basics|restorate|fitline|儿童倍适)/i.test(text)) {
|
||||
return '德国PM FitLine 细胞营养素产品 大白Basics 小红Activize 小白Restorate 儿童倍适';
|
||||
}
|
||||
if (/(地址|电话|联系方式)/i.test(text)) return '德国PM 日本 美国 加拿大 香港 地址 电话';
|
||||
if (/(实力|背景)/i.test(text)) return '德国PM 公司实力介绍 邓白氏 99分 AAA+';
|
||||
return '德国PM 1993年 创立 100多个国家 FitLine 公司介绍';
|
||||
}
|
||||
if (/(德国PM介绍|介绍德国PM|德国PM公司介绍|PM公司介绍|PM介绍)/i.test(text)) return '德国PM 1993年 创立 100多个国家 FitLine 公司介绍 邓白氏 99分 AAA+';
|
||||
if (/(NTC.*(核心优势|核心竞争力|优势|原理|厉害)|核心优势.*NTC|核心竞争力.*NTC)/i.test(text)) return 'NTC营养保送系统 核心优势 吸收利用 原理';
|
||||
if (/(PM基础三合一介绍|基础三合一介绍|PM基础套装介绍|基础套装介绍)/i.test(text)) return '德国PM细胞营养素 基础套装 大白 小红 小白 介绍';
|
||||
if (/儿童倍适/i.test(text)) return questionDimension ? `儿童倍适 ${questionDimension[0]}` : '儿童倍适';
|
||||
if (/(小红产品|小红|Activize Oxyplus|Activize)/i.test(text)) return questionDimension ? `Fitline小红产品 Activize ${questionDimension[0]}` : 'Fitline小红产品提升能量原理';
|
||||
if (/(大白产品|大白|倍适|Basics)/i.test(text)) return questionDimension ? `德国PM细胞营养素 大白 Basics ${questionDimension[0]}` : '德国PM细胞营养素 大白 Basics';
|
||||
if (/(小白产品|小白|维适多|Restorate)/i.test(text)) return questionDimension ? `德国PM细胞营养素 小白 Restorate ${questionDimension[0]}` : '德国PM细胞营养素 小白';
|
||||
if (/(NTC营养保送系统|Nutrient Transport Concept)/i.test(text)) return 'NTC营养保送系统';
|
||||
if (/火炉原理/i.test(text)) return '火炉原理';
|
||||
if (/(阿育吠陀|Ayurveda)/i.test(text)) return '阿育吠陀医学原理';
|
||||
if (/(PM-FitLine|PM细胞营养素)/i.test(text)) return '德国PM细胞营养素 基础套装 大白 小红 小白';
|
||||
if (/(我们公司.*产品|公司.*产品|产品.*推荐|推荐.*产品|产品有哪些|产品介绍|产品列表)/i.test(text)) return '德国PM FitLine 细胞营养素产品 大白Basics 小红Activize 小白Restorate 儿童倍适';
|
||||
if (/(治病吗|能治病吗|产品治病|治疗疾病|替代药|是不是药)/i.test(text)) return 'PM产品 不是药 不能替代药物 保健食品 营养补充';
|
||||
if (/(多久见效|多久有效|多久能见效|多长时间见效|几天见效|什么时候见效)/i.test(text)) return 'PM产品 多久见效 吸收利用 周期 个体差异';
|
||||
if (/(为什么.*(全套|搭配|三合一)|为什么要.*(全套|搭配|三合一)|为何.*(全套|搭配|三合一)|产品需要全套)/i.test(text)) return '德国PM细胞营养素 全套搭配 NTC营养保送系统 协同作用';
|
||||
if (/(与其它保健品区别|与其他保健品区别|和其它保健品区别|和其他保健品区别|保健品区别)/i.test(text)) return 'PM产品 与其他保健品区别 NTC营养保送系统 吸收利用';
|
||||
if (/(新人起步三关|起步三关)/i.test(text)) return '培训新人起步三关';
|
||||
if (/(精品会议|会议组织)/i.test(text)) return '培训打造精品会议具体如下';
|
||||
if (/成长上总裁/i.test(text)) return '培训成长上总裁';
|
||||
if (/(招商|代理|加盟|合作|事业机会|招商稿|代理政策)/i.test(text)) return '一成系统 PM事业 招商与代理 软件赋能 0成本高效率';
|
||||
if (/(如何发展PM事业|怎么发展PM事业|PM事业发展逻辑|介绍PM事业|两分钟介绍PM事业|分享.*故事.*自我介绍|自我介绍|商机|PM价值)/i.test(text)) return '一成系统 PM事业 发展逻辑 商机 价值 软件赋能 三大平台 四大Ai生态 0成本高效率';
|
||||
|
||||
// === 一成系统相关业务话题 ===
|
||||
if (/(招商|代理|加盟|事业机会|招商稿|代理政策)/i.test(text)) return '一成系统 PM事业 招商与代理 软件赋能 0成本高效率';
|
||||
if (/(如何发展PM事业|怎么发展PM事业|PM事业发展逻辑|介绍PM事业|两分钟介绍PM事业)/i.test(text)) return '一成系统 PM事业 发展逻辑 商机 价值 软件赋能 三大平台 四大Ai生态 0成本高效率';
|
||||
if (/(为什么选择德国PM|为何选择德国PM|为什么选德国PM|为什么选PM|为何选PM)/i.test(text)) return '一成系统 德国PM 选择理由 公司实力 产品优势 软件赋能 0成本高效率';
|
||||
if (/(陌生客户|陌生人).*(沟通|开口|邀约|交流|切入).*(PM事业|德国PM|PM)/i.test(text)) return '一成系统 PM事业 陌生客户 沟通 邀约 话术 软件赋能';
|
||||
if (/(线上拓客|线上成交|线上开发客户|线上获客|线上成交率)/i.test(text)) return '一成系统 PM事业 线上拓客 成交 获客';
|
||||
if (/(团队.*AI智能生产力|AI智能生产力.*团队|团队.*AI生产力|AI生产力.*团队)/i.test(text)) return '一成系统 AI智能生产力 赋能团队';
|
||||
if (/(三大平台|四大Ai生态|四大生态)/i.test(text)) return '一成系统 三大平台 四大Ai生态';
|
||||
if (/(请分享.*故事.*自我介绍|故事.*自我介绍|个人故事.*自我介绍)/i.test(text)) return '一成系统 PM事业 故事分享 自我介绍 软件赋能';
|
||||
if (/(一成AI|AI落地|ai落地|转观念|落地对比)/i.test(text)) return '2026一成Ai落地对比与转观念';
|
||||
if (/(传销|骗局|骗子|正规吗|合法吗|正不正规|合不合法|是不是传销|直销还是传销|层级分销|非法集资|拉人头|下线|发展下线|报单|人头费)/i.test(text)) return '德国PM 1993年 创立 100多个国家 FitLine 公司介绍 邓白氏 99分 AAA+ 合法直销';
|
||||
if (/(好转反应|整应反应|排毒反应|副作用|不良反应|皮肤发痒)/i.test(text)) return 'PM产品整应反应好转反应解析';
|
||||
if (/(促销活动|促销|优惠|打折|活动分数|5\+1)/i.test(text)) return '促销活动 5+1活动分数';
|
||||
if (/暖炉原理/i.test(text)) return '火炉原理';
|
||||
if (/(CC套装|CC胶囊)/i.test(text)) return questionDimension ? `CC套装 CC胶囊 ${questionDimension[0]}` : 'CC套装 CC胶囊';
|
||||
if (/(IB5|口腔免疫喷雾)/i.test(text)) return questionDimension ? `IB5 口腔免疫喷雾 ${questionDimension[0]}` : 'IB5 口腔免疫喷雾';
|
||||
if (/(Q10|辅酵素|氧修护)/i.test(text)) return questionDimension ? `Q10 辅酵素 氧修护 ${questionDimension[0]}` : 'Q10 辅酵素 氧修护';
|
||||
if (/(Med Dental\+|Dental\+|草本护理牙膏)/i.test(text)) return questionDimension ? `Med Dental+ 草本护理牙膏 ${questionDimension[0]}` : 'Med Dental+ 草本护理牙膏';
|
||||
if (/(Men Face|全效男士护肤抗衰乳霜)/i.test(text)) return questionDimension ? `Men Face 全效男士护肤抗衰乳霜 ${questionDimension[0]}` : 'Men Face 全效男士护肤抗衰乳霜';
|
||||
if (/(CC-Cell|CC Cell|CC乳霜)/i.test(text)) return questionDimension ? `CC-Cell 胶囊 乳霜 ${questionDimension[0]}` : 'CC-Cell 胶囊 乳霜';
|
||||
if (/(D-Drink|小绿排毒饮|14天排毒D饮料Plus)/i.test(text)) return questionDimension ? `D-Drink 小绿排毒饮 14天排毒D饮料Plus ${questionDimension[0]}` : 'D-Drink 小绿排毒饮 14天排毒D饮料Plus';
|
||||
if (/(ProShape|ProShape® Amino|氨基酸|支链氨基酸|BCAA)/i.test(text)) return questionDimension ? `ProShape Amino 氨基酸 BCAA ${questionDimension[0]}` : 'ProShape Amino 氨基酸 BCAA';
|
||||
if (/(Herbal Tea|草本茶)/i.test(text)) return questionDimension ? `Herbal Tea 草本茶 ${questionDimension[0]}` : 'Herbal Tea 草本茶';
|
||||
if (/(Hair\+|med Hair\+|口服发宝|外用发健)/i.test(text)) return questionDimension ? `Hair+ med Hair+ 口服发宝 外用发健 ${questionDimension[0]}` : 'Hair+ med Hair+ 口服发宝 外用发健';
|
||||
if (/(Fitness-Drink|运动饮料健康饮品|运动饮料)/i.test(text)) return questionDimension ? `Fitness-Drink 运动饮料健康饮品 ${questionDimension[0]}` : 'Fitness-Drink 运动饮料健康饮品';
|
||||
if (/(TopShape|孅萃TopShape纤萃减肥|纤萃减肥)/i.test(text)) return questionDimension ? `TopShape 孅萃TopShape纤萃减肥 ${questionDimension[0]}` : 'TopShape 孅萃TopShape纤萃减肥';
|
||||
if (/(Generation 50\+|乐活50\+)/i.test(text)) return questionDimension ? `乐活50+ Generation 50+ ${questionDimension[0]}` : '乐活50+ Generation 50+';
|
||||
if (/(Apple Antioxy|苹果细胞抗氧素|Antioxy|Zellschutz|细胞抗氧素)/i.test(text)) return questionDimension ? `Apple Antioxy Zellschutz 细胞抗氧素 ${questionDimension[0]}` : 'Apple Antioxy Zellschutz 细胞抗氧素';
|
||||
if (/Women\+/i.test(text)) return questionDimension ? `Women+ ${questionDimension[0]}` : 'Women+';
|
||||
if (/乐活奶昔|乐活/i.test(text)) return questionDimension ? `乐活奶昔 ${questionDimension[0]}` : '乐活奶昔';
|
||||
if (/(乳清蛋白|蛋白粉)/i.test(text)) return questionDimension ? `乳清蛋白粉 ${questionDimension[0]}` : '乳清蛋白粉';
|
||||
if (/(乳酪煲|乳酪饮品|乳酪)/i.test(text)) return questionDimension ? `乳酪煲 乳酪饮品 ${questionDimension[0]}` : '乳酪煲 乳酪饮品';
|
||||
if (/(基础二合一|二合一)/i.test(text)) return questionDimension ? `基础二合一 ${questionDimension[0]}` : '基础二合一';
|
||||
if (/倍力健/i.test(text)) return questionDimension ? `倍力健 ${questionDimension[0]}` : '倍力健';
|
||||
if (/(关节套装|关节舒缓)/i.test(text)) return questionDimension ? `关节套装 关节舒缓膏 ${questionDimension[0]}` : '关节套装 关节舒缓膏';
|
||||
if (/(男士乳霜|男士护肤)/i.test(text)) return questionDimension ? `全效男士乳霜 ${questionDimension[0]}` : '全效男士乳霜';
|
||||
if (/(去角质|面膜)/i.test(text)) return questionDimension ? `去角质面膜 ${questionDimension[0]}` : '去角质面膜';
|
||||
if (/发宝/i.test(text)) return questionDimension ? `发宝 ${questionDimension[0]}` : '发宝';
|
||||
if (/叶黄素/i.test(text)) return questionDimension ? `叶黄素 ${questionDimension[0]}` : '叶黄素';
|
||||
if (/(奶昔)/i.test(text)) return questionDimension ? `奶昔 ${questionDimension[0]}` : '奶昔';
|
||||
if (/(健康饮品)/i.test(text)) return questionDimension ? `健康饮品 ${questionDimension[0]}` : '健康饮品';
|
||||
|
||||
// 第二层:当前文本是追问/代词,才通过上下文推断主题
|
||||
const isFollowUp = /^(这个|那个|它|它的|他|他的|该|这款|那款|详细|继续|怎么|为什么|适合谁|什么意思|怎么用|怎么吃|功效|成分|好处|原理|规格|包装|剂型|形态|一天几次|每天几次|每日几次)/.test(text);
|
||||
if (isFollowUp) {
|
||||
if (/(基础三合一|三合一基础套|基础套装|大白小红小白)/i.test(recentContextText)) return questionDimension ? `德国PM细胞营养素 基础套装 大白 小红 小白 ${questionDimension[0]}` : '德国PM细胞营养素 基础套装 大白 小红 小白';
|
||||
if (/(身未动,?梦已成|批发式晋级)/i.test(recentContextText)) return '一成系统 身未动梦已成 批发式晋级 三大平台 四大Ai生态';
|
||||
if (/行动圈/i.test(recentContextText)) return '一成系统 行动圈 数字化工作室 团队管理 目标考核';
|
||||
if (/盟主社区/i.test(recentContextText)) return '一成系统 盟主社区 AI众享 社区盟主 引流 转化';
|
||||
if (/(一成系统|Ai众享|数字化工作室|盛咖学愿)/i.test(recentContextText)) return '一成系统 德国PM事业发展的强大赋能工具 三大平台 四大Ai生态';
|
||||
if (/DSN/i.test(recentContextText)) return '德国PM DSN 全球100强 欧洲第1';
|
||||
if (/(ELAB|科隆名单|Halal|GMP)/i.test(recentContextText)) return '德国PM ELAB 科隆名单 Halal GMP 安全认证';
|
||||
if (/(邓白氏|AAA\+)/i.test(recentContextText)) return '德国PM 邓白氏 AAA+ 99分';
|
||||
if (/(宣明会|世界宣明会)/i.test(recentContextText)) return '德国PM 宣明会 世界宣明会 慈善合作';
|
||||
if (/(Rolf Sorg|斯派尔|Speyer|卢森堡)/i.test(recentContextText)) return '德国PM Rolf Sorg 斯派尔 卢森堡 总部 公司介绍';
|
||||
if (/(培安|烟台)/i.test(recentContextText)) return '德国PM 培安 烟台 中国市场投资';
|
||||
if (/(小红产品|小红|Activize)/i.test(recentContextText)) return questionDimension ? `Fitline小红产品 Activize ${questionDimension[0]}` : 'Fitline小红产品提升能量原理';
|
||||
if (/(大白产品|大白|Basics)/i.test(recentContextText)) return questionDimension ? `德国PM细胞营养素 大白 Basics ${questionDimension[0]}` : '德国PM细胞营养素 大白 Basics';
|
||||
if (/(小白产品|小白|Restorate)/i.test(recentContextText)) return questionDimension ? `德国PM细胞营养素 小白 Restorate ${questionDimension[0]}` : '德国PM细胞营养素 小白';
|
||||
if (/儿童倍适/i.test(recentContextText)) return questionDimension ? `儿童倍适 ${questionDimension[0]}` : '儿童倍适';
|
||||
if (/火炉原理/i.test(recentContextText)) return '火炉原理';
|
||||
if (/(阿育吠陀|Ayurveda)/i.test(recentContextText)) return '阿育吠陀医学原理';
|
||||
if (/(NTC营养保送系统)/i.test(recentContextText)) return 'NTC营养保送系统';
|
||||
if (/(Med Dental\+|草本护理牙膏)/i.test(recentContextText)) return questionDimension ? `Med Dental+ 草本护理牙膏 ${questionDimension[0]}` : 'Med Dental+ 草本护理牙膏';
|
||||
if (/(Men Face|全效男士护肤抗衰乳霜)/i.test(recentContextText)) return questionDimension ? `Men Face 全效男士护肤抗衰乳霜 ${questionDimension[0]}` : 'Men Face 全效男士护肤抗衰乳霜';
|
||||
if (/(CC-Cell|CC胶囊|CC乳霜)/i.test(recentContextText)) return questionDimension ? `CC-Cell 胶囊 乳霜 ${questionDimension[0]}` : 'CC-Cell 胶囊 乳霜';
|
||||
if (/(D-Drink|小绿排毒饮|14天排毒D饮料Plus)/i.test(recentContextText)) return questionDimension ? `D-Drink 小绿排毒饮 14天排毒D饮料Plus ${questionDimension[0]}` : 'D-Drink 小绿排毒饮 14天排毒D饮料Plus';
|
||||
if (/(ProShape|氨基酸|BCAA)/i.test(recentContextText)) return questionDimension ? `ProShape Amino 氨基酸 BCAA ${questionDimension[0]}` : 'ProShape Amino 氨基酸 BCAA';
|
||||
if (/(Herbal Tea|草本茶)/i.test(recentContextText)) return questionDimension ? `Herbal Tea 草本茶 ${questionDimension[0]}` : 'Herbal Tea 草本茶';
|
||||
if (/(Hair\+|med Hair\+|口服发宝|外用发健)/i.test(recentContextText)) return questionDimension ? `Hair+ med Hair+ 口服发宝 外用发健 ${questionDimension[0]}` : 'Hair+ med Hair+ 口服发宝 外用发健';
|
||||
if (/(Fitness-Drink|运动饮料健康饮品|运动饮料)/i.test(recentContextText)) return questionDimension ? `Fitness-Drink 运动饮料健康饮品 ${questionDimension[0]}` : 'Fitness-Drink 运动饮料健康饮品';
|
||||
if (/(TopShape|孅萃TopShape纤萃减肥|纤萃减肥)/i.test(recentContextText)) return questionDimension ? `TopShape 孅萃TopShape纤萃减肥 ${questionDimension[0]}` : 'TopShape 孅萃TopShape纤萃减肥';
|
||||
if (/(Generation 50\+|乐活50\+)/i.test(recentContextText)) return questionDimension ? `乐活50+ Generation 50+ ${questionDimension[0]}` : '乐活50+ Generation 50+';
|
||||
if (/(Apple Antioxy|苹果细胞抗氧素|Antioxy|Zellschutz|细胞抗氧素)/i.test(recentContextText)) return questionDimension ? `Apple Antioxy Zellschutz 细胞抗氧素 ${questionDimension[0]}` : 'Apple Antioxy Zellschutz 细胞抗氧素';
|
||||
}
|
||||
return '';
|
||||
// === 敏感话题兜底(必须精确控制回复内容) ===
|
||||
if (/(传销|骗局|骗子|正规吗|合法吗|正不正规|合不合法|是不是传销|直销还是传销|层级分销|非法集资|拉人头|下线|发展下线|报单|人头费)/i.test(text)) return '德国PM 1993年 创立 100多个国家 FitLine 公司介绍 邓白氏 99分 AAA+ 合法直销';
|
||||
|
||||
// === 别名纠正(向量检索不认的别名) ===
|
||||
if (/暖炉原理/i.test(text)) return '火炉原理';
|
||||
|
||||
// 所有其它查询(产品/公司/认证/培训等):不做确定性改写
|
||||
// 依赖 normalizeKnowledgeQueryAlias(别名归一化)+ enrichQueryWithContext(上下文补充)+ VikingDB + reranker
|
||||
return null;
|
||||
}
|
||||
|
||||
static applyKnowledgeQueryAnchor(query) {
|
||||
@@ -560,15 +482,39 @@ class ToolExecutor {
|
||||
.replace(/Basics/gi, 'Basics')
|
||||
.replace(/基础三合一|三合一基础套|大白小红小白|基础套装?/g, 'PM细胞营养素 基础套装')
|
||||
.replace(/儿童倍适|儿童产品/g, '儿童倍适')
|
||||
.replace(/小红精华液/g, 'Activize Serum 小红精华液')
|
||||
.replace(/小红产品/g, '小红产品 Activize Oxyplus')
|
||||
.replace(/大白产品/g, '大白产品 Basics')
|
||||
.replace(/小白产品/g, '小白产品 Restorate')
|
||||
.replace(/(?<!小红产品\s*)(?<!大白产品\s*)(?<!小白产品\s*)小红/g, '小红产品 Activize Oxyplus')
|
||||
.replace(/(?<!小红产品\s*)(?<!大白产品\s*)(?<!小白产品\s*)小红(?!精华)/g, '小红产品 Activize Oxyplus')
|
||||
.replace(/(?<!小红产品\s*)(?<!大白产品\s*)(?<!小白产品\s*)大白/g, '大白产品 Basics')
|
||||
.replace(/(?<!小红产品\s*)(?<!大白产品\s*)(?<!小白产品\s*)(?<!儿童)小白/g, '小白产品 Restorate')
|
||||
.replace(/维适多/g, '小白产品 Restorate')
|
||||
.replace(/火炉原理/g, '火炉原理')
|
||||
.replace(/阿育吠陀|Ayurveda/gi, '阿育吠陀')
|
||||
// === 产品俗名/简称 → 标准名+英文名(增强向量检索命中率)===
|
||||
.replace(/小绿/g, 'D-Drink 小绿 排毒饮')
|
||||
.replace(/(?<!小绿 )排毒饮/g, 'D-Drink 排毒饮')
|
||||
.replace(/(?<!草本护理)牙膏/g, '草本护理牙膏 Med Dental+')
|
||||
.replace(/(?:口腔免疫喷雾|口腔喷雾|免疫喷雾)/g, 'IB5 口腔免疫喷雾')
|
||||
.replace(/(?<!免疫)喷雾/g, 'IB5 口腔免疫喷雾')
|
||||
.replace(/(?<!Herbal Tea )草本茶/g, 'Herbal Tea 草本茶')
|
||||
.replace(/发宝|发健/g, 'Med Hair+ 发宝')
|
||||
.replace(/(?:男士乳霜|男士护肤|男士面霜)/g, 'Men Face 男士护肤乳霜')
|
||||
.replace(/纤萃/g, 'TopShape 纤萃')
|
||||
.replace(/运动饮料/g, 'Fitness-Drink 运动饮料')
|
||||
.replace(/(?<!Generation 50\+? )乐活/g, 'Generation 50+ 乐活')
|
||||
.replace(/(?<!Zellschutz )细胞抗氧素/g, 'Zellschutz 细胞抗氧素')
|
||||
.replace(/CC套装|CC胶囊|CC乳霜/g, 'CC-Cell')
|
||||
.replace(/(?<!Q10 )辅酵素/g, 'Q10 辅酵素')
|
||||
.replace(/氧修护/g, 'Q10 氧修护')
|
||||
.replace(/小黑/g, 'MEN+ 倍力健 小黑')
|
||||
.replace(/(?<!MEN\+? )倍力健/g, 'MEN+ 倍力健')
|
||||
.replace(/(?<!ProShape Amino )氨基酸/g, 'ProShape Amino 氨基酸')
|
||||
.replace(/BCAA/gi, 'ProShape Amino BCAA')
|
||||
.replace(/(?<!胶原蛋白)胶原蛋白(?!肽)/g, '胶原蛋白肽')
|
||||
.replace(/乳酪煲|乳酪饮品|乳酪/g, '乳酪煲 乳酪饮品')
|
||||
.replace(/(?<!关节套装 )关节舒缓/g, '关节套装 关节舒缓')
|
||||
.trim();
|
||||
}
|
||||
|
||||
@@ -694,7 +640,7 @@ class ToolExecutor {
|
||||
};
|
||||
}
|
||||
|
||||
static async searchKnowledge({ query, response_mode = 'answer', context = [], session_id = null, original_text = '', _session = null }) {
|
||||
static async searchKnowledge({ query, response_mode = 'answer', context = [], session_id = null, original_text = '', _session = null, skipCache = false }) {
|
||||
const startTime = Date.now();
|
||||
query = query || '';
|
||||
const responseMode = response_mode === 'snippet' ? 'snippet' : 'answer';
|
||||
@@ -729,14 +675,15 @@ class ToolExecutor {
|
||||
}
|
||||
|
||||
const rewrittenQuery = this.rewriteKnowledgeQuery(query, context, session_id, _session);
|
||||
const kbTarget = this.selectKnowledgeBaseTargets(rewrittenQuery || query, context);
|
||||
// 全库检索:始终搜索所有 collection,由 VikingDB + reranker 判断相关性
|
||||
const allDatasetIds = String(process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS || '')
|
||||
.split(',').map(id => id.trim()).filter(Boolean);
|
||||
const kbTarget = { datasetIds: allDatasetIds, matchedRoutes: ['all'] };
|
||||
const effectiveQuery = rewrittenQuery || query;
|
||||
if (rewrittenQuery && rewrittenQuery !== query) {
|
||||
console.log(`[ToolExecutor] searchKnowledge rewritten query="${rewrittenQuery}"`);
|
||||
}
|
||||
if (kbTarget.datasetIds.length > 0) {
|
||||
console.log(`[ToolExecutor] searchKnowledge selected dataset_ids=${kbTarget.datasetIds.join(',')} routes=${kbTarget.matchedRoutes.join(',')}`);
|
||||
}
|
||||
console.log(`[ToolExecutor] searchKnowledge full-scan all ${allDatasetIds.length} collections`);
|
||||
|
||||
const kbIds = process.env.VOLC_ARK_KNOWLEDGE_BASE_IDS;
|
||||
if (kbIds && kbIds !== 'your_knowledge_base_dataset_id') {
|
||||
@@ -758,12 +705,13 @@ class ToolExecutor {
|
||||
};
|
||||
}
|
||||
try {
|
||||
// 缓存检查:相同effectiveQuery + datasetIds命中缓存时直接返回,避免重复API调用
|
||||
// 缓存检查:优先 Redis,降级内存 Map(skipCache 时跳过)
|
||||
const cacheKey = getKbCacheKey(effectiveQuery, kbTarget.datasetIds, profileScope);
|
||||
const cached = getKbCache(cacheKey);
|
||||
const redisCached = skipCache ? null : await redisClient.getKbCache(cacheKey).catch(() => null);
|
||||
const cached = skipCache ? null : (redisCached || getKbCache(cacheKey));
|
||||
if (cached) {
|
||||
const latencyMs = Date.now() - startTime;
|
||||
console.log(`[ToolExecutor] Ark KB cache hit in ${latencyMs}ms key="${cacheKey.slice(0, 60)}"`);
|
||||
console.log(`[ToolExecutor] Ark KB cache hit in ${latencyMs}ms key="${cacheKey.slice(0, 60)}" source=${redisCached ? 'redis' : 'memory'}`);
|
||||
return {
|
||||
...cached,
|
||||
original_query: query,
|
||||
@@ -774,12 +722,45 @@ class ToolExecutor {
|
||||
cache_hit: true,
|
||||
};
|
||||
}
|
||||
console.log('[ToolExecutor] Trying Ark Knowledge Search...');
|
||||
const arkResult = await this.searchArkKnowledge(effectiveQuery, context, responseMode, kbTarget.datasetIds, query, assistantProfile);
|
||||
|
||||
// 根据检索模式选择链路
|
||||
const retrievalMode = process.env.VOLC_ARK_KB_RETRIEVAL_MODE || 'answer';
|
||||
let arkResult;
|
||||
|
||||
if (retrievalMode === 'raw') {
|
||||
// ★ 新链路:纯检索 + 重排,不经 LLM 加工
|
||||
console.log('[ToolExecutor] Using RAW retrieval mode (kbRetriever)');
|
||||
const rawResult = await kbRetriever.searchAndRerank(effectiveQuery, {
|
||||
datasetIds: kbTarget.datasetIds,
|
||||
sessionId: session_id,
|
||||
session: _session,
|
||||
originalQuery: query,
|
||||
});
|
||||
// 转换为与旧格式兼容的结构
|
||||
arkResult = {
|
||||
query: rawResult.query,
|
||||
results: rawResult.ragPayload.length > 0
|
||||
? rawResult.ragPayload.map(item => ({ title: item.title, content: item.content }))
|
||||
: [{ title: '未找到', content: `知识库中暂未找到与"${query}"直接相关的信息,请换个更具体的问法再试。` }],
|
||||
total: rawResult.ragPayload.length,
|
||||
source: 'ark_knowledge',
|
||||
hit: rawResult.hit,
|
||||
reason: rawResult.reason,
|
||||
retrieval_mode: 'raw',
|
||||
top_score: rawResult.topScore,
|
||||
chunks_count: rawResult.rerankedChunks?.length || 0,
|
||||
};
|
||||
} else {
|
||||
// 旧链路:LLM 加工模式
|
||||
console.log('[ToolExecutor] Using ANSWER retrieval mode (searchArkKnowledge)');
|
||||
arkResult = await this.searchArkKnowledge(effectiveQuery, context, responseMode, kbTarget.datasetIds, query, assistantProfile);
|
||||
}
|
||||
|
||||
const latencyMs = Date.now() - startTime;
|
||||
console.log(`[ToolExecutor] Ark KB search succeeded in ${latencyMs}ms`);
|
||||
// 缓存所有结果(hit用5分钟TTL,no-hit用2分钟TTL),避免重复API调用
|
||||
console.log(`[ToolExecutor] Ark KB search succeeded in ${latencyMs}ms mode=${retrievalMode}`);
|
||||
// 缓存到 Redis + 内存双写
|
||||
setKbCache(cacheKey, arkResult);
|
||||
redisClient.setKbCache(cacheKey, arkResult).catch(() => {});
|
||||
return {
|
||||
...arkResult,
|
||||
original_query: query,
|
||||
|
||||
Reference in New Issue
Block a user