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

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