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

很多 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 调大或调小,而是加了一层 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 改完,回答更像人话了,但引用命中率下降了,这种改动就不能直接上线。
最后效果大概是这样,数字做了脱敏,只保留变化趋势:
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
07这次之后,我对 RAG 的看法变了
RAG 不是“向量库 + 大模型”的胶水工程。它更像一套检索系统,只是最后多了一个生成层。
真正影响效果的,往往是这些不太酷的细节:
-
chunk 是否保留了标题和业务边界。 -
Embedding 文本是不是包含足够语义。 -
metadata 能不能先过滤掉明显错误的候选。 -
rerank 能不能把真正相关的内容排上来。 -
Prompt 有没有要求引用和拒答。 -
有没有一套能持续评估的测试集。
如果只把文档切一切、embed 一下、塞进向量库,RAG 很容易在 Demo 里看起来能用,在真实问题里开始露馅。
我现在更愿意把 RAG 当成“带生成能力的检索工程”。先把资料找对,再谈回答写得漂不漂亮。否则模型越会说话,错得越像真的。
夜雨聆风