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.12;
- FastAPI 0.138.1;
- sentence-transformers 5.6.0;
BAAI/bge-small-zh-v1.5做中文向量召回;BAAI/bge-reranker-base做候选重排;rank-bm25做 BM25;- macOS,CPU 推理。
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_score + 0.5 * bm25_score
换一批文档、换一种分词或换一个模型后,比例可能完全失效。本文使用 RRF(Reciprocal Rank Fusion):
RRF(d) = Σ 1 / (k + rank_i(d))
它只看文档在各路结果里的名次,不直接比较原始分数。这样能先得到一个稳定候选集,再交给重排模型做更细判断。
RRF 不是万能参数。k=60 是常见起点,不是业务真理,仍要用自己的查询集评估。
五、为什么还要加 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 -m venv .venv
# macOS / Linux
source .venv/bin/activate
# Windows PowerShell
# .venv\Scripts\Activate.ps1
pip install -r requirements.txt
如果 PyTorch 需要 CUDA,请优先按 PyTorch 官方安装页选择与显卡驱动匹配的命令,再安装其他依赖。不要为了“能装上”随便混用 CPU、CUDA 和系统 Python。
九、核心代码:混合召回、重排、拒答与引用检查
新建 rag_core.py:
{{RAG_CORE_PY}}
这段代码里最值得单独解释的是四处。
1. 查询加指令,文档不加
query_embedding = self.embedder.encode(
[QUERY_INSTRUCTION + query],
normalize_embeddings=True,
)
这是短查询检索长段落的非对称检索设置。不要把同一条指令也塞进每个知识库段落。
2. RRF 只融合名次
for order in (dense_order, bm25_order):
for rank, index in enumerate(order, start=1):
rrf_scores[index] = rrf_scores.get(index, 0.0) + 1.0 / (
self.settings.rrf_k + rank
)
这里没有假装 BM25 分数与向量分数可以直接比较。候选进入重排后,最终顺序由问题与段落的成对相关性决定。
3. 拒答不是只看一个分数
has_dense_support = hit.dense_score >= min_dense
has_lexical_support = (
hit.bm25_score > 0
and hit.token_overlap >= min_overlap
)
answerable = rerank_passed and (
has_dense_support or has_lexical_support
)
精确型号查询可能向量分不高,但关键词证据很强;口语化查询可能几乎没有字面重合,但语义与重排结果都很好。把两类信号合在一起,比“相似度低于 0.7 一律拒答”更接近真实业务。
4. 引用校验不是事实核验
Demo 会检查每个事实句有没有 [Sx]、编号是否存在,并再次计算该句与引用段落的相关性。如果失败,答案不会继续播报。
但相关性模型不能证明“引用一定蕴含这句话”。金额、日期、否定词、条件范围仍应加规则检查,高风险业务还要抽样复核。
十、FastAPI 接口
新建 app.py:
{{APP_PY}}
模型在 lifespan 阶段加载一次,而不是每次请求重新加载。同步模型推理通过线程池执行,避免直接堵住事件循环。
启动:
fastapi dev app.py
第一次启动需要等待两个模型完成下载和加载。看到服务启动后,先检查:
curl http://127.0.0.1:8000/health
再提问:
curl -X POST http://127.0.0.1:8000/ask \
-H "Content-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 -X POST http://127.0.0.1:8000/ask \
-H "Content-Type: application/json" \
-d '{"query":"帮我预测下周黄金价格"}'
理想行为不是硬找一条客服资料,而是返回:
{
"status": "refused",
"answer": "当前知识库没有足够信息,我为你转人工核实。"
}
十一、批量评估,不要凭单个问题判断准确率
新建 evaluate.py:
{{EVALUATE_PY}}
运行:
python evaluate.py
它会输出每条查询的 Top1、预期文档、拒答判断和本次检索耗时,最后计算:
Recall@3:正确文档是否进入最终 3 条证据;MRR:正确文档越靠前,分数越高;RefusalAccuracy:能回答与该拒答的判断是否正确。
我的本次运行记录如下:
{{EVAL_OUTPUT}}
这组数据只能证明示例链路在 8 条虚构知识、10 个合成问题上可以运行。真实项目至少还要补:
- ASR 常见误识别问法;
- 数字、地址、订单号和产品型号;
- 同一政策的多种口语表达;
- 高相似但结论相反的困难负样本;
- 已过期政策与新政策冲突;
- 明确超出知识库范围的问题。
十二、接入 Ollama 生成带引用回答
默认 extractive 模式不需要 LLM。要生成自然回答,先在本机准备 Ollama 模型,然后设置:
export LLM_MODE=ollama
export OLLAMA_MODEL=qwen3
.env.example 完整配置:
{{ENV_EXAMPLE}}
Ollama 的 /api/chat 默认流式返回,本文代码显式传入 stream: false,因为要等完整答案出来后统一做引用检查。真正接回 Voice Agent 时可以把生成改成流式,但不要在校验完成前直接把每个 token 都送给 TTS。
更稳妥的做法是按句缓冲:
LLM token stream
-> 形成完整句
-> 检查该句引用
-> 通过后进入 TTS 队列
-> 失败则停止后续生成并转人工
否则一句无依据的话已经播出,事后再把整段答案判失败也来不及。
十三、常见报错与排查
1. 模型下载超时
典型现象:
OSError: Can't load the model for 'BAAI/bge-reranker-base'
先确认不是模型名拼错,再检查 Hugging Face 是否可访问。生产环境建议提前下载到固定目录:
export EMBEDDING_MODEL=/models/bge-small-zh-v1.5
export RERANKER_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 | 关键词/数字识别准确率 | 先确认查询有没有听错 |
| 召回 | Recall@K | 正确证据是否进入候选 |
| 排序 | MRR、nDCG@K | 正确证据是否排在前面 |
| 拒答 | 错误接受率、错误拒绝率 | 胡答与过度拒答分别统计 |
| 引用 | 引用覆盖率、无效引用率 | 每个事实句是否有有效来源 |
| 性能 | dense、BM25、rerank、generation 延迟 | 找到真正慢的阶段 |
| 业务 | 转人工率、重复追问率 | 技术分数最终是否改善对话 |
对 Voice Agent 还要额外看“开始播报前的等待时间”。一个离线评估很准、在线每次多等两秒的 RAG,也可能把通话体验拖垮。
十五、FAQ
Voice 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.11401
- Cormack 等,《Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods》:https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf
- BAAI,
bge-small-zh-v1.5模型卡:https://huggingface.co/BAAI/bge-small-zh-v1.5 - BAAI,
bge-reranker-base模型卡:https://huggingface.co/BAAI/bge-reranker-base - Sentence Transformers,Cross-Encoder 文档:https://www.sbert.net/docs/package_reference/cross_encoder/model.html
- Rank-BM25 项目说明:https://github.com/dorianbrown/rank_bm25
- FastAPI 官方文档:https://fastapi.tiangolo.com/
- Ollama Chat API:https://docs.ollama.com/api/chat
更多推荐
所有评论(0)