乐于分享
好东西不私藏

RAG 不是把文档丢进向量库:一次“答非所问”的排查

RAG 不是把文档丢进向量库:一次“答非所问”的排查

RAG / Embedding / 向量检索

很多 RAG 项目看起来已经跑通了:文档切片、Embedding、向量库、召回、拼 Prompt。真正上线后才发现,最难的不是让它回答,而是让它别乱答。

前段时间看过一个内部知识库问答的问题。

系统流程很标准:把文档切成 chunk,生成 Embedding,写入向量库;用户提问时做向量检索,拿 topK 结果拼到 Prompt 里,再让大模型回答。

Demo 阶段效果还不错。问“报销流程怎么走”“某个系统怎么申请权限”,基本能答上来。可一到真实用户手里,就开始出现一种很烦的问题:它不是完全不会答,而是经常答得像“差一点对”。

比如用户问的是“外包人员 VPN 权限怎么续期”,模型拿到的上下文却是“正式员工 VPN 首次申请”。回答看起来很顺,但业务上是错的。

这类问题不能简单甩锅给大模型幻觉。很多时候,模型只是认真地基于错误上下文生成了一个漂亮答案。

01先拆问题:是模型答错,还是召回错了

排查 RAG 的第一步,我现在不会直接改 Prompt。

Prompt 当然重要,但如果召回内容本身就偏了,Prompt 写得再严谨,也只是让模型更认真地读错资料。

我先把一次问答拆成三段日志:

  • 用户原始问题和改写后的 query
  • 向量召回的 topK 文档片段、score、docId、chunkId
  • 最终喂给模型的上下文和回答
{  "query": "外包人员 VPN 权限怎么续期",  "rewriteQuery": "外包人员 VPN 权限 续期 流程",  "topK": [    {"docId": "vpn-001", "chunkId": 12, "score": 0.78, "title": "正式员工 VPN 首次申请"},    {"docId": "vpn-004", "chunkId": 3,  "score": 0.74, "title": "VPN 常见问题"},    {"docId": "hr-021",  "chunkId": 8,  "score": 0.71, "title": "外包人员入场材料"}  ]}

这条日志一出来,方向就变了。模型不是凭空乱答,它压根没有拿到“外包人员续期”的那段制度。

02chunk 切得太碎,语义被切断了

第一个问题出在文档切片。

原来的切法很机械:按固定 500 字切,重叠 50 字。这样做省事,但对制度类文档不太友好。因为很多关键限制条件在小标题里,正文里只写“按上述范围执行”。

结果就是:正文 chunk 里有“续期流程”,但没有“外包人员”;另一个 chunk 里有“外包人员”,但没有“续期流程”。Embedding 以后,两段都像相关,又都不完整。

后面改成按标题层级切片,chunk 里保留路径信息:

docTitle: VPN 权限管理办法sectionPath: 第三章 / 外包人员权限 / 续期content: 外包人员 VPN 权限到期前 7 天...

向量化时,不只 embed 正文,而是把标题路径和正文一起拼进去:

embeddingText = docTitle + "\n" + sectionPath + "\n" + content

这个改动看起来不大,但对召回很有帮助。因为“外包人员”“VPN”“续期”这些关键信息终于出现在同一个语义单元里。

03topK 不是越大越好

第二个问题是 topK。

一开始为了“尽量别漏”,topK 设得比较大。结果召回内容确实多了,但噪音也一起进来了。模型上下文里同时出现正式员工、外包人员、供应商、实习生几套规则,它就开始自己缝。

我把几组问题拿出来做了个小表,手工标注哪些 chunk 真正能回答问题:

问题类型
topK=3
topK=8
现象
流程类
容易漏步骤
噪音增加
需要 rerank
定义类
基本够用
收益不大
小 topK 更稳
权限类
容易混角色
更容易混角色
需要元数据过滤

最后不是简单把 topK 调大或调小,而是加了一层 rerank。向量召回先拿 12 条,再用 rerank 模型按 query 和 chunk 的相关性重排,最后只取前 4 条进 Prompt。

vectorCandidates = vectorSearch(query, topK = 12)reranked = rerank(query, vectorCandidates)context = reranked.take(4)

这个方案增加了一点延迟,但答非所问的比例明显下降。RAG 里很多时候不是召回越多越好,而是要让真正相关的内容排到前面。

04只做向量相似度,不够

第三个问题是元数据过滤。

用户问“外包人员 VPN 续期”,这里面“外包人员”不只是普通关键词,它是权限角色。角色错了,答案就错了。

所以后面给文档 chunk 补了几类 metadata:

  • 适用角色:正式员工、外包人员、供应商、实习生
  • 业务域:VPN、邮箱、代码仓库、工单系统
  • 文档状态:生效、废弃、草稿
  • 生效时间和版本号

检索时先做 metadata filter,再做向量召回,而不是把所有文档混在一起算相似度。

filter = {  "role": "contractor",  "domain": "vpn",  "status": "active"}results = vectorStore.search(queryVector, filter, topK = 12)

这个改动比调 Prompt 更有效。因为它直接减少了错误候选进入上下文的机会。

05Prompt 也要加边界,但别指望它救所有场

召回修完以后,Prompt 才值得继续调。

我们给 Prompt 加了几个很土但有用的限制:

  • 只能依据给定上下文回答。
  • 如果上下文没有明确答案,就回复“资料中没有找到”。
  • 涉及角色、时间、版本冲突时,优先使用生效版本。
  • 回答末尾带上引用的文档标题和章节。
如果上下文中没有明确说明,请不要推测。如果多个片段存在冲突,优先使用 status=active 且 version 最大的片段。回答必须引用 docTitle 和 sectionPath。

这一步对“编得很顺”的问题有帮助。用户至少能看到答案来自哪份制度,后面追查也有依据。

但我不太愿意把 Prompt 当万能修补。RAG 的主要矛盾,很多时候在召回和上下文组织,不在最后那几句提示词。

06最后看指标,不只看回答好不好听

改完以后,不能只靠几个演示问题判断效果。我们补了几个比较朴素的指标:

  • Recall@K:正确文档是否进入候选集。
  • MRR:正确答案排在第几位。
  • 无答案识别率:资料没有时能不能拒答。
  • 引用命中率:回答引用的章节是否真的支撑答案。
  • 人工抽检:每周抽一批真实问题标注。

有了这些指标以后,优化就不再是“感觉更好了”。比如某次 Prompt 改完,回答更像人话了,但引用命中率下降了,这种改动就不能直接上线。

最后效果大概是这样,数字做了脱敏,只保留变化趋势:

指标
调整前
调整后
正确文档进入 topK
70% 左右
90% 左右
答非所问
偏多
明显下降
无资料时拒答
不稳定
稳定很多
平均延迟
较低
增加 200ms 左右

07这次之后,我对 RAG 的看法变了

RAG 不是“向量库 + 大模型”的胶水工程。它更像一套检索系统,只是最后多了一个生成层。

真正影响效果的,往往是这些不太酷的细节:

  • chunk 是否保留了标题和业务边界。
  • Embedding 文本是不是包含足够语义。
  • metadata 能不能先过滤掉明显错误的候选。
  • rerank 能不能把真正相关的内容排上来。
  • Prompt 有没有要求引用和拒答。
  • 有没有一套能持续评估的测试集。

如果只把文档切一切、embed 一下、塞进向量库,RAG 很容易在 Demo 里看起来能用,在真实问题里开始露馅。

我现在更愿意把 RAG 当成“带生成能力的检索工程”。先把资料找对,再谈回答写得漂不漂亮。否则模型越会说话,错得越像真的。