Voice Agent 的 RAG 为什么会答非所问?从混合检索到引用校验

📅 2026/6/30 7:02:39
Voice Agent 的 RAG 为什么会答非所问?从混合检索到引用校验
Voice Agent 的 RAG 为什么会答非所问从混合检索到引用校验先说结论Voice Agent 接上 RAG 以后仍然答非所问问题往往不在“大模型不够聪明”而在检索链路只做了“向量 TopK 拼 Prompt”。对电话客服、语音助手这类场景我目前更愿意从一条保守链路开始ASR 文本 - 查询清洗 - BM25 关键词召回 向量语义召回 - RRF 融合 - Cross-Encoder 重排 - 可回答性判断 - 带来源编号生成 - 引用完整性检查 - 通过则播报失败则拒答或转人工这篇文章会实现一个可以本地运行的最小版本。它适合中文 FAQ、售后规则、订单政策、内部知识库等“答案必须来自指定资料”的场景。它暂时不处理多跳推理、图片表格解析、实时网页抓取和超大规模向量库这些问题先不往一个 Demo 里硬塞。文章里的知识库和问题集都是我为了验证链路写的合成样例。后文给出的实测只用于检查代码是否跑通不能代表任何生产知识库的准确率。一、为什么向量检索看起来相关回答却还是错我先把最常遇到的几种错误分开。否则一看到错误答案就调 Prompt通常会越调越乱。1. ASR 已经把问题听错了用户说的是“退款原路退回”ASR 可能识别成“退款原路退货”用户说订单号、手机号、产品型号时也很容易丢数字或同音替换。这时 RAG 检索到错误资料并不奇怪因为它收到的查询本来就错了。生产环境里要把原始转写、置信度、热词命中和最终检索词一起记录不能只保存 LLM 的最终答案。2. 单纯向量检索不擅长精确词向量检索擅长语义。用户问“银行卡多久到账”它可能找到“退款进度”但订单号、接口名、政策版本号、产品型号这类精确字符BM25 往往更稳。Voice Agent 的查询还特别短。短句一旦少了一个实体向量模型很容易找到“语义大致相近、业务结论完全不同”的段落。3. 召回正确排序却把错误段落放在前面Top10 里已经有正确文档并不等于送给 LLM 的 Top3 是正确的。如果只按向量余弦相似度排序关键词强命中的规则条款可能被泛泛的介绍性段落压到后面。因此需要把“快速召回”和“精细判断”拆开。第一阶段尽量别漏第二阶段再用 Cross-Encoder 同时阅读问题与候选段落重新判断谁更适合回答。4. 检索没有答案模型却被迫回答知识库里没有“公司创始人”用户偏偏问了这个问题。若系统仍把几个不相关片段塞进 Prompt再要求模型“友好回答”模型很可能从常识、训练记忆或相邻段落里补一个答案。所以 RAG 不只是“找资料”还必须有不回答的能力。5. 答案带了引用但引用并不支持这句话让模型在末尾随便加一个[S1]很容易。真正要检查的是引用编号是否来自本轮检索结果每个事实句是否都有引用被引用段落与该句是否至少相关更严格时被引用文本是否真的蕴含这条事实。本文实现前三层。最后一层需要 NLI、规则或人工抽检配合不能把“相似”冒充成“事实已证明”。二、这次 Demo 的运行环境我使用的环境是Python 3.12FastAPI 0.138.1sentence-transformers 5.6.0BAAI/bge-small-zh-v1.5做中文向量召回BAAI/bge-reranker-base做候选重排rank-bm25做 BM25macOSCPU 推理。Python 3.11 也可以。Windows 建议使用 PowerShell 或 WSL。第一次启动会从 Hugging Face 下载模型文件耗时取决于网络正式部署时建议提前下载并固定模型版本不要让每个实例启动时临时拉取。为了让没有本地大模型的人也能跑通默认使用extractive模式直接返回检索到的证据并附上引用编号。把LLM_MODE改成ollama后会调用本机 Ollama 的/api/chat生成更自然的回答。三、完整链路图┌──────────────────┐ ASR 最终文本 ───── │ query normalize │ └────────┬─────────┘ │ ┌──────────────┴──────────────┐ │ │ ┌──────▼──────┐ ┌──────▼──────┐ │ BM25 recall │ │ dense recall│ └──────┬──────┘ └──────┬──────┘ │ │ └──────────────┬──────────────┘ ▼ RRF rank fusion │ ▼ Cross-Encoder rerank │ ┌─────────┴─────────┐ │ answerable gate │ └──────┬───────┬────┘ │是 │否 ▼ ▼ answer with refusal / citations handoff │ ▼ citation verifier │ ┌──────┴──────┐ │通过 │失败 ▼ ▼ 播报 拒答/转人工这里有两个“闸门”生成前判断资料够不够生成后检查引用有没有失控。只做其中一个都不够。生成前不过滤模型会拿垃圾上下文作答生成后不检查模型可能引用不存在的来源。四、为什么要把 BM25 和向量召回放在一起1. BM25 解决“字面必须对上”BM25 会根据词频、逆文档频率和文档长度给出相关性分数。它不理解“退款进度”和“款项何时到账”语义相近但它很适合找订单号与产品型号API 名称、错误码、参数名政策条款中的固定词人名、地名、专有名词。rank-bm25本身不做中文分词所以代码里用jieba.lcut_for_search()对文档和查询执行相同预处理。这一点很容易漏文档按一种方式切词、查询按另一种方式切词分数会直接失真。2. 向量召回解决“说法不一样”用户不会照着知识库原句提问。知识库写的是“退款审核通过后原路退回”用户可能说钱已经退了银行卡怎么还没收到向量模型把问题和段落映射到同一空间能补上字面不一致的问题。BGE 的官方模型卡也特别提醒短查询检索长段落时可以给查询添加检索指令而文档不需要添加。3. 为什么不直接把两种分数相加BM25 分数和余弦相似度不是同一量纲。今天写0.5*dense_score0.5*bm25_score换一批文档、换一种分词或换一个模型后比例可能完全失效。本文使用 RRFReciprocal Rank FusionRRF(d) Σ 1 / (k rank_i(d))它只看文档在各路结果里的名次不直接比较原始分数。这样能先得到一个稳定候选集再交给重排模型做更细判断。RRF 不是万能参数。k60是常见起点不是业务真理仍要用自己的查询集评估。五、为什么还要加 Cross-Encoder 重排向量模型通常分别编码查询与文档文档向量可以提前计算速度快适合召回。Cross-Encoder 会把“问题 候选段落”一起送进模型逐对计算相关性速度更慢但更适合对少量候选做精排。所以链路不是让重排模型扫描全库而是全库 - BM25 / dense 各取 Top8 - RRF 合并 - reranker 排序 - 最终 Top3还有一个容易踩坑的地方bge-reranker输出的是未限定范围的相关性分数不是概率。0.8不能自动解释成“80% 可信”。同样向量相似度大于0.5也不代表文档必然相关。本文.env.example里的阈值只负责让 Demo 跑起来。上线前必须用真实问法标注哪些问题知识库能回答正确文档是哪一条哪些是高风险误答哪些必须转人工。然后再根据误答成本选择阈值。客服场景里错答一次的成本通常高于多拒答一次我会优先压低错误接受率。六、项目目录voice-rag-guard/ ├── app.py ├── rag_core.py ├── evaluate.py ├── requirements.txt ├── .env.example └── data/ ├── knowledge.jsonl └── eval.jsonl七、准备知识库和测试集为了让文章可以独立运行我只放 8 条虚构客服规则。实际项目里不要直接把整份 PDF 当一个文档建议先保留标题、章节、版本、来源 URL 和生效时间再按语义边界切块。data/knowledge.jsonl{{KNOWLEDGE_JSONL}}data/eval.jsonl同时放可回答与不可回答问题{{EVAL_JSONL}}不可回答问题不是“边角料”。如果评估集里全是知识库能回答的问题就测不出系统会不会胡答。八、安装依赖requirements.txt{{REQUIREMENTS_TXT}}创建环境python-mvenv .venv# macOS / Linuxsource.venv/bin/activate# Windows PowerShell# .venv\Scripts\Activate.ps1pipinstall-rrequirements.txt如果 PyTorch 需要 CUDA请优先按 PyTorch 官方安装页选择与显卡驱动匹配的命令再安装其他依赖。不要为了“能装上”随便混用 CPU、CUDA 和系统 Python。九、核心代码混合召回、重排、拒答与引用检查新建rag_core.py{{RAG_CORE_PY}}这段代码里最值得单独解释的是四处。1. 查询加指令文档不加query_embeddingself.embedder.encode([QUERY_INSTRUCTIONquery],normalize_embeddingsTrue,)这是短查询检索长段落的非对称检索设置。不要把同一条指令也塞进每个知识库段落。2. RRF 只融合名次fororderin(dense_order,bm25_order):forrank,indexinenumerate(order,start1):rrf_scores[index]rrf_scores.get(index,0.0)1.0/(self.settings.rrf_krank)这里没有假装 BM25 分数与向量分数可以直接比较。候选进入重排后最终顺序由问题与段落的成对相关性决定。3. 拒答不是只看一个分数has_dense_supporthit.dense_scoremin_dense has_lexical_support(hit.bm25_score0andhit.token_overlapmin_overlap)answerablererank_passedand(has_dense_supportorhas_lexical_support)精确型号查询可能向量分不高但关键词证据很强口语化查询可能几乎没有字面重合但语义与重排结果都很好。把两类信号合在一起比“相似度低于 0.7 一律拒答”更接近真实业务。4. 引用校验不是事实核验Demo 会检查每个事实句有没有[Sx]、编号是否存在并再次计算该句与引用段落的相关性。如果失败答案不会继续播报。但相关性模型不能证明“引用一定蕴含这句话”。金额、日期、否定词、条件范围仍应加规则检查高风险业务还要抽样复核。十、FastAPI 接口新建app.py{{APP_PY}}模型在lifespan阶段加载一次而不是每次请求重新加载。同步模型推理通过线程池执行避免直接堵住事件循环。启动fastapi dev app.py第一次启动需要等待两个模型完成下载和加载。看到服务启动后先检查curlhttp://127.0.0.1:8000/health再提问curl-XPOST http://127.0.0.1:8000/ask\-HContent-Type: application/json\-d{query:退款审核通过后银行卡多久能到账}返回里应该至少包含{status:answered,answer:... [S1],evidence:[{citation_id:S1,id:KB-REFUND-001,source:demo://after-sale/refund,scores:{dense:...,bm25:...,rrf:...,rerank:...}}],citation_check:{passed:true},timings_ms:{dense_ms:...,bm25_ms:...,rerank_ms:...,total_ms:...}}这里故意没有填固定延迟和分数。模型缓存、CPU/GPU、线程数、文档长度和网络环境都会改变结果复制一组“漂亮数字”没有意义。再测试不可回答问题curl-XPOST http://127.0.0.1:8000/ask\-HContent-Type: application/json\-d{query:帮我预测下周黄金价格}理想行为不是硬找一条客服资料而是返回{status:refused,answer:当前知识库没有足够信息我为你转人工核实。}十一、批量评估不要凭单个问题判断准确率新建evaluate.py{{EVALUATE_PY}}运行python evaluate.py它会输出每条查询的 Top1、预期文档、拒答判断和本次检索耗时最后计算Recall3正确文档是否进入最终 3 条证据MRR正确文档越靠前分数越高RefusalAccuracy能回答与该拒答的判断是否正确。我的本次运行记录如下{{EVAL_OUTPUT}}这组数据只能证明示例链路在 8 条虚构知识、10 个合成问题上可以运行。真实项目至少还要补ASR 常见误识别问法数字、地址、订单号和产品型号同一政策的多种口语表达高相似但结论相反的困难负样本已过期政策与新政策冲突明确超出知识库范围的问题。十二、接入 Ollama 生成带引用回答默认extractive模式不需要 LLM。要生成自然回答先在本机准备 Ollama 模型然后设置exportLLM_MODEollamaexportOLLAMA_MODELqwen3.env.example完整配置{{ENV_EXAMPLE}}Ollama 的/api/chat默认流式返回本文代码显式传入stream: false因为要等完整答案出来后统一做引用检查。真正接回 Voice Agent 时可以把生成改成流式但不要在校验完成前直接把每个 token 都送给 TTS。更稳妥的做法是按句缓冲LLM token stream - 形成完整句 - 检查该句引用 - 通过后进入 TTS 队列 - 失败则停止后续生成并转人工否则一句无依据的话已经播出事后再把整段答案判失败也来不及。十三、常见报错与排查1. 模型下载超时典型现象OSError: Cant load the model for BAAI/bge-reranker-base先确认不是模型名拼错再检查 Hugging Face 是否可访问。生产环境建议提前下载到固定目录exportEMBEDDING_MODEL/models/bge-small-zh-v1.5exportRERANKER_MODEL/models/bge-reranker-base不要让每个容器同时从公网拉模型。2.rank_bm25搜不到中文rank_bm25不负责分词。请确认文档和查询都经过同一套tokenize()并把业务词加入自定义词典例如产品型号、业务缩写和渠道名称。3. 向量分数都很高BGE 模型卡明确提醒相似度大于0.5不等于相关。看排序、看正负样本分布再在自己的验证集上选阈值。4. 重排很慢先确认没有把全库送进 reranker。Cross-Encoder 应只处理候选集。然后再尝试降低CANDIDATE_K减少段落最大长度批量推理使用 GPU、ONNX 或 OpenVINO缓存高频问题结果。优化前要记录召回率别把速度提上去了正确文档却在重排前就被裁掉。5. 每句话都有引用仍然答错检查三件事引用段落是不是旧版本文档切块是否丢了前置条件模型是否把“可以”改成了“不可以”或改错数字。这类问题需要版本元数据、数字规则、否定词检查或 NLI而不是继续堆 Prompt。6. FastAPI 并发后延迟突然变高CPU 模型推理不是免费的异步任务。线程池只能避免阻塞事件循环不能让一颗 CPU 同时完成无限推理。生产环境要限制并发、做请求队列并分别记录召回、重排和生成耗时。十四、应该记录哪些指标只看最终“回答正确率”很难定位问题。我会至少拆成阶段指标说明ASR关键词/数字识别准确率先确认查询有没有听错召回RecallK正确证据是否进入候选排序MRR、nDCGK正确证据是否排在前面拒答错误接受率、错误拒绝率胡答与过度拒答分别统计引用引用覆盖率、无效引用率每个事实句是否有有效来源性能dense、BM25、rerank、generation 延迟找到真正慢的阶段业务转人工率、重复追问率技术分数最终是否改善对话对 Voice Agent 还要额外看“开始播报前的等待时间”。一个离线评估很准、在线每次多等两秒的 RAG也可能把通话体验拖垮。十五、FAQVoice Agent 的 RAG 为什么会答非所问常见原因是 ASR 转写错误、文档切块丢条件、向量召回漏掉精确术语、正确文档排序靠后、知识库无答案却没有拒答以及答案引用未校验。应按链路逐段记录而不是只改 Prompt。BM25 和向量检索必须二选一吗不需要。BM25 擅长精确字符向量检索擅长语义改写。混合召回通常更适合包含产品名、订单号、政策词和自然口语的客服知识库。RRF 和 reranker 有什么区别RRF 用多路结果的名次合并候选不直接理解问题与文档。reranker 会成对阅读问题和候选段落给出更精细的最终排序。前者适合融合后者适合精排。相似度低于多少应该拒答没有通用阈值。模型、语料和切块方式都会改变分数分布。应在包含可回答、不可回答和困难负样本的验证集上调阈值并根据误答成本取舍。有引用是否代表答案一定真实不代表。引用编号存在、段落与句子相关只能说明“引用形式和相关性基本正常”。要证明事实被来源支持还需做数字、条件、否定词与文本蕴含检查。为什么不让 LLM 自己判断“资料够不够”可以把 LLM 判断作为一条信号但不应是唯一闸门。生成模型也会过度自信。检索分数、关键词证据、标注阈值和业务规则更容易回放与审计。这个 Demo 能直接上生产吗不能。它没有鉴权、限流、持久化向量库、文档版本管理、灰度阈值、监控告警和真实 ASR 噪声测试。它的作用是先把正确的链路骨架跑通。十六、总结这次最重要的不是又搭了一个 RAG Demo而是把“答案从哪里来”变成了可以检查的过程BM25 保住精确词 向量召回补语义 RRF 合并多路名次 reranker 重排候选 answerable gate 决定是否该答 citation verifier 阻止无来源答案直接播报如果这条链路仍然答错日志能告诉我们问题发生在 ASR、召回、排序、阈值还是生成而不是笼统归因于“大模型幻觉”。上一篇《Voice Agent 如何实现自然插话从 VAD 到 Barge-in 完整拆解》处理的是用户打断后怎样停止旧轮次。这一篇解决“停下来听见之后能不能根据正确资料回答”。下一篇会继续往下接流式 TTS 怎么进入 Voice Agent首包延迟、分句、PCM 播放队列与取消应该怎么做。如果你也遇到过“向量 Top1 看起来很相关业务结论却相反”的情况可以把脱敏后的 query、TopK 标题和各阶段分数贴出来。只看最终答案很难排查看到召回与重排日志通常就能判断问题在哪一层。参考资料Lewis 等《Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks》https://arxiv.org/abs/2005.11401Cormack 等《Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods》https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdfBAAIbge-small-zh-v1.5模型卡https://huggingface.co/BAAI/bge-small-zh-v1.5BAAIbge-reranker-base模型卡https://huggingface.co/BAAI/bge-reranker-baseSentence TransformersCross-Encoder 文档https://www.sbert.net/docs/package_reference/cross_encoder/model.htmlRank-BM25 项目说明https://github.com/dorianbrown/rank_bm25FastAPI 官方文档https://fastapi.tiangolo.com/Ollama Chat APIhttps://docs.ollama.com/api/chat