19 KiB
19 KiB
语音通话模块重构方案 — KB直出 + 重排 + Redis上下文
版本:1.0 | 日期:2026-03-24
1. 现状分析
1.1 当前 KB 查询链路
ASR final
→ realtimeDialogRouting.resolveReply()
→ ToolExecutor.searchKnowledge()
→ rewriteKnowledgeQuery() // query改写
→ searchArkKnowledge() // ★ 核心瓶颈
┌──────────────────────────────────────────────┐
│ POST ark.cn-beijing.volces.com │
│ /api/v3/chat/completions │
│ │
│ model: Seed-2.0-lite (ep-xxx) │
│ metadata.knowledge_base: { dataset_ids, ... }│
│ max_tokens: 80 │
│ thinking: disabled │
│ │
│ → 1. 向量检索 top_k=3 chunks │
│ → 2. LLM 基于 chunks 生成回答文本(80 tokens)│ ← 要去掉
└──────────────────────────────────────────────┘
→ classifyKnowledgeAnswer() // 判断 hit/no-hit
→ external_rag (event 502) // 注入 S2S
→ S2S 基于 RAG 内容合成语音
1.2 痛点
| 痛点 | 说明 |
|---|---|
| LLM 加工多余 | KB 检索后还经过 Seed-2.0-lite 生成 80 tokens 回答,增加 ~1-2s 延迟,且回答被模型"改写",不够原汁原味 |
| 无重排 | 向量检索 top_k=3 直接使用,靠 threshold=0.3 卡阈值,相关性排序不够精准 |
| 上下文断裂 | S2S 仅接收单次 RAG 内容,不知道前几轮聊了什么,多轮追问体验差 |
| context 来源慢 | 上下文从 MySQL getRecentMessages() 获取,单次 50-200ms |
| 内存缓存不可靠 | KB 缓存用 Node.js Map,进程重启即丢失,PM2 多实例不共享 |
2. 重构目标
ASR final
→ resolveReply()
→ rewriteKnowledgeQuery()
→ ★ 方舟 KB 纯检索(返回原始 chunks,不经 LLM 加工)
→ ★ 重排模型(reranker)对 chunks 按相关性排序
→ 取 top 3 reranked chunks
→ ★ 从 Redis 读取最近 5 轮对话
→ 构建 external_rag payload:{ chunks + conversation_context }
→ event 502 注入 S2S
→ S2S 结合上下文 + KB 原始片段,自主生成回答并合成语音
核心改变
| 项目 | 现状 | 目标 |
|---|---|---|
| KB 检索 | chat/completions + knowledge_base metadata(检索+生成一体) | 纯检索 API,只返回原始 chunks |
| 回答生成 | Seed-2.0-lite 生成 80 tokens | 去掉,由 S2S 直接基于 RAG 内容生成 |
| 重排 | 无 | 方舟重排模型,对检索结果排序 |
| 上下文存储 | MySQL + Node.js 内存 Map | Redis(最近 5 轮 = 10 条消息) |
| S2S 输入 | 单条 RAG 回答文本 | 3 条原始 KB 片段 + 5 轮对话上下文 |
3. 改动全景图
涉及文件 改动类型 改动量级 说明
─────────────────────────────────────────────────────────
新增文件
services/redisClient.js 新增 ~120行 Redis 连接管理 + 对话读写
services/kbRetriever.js 新增 ~200行 KB 纯检索 + 重排 + 结果组装
修改文件
toolExecutor.js 重构 大 searchArkKnowledge → 调用 kbRetriever
nativeVoiceGateway.js 修改 中 persist* 同步写 Redis;sendExternalRag 注入上下文
realtimeDialogRouting.js 修改 小 resolveReply 适配新返回格式
package.json 修改 小 添加 ioredis 依赖
.env / .env.example 修改 小 新增 Redis + 重排模型配置
不动的文件
realtimeDialogProtocol.js 不动 event 502 协议格式不变
knowledgeKeywords.js 不动 关键词路由不变
contextKeywordTracker.js 不动 关键词追踪不变
contentSafeGuard.js 不动 内容安全不变
fastAsrCorrector.js 不动 ASR纠错不变
4. 详细设计
Phase 1:Redis 基础设施
4.1 新增 services/redisClient.js
职责:Redis 连接管理 + 对话历史读写
功能清单:
├── createRedisClient() // 连接管理,断线重连,错误日志
├── pushMessage(sessionId, msg) // LPUSH + LTRIM 保留最近 10 条
├── getRecentHistory(sessionId) // LRANGE 获取最近 5 轮(10 条)
├── clearSession(sessionId) // DEL 清理
└── setKbCache / getKbCache // KB 结果缓存(替代内存 Map)
数据结构设计:
Key: voice:history:{sessionId}
Type: List
Items: JSON string → { role, content, source, timestamp }
MaxLen: 10 (5轮 × 2条/轮)
TTL: 1800s (30分钟)
Key: voice:kb_cache:{cacheKey}
Type: String (JSON)
TTL: 300s (hit) / 120s (no-hit)
降级策略:Redis 不可用时,自动降级到当前的内存 Map + MySQL 方案,不阻塞主链路。
4.2 环境变量
# Redis
REDIS_URL=redis://127.0.0.1:6379
REDIS_PASSWORD=
REDIS_DB=0
REDIS_KEY_PREFIX=bigwo:
# 重排模型
VOLC_ARK_RERANKER_ENDPOINT_ID=ep-xxxxxxxx
VOLC_ARK_RERANKER_TOP_N=3
# KB 纯检索(替代 chat/completions)
VOLC_ARK_KB_RETRIEVAL_MODE=raw
# 可选值: raw(纯检索,本次启用)| answer(当前模式,保留降级用)
4.3 依赖
{
"ioredis": "^5.4.0"
}
选 ioredis 而非 redis:自动重连、Sentinel/Cluster 原生支持、性能更好。
Phase 2:KB 检索改造
4.4 新增 services/kbRetriever.js
职责:KB 纯检索 + 重排 + 结果组装(替代 searchArkKnowledge 中的检索+生成一体逻辑)
功能清单:
├── retrieveChunks(query, datasetIds, topK) // 纯向量检索,返回原始 chunks
├── rerankChunks(query, chunks, topN) // 重排模型调用
├── buildRagPayload(chunks, history) // 组装 external_rag 载荷
└── searchAndRerank(query, opts) // 主入口:检索 → 重排 → 组装
核心流程:
searchAndRerank(query, { datasetIds, sessionId, session })
│
├─ 1. retrieveChunks(query, datasetIds, topK=10)
│ POST /api/v3/chat/completions
│ model: 最轻量模型
│ metadata.knowledge_base: { dataset_ids, top_k: 10, threshold: 0.1 }
│ max_tokens: 1 ← 关键:只要1个token,目的是触发检索
│ stream: false
│ ※ 从 response 中提取 references/chunks 字段
│ ※ 如果方舟API不在response中返回原始chunks:
│ → 降级方案:用 snippet 模式 prompt 让模型返回原文
│ → 或调用方舟知识库独立检索API(如有)
│
├─ 2. rerankChunks(query, chunks, topN=3)
│ POST /api/v3/chat/completions(或 /api/v3/rerank)
│ model: reranker-endpoint-id
│ 输入:query + documents[]
│ 输出:按相关性排序的 chunks + scores
│ 超时:3s
│ 降级:重排失败时直接使用原始检索排序
│
├─ 3. getRecentHistory(sessionId) ← 从 Redis
│ 返回最近 5 轮对话
│
└─ 4. buildRagPayload(top3Chunks, history)
组装 ragItems[]:
[
{ title: "对话上下文", content: "用户: xxx\n助手: xxx\n..." },
{ title: "知识库片段1", content: chunk1.content },
{ title: "知识库片段2", content: chunk2.content },
{ title: "知识库片段3", content: chunk3.content },
]
4.5 关于方舟 KB 纯检索的技术方案
方舟 chat/completions + knowledge_base 的 response 结构中,choices[0].message 包含 LLM 生成的回答,但原始检索 chunks 可能在 references 字段中返回(取决于方舟版本)。
需要验证的关键点:
方案 A(优先):方舟 response 中有 references 字段
→ response.data.references = [{ content, score, doc_name, ... }, ...]
→ 直接提取,不用 choices[0].message.content
方案 B(备选):方舟有独立的知识库检索 API
→ POST /api/v3/knowledge-base/retrieve 或类似端点
→ 纯向量检索 + BM25,不经 LLM
方案 C(保底):利用 snippet 模式 prompt 提取原文
→ system prompt = "原样输出检索到的所有文档片段,不改写不总结"
→ max_tokens = 500
→ 解析输出为独立 chunks
建议:先用方案 A 验证 references 字段是否存在;若不存在,采用方案 B 或 C。
4.6 重排模型选型
| 模型 | 延迟(估) | 精度 | 建议 |
|---|---|---|---|
| bge-reranker-v2-m3 | ~200ms | 高 | 首选,多语言支持好 |
| bge-reranker-large | ~150ms | 较高 | 备选 |
| cohere-rerank-v3 | ~300ms | 最高 | 如果方舟支持 |
需要在方舟平台创建重排模型的推理接入点。
Phase 3:S2S 上下文注入
4.7 修改 nativeVoiceGateway.js
改动 1:persistUserSpeech / persistAssistantSpeech 同步写 Redis
persistUserSpeech(session, text)
├─ 原有逻辑不变(MySQL + 字幕推送)
└─ 新增:redisClient.pushMessage(sessionId, { role: 'user', content: text })
persistAssistantSpeech(session, text, opts)
├─ 原有逻辑不变
└─ 新增:redisClient.pushMessage(sessionId, { role: 'assistant', content: text })
改动 2:sendExternalRag 接受新格式
现状:sendExternalRag(session, [{ title, content }])
→ 直接发 event 502
新增:注入上下文到 ragItems
→ ragItems 前置一条 { title: "对话上下文", content: "最近5轮..." }
→ 再跟 3 条 KB 片段
→ 发 event 502
4.8 修改 realtimeDialogRouting.js — resolveReply
现状 resolveReply:
→ ToolExecutor.execute('search_knowledge', { response_mode: 'answer', ... })
→ extractToolResultText → 得到 LLM 生成的回答文本
→ delivery: 'external_rag', ragItems: [{ content: LLM回答 }]
改为:
→ kbRetriever.searchAndRerank(query, { sessionId, session, datasetIds })
→ 返回 { chunks: [...], rerankedChunks: [...], history: [...], ragPayload: [...] }
→ delivery: 'external_rag', ragItems: ragPayload(已包含上下文 + 3个原始片段)
4.9 修改 toolExecutor.js
现状 searchKnowledge:
→ searchArkKnowledge() → LLM 加工 → classifyKnowledgeAnswer()
改为:
→ kbRetriever.searchAndRerank() → 纯检索 + 重排
→ 判断 hit/no-hit 改为基于 reranker score 阈值
→ 不再调用 classifyKnowledgeAnswer()(因为没有 LLM 生成的回答文本了)
Phase 4:清理与适配
4.10 可移除/简化的代码
| 位置 | 内容 | 原因 |
|---|---|---|
toolExecutor.js L925-946 |
system prompt 构建(baseAnswerPrompt, buildQuestionSlotInstruction等) | 不再需要 LLM 生成回答 |
toolExecutor.js L953-966 |
chat/completions body 构建(max_tokens:80, thinking:disabled) | 改为纯检索调用 |
toolExecutor.js L981-996 |
classifyKnowledgeAnswer() 调用 | 改为 reranker score 判断 |
toolExecutor.js L47-74 |
内存 kbQueryCache Map | 迁移到 Redis |
realtimeDialogRouting.js L275 |
normalizeTextForSpeech(replyText).replace(...) 去除"根据知识库" |
不再有 LLM 加工文本 |
4.11 保留不动的模块
| 模块 | 原因 |
|---|---|
| KB 保护窗口(60s) | 多轮追问保护仍有价值 |
| shouldForceKnowledgeRoute | 路由判断逻辑不受影响 |
| prequery 预查询 | 仍然有效,改为调用 kbRetriever |
| ASR 纠错 + 词表 | 与 KB 检索解耦 |
| HOT_ANSWERS | 保留作为 0ms 极速响应,但需标记来源 |
| 品牌保护兜底(传销问题) | 安全需求,保留 |
| 助手资料系统 | 与 KB 检索解耦 |
5. 数据流对比
5.1 现状(LLM 加工模式)
用户: "小红产品怎么吃?"
↓
searchArkKnowledge()
→ Ark API: model=Seed-2.0-lite, max_tokens=80
→ KB 检索到 3 chunks + LLM 生成回答:
"小红Activize的服用方法是兑100-150ml温水,小口慢饮,建议在大白之后15-30分钟。"
↓
event 502: [{ title: "知识库结果", content: "小红Activize的服用方法..." }]
↓
S2S 合成语音播报(S2S 不知道前几轮聊了什么)
5.2 重构后(直出 + 重排 + 上下文模式)
用户: "小红产品怎么吃?"
↓
kbRetriever.searchAndRerank()
→ 纯检索: top_k=10, threshold=0.1 → 得到 10 个原始 chunks
→ 重排: reranker 对 10 chunks 排序 → 取 top 3
→ Redis: 取最近 5 轮对话
↓
event 502: [
{ title: "对话上下文",
content: "用户: 你们有什么产品?\n助手: 我们有基础三合一...\n用户: 小红是什么?\n助手: 小红是Activize Oxyplus..." },
{ title: "知识库片段1",
content: "Activize Oxyplus(小红):水溶性CoQ10+维生素B族+瓜拉那提取物..." },
{ title: "知识库片段2",
content: "基础三合一服用方法:大白空腹→小红15-30分钟后→小白睡前..." },
{ title: "知识库片段3",
content: "小红用法:兑100-150ml温水,小口慢饮。水温不超过40度..." },
]
↓
S2S 基于上下文 + 3个原始片段自主生成自然口语回答,合成语音
6. 延迟预估
| 环节 | 现状 | 重构后 | 变化 |
|---|---|---|---|
| query 改写 | ~5ms | ~5ms | 不变 |
| KB 检索 | ~2000ms(检索+LLM生成) | ~800ms(纯检索) | ↓60% |
| 重排 | 无 | ~200ms | 新增 |
| Redis 读上下文 | 无(MySQL ~100ms) | ~5ms | ↓95% |
| 组装 payload | ~1ms | ~2ms | 不变 |
| 总计 | ~2300ms | ~1000ms | ↓56% |
注意:S2S 接收到更丰富的 RAG 内容后,语音合成可能略增,但整体用户体验(首字节到达时间)应显著改善。
7. 风险与降级
| 风险 | 概率 | 影响 | 降级方案 |
|---|---|---|---|
| 方舟 API 不返回原始 chunks(无 references 字段) | 中 | 高 | 用 snippet prompt + max_tokens=500 提取原文 |
| 重排模型部署/调用失败 | 低 | 中 | 跳过重排,直接使用检索排序 |
| Redis 连接失败 | 低 | 中 | 降级到内存 Map + MySQL |
| S2S 处理多条 RAG 片段质量下降 | 中 | 高 | A/B 测试对比,保留旧模式开关 |
| Redis 内存溢出 | 低 | 低 | TTL=30min + maxmemory-policy=allkeys-lru |
关键降级开关
# .env 中控制
VOLC_ARK_KB_RETRIEVAL_MODE=raw # raw | answer(旧模式)
ENABLE_RERANKER=true # true | false
ENABLE_REDIS_CONTEXT=true # true | false
每个新能力可独立关闭,回退到当前行为。
8. 实施计划
阶段一:基础设施(1天)
| # | 任务 | 文件 | 预估 |
|---|---|---|---|
| 1.1 | 安装 ioredis 依赖 | package.json | 5min |
| 1.2 | 新建 redisClient.js,实现连接管理 + pushMessage + getRecentHistory | services/redisClient.js | 2h |
| 1.3 | .env 增加 Redis 配置 | .env, .env.example | 10min |
| 1.4 | 服务器安装 Redis(宝塔一键装) | 运维 | 30min |
| 1.5 | 验证 Redis 连接 + 读写 | 手动测试 | 30min |
阶段二:Redis 对话存储接入(0.5天)
| # | 任务 | 文件 | 预估 |
|---|---|---|---|
| 2.1 | persistUserSpeech 增加 Redis 写入 | nativeVoiceGateway.js | 30min |
| 2.2 | persistAssistantSpeech 增加 Redis 写入 | nativeVoiceGateway.js | 30min |
| 2.3 | resolveReply 中的 getRecentMessages 改为优先读 Redis | realtimeDialogRouting.js | 1h |
| 2.4 | KB 缓存迁移到 Redis(替代内存 Map) | toolExecutor.js, redisClient.js | 1h |
阶段三:KB 纯检索 + 重排(1.5天)
| # | 任务 | 文件 | 预估 |
|---|---|---|---|
| 3.1 | 方舟 API 验证:确认 references 字段是否可用 | 手动测试 | 2h |
| 3.2 | 新建 kbRetriever.js:retrieveChunks 实现 | services/kbRetriever.js | 3h |
| 3.3 | 方舟部署重排模型,获取 endpoint_id | 方舟控制台 | 1h |
| 3.4 | kbRetriever.js:rerankChunks 实现 | services/kbRetriever.js | 2h |
| 3.5 | kbRetriever.js:buildRagPayload + searchAndRerank 主流程 | services/kbRetriever.js | 2h |
| 3.6 | toolExecutor.js 改造:searchKnowledge 调用 kbRetriever | toolExecutor.js | 2h |
阶段四:S2S 注入适配(0.5天)
| # | 任务 | 文件 | 预估 |
|---|---|---|---|
| 4.1 | resolveReply 适配新的 ragItems 格式(多片段 + 上下文) | realtimeDialogRouting.js | 1h |
| 4.2 | sendExternalRag 验证多 items 发送 | nativeVoiceGateway.js | 1h |
| 4.3 | hit/no-hit 判断改为 reranker score 阈值 | kbRetriever.js | 1h |
阶段五:测试 + 部署(1天)
| # | 任务 | 说明 | 预估 |
|---|---|---|---|
| 5.1 | 单元测试:kbRetriever、redisClient | tests/ | 2h |
| 5.2 | 集成测试:端到端语音对话验证 | 真机测试 | 2h |
| 5.3 | A/B 对比:旧模式 vs 新模式(延迟、回答质量) | 测试脚本 | 2h |
| 5.4 | 部署到生产 | deploy 脚本 | 1h |
总工期预估:4-5 天
9. 验证标准
功能验证(必须全部通过)
| # | 场景 | 期望结果 |
|---|---|---|
| V1 | 问"小红怎么吃" | S2S 基于 KB 原始片段回答,包含正确服用方法 |
| V2 | 连续追问"那大白呢""小白呢" | S2S 结合上下文理解"那"指的是什么,正确回答 |
| V3 | 问"你们公司正规吗" | 品牌保护兜底正常触发 |
| V4 | Redis 断开时问产品问题 | 自动降级到 MySQL,不报错 |
| V5 | 重排模型超时 | 跳过重排,使用原始检索结果 |
| V6 | KB 检索无结果 | 走 honest_fallback 或 upstream_chat |
性能验证
| 指标 | 当前基线 | 目标 |
|---|---|---|
| KB 查询延迟(P50) | ~2300ms | <1200ms |
| KB 查询延迟(P95) | ~4500ms | <2000ms |
| 上下文读取延迟 | ~100ms (MySQL) | <10ms (Redis) |
| 多轮追问准确率 | ~40%(无上下文) | >80%(5轮上下文) |
10. 后续迭代方向
| 方向 | 说明 | 优先级 |
|---|---|---|
| 语义缓存 | 用 embedding 相似度判断是否命中缓存,而非精确 query 匹配 | P1 |
| Redis Streams | 对话历史用 Streams 代替 List,支持消费者组、重放 | P2 |
| Hybrid Search | 向量检索 + BM25 关键词检索融合,提升召回率 | P1 |
| 动态 top_k | 根据 query 复杂度自动调整检索数量 | P3 |
| 多轮 query 改写 | 基于 Redis 上下文做指代消解("那个"→"小红") | P1 |