前后端代码都不报错,向量库里的文档也一条条老老实实存了进去,相似度检索一调,top5结果也返回得有模有样。结果用户问一句“咱们的退款规则到底是几天内申请有效”,大模型张口就来:“根据相关规定,用户可在签收后30天内申请退款。”可你的知识库里白纸黑字写的是“生鲜类商品不支持无理由退款”。
这种场景我碰过不止一次,团队里最常见的反应就是:“向量库是不是接错了?”“embedding是不是选错了模型?”但查了一圈,链路每个环节看起来都是通的。于是问题就变成了一个非常典型的RAG落地困境——向量库跑通了,答案却还在胡说。
这篇文章我想把这几年做知识库问答的排查经验完整讲一遍。先说明一点:我这里说的“跑通”,指的是数据能入库、查询能返回片段、大模型能基于片段生成答案。但“能返回”和“返回得对”之间隔着一整条质量链路。文章会从链路拆解、检索端暗坑、上下文组织、排查方法到落地方案逐一展开,适合正在做RAG知识库、企业文档问答、客服机器人,或者刚把向量库接入LLM应用却对回答质量不满意的同学。没有晦涩理论,全是能直接上手的排查动作。
1. 先复现一次“胡说”:答案质量问题的范围排查
1.1 “跑通”只代表链路通了,不代表每一环都在正常工作
很多人对RAG项目有一个默认假设:只要数据进了向量库、检索能召回、大模型能回答,那答案的质量就等于“检索质量”加上“生成质量”。这个假设本身没错,但它掩盖了一个关键事实——链路上的每一环都有各自的“坏法”,而且坏法之间还会互相吞掉问题。
举个实际例子。我之前接手过一个售后知识库项目,文档是几百页Word整理成的Markdown,里面大量内容是表格和条款清单。入库时做了常规清洗、按500字符切片、overlap设64,向量用的通用中文embedding模型,检索top_k设5,然后把top5片段拼进Prompt丢给LLM。第一次联调,问“保修期外维修收费”,答案倒是流畅,但收费标准和文档里的完全对不上。
逐个排查后发现:
- 切片切碎了表格:一个收费规则表格被切成三块,每一块拿出去做相似度检索,单看都不足以回答完整问题;
- 检索到的5个片段里边,真正相关的只有1个,其他4个是蹭到“维修”“收费”关键词但讲的是别的事;
- 拼进Prompt之后,LLM把夹在中间的无关片段也当成了依据,于是给出一个四不像答案。
这里有个特别容易忽略的认知:检索返回的是“片段”,不是“答案”。向量库帮你做的只是在一堆碎片里找“看起来相关的碎片”。至于这些碎片拼起来够不够回答用户的问题、有没有把关键信息切丢、模型会不会被噪声带偏,向量库一概不管。所以“跑通了但仍旧胡说”的本质,往往是链路上某个环节以“看起来正常”的方式失败着。
定位这类问题,最忌讳的就是凭感觉瞎调。要么先做范围排查,把故障锁定在检索端还是生成端,否则今天调完切片、明天换embedding,问题依旧,还会越改越乱。
1.2 一次带“标准答案”的试验性提问,把问题锁进具体环节
范围排查其实不复杂。第一步不是改代码,而是手工做一次“控制变量”实验。
挑一条知识库里有标准答案、且答案比较“确定性”的问题,比如“客服热线的工作时间是什么时候”,因为这类问题对上下文要求低、答案硬,容易观察哪一环出了问题。然后按下面的顺序依次验证:
- 只问LLM,不给上下文:看模型凭自己的知识能答出什么。如果它连基础定义都说不清,那生成端本身可能就不适合这项任务,但大多数情况下这一步模型能答个大概。
- 只给一条准确的片段,不给多余内容:从库里人工挑出那条包含标准答案的原文片段,拼进Prompt问同一个问题。如果这一步答对了,说明生成端没问题,问题出在检索端或上下文组装;如果这一步答不对,说明Prompt设计、模型参数或上下文格式有问题,先去查生成端。
- 跑完整检索链路,再拼top5问:这就是生产环境的真实路径。如果第2步对、第3步错,几乎可以100%确定问题出在“检索返回的内容不合格”或者“返回内容拼接方式不合格”上。
这个三步实验我每次做RAG排障都会先跑一遍,比任何指标都直观。它回答的不是“哪里写得不对”,而是“问题到底在哪个环节”。把故障范围从“整个系统”缩到“检索端”或“生成端”,后面才有得调。
2. 检索端的三个暗坑:切片、向量模型与召回策略
2.1 切片把知识单元切碎了,相似度算得再准也白搭
向量检索的前置工作是切片。切片这件事看起来简单,实际是整套RAG里面最容易被低估的环节,因为它不报错。切大了、切小了、切歪了,系统都不会告诉你。
常见做法是固定字符数切片,比如先设chunk_size=500, overlap=64。这种策略对纯叙述性文本勉强够用,但只要文档里有表格、条款列表、代码块、参数说明这类结构化内容,就很容易出事。
我踩过的典型坑是这样的:
- 知识单元被拦腰截断:文档里“第3.2条 退换货政策”下面跟着一段完整解释,但固定切片正好在解释的中间切了一刀。前半段没有结论,后半段没有上下文,检索时无论哪一块单独出现,都撑不起一个完整答案。
- 表格被切散:表格本身是一个完整的语义单元,但字符切片不认逻辑边界,一份“收费标准表”可能被切成三四个chunk。每个chunk里的几行数据单独看,既看不出表头字段,也看不出适用条件,模型拿到这种碎片自然只能胡编。
- 语义重复的切片过多:overlap设得大一点,相邻chunk重复内容多,检索结果里经常出现两三条高度雷同的片段,白白占用上下文窗口。
更合理的方式是按结构边界切片,再根据长度做二次调整。比如先用标题层级、章节号、表格标签、段落空行做一级切分,确保一个语义单元尽量完整;然后对超长段落做字符级切分,同时要求“切点优先落在句子结束符或换行符上”。对表格这类特殊结构,尽量整体保留,不要硬切,必要时单独转成Markdown表格或JSON片段再入库。
还有一个很多人不重视的细节:切片时要把“来源路径”和“章节路径”拼进chunk的metadata里。后面排查时如果发现某段检索结果不对,能直接定位它是从哪个文档哪一节来的,不然几百个chunk混在一起,你有理也说不清。
2.2 embedding模型认不认你的领域词汇
切片没大问题,检索结果还是一堆不相关片段,那就得检查embedding模型和你的语料匹不匹配。
通用embedding模型是在海量通用文本上训练的,对日常表达理解得很好,但到了特定领域就会露怯。比如“向量召回率”“倒排索引”“余弦相似度”这些词,通用模型能理解字面意思,却不一定能把握它们在技术语境下的精确关系。再比如法律条款里的“视为”“但书”“除外”,医疗报告里的各种缩写,如果模型没在对应语料上充分训练,问“视为同意是什么意思”,检索出来的片段很可能牛头不对马嘴。
判断embedding合不合适的办法很简单:把你实际要检索的问题收集几十条,用当前模型算一遍召回,然后人工看“真正相关的那个片段排在第几”。如果相关片段经常排在top5之外,基本可以断定这套向量模型对领域语义分辨能力不够。
这里有几个可选的调整方向,按性价比排序:
- 换更强或更对口的中文embedding模型。现在可选的国产模型不少,比如BGE系列,对中文和领域术语的支持普遍比通用英文模型好。换模型之前,先在同样的测试集上把新旧模型的召回结果都打出来对比,别凭传说选型。
- 给关键实体做同义改写。有些词标准名和口语名不一致。用户问“电脑开不了机”,文档里写的是“设备无法正常启动”,向量相似度天然就低。可以在入库时做实体归一,把常见口语变体都存进metadata里,检索时做一次query扩展。
- 别迷信高维数和“更大更强”。embedding维度高不一定代表领域理解好。对垂直知识库,很多时候一个针对性微调过的小模型,比大而全的通用模型好用得多。
2.3 top-K和阈值该怎么定:召回少了漏,召回多了乱
就算切片和embedding都过关,召回策略本身也可能拖后腿。最典型的两个参数是top_k和similarity_threshold。
先把这两个参数的作用说清楚:
- top_k:限制每次检索返回多少个候选片段。设太小(比如2-3),相关片段排在第三第四就丢了,答案自然缺料;设太大(比如20),噪声片段一股脑全进上下文,模型容易被干扰。
- similarity_threshold:设置一个最低相似度分,低于这个分的片段直接丢弃。设太高,可能把好片段误杀;设太低,等于没设,垃圾全放进来。
这两个值的“最优解”不是拍脑袋定的,而是依赖你的数据分布。一个通用的起步配置是先设top_k=5、不设阈值,然后在真实问题上做人工检查:如果top5里相关片段少于2条,说明要么embedding有问题,要么top_k太小;如果top5里经常混入明显不相关的片段,说明噪声太多,这时候再逐步加上阈值过滤。
另外要注意,向量库返回的相似度分数和业务理解上的“相关度”不是一回事。很多向量库默认返回的是余弦相似度或L2距离,不同索引类型、不同模型算出来的分数量纲千差万别。你在这套模型里觉得“0.75已经是高分了”,换一个模型可能就是普通水平。所以尽量别硬套其他项目的阈值经验,用你自己测试集上的分数分布去定。
3. 检索结果交给LLM之前,上下文的“最后一公里”改造
3.1 片段顺序与去重:LLM天然默认前排内容是重点
检索返回的片段,默认顺序往往来自向量库的相似度排名,这个顺序本身合理,但它拼进Prompt之后会带来一个容易被忽视的影响:大模型对Prompt前部的信息更敏感,后面塞太多内容时,中间和末尾的片段很容易被“稀释”。
举个我踩过的例子。检索top5里,真正包含关键条款的片段排在第三位,前两条相似度更高但内容宽泛、没有实质答案。模型读完前两条已经被“带偏”,到了第三条反而没有分配足够注意力,最终答案参考的是前两条泛泛内容的混合。这种情况在长上下文中尤其严重。
处理办法有两个方向:
- 重排后只取高置信片段:不要无条件把top5全塞进去,而是先做一轮去重和重排,只保留信息量足够、彼此不重复的2-4条,减少干扰。
- 按逻辑重排再拼接:如果片段之间有明确依赖关系,比如“定义在前、规则在后”,可以在拼接时手工调整顺序,保证答案相关的核心片段处于Prompt前部。
另外,重叠切片造成的重复问题也很常见。同一个知识点被多个chunk重复包含,检索后返回的5条里可能有3条高度重合,挤占了上下文不说,还让模型误以为“这个信息重复出现,一定是重点”,于是答案过度偏向这一条。去重可以在拼接前做,比对片段间相似度,超过阈值就丢弃靠后的那条。
3.2 指令模板:不给规则,模型就会按“自由发挥”的方式答题
这部分是我见过翻车率最高的地方。很多人把检索结果塞进Prompt就算完事,Prompt长得像这样:
请回答用户的问题:{question} 参考资料:{context}这种写法的后果是:大模型不知道这些资料是“唯一事实来源”,它默认可以和自己的已有知识混合使用。模型本身训练时见过大量相似问题,一旦检索片段不完整,它就会用记忆里的“最像答案”来补全,于是开始一本正经地胡说。
一个能有效压制幻觉的指令模板,至少要包含以下几点:
- 明确角色:告诉模型它是某个知识库的问答助手,只能基于给定资料回答。
- 锁定依据范围:明确“以下资料是唯一可信来源,不要使用资料之外的知识”。
- 规定不知道时的行为:资料中找不到明确答案时,直接说“根据现有资料无法确定”,而不是编一个。
- 限制输出格式:简单问题直接回答,复杂问题用列表或分点,避免长篇大论。
一个经过验证可用的模板结构(不一定照抄,但Components要齐全):
你是一个知识库问答助手。请严格依据下面提供的资料回答问题。 资料: {context} 用户问题:{question} 要求: 1. 如果资料中有明确答案,请直接回答,并在回答末尾标出依据的片段编号; 2. 如果资料不足以回答,请明确说“现有资料无法确定”,不要自行推测; 3. 不要使用资料之外的任何先验知识; 4. 回答保持简洁,不超过200字。这种结构的作用,是让模型在“补全”和“拒答”之间做出正确选择。我实测下来,只加上“无法确定就直说”这一条,就能让胡编率下降一大截。很多人遇到答非所问,第一时间换模型、调温度,其实就是忘了给模型立规矩。
3.3 上下文窗口截断和冲突信息:两处容易被忽略的质量黑洞
就算指令模板写得没问题,还有两个隐藏陷阱会让答案继续胡说。
第一个是上下文截断。现代大模型都有token上限,知识库检索回来的片段一多,加上系统指令、历史对话,很容易顶到窗口上限。很多框架的做法是从末尾直接截断,但问题在于:最长的上下文末尾往往是最后检索回来的片段,而它可能恰好就是最相关的那条。一旦被截掉,模型拿到的上下文就是残废的。解决方案是做好优先级排序:系统指令和“当前问题对应的核心片段”优先保障,历史对话适度截断,而不是一刀切。
第二个是冲突信息。知识库不是教科书,不同文档对同一事实的说法经常不一致。比如A文档说保修期1年,B文档说保修期2年。检索时两个片段同时被召回,模型一看资料打架了,就会自己做判断。而这个判断没有规则约束,完全看模型心情,于是有时选A有时选B,输出还振振有词。
处理冲突信息有两个思路:
- 录入时建立优先级:在metadata里给文档打上来源等级或时间戳,比如“官方最新版 > 运营补充说明 > 历史版本”。Prompt里告诉模型“当资料冲突时,优先采用来源等级更高/更新时间更新的片段”。
- 回答时暴露冲突:如果无法判断,可以要求模型在答案中说明“各资料说法不一致”,并列出不同来源。这比让模型强行二选一更可靠。
4. 我的排查链路:从一句“错话”一步步追到根因
4.1 第一步:把检索候选直接摊开人工过目
优化不能靠猜。当系统给出一个错误答案时,我做的第一件事永远是:把这次问答的完整链路记录打出来。包括:用户问题是什么、向量检索返回了哪些片段(带metadata和相似度分数)、拼接后的Prompt长什么样、模型最终输出是什么。
如果链路记录没提前做,临时抓瞎很难复现。所以我在项目里会直接写一个简单的排查脚本,手动指定一条query,把检索结果逐条打印,类似下面这个模式:
from pprint import pprint query = "生鲜类商品是否支持无理由退货" results = vector_store.search(query, top_k=5) for i, hit in enumerate(results): print(f"===== 候选 {i+1},相似度:{hit.score} =====") print(f"来源:{hit.metadata.get('source')}") print(hit.text[:200]) print()这一步不用任何高级工具,它唯一的目的就是让你用眼睛确认一件事:“检索结果和用户问题到底相不相关?”
我在实际排障中遇到过三种典型情况:
- 检索结果里,真正相关的片段在top3之后,排在前面的全是似相关非相关的泛泛内容。这说明排版和召回策略有问题。
- 检索结果里所有片段都是同一篇文章的重复内容。这通常是切片overlap过大,或者库里同一篇文档被重复导入了多次。
- 检索结果看起来相关,但片段内容本身残缺,比如表格被切碎、条款被拦腰斩断。这说明切片环节有问题。
这三种情况对应三种完全不同的修法。所以第一步“人工过目”必须做,而且不要跳过。它成本最低、信息量最大。
4.2 第二步:用20-50条带标注的评测问题量化召回质量
人工看一两遍能发现明显问题,但无法确认全面情况。这时候就需要一个轻量级的评测集。
做法不复杂:从真实用户问题或业务场景里整理出20-50条问题,然后针对每一条人工标注“应该命中哪些文档/片段”。不需要很多,关键是覆盖典型问题类型。然后写脚本跑一遍检索,统计“相关片段的召回到第几名才出现”。
我通常用一个最简单粗暴的指标:Recall@K,也就是“前K个检索结果中是否包含标注的相关片段”。比如K=5,20条问题里15条的标注片段出现在top5中,那Recall@5就是75%。
def evaluate_recall(eval_set, k=5): hit = 0 for item in eval_set: results = vector_store.search(item["query"], top_k=k) returned_ids = [r.id for r in results] relevant_ids = item["relevant_ids"] if set(returned_ids) & set(relevant_ids): hit += 1 return hit / len(eval_set) print("Recall@5:", evaluate_recall(eval_data))如果Recall@5连70%都没有,那就别急着优化prompt,先把召回率提上来。评测集建好之后,每一次改动(换embedding、改切片、调top_k)都能通过重跑这个评测集来验证效果。没有这个基准,你会陷入“改完感觉好了点”的玄学循环。
4.3 第三步:绕开检索做金标准问答,隔离“生成端”问题
检索端检查完,接着要确认生成端本身靠不靠谱。方法在我开头提的控制变量实验里已经讲过,这里展开说下。
选一条评测问题,人工从库里找到那个包含标准答案的片段,把这段原文直接作为唯一上下文丢给Prompt,让模型回答。如果这一步模型答得又准又稳,说明整个生成链路(模板、参数、模型)没问题,问题出在“模型拿到的上下文不够好”。如果这一步模型都答不对,那就是生成端的锅,得从指令模板、temperature参数、甚至模型本身入手。
这里有个细节值得注意:temperature参数。它是影响答案“创造性”的直接因素。很多问答场景下,temperature默认0.7甚至1.0,模型会倾向于生成更加多样化的文本,在RAG场景里这非常危险。做知识库问答我一般建议先压到0.1-0.3再测。同一套上下文,temperature从0.7降到0.1,胡编率肉眼可见下降。因为“胡说”很多时候不是模型不懂,而是它在“多种合理表达里自由发挥”,温度越低,越倾向于选择最简单直接的答案。
另外还可以做一次“无上下文”的对照:只给问题、不给任何资料。如果模型在没有任何资料时也能答得挺像样,说明它在用自己的记忆补全。那RAG场景下它一定会混用记忆和资料,这时钳制它的办法就是指令模板里那句“不得使用资料之外的知识”。在评测里多测几次,如果“无上下文”时和“有完整上下文”时答案差异巨大,说明模型对上下文的依赖性是合格的;如果两者差不多,说明模型基本没在认真看上下文,只凭惯性在答题。这种情况往往要检查Prompt结构是否把问题和上下文分得太模糊,或者干脆换一个更适合指令遵循的模型。
4.4 第四步:改一处验一处,用回归测试防止优化反弹
排障到了这一步,问题基本定位清楚了,接下来就是动手改。但改的时候要沉住气,一次只改一个变量。
比如你先怀疑切片有问题,那就只改切片逻辑,embedding和top_k都不动,改完重新跑评测集对比Recall@5。如果改了切片后命中率提高了,确认是切片的锅;如果没变化,再考虑是不是embedding的问题。这看起来很笨,但恰恰是最快的。一次改多个参数,出效果了不知道是谁的功劳,没效果也不知道是谁拖的后腿,等于白改。
回归测试还有个作用:防反弹。有些优化在某些问题上有效,却会把另一些问题的效果弄坏。比如你把top_k从5调到10,可能召回率上来了,但上下文变长后干扰增多,某些简单问题的回答精度反而下降。这些副作用只有通过同一条评测集反复跑才能暴露。所以评测集要持续沉淀,每次开会、每次上线都拿它跑一遍,它就是你这条RAG管线的“体检指标”。
5. 从“勉强能用”到“稳定靠谱”:落地项目里的配置与运维方案
5.1 混合检索加重排序:召回和精排的组合拳
把上面的坑都踩过一遍、优化到“基本不胡说”之后,如果你还想要更高的上限,我建议引入混合检索和重排序。
先说混合检索。纯向量检索擅长语义匹配,但有一个明显短板:对精确匹配不敏感。用户问题里如果直接出现了文档里的某个编号、某个专有名词、某段代码,向量检索常常还不如关键词匹配靠谱。比如“编号QW-2030的订单怎么退款”,文档里恰好有一节叫“QW-2030订单退款流程”,这时候BM25全文检索一捞就中,向量检索反而可能因为改写而错失。
常见的混合方案是:向量检索和BM25(或ES等全文检索)并行执行,再用RRF(Reciprocal Rank Fusion,倒数排名融合)把两路结果合并。RRF的思路很简单:给每个候选按它在各路结果里的排名算一个分,排名越靠前分越高,然后把多路分数相加,按总分排序。这个算法不依赖绝对分数,避免了“向量分和BM25分量纲不同没法直接相加”的问题。实测下来,混合检索对包含精确术语、编号、型号类的问题提升特别明显。
再说重排序。向量召回是按embedding相似度排的,但“embedding相似”不等于“最适合回答问题”。重排序阶段通常用一个cross-encoder模型,比如bge-reranker系列,把召回回来的比如20条候选和用户问题一起交给模型,两两打分,按“与问题的真实相关程度”重新排序,然后只取前3-5条进Prompt。cross-encoder模型比双向量模式的embedding更擅长捕捉细粒度语义关系,代价是速度慢,但因为只在候选集上跑,规模可控。
一个经过验证的链路是:向量召回20条 + BM25召回20条 → RRF融合 → 重排序取前5条 → 注入Prompt。相对单一向量检索,这个方案在大多数知识库场景下能把精准率提升一截。代价是要多维护两个组件,但对于“答案胡说”这种致命问题,值得。
5.2 入库端的元数据与标题路径:给每个片段一张“身份证”
优化完检索和生成,再往深一步,就是怎么让系统更“聪明”地选料。这里我特别推荐在入库阶段就把metadata做足。
metadata不只是存一个来源文件名那么简单。我在生产项目里通常会给每个chunk记录:
- 来源文档ID和标题:定位原始出处。
- 章节路径:比如“产品手册 > 售后政策 > 退换货规则”,可以在排查时快速定位,也可以在检索后做层级过滤。
- 文档版本号和时间戳:用于冲突处理,比如“取最新时间戳”的规则必须有这个字段做支撑。
- 文档类型标签:比如“官方规则”“运营说明”“用户反馈”,不同类型在回答时的优先级不同。
- 关键词/同义扩展:把口语说法、常见错别字、缩写都放进metadata,检索时可以多路召回。
这些metadata用得好的话,还能实现检索前的过滤。比如用户问的是“2024年的政策”,而你库里混着2022、2023、2024多个版本,通过metadata把版本过滤掉再算相似度,比纯向量匹配靠谱得多。系统上线后,如果某个业务线经常要按品牌、按渠道筛选,metadata就是开启这些能力的钥匙。
5.3 长期维护:RAG系统的质量是养出来的,不是配出来的
最后说一个很容易被低估的事:向量库+RAG系统上线后,必须持续维护。文档会更新,用户问法会变化,embedding模型也有新版本,系统在上线当天可能是“90分”,半年后就可能跌到“70分”,而对你自己来说这种下滑是缓慢的、不易察觉的。
因此,项目落地时至少要把下面几件事固化到日常流程里:
- 持续沉淀评测集:每次上线后,把线上出现过的“AI胡说”案例、用户反馈中的问题补进评测集,定期重跑Recall@K和回答正确率。
- 记录失败case:凡是用户明确投诉或答案被人工纠正过的case,都应该回到检索链路里复盘。是没召回、召回错、还是生成错?分类记录,比盲目调参高效得多。
- 定期更新文档:知识库的语料和线上文档同步。文档改版后,旧版本要及时下线,否则冲突信息会让模型天天左右横跳。
- 监控关键指标:比如检索命中率、拒答率(模型说“资料不足”的比例)、平均答案长度、相似度分数分布。拒答率突然升高可能意味着阈值设严了;分数分布整体下移可能说明文档更新后embedding没跟上。
这几件事都不难,但很多人不做。原因是RAG项目“能跑通”给了大家一种虚假的完成感,好像系统上线就结束了。实际上,只要语料在变、用户在问、模型在升级,这套系统的质量就永远处于动态变化中。把维护机制搭起来,才能让“不胡说”从一次调优变成一种常态。
把切片粒度调合理、换掉不合适的embedding模型、在Prompt里立清楚“只能依据资料回答”的规矩、把temperature压下来、用混合检索加固召回再用重排再滤一遍噪声——按这条路走下来,向量库就真正从“能跑”变成了“能用”。下次再遇到“跑通了还在胡说”,先别急着怀疑向量库,回去打开链路记录看一眼,问题多半就摆在明面上。