RAG答非所问?先查向量,再怪模型:完整排查与优化指南
大概三年前,我第一次在自己的知识库项目里被 RAG 搞得怀疑人生:用户问“上个月的报销流程什么时候生效”,系统却答非所问地扯出一堆与报销无关的离职手续说明。团队第一反应非常一致——换模型。结果从开源模型换到商业大模型,从 7B 换到 72B,问题纹丝不动。直到我耐住性子把向量检索的结果一条条导出来,才发现问题根本不在模型身上。从那以后我养成了一个习惯:凡是 RAG 答非所问,先查向量,再怪模型。这句话我几乎在每一次技术分享里都会重复,今天把整套排查思路写出来,希望能帮做知识库问答的人少走几个月的弯路。
RAG 的完整链路是:文档加载、文本切分、向量化入库、用户问题向量化、相似度检索、重排序(可选)、拼接上下文、大模型生成。你看到的“答非所问”是链条最后一环的输出,但炸药通常埋在前面几环。这篇文章会告诉你如何先查向量检索侧,如何定位到具体坏环节,以及每个环节的优化参数和经验值,适合刚接触 RAG 的新手,也适合已经上线项目但召回质量不稳定的开发者。
1. 先搞清楚:RAG 答非所问,到底是谁的锅
1.1 一个让模型“背锅”的真实场景
我先还原一下那个让团队吵了两周的生产事故。内部有一个规章制度问答系统,文档库大概 300 多份制度文件,已经切块、向量化之后进了向量数据库,生成端用的是一个 70B 级别的开源模型。用户反馈说:“我上个月提交的加班调休申请,为什么流程走到部门主管那里被驳回了?”
系统返回的内容是:调休申请应在每季度末提交,逾期不再受理,建议联系人事部门处理。
看着好像有点相关,但用户真正问的是“为什么被驳回、当前状态卡在哪一步”,知识库里其实有一份《加班与调休管理制度》,第 4.2 条明确写了“驳回后系统将自动通知申请人补充材料,补交后重新进入审批流”。也就是说这条答案确实存在于知识库里,只是没有被检索出来。我们的第一反应是换模型,因为“生成能力不行”“理解能力不够”这种话听起来太合理了。换完之后,同样的输入还是输出同样的错误结果,因为模型拿到的上下文根本没有第 4.2 条的内容,它再怎么“聪明”也无从知道。
这时候才有人开始怀疑检索环节。我们把用户问题丢进向量检索,打印出 top 5 的检索片段,发现一个问题:召回的五条里确实有好几条来自《加班与调休管理制度》,但内容全部集中在制度开头部分,也就是“适用范围、定义、总则”这类章节,真正包含“驳回处理流程”的 chunk 排名在二十名开外。
1.2 检索侧失灵的五种典型表现
根据这几年我经手的项目,检索侧的问题大致有五种表现,你可以对照自查。
第一种叫召回为空。检索跑完,一条相关片段都没有,或者检索结果与问题毫无关联。这种问题通常出在文本切分、嵌入模型或数据源本身,向量库里可能本来就是脏数据。第二种叫召回噪声。能查到一堆相似片段,但仔细看全是“看起来像、实际上不对”的邻居。常见原因有两个:chunk 切得太大,一个 chunk 包含多个主题,向量表征被稀释;或者嵌入模型领域不适配,把“报销”和“报销比例下调”理解得几乎一样。第三种叫召回错位。答案确实在库里,但它所在的 chunk 被切断了,正好把关键句子的边界切断,检索能查到一半,另一半跑到别的 chunk 里。第四种叫召回冗余。命中的片段太多太杂,拼接进上下文之后,模型被大量不相关内容干扰,反而抓不到重点。第五种最隐蔽——数据源本身是坏的,比如扫描版 PDF 经过 OCR 之后全是乱码,向量库里存的就是一堆错别字和破碎句。用户问得再好,也只能召回垃圾。
你以为的“模型不够聪明”,有很多其实是这五种情况中的一种或几种叠加。所以在动模型之前,花 20 分钟把检索结果导出来看一眼,成本极低,收益极高。
1.3 为什么“再怪模型”前必须“先查向量”
RAG 的本质可以写成一条很土但有用的公式:生成质量 ≈ 召回质量 × 生成能力。大模型是最后一个环节,它的职责是“在给定素材里组织答案”,不是“凭空推理出知识库里没有的内容”。你把错误的片段喂给它,它唯一能做的就是基于错误信息一本正经地胡说八道。这就像米其林大厨拿到发臭的鱼,厨艺再高也救不回来,问题出在采购员身上。
再换个角度理解“先查向量”的成本。换一个更强的模型意味着什么?要重新评估 API 成本、推理延迟、上下文窗口、输出稳定性,还要重新做一轮 prompt 适配,一套折腾下来快则三四天,慢则一两周,而且往往没有效果。相比之下,把向量检索结果打印出来、人工扫一眼,可能只需要半个小时。先查向量不是偏见,而是成本最低、信息量最大的排查动作。我在项目里给所有知识库问答系统强制加了一条规则:任何“答非所问”的反馈,必须附带检索诊断截图,否则不进研发排期。靠这一条规则,线上反馈的排查效率至少提升了一倍。
2. 向量检索链路的核心环节拆解:四个最容易出问题的点
2.1 文本切分:chunk 大小决定了能不能被查到
文本切分是整个 RAG 里最容易被低估的环节。很多团队最初图省事,直接按固定字符数切,比如每 500 字一刀切出来。这种做法在语料结构规整时还能用,一旦文档出现章、节、表格、列表、代码块,固定窗口会把完整语义硬生生劈成两半。
我见过最典型的失败案例:制度文档里有一条流程描述横跨两个 chunk,前半段在 chunk_017,后半段在 chunk_018。用户问“驳回后的重新提交入口在哪里”,向量检索最匹配的是 chunk_017,因为它包含了“驳回”两个字,但真正写着“在 OA 系统发起重新提交”的内容在 chunk_018。模型只拿到 chunk_017,自然答不出入口位置。
解决思路有三个方向。第一,尽量用文档自带的语义边界切分,比如 Markdown 标题、PDF 目录层级、Word 样式分隔符。标题是一个天然的段落话题边界,按这个切分,chunk 内部的主题一致性会好很多。第二,控制 chunk 大小。中文场景下我通常把 chunk 控制在 300~500 字,对应大约 200~400 token,既不至于细碎到丢失上下文,也不至于大到淹没具体细节。第三,设置重叠。相邻 chunk 之间保留 50~80 字的 overlap,避免句子在边界处被切断。这个策略很土,但对指标提升非常明显。
还有一个容易被忽略的细节:表格和代码块不适合和其他文本混在一个 chunk 里。我的经验是把它们单独提取出来,作为独立片段入库,否则向量会被结构信息干扰。
2.2 嵌入模型:语义空间不一致,检索结果直接归零
文本切分解决“内容在不在 chunk 里”的问题,嵌入模型解决“能不能被检索到”的问题。这里我要强调一个所有踩过坑的人都会点头的结论:上线之后千万不要随意换 embedding 模型,换了就一定要全量重建向量索引。
为什么这么严重?因为 embedding 模型的本质是把你的一段文本映射到一个高维语义空间里的坐标。不同模型训练目标不一样,语义空间也不一样。有的模型维度是 768,有的是 1024,维度不同往往直接报错;但要命的是维度恰好相同,模型却换了的场景。你旧索引里的向量是用模型 A 算的,查询时用的是模型 B,两批向量根本不在同一个语义空间里,检索结果的分数会变得非常诡异,相关性普遍很低,偶尔把完全不相关的东西排到前面。最恶心的是系统不报错,线上一切“正常”,只是问答质量悄悄下降。我见过一个项目因此浪费了整整两个星期。
选模型也有门道。通用场景,可以用 bge、m3e 这类开源的中文 embedding 模型,效果已经很能打;专业领域,比如法律、医疗、财务,通用模型对行业术语的理解经常飘,建议用领域数据做微调,或者至少准备一批代表性 query,在不同模型之间做命中率对比。判断标准很简单:选一批必须能答对的问题,跑一次向量检索,看正确 chunk 有没有进入 top 5。你会发现模型之间差距可以大到令人吃惊。
另外,语言一致性也要注意。如果你的知识库主要是中文,用中文效果好的 embedding 模型,而不是用一个偏向英文的通用多语言模型。这个差别在检索分数上可能只有 0.1,但在命中率上要差几个档次。
2.3 相似度计算与召回策略:别让 TopK 帮倒忙
切块和模型都正常,检索结果还是不对,就要看相似度计算和召回策略了。
不少向量数据库默认支持多种距离计算方式,常见的有余弦相似度、点积、欧氏距离(L2)。很多人对着公式看了半天,其实只用记住一句话:绝大多数 RAG 场景用余弦相似度就够了,它是归一化后的向量夹角度量,对向量长度不敏感,语义方向匹配程度高。如果你用了点积,而向量没有做归一化,长文本的向量范数天然偏大,排序结果会被某些没意义的维度带跑偏。如果你用了 L2 距离,要记住数值越小越相关,跟余弦相似度正好相反,很多人第一次看输出时会下意识搞反。
TopK 的设置也很讲究。太小的 TopK,比如只取 1,模型拿到一个片段就要硬答,信息量不够;太大的 TopK,比如取 20,很容易把一堆低分噪声塞进上下文,模型反而被带跑。我的经验是:不接重排序时,TopK 取 5 左右比较稳妥;接重排序时,可以多召回一些候选,比如先取 20 再精排到 5。
还有一个很常见的错误:用一个固定的相似度阈值来过滤所有文档。不同 embedding 模型的分数分布差异极大,有人用 0.7 作为阈值,业务正常;有人照抄到一个用其他模型的系统里,发现超过 0.7 的几乎没有。正确做法是先统计你自己库里所有查询的分数分布,再用分位数来定阈值。我习惯的做法是,把检索任务跑一遍并记录每条正确答案的分数,取一个能保住 90% 正确答案的下限值作为阈值。
2.4 检索结果可视化:手动检查召回质量是第一步
说了这么多理论,真正的第一步其实是可视化。所谓可视化,不需要造一个大屏系统,而是把每个用户问题对应的检索候选片段和分数打印出来,用肉眼判断“答案在不在这里面”。这一步做好了,问题归因就完成了一半。
我常用的调试脚本风格是这样的:
from langchain.embeddings import HuggingFaceEmbeddings from langchain.vectorstores import Chroma embedding = HuggingFaceEmbeddings(model_name="BAAI/bge-large-zh-v1.5") store = Chroma(persist_directory="./kb_store", embedding_function=embedding) query = "上个月的报销流程什么时候生效" docs = store.similarity_search_with_relevance_scores(query, k=5) for i, (doc, score) in enumerate(docs, 1): print(f"Top{i}\tScore={score:.4f}\tSource={doc.metadata.get('source', 'unknown')}") print(doc.page_content[:120].replace("\n", " ")) print("-" * 80)把这段脚本跑一遍,你就能得到一张直观的召回质量报告。接下来判断就很简单:
- 如果正确答案出现在 top 5 里,说明从入库到检索的整条链路是通的,问题极可能在生成侧,比如 prompt 指令不清楚、模型没有按检索结果作答、上下文太长被截断。
- 如果正确答案完全没出现,或者排名非常靠后,那首先要怀疑的是检索侧,而不是生成侧。继续顺着切分、嵌入模型、相似度计算三个方向查。
- 如果 top 5 全是看似相关实则无关的噪声,优先怀疑 chunk 切分过大、主题过于混杂。
大部分项目的“答非所问”,到了这一步就能锁定一个大致范围。
3. 实操排障:一整套从现象到根因的排查流程
3.1 第一步:先导出 query 对应的检索候选,不看生成结果
当我接到一个“RAG 答非所问”的反馈,第一件事永远是:把用户问题单独拎出来,跑一遍向量检索,导出 top 5 或 top 10 的候选片段。这一步千万不要带生成环节,不要看大模型的输出,因为你一旦把生成结果混进来,就会忍不住替模型找补,分析它就跟着跑偏了。
实际操作中,我会把排查脚本做成一键运行的模式。从业务日志里捞出一批出问题的用户问题,逐条调用向量检索接口,把 query、命中的 chunk 文本、分数、来源文档写到 CSV 里。然后我逐条读,读的时候只判断两个问题:第一,这个 chunk 是不是知识库里该出现的内容;第二,它和用户问题的语义相关度是否配得上它的排名。
这一步最反直觉的地方在于:很多人第一次看到检索结果时会发现,命中的内容其实没有完全跑题,只是没有命中答案所在的那一段。这时候不要急着觉得“检索没问题”,而是要看“正确的答案在不在候选里”。不在,就是检索侧的锅,即使相关片段看起来再相似也没用。
3.2 第二步:逐条对照 chunk,打标签定位问题环节
导出候选之后,我会做一张标签表,把每个问题归因到具体环节。这张表长这样:
| 检查项 | 判断方式 | 根因归属 |
|---|---|---|
| 正确 chunk 是否被切坏 | 在原文档中定位该 chunk 的起止位置,看关键句子是否被截断 | 文本切分问题 |
| 正确 chunk 是否入库 | 在库中按原文检索,看是否存在乱码、缺段、错字 | 数据源/入库问题 |
| 正确 chunk 的向量语义是否准确 | 用候选 query 单独和该 chunk 算相似度,看分数是否异常低 | 嵌入模型问题 |
| 相似度分数分布是否合理 | 统计所有命中的分数区间,看正确项是否被噪声挤掉 | 召回参数问题 |
| 候选前几名都是同一类噪声 | 观察命中的集中程度,是否集中在某个固定主题 | chunk 粒度/主题混杂问题 |
| 正确答案命中了但生成时没用 | 构造一段只包含正确答案的 prompt 重新生成 | 生成侧 prompt 问题 |
如果正确答案在库里存在,但没有被切坏,同时向量分数也正常,却排不出前五,那很可能是召回参数的问题。调整策略是:把 TopK 临时调大到 20,看正确答案排第几。如果正确答案排在第 15,说明向量检索整体是有效的,只是阈值和排序权重需要重新标定;如果正确答案排到几百名开外,那就要回头查嵌入模型或文本切分。
打标签这件事看起来繁琐,但做一次之后,你会对自己的系统建立非常强烈的直觉,哪个环节薄弱,一眼就能看出来。
3.3 第三步:用混合检索 + 重排序给召回兜底
纯向量检索最大的软肋是什么?它对“精确匹配”不敏感。用户问“ABC-2024-X 型号的驱动在哪里下载”,知识库里文档写法是 “ABC2024X”,二者语义相近,但向量表示可能被其他成分干扰。这时 BM25 这种关键词检索反而能精确命中。
所以我在正式项目里几乎不用纯向量检索,而是使用“混合检索”:同时跑向量召回和关键词召回,把两路结果合并去重后再统一排序。这样既保证语义泛化能力,又保住精确匹配能力。实现上,LangChain、LangChain4j、LlamaIndex 这些框架都有现成的 hybrid retriever 组合方式,向量库可以选择支持全文索引的,也可以在外部叠加一个 ES 来做关键词召回。
两路结果合并之后,还可以接一个重排序(rerank)环节。向量检索是“粗排”,重排序是“精排”。重排序模型一般用 cross-encoder 结构,把 query 和每个候选拼接成一对文本,直接输出相关度分数,比向量相似度靠谱得多。实践中的配置可以参考:向量召回 20 条 + BM25 召回 20 条,合并去重后取 30 条,再通过重排序模型压缩到 5 条进入上下文。成本会有所上升,但召回质量的提升是肉眼可见的。
我见过一个项目在加了混合检索和重排序之后,答案准确率从 61% 直接跳到 84%。这个提升远大于换一个更大参数量的模型带来的提升。
3.4 第四步:从“能查到”到“答得对”的优化参数参考
排查和优化不能永远靠感觉,需要一套可复现的参数基线。以下是我在多个中英文混合项目中调整过的经验起点,你可以根据自己的语料和模型做微调。
| 环节 | 参数 | 推荐起点 | 调整方向 |
|---|---|---|---|
| 文本切分 | chunk_size | 300~500 字 | 语料主题越杂,chunk 越小 |
| 文本切分 | overlap | 50~80 字 | 句子边界被切断时加大 |
| 嵌入模型 | 模型选择 | bge / m3e 系列 | 专业领域考虑微调 |
| 向量检索 | TopK | 5 | 接重排序时可以先取 20 |
| 向量检索 | 相似度阈值 | 按分数分布分位数定 | 不要跨模型照抄阈值 |
| 混合检索 | BM25 权重 | 0.3~0.5 | 精确型号多时加大 |
| 重排序 | 候选数量 | 30 → 5 | 延迟敏感则减少候选 |
| 索引参数 | HNSW M | 32 | 召回率低时加大 |
| 索引参数 | efSearch | 128 | 追求精度可加到 256 |
这些参数不是拍脑袋拍的,我吃过大亏之后总结出来的经验是:每次只调一个参数,控制变量,记录指标。不要同时改 chunk、模型、阈值,不然出了问题你根本不知道是哪一个改坏的。
在实际项目的落地中,还有一个很容易被忽略的问题:每次修改参数都需要重新加载并评估一批测试 query,而不是随便问两句话觉得“看起来不错”就上线了。维护一组固定回归测试集是值得的,哪怕只有几十条真实用户问题,至少能让你在改动之后快速判断有没有退步。
4. 高频问题与排查技巧速查表
4.1 典型症状、可能原因与处置建议
为了让排查过程更高效,我整理了一个速查表。每个症状背后都有多条可能原因,建议按表格顺序依次排查,不要跳。
| 现象 | 优先排查方向 | 处置建议 |
|---|---|---|
| 回答内容与知识库完全无关,模型在“自由发挥” | 检索命中是否为空、相似度阈值是否形同虚设 | 导出 top5 看相关性,设置有效阈值,必要时直接禁用无命中回答 |
| 回答沾边但不精确 | chunk 跨主题、切分不合理 | 按标题/段落边界切分,调小 chunk_size |
| 回答很泛、没有细节 | TopK 太小、正确答案被切碎 | 增大 TopK,增加 overlap,检查是否漏召回答案所在 chunk |
| 检索结果正确但模型答错 | prompt 表述、上下文截断 | 在 prompt 中明确“必须基于给定内容回答,不要使用内部知识”,检查最大 token 限制 |
| 检索结果分数普遍极低 | 索引向量与查询向量语义空间不一致 | 检查是否换过 embedding 模型,确认后用新模型全量重建索引 |
| 召回结果全是同一个主题的旧内容 | 向量库中重复文档多、上线后增量语料没有去重 | 建入库去重逻辑,对重复 chunk 做过滤 |
| 特定型号/编号永远查不到 | 向量检索对精确匹配不敏感 | 加 BM25 关键词召回,或对编号类内容单独建精确索引 |
| 换模型后不升反降 | 新模型与检索链路不匹配 | 回退旧模型,或对新模型做小批量 A/B 测试再决定 |
这张表是我在给团队做内部培训时整理的,后来很多同事把它贴在自己的工位上。它不是唯一解,但能覆盖大部分线上“答非所问”的真实原因。
4.2 我在真实项目中踩过的坑
第一个坑是换 embedding 模型没重建索引。有一回我在开发环境换了一个效果更好的新模型,测试问答手感明显变好,就顺手部署到生产。第二天线上反馈炸了,答非所问的比例比以前还高。查了半天,发现生产环境的索引还是旧向量,查询却用了新模型。向量维度一模一样,系统完全没报错,但两批向量的语义空间完全不对位,检索排名自然乱了套。那次之后我在发布清单里加了一条:embedding 模型版本号必须与索引版本一致,否则不允许发布,还要在元数据里写入嵌入模型的哈希值。
第二个坑是扫描版 PDF 的 OCR 乱码。接手一个法律法规问答项目时,同事说数据已经清洗好了,可以直接入库。我随手抽查了十几条向量片段,发现有好几条是“局 关 于 印 发 通 知”这种带着空格和错字的破碎文本。这样的数据入库后,用户怎么问都是召回垃圾。后来我们建立了入库前的抽样审核机制:任何一批新增文档必须先抽出 1% 的片段人工阅读,乱码文本肉眼可辨,这一步能拦截掉绝大多数脏数据,成本比事后返工低太多。
第三个坑是“阈值一刀切”。我最初给一个系统设了 0.7 的相似度阈值,上线一段时间后召回质量下降,但检索结果“看起来都大于 0.7”。后来我统计了全量查询的分数分布,发现正确答案普遍在 0.62~0.68 之间,而大量噪声反而集中在 0.72 以上。这个分数分布说明该模型下 0.7 不是分界线。从此以后我学到:阈值必须基于你自己的数据和模型去统计,不要在网上看到别人用 0.7 自己也抄一个 0.7。
第四个坑是重排序参数没调好反而变差。引入重排序模型之后,团队把所有请求都改走 30 条粗排 + 重排序到 5 条的新链路,结果发现部分用户的响应时间从原来的 1 秒飙升到 4 秒。排查发现重排序模型被默认加载在 CPU 上推理,一个 batch 处理 30 条候选要跑好几秒。最后改成 GPU 推理并且动态 batch,延迟才恢复正常。这个教训提醒我:任何优化方案都要考虑线上延迟和成本预算,不能只盯着召回率一个指标。
这几个坑的共同点是:问题都不是模型“笨”,而是数据、索引、参数配置这些不起眼的环节在捣乱。如果你现在正被 RAG 的答非所问折磨,我的建议很直接:先把用户问题对应的检索候选导出来,肉眼扫一遍,再决定下一步有没有必要去动模型。很多时候,答案本身就躺在你的知识库里,只是向量检索没有把它捞上来。
实际上,我个人的体会是,排查 RAG 问题最关键的技能不是会用多大参数量的模型,而是能在一分钟内判断“问题出在检索还是生成”。这个判断力只能靠你亲手把几十个坏案例翻出来、逐条打标签练出来。刚开始会觉得枯燥,但当你连续解决三五个线上诡异问题之后,你会发现自己对整个系统的理解上升了一个层次。到时候再有人跟你抱怨“RAG 答非所问”,你也可以云淡风轻地说出那句话:先查向量,再怪模型。