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:
User
2026-03-26 14:30:32 +08:00
parent 9567eb7358
commit 56940676f6
15 changed files with 2096 additions and 170 deletions

View File

@@ -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) {

View File

@@ -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}`;
}

View 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,
};

View File

@@ -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 351S2S可能拆段/改写/丢失内容)
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) {

View File

@@ -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,

View 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,
};

View File

@@ -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降级内存 MapskipCache 时跳过)
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分钟TTLno-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,