我们做了个挺受欢迎的内部知识库问答机器人:把公司几千篇文档灌进向量库,用户用大白话提问,它检索相关片段、连同问题一起喂给大模型,生成有理有据的回答。上线初期口碑很好,直到某天,客服同事截图来问:"它说我们的退款政策是 30 天无理由,可我们明明写的是 7 天啊?"我一查文档,白纸黑字就是 7 天。机器人不光答错了,还答得无比笃定,连"根据公司政策"这种话都给加上了。
这种错最吓人——它不是支支吾吾说"我不确定",而是一本正经地胡说八道,语气和正确答案时一模一样,用户根本分辨不出来。我第一反应是模型"幻觉"了,想着是不是该换个更强的模型。可顺着这条线查下去,真相却让我后背发凉:模型其实没那么冤,它只是忠实地照着我喂给它的"参考资料"作答——而那份参考资料,根本就是检索环节捞错的、风马牛不相及的片段。
换句话说,问题压根不在大模型,而在它前面那个 RAG(检索增强生成)的检索环节:文档切分得乱七八糟,把一句话拦腰斩断;向量检索又不设相关性门槛,哪怕库里没有真正相关的内容,也硬要返回几个"最不离谱"的片段凑数。模型拿到这堆似是而非的垃圾上下文,再加上它"乐于助人、有问必答"的天性,自然就顺着错误的料,编出了一个流畅而错误的答案。这篇文章,就从这次"自信地胡说"事故出发,把 RAG 问答里最容易翻车的那些环节,一次掰开揉碎。
先摆几个关于 RAG 的想当然
动手复盘前,先把我自己曾经笃信、后来被打脸的几个念头列出来,你也照照镜子。
| 想当然的念头 | 残酷的真相 |
|---|---|
| "答错了,肯定是模型不够强,换个大的就好" | 多数 RAG 错误的根子在检索,喂错了料,再强的模型也只会错得更流畅 |
| "把文档灌进向量库,检索自然就准了" | 切分方式直接决定召回质量,切坏了,语义全乱 |
| "检索总会返回最相关的内容" | 它返回的是"最相似的",哪怕都不相关,也照样给你凑几个 |
| "相似度高就等于答案对" | 向量相似 ≠ 语义相关,更 ≠ 能回答这个问题 |
| "模型说得这么肯定,应该没错" | 大模型的"自信"和"正确"完全是两回事,它不会因为没把握就闭嘴 |
这些念头的共同病根,是把 RAG 当成了一个"塞进去就能用"的黑盒,以为只要接上大模型,准确性就有了保障。可实际上,RAG 是一条环环相扣的链路:文档切分、向量化、检索、重排、拼接上下文、最后才轮到生成。任何一个上游环节出错,都会被下游忠实地放大成一个错误答案。要理解这次事故,得先看清这条链路到底长什么样。
第一件事:看懂 RAG 这条链路,错误到底从哪进来的
RAG 的核心思想其实很朴素:大模型的知识是"冻结"在训练时刻的,既不知道你公司的内部文档,也可能记不准细节。那就别让它凭记忆答,而是先去你的知识库里检索出相关资料,把资料连同问题一起塞进提示词,让它"看着材料回答"。这样既能用上私有知识,又能减少凭空捏造。
但"看着材料回答"有个隐含前提:那份材料得是对的、相关的。如果检索环节捞回来的是错的料,模型就成了"照着错误小抄答题的好学生"——它越听话,错得越理直气壮。我这次的事故,正是栽在这个隐含前提上。下面这张图,把 RAG 从提问到回答的完整链路画出来,顺便标出错误最容易溜进来的两个口子:
看懂这张图,排查方向就清晰了:当 RAG 答错时,别急着怪模型(图的右下角),要先回头看检索这一步(图的中间)到底捞回了什么。在动模型之前,先把检索召回的原始片段打印出来看一眼——这一个动作,往往就能让真凶现形。接下来,我们就从这个最关键的排查动作讲起。
第二件事:别猜,先把检索召回的片段打印出来
RAG 出错时最大的认知误区,是把它当成一个不可观测的黑盒,只盯着最终那段输出干着急。其实最有效的排查,简单到有点反直觉:把检索这一步召回的原始片段,连同相似度分数,原原本本打印出来看一眼。我当时就是这么干的,真相几秒钟就浮出水面。
# 把检索环节单独拎出来,打印召回的片段和分数
query = "我们的退款政策是多少天?"
results = vector_store.similarity_search_with_score(query, k=4)
for i, (doc, score) in enumerate(results):
print(f"--- 第 {i+1} 个片段, 相似度分数={score:.4f} ---")
print(doc.page_content[:200]) # 看看到底捞回了什么
print(f"来源: {doc.metadata.get('source')}")
打印出来的结果让我哭笑不得:召回的四个片段里,没有一个真正讲退款政策的,有的是"会员积分有效期 30 天",有的是"试用期 30 天"——它们和"退款政策"八竿子打不着,只是恰好都含有"30 天"这种字眼,在向量空间里和我的问题有那么点"形似而神不似"的相似度。而真正写着"7 天无理由退款"的那段文档,因为切分时被拦腰斩断、语义残缺,反而没能排进前列。模型拿到这堆"30 天"的料,自然就拼出了"30 天无理由"的错误答案。
这个排查动作的意义在于:它一刀切开了"检索问题"和"生成问题"的边界。如果召回的片段本身就不相关,那再怎么调模型、改提示词都是缘木求鱼;反之如果召回明明很准、模型却答错,才轮到去查生成环节。RAG 排查的第一性原理就是:先验证喂进去的料对不对,再谈模型答得好不好。
第三件事:文档切分,这是召回质量的命根子
顺着"为什么正确的片段没被召回"往上查,根子在文档切分(chunking)。向量检索不是拿整篇文档去匹配,而是预先把文档切成一个个小片段(chunk),分别向量化存入库。切分的方式,直接决定了每个片段的语义是否完整、是否可被检索到。我最初的切法极其粗暴——按固定字数硬切:
# 反例:按固定长度暴力切分,完全不顾语义边界
def bad_chunk(text, size=200):
return [text[i:i+size] for i in range(0, len(text), size)]
# 后果:一句"退款政策为 7 天无理由"可能被从中间切开
# 前一片段结尾是"退款政策为 7",后一片段开头是"天无理由"
# 两个残片各自语义破碎,都匹配不上完整的提问
正确的做法,是用带语义边界感知、且片段之间有重叠的切分方式。重叠(overlap)很关键:让相邻片段共享一小段内容,避免恰好被切在关键句中间导致信息丢失。主流框架都提供了现成的递归切分器:
# 正解:递归切分,优先在段落/句子边界断开,且片段间留重叠
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 每片大小, 按内容密度调
chunk_overlap=80, # 相邻片段重叠, 防止切断关键句
separators=["\n\n", "\n", "。", "!", "?", " ", ""], # 优先在这些边界切
)
chunks = splitter.split_documents(docs)
# 这样"退款政策为 7 天无理由"会被完整保留在某个片段里
切分没有放之四海皆准的最优解:片段太大,会混入无关信息、稀释语义、还浪费上下文窗口;片段太小,又容易丢失上下文、语义不完整。要点是尊重内容的自然边界(段落、标题、句子),并辅以适度重叠。对于结构化强的文档(带标题层级的手册、FAQ),按标题或问答对来切,往往比按字数切效果好得多。切分是 RAG 的地基,地基歪了,上面盖什么都是斜的。
第四件事:给检索设个相关性门槛,允许它说"没有"
事故的另一半根因,是检索环节从不拒绝。similarity_search 这类接口的本质,是返回"最相似的 K 个",注意是"最相似",而不是"足够相关"。这意味着,哪怕你的知识库里压根没有能回答问题的内容,它也会忠实地给你凑回 K 个最不离谱的片段——而这堆"矮子里拔将军"选出来的料,正是模型胡说的燃料。
解法是给检索设一个相关性分数门槛:只有相似度达标的片段才算数,如果一个都不达标,就大方地承认"我没找到相关资料",而不是硬塞垃圾给模型。
# 带阈值的检索:不达标就视为"没找到"
def retrieve_with_threshold(query, k=4, min_score=0.75):
results = vector_store.similarity_search_with_relevance_scores(query, k=k)
# 只保留相关性达标的片段
good = [(doc, score) for doc, score in results if score >= min_score]
return good
hits = retrieve_with_threshold(query)
if not hits:
# 关键:宁可不答,也不要拿垃圾上下文去硬编
answer = "抱歉,知识库中没有找到与该问题相关的资料,建议人工确认。"
else:
answer = llm_generate(query, context=[d.page_content for d, _ in hits])
这个改动看似简单,意义却很大:它把 RAG 从一个"必须有问必答"的逞强者,变成了一个"知之为知之"的诚实助手。对一个知识库问答系统来说,"我不知道"远比"自信地说错"有价值。那个具体的分数阈值需要你拿真实问题去调:太高会漏掉本该召回的内容,太低则形同虚设,得在召回率和准确率之间找平衡。
第五件事:用混合检索 + 重排,把真正相关的顶上来
光设门槛还不够,有时正确片段确实在库里,却因为单纯的向量相似度排不到前面而被埋没。这时要从"怎么检索"和"检索后怎么排"两头改进。
第一招是混合检索(hybrid search):向量检索擅长抓语义,但对精确的关键词、专有名词、编号反而不敏感;而传统的关键词检索(如 BM25)恰恰相反。两者结合,优势互补。第二招是重排(rerank):先用检索快速召回一批候选(比如 Top-20),再用一个更精准但更重的重排模型,对这 20 个逐一打分、重新排序,取最相关的几个喂给大模型。
# 召回 + 重排:先粗筛一批, 再用 reranker 精排
candidates = vector_store.similarity_search(query, k=20) # 先多召回
# 用交叉编码器重排序, 它对"问题-片段"配对的相关性判断更准
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-base")
pairs = [(query, doc.page_content) for doc in candidates]
scores = reranker.predict(pairs)
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
top_docs = [doc for doc, _ in ranked[:4]] # 取精排后最相关的 4 个
"先广撒网召回、再精挑细选重排"这套两段式策略,是当下 RAG 提升准确率最立竿见影的手段之一。召回保证"该有的别漏",重排保证"最好的排前面",两者配合,能显著压低那种"正确答案在库里却没被用上"的尴尬。
第六件事:用提示词给模型"立规矩",锁死在资料里
检索这头理顺了,生成这头也得上一道保险。要在提示词里给模型明确立规矩:只许根据我给的资料回答,资料里没有的,就老实说不知道,绝不许自由发挥。这是压制"自信胡说"的最后一道闸门。
PROMPT = """你是企业知识库助手。请严格遵守以下规则:
1. 只能依据【参考资料】回答, 不得使用资料之外的任何知识。
2. 如果参考资料不足以回答问题, 必须明确回复"根据现有资料无法回答", 禁止猜测或编造。
3. 回答时请标注信息来自哪段资料, 便于核对。
【参考资料】
{context}
【用户问题】
{question}
请依据上述规则作答:"""
# 这套约束能显著降低"无中生有", 但要清楚: 它是减害, 不是根治
# 真正的根治依然在前面的检索质量
但要诚实地说清楚:提示词约束只是减害,不是免死金牌。它能降低模型的自由发挥,却挡不住"检索喂错了相关的料"这种情况——因为对模型来说,那份错料看起来就是合规的"参考资料"。所以千万别本末倒置,以为靠几句提示词就能救活一个检索稀烂的 RAG。资料对了,提示词是锦上添花;资料错了,提示词只能让它错得稍微克制一点。
到这儿,这次事故的来龙去脉和系统性解法都齐了。我把排查思路收成一张决策图,下次 RAG"答得不对劲"时照着走:
把这套动作固化下来,绝大多数"RAG 玄学胡说"都能定位到具体环节。最后,拧成几条可直接照做的铁律:
- RAG 答错先看检索, 别先怪模型,把召回片段和分数打印出来,真凶多半在那里。
- 文档切分要尊重语义边界并留 overlap,别按固定字数把句子拦腰斩断。
- 给检索设相关性门槛,一个都不达标时,让系统诚实地说"没找到"。
- "我不知道"比"自信说错"更有价值,宁可不答,也别拿垃圾上下文硬编。
- 用混合检索 + 重排,把真正相关的片段从候选里顶到最前面。
- 提示词里给模型立规矩,锁死在资料内作答,但记住它只是减害不是根治。
- 建一套评测集,用真实问答持续衡量召回率与准确率,别凭感觉判断好坏。
一张 RAG 翻车速查表
把常见的 RAG 故障现象、根因和对策汇成一张表,下次问答机器人"不对劲"时对号入座。
| 现象 | 多半的根因 | 对策 |
|---|---|---|
| 自信地答错(本文事故) | 检索召回了不相关片段 | 打印召回片段, 设相关性门槛 |
| 正确答案库里有却答不出 | 切分切碎/向量排序埋没了它 | 语义切分 + 混合检索 + 重排 |
| 什么都问什么都瞎答 | 检索从不拒绝, 没有兜底 | 阈值不达标就回"没找到" |
| 答案东拼西凑、自相矛盾 | 召回片段过多过杂, 上下文噪声大 | 减小 K 值, 重排后只取最相关几个 |
| 专有名词/编号查不准 | 纯向量检索对精确词不敏感 | 叠加 BM25 关键词检索(混合) |
| 明明资料对却仍发挥过度 | 提示词没约束模型 | 提示词锁死在资料内, 允许说不知道 |
| 改了半天不知有没有变好 | 全凭感觉, 没有量化评测 | 建评测集, 跑召回率/准确率 |
更进一步:没有评测集,你就是在盲调
修好这次事故后,我做的最重要的一件事,不是某个具体的代码改动,而是建了一套评测集。在此之前,我对 RAG 好坏的判断完全靠"随手问几句感觉还行",这种盲调极其危险——你改了切分参数、调了阈值、换了重排模型,到底是变好了还是变坏了?光靠感觉,根本说不清,甚至可能按下葫芦浮起瓢。
评测集不复杂:收集几十上百条真实的用户问题,给每条标注"正确答案"和"应该召回哪些文档片段"。然后把 RAG 流程跑一遍,量化两个层面的指标——检索层看召回率(该召回的相关片段有没有被召回)、命中率;生成层看答案的正确性、有没有忠实于资料(faithfulness)、有没有答非所问。有了这套数字,每一次调整都能用数据说话,而不是赌运气。
业界也有 RAGAS、TruLens 这类现成的评测框架,能帮你半自动地算这些指标,甚至用一个强模型当"裁判"去评判答案质量。无论用什么工具,核心理念是一致的:RAG 是个需要持续度量、持续迭代的系统,而不是一次性搭好就万事大吉的功能。没有评测集的 RAG 优化,本质上就是蒙着眼睛拧旋钮。
两个容易忽略的暗坑:多轮对话与过期文档
主干修好后,我又在压测里揪出两个更隐蔽、却同样会让答案跑偏的坑,顺手记在这里。
第一个是多轮对话里的"问题漂移"。RAG 在多轮场景下有个陷阱:用户第二句问"那它要多久?",这个"它"指代的是上一轮的主语,可如果你直接拿"那它要多久"这句去检索,向量库根本不知道"它"是谁,召回的全是噪声。解法是在检索前先做一次"问题改写(query rewriting)":结合对话历史,把这种带指代、省略的口语问题,补全成一个独立完整、可被检索的问题。
# 多轮对话: 先把口语化、带指代的问题改写成独立问题再检索
rewrite_prompt = """根据对话历史, 把用户最新的问题改写成一个
不依赖上下文、独立完整、适合用于检索的问题。
对话历史:
{history}
最新问题: {question}
改写后的独立问题:"""
standalone_q = llm_generate(rewrite_prompt.format(history=hist, question=q))
# 用改写后的 standalone_q 去检索, 而不是原始的"那它要多久"
hits = retrieve_with_threshold(standalone_q)
第二个是过期与权限文档的污染。知识库会新陈代谢:旧版政策、作废的流程、不该对所有人可见的内部资料,如果一股脑都躺在向量库里,检索就可能召回一份"曾经正确、如今作废"的文档,答出一个过时的答案——这和我这次的事故一样致命,只是更隐蔽。解法是给每个片段挂上元数据(metadata):版本、生效日期、可见范围、文档状态,检索时按元数据先过滤,再做相似度匹配。
# 给片段挂元数据, 检索时先按"有效且有权限"过滤
results = vector_store.similarity_search(
standalone_q,
k=4,
filter={"status": "active", "visible_to": user_role}, # 过期/越权的直接排除
)
# 这样既不会召回作废文档, 也不会泄露越权内容
这两个坑的共同启示是:RAG 的"相关性"不只是语义维度的事。一份语义上完美匹配、却已经作废或越权的文档,被召回反而是灾难。所以成熟的检索,要在语义相似之外,再叠上时效、权限、版本这些结构化的约束——让召回的不仅"像",而且"对"、"该看"。
写在最后
这次事故给我最深的一击,是它彻底纠正了我对大模型应用的一个误解:我曾以为接入 RAG 之后,系统的"智能"主要由那个光鲜的大模型决定。可真相恰恰相反——在一个 RAG 系统里,大模型往往是最不容易出错的那一环,真正脆弱、真正决定成败的,是它前面那条朴实无华的数据链路:文档怎么切、怎么存、怎么检索、怎么排序、怎么兜底。模型再聪明,也只能基于你喂的料作答;料是垃圾,它就只能产出"包装精美的垃圾"。
所以做 AI 应用,最忌讳的就是被模型的光环晃了眼,把所有精力都押在"换更强的模型"上,却对身下那条数据管道视而不见。一个 RAG 系统的天花板,常常不是由模型能力封顶的,而是由检索质量、数据质量这些"不性感"的工程细节决定的。这次"自信地胡说"事故让我重新敬畏起这些细节:它们不会出现在任何一张炫目的 demo 里,却实实在在地决定了你的 AI,究竟是个靠谱的助手,还是一个流畅的骗子。愿你我做的每一个 RAG,都把功夫下在那条看不见的管道上——因为用户最终信任的,从来不是模型有多大,而是答案有多对。
—— 别看了 · 2026