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

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