RAG 上线即翻车:从 demo 惊艳到胡说八道的检索调优

一个 demo 阶段对答如流、让老板拍板上线的内部文档问答助手,上线第一天就开始满嘴跑火车:问报销流程扯到考勤,还一本正经地援引文档里根本不存在的条款。翻开检索日志才发现,问题压根不在大模型——是系统把一堆不相关的资料喂了进去,逼它瞎编。从这次事故出发,这篇文章把 RAG 检索这条线讲透:切分策略、embedding 选型、rerank 与混合检索、prompt 约束、量化评估到知识库更新。

事情是从一个让老板眼前一亮的 demo 开始的。我们给公司内部那几千篇制度文档做了个 RAG 问答助手——你用大白话问"出差住宿标准是多少",它就从文档里找出依据,生成一段有理有据的回答。演示那天我精心挑了几个问题,它对答如流,会议室里一片点头,当场拍板:下周上线。

然后,现实就给了我一记响亮的耳光。用户真正用起来的第一天,投诉就来了:有人问"研发岗的报销流程",它扯了一通考勤打卡制度;有人问一个具体的福利政策,它一本正经地"援引"了一条文档里根本不存在的条款,语气笃定得让人差点信了。一个 demo 阶段聪明绝顶的系统,上线后却像是换了个脑子,开始满嘴跑火车。

我的第一反应是"模型不行,得换更强的大模型"。但翻了检索日志之后,我愣住了:问题压根不在大模型那一端。系统喂给它的"参考资料",大多跟用户的问题八竿子打不着——它拿着一堆不相关的片段,被要求生成答案,除了瞎编它还能怎么办?换句话说,不是模型在胡说,是我把错的资料塞给了它,逼着它胡说。

真正的病根,藏在 RAG 那个最不起眼、却最决定成败的环节——检索(Retrieval)里。这篇文章,就是我把那套"demo 惊艳、生产翻车"的问答系统从头调优一遍之后,沉淀下来的检索避坑指南。它不讲怎么调大模型,只讲怎么让大模型拿到对的资料。

先纠正几个关于 RAG 的常见误解

动手之前,先把我自己踩过、也是初学者最容易中招的几个误解摆出来。如果你也这么想过,这篇文章应该正好对症。

常见误解 真相
RAG 答得不好,就是大模型不够强,换个更贵的就行 八成问题出在检索:喂错了资料,再强的模型也只能基于错料瞎编
把文档按固定字数切块就行,切多大无所谓 切分策略直接决定召回质量:切太大噪声多,切太小语义断裂,且最好按语义边界切
embedding 模型随便选一个,都差不多 选错模型(尤其中文用了纯英文模型)会让相似度计算彻底失真,召回全错
向量相似度 Top-K 召回就够了 纯向量召回常有"看着像、其实不相关"的噪声,加一层 rerank 重排能大幅提纯
检索到内容直接拼进 prompt,模型自己会判断 不加约束,模型会把检索内容和自己的"记忆"混着用,该说"不知道"时也硬编
效果好不好,自己多问几个问题感受一下就行 凭感觉调优等于蒙眼开车,必须有召回命中率这类可量化指标

第一件事:先看清 RAG 到底在做什么

要修好它,得先看清它的工作流。RAG(Retrieval-Augmented Generation,检索增强生成)的核心思路其实很朴素:大模型的脑子里没有你公司的内部文档,也记不住最新的政策,那就在它回答之前,先去你的知识库里捞出最相关的几段资料,连同问题一起塞给它,让它"看着资料回答"。这样既能利用大模型的语言能力,又能让答案有据可依,还能大幅压制凭空捏造的幻觉。

它分成两个阶段。离线阶段(建库):把文档切成一个个小块(chunk),用 embedding 模型把每一块转成一个向量,存进向量数据库。在线阶段(问答):把用户问题也转成向量,在向量库里找出最相似的几块,拼进 prompt,交给大模型生成最终回答。整个链路如下:

看清这张图,就能定位我那次事故的爆点:回答质量,几乎完全取决于第 G 步"检索到的 Top-K chunk 到底相不相关"。而这一步的质量,又被前面的切分(B)embedding(C/F)死死扼住。我之前一门心思盯着最后的"大模型生成",却对真正决定成败的 B、C、G 三步毫不上心——这才是 demo 惊艳、生产翻车的根本原因。下面就一步步拆开这几个最致命的坑。

第二件事:切分,决定了检索的天花板

第一个、也是最被低估的坑,是 chunking(文档切分)。我最初的做法粗暴到可笑——按固定 1000 个字符一刀切下去:

# 反例:按固定长度硬切,完全不顾语义边界
def naive_chunk(text, size=1000):
    return [text[i:i+size] for i in range(0, len(text), size)]

这种切法有两个致命伤。其一,它会把一个完整的语义单元拦腰斩断:一条报销规定可能正好被切在第 1000 个字,前半句留在 chunk A,后半句跑到 chunk B,检索时哪一块都不完整,模型自然拼不出正确答案。其二,一个 chunk 里可能塞了好几个不相干的主题,向量是这一整块的"平均语义",主题一杂,这个向量就谁都不像,检索精度直线下降。

改进的方向是按语义边界切,并让相邻块之间留一点重叠(overlap),避免边界处的信息被切丢:

# 正解:优先按段落/标题等自然边界切,块间保留重叠
from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,            # 单块目标大小,小而聚焦
    chunk_overlap=80,          # 相邻块重叠,防止边界语义被切断
    separators=["\n\n", "\n", "。", "!", "?", " ", ""],  # 优先按段落、再句子切
)
chunks = splitter.split_text(doc_text)

这里有两个关键参数要按你的文档调:chunk_size 不是越大越好——大块召回时夹带的无关噪声多,小块语义更聚焦但可能丢上下文,通常 300~800 字是个好起点;chunk_overlap 则像给每块加了点"前情提要",让被切在边界的信息在相邻块里都能找到。切分是整个 RAG 质量的天花板:这一步丢失或污染的信息,后面再强的模型、再花哨的检索都补不回来。

第三件事:embedding 模型选错,相似度全是错的

切分修好后,我的召回还是很烂。第二个真凶浮出水面:embedding 模型用错了。当时图方便,直接用了一个广为人知、但主要面向英文语料训练的 embedding 模型,来处理我们全中文的文档。结果就是:中文文本被映射到向量空间里,语义关系被严重扭曲——两段意思相近的中文,算出来的相似度可能还不如两段毫不相关的。

embedding 模型的作用,是把文本变成一个能反映"语义远近"的向量;语义越接近,向量在空间里越靠近。如果模型对中文的理解本身就差,那它产出的"语义距离"就是错的,后续基于这个距离的一切检索都建立在沙子上。修复就是换一个在中文语料上表现好的 embedding 模型:

# 用对语言/领域的 embedding 模型,中文场景选中文友好的模型
from sentence_transformers import SentenceTransformer

# 反例:用纯英文模型编码中文 → 语义距离失真
# model = SentenceTransformer("all-MiniLM-L6-v2")

# 正解:选在中文上训练充分的模型(如 BGE / m3e 等中文友好模型)
model = SentenceTransformer("BAAI/bge-large-zh-v1.5")

doc_vecs = model.encode(chunks, normalize_embeddings=True)

还有一个极其隐蔽、却能让召回彻底崩盘的坑:建库时编码文档用的是 A 模型,在线检索时编码问题却用了 B 模型。两个模型的向量空间根本不是同一个坐标系,算出来的相似度毫无意义。务必保证"文档入库"和"问题检索"用的是同一个 embedding 模型、同一套预处理。这个错误代码上几乎看不出来,只有当你发现"召回结果像随机抽的"时,才会想起去核对这一点。

第四件事:只靠向量相似度不够,加一层 rerank

切分和 embedding 都修好后,召回质量明显好转,但仍有个顽固现象:Top-K 里总混着一两个"看着像、其实跑题"的 chunk。这是向量检索的固有局限——它快、能覆盖语义,但精度有限,常把"字面或语义大致接近、实则答非所问"的片段也捞上来。

业界成熟的解法是两段式检索:先用向量检索"广撒网"召回较多候选(比如 Top-50),再用一个更精准的 rerank(重排)模型对这些候选逐一打分、精排,只保留最相关的几条(Top-3~5)喂给大模型。rerank 模型(交叉编码器)会把"问题"和"候选 chunk"成对地一起喂进模型深度比对,精度远高于向量的粗略相似度:

# 两段式:向量粗召回 → rerank 精排
from sentence_transformers import CrossEncoder

reranker = CrossEncoder("BAAI/bge-reranker-large")

# 1) 向量检索先召回较多候选(广撒网)
candidates = vector_store.search(query_vec, top_k=50)

# 2) rerank:问题与每个候选成对打分,取分数最高的前几条
pairs = [(query, c.text) for c in candidates]
scores = reranker.predict(pairs)
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
top_chunks = [c.text for c, _ in ranked[:4]]   # 只把最相关的 4 条喂给 LLM

另一个常和 rerank 搭配的利器是混合检索(hybrid search):把传统的关键词检索(BM25,擅长精确匹配专有名词、编号、术语)和向量检索(擅长语义近似)的结果融合起来。纯向量检索有个软肋——遇到"报销单号 R2024"这种关键词精确匹配的诉求,语义相似度反而不灵;而 BM25 正好补上这一块。向量管"意思相近",BM25 管"字面命中",两者融合再 rerank,召回的鲁棒性会上一个台阶。

第五件事:Prompt 要给模型"戴上嚼子"

检索这条线理顺后,还剩最后一公里:怎么把检索到的资料拼进 prompt。我最初就是简单地"问题 + 资料"一拼了事,结果模型还是会在资料不足时,偷偷调用自己预训练的"记忆"来补全,该说"我不知道"的时候硬编一个答案——这正是那条"不存在的条款"的由来。

解法是用 prompt 给模型套上明确的行为约束:严格限定它只能依据提供的资料回答,资料里没有就如实说不知道。

PROMPT = """你是公司制度问答助手。请严格遵守以下规则:
1. 只能依据下面【参考资料】中的内容回答,不得使用资料之外的任何知识。
2. 如果参考资料里找不到答案,必须明确回答"根据现有资料无法确定",不要编造。
3. 回答时尽量引用资料中的原文依据。

【参考资料】
{context}

【用户问题】
{question}

请基于以上资料作答:"""

prompt = PROMPT.format(context="\n\n".join(top_chunks), question=query)
answer = llm.generate(prompt)

这几行约束看似简单,却把幻觉率压下去一大截。其中第 2 条"找不到就明说不知道"尤其关键——它给了模型一个"诚实的退路",好过逼它在信息不足时硬挤一个答案。RAG 系统宁可回答"资料里没有",也绝不能一本正经地编造;在企业场景里,一个错误但笃定的答案,比一句"我不确定"危险得多。

第六件事:别凭感觉调优,要有可量化的指标

最后这条,是我那次事故最深的反省。整个 demo 阶段,我评估效果的方式就是"自己多问几个问题,感觉答得不错"——这等于蒙眼开车。真正该做的,是建一个小小的评测集,用可量化的检索指标来驱动调优:

指标 衡量什么 怎么用
命中率 / 召回率 (Recall@K) 正确答案所在的 chunk,有没有出现在 Top-K 里 检索这一环的核心指标,先保证它高
MRR / 命中位次 正确 chunk 排在第几位 评估 rerank 效果,越靠前越好
答案正确率 最终生成的答案是否事实正确 端到端效果,人工或用大模型打分
"拒答"准确率 资料里没有时,是否正确说了"不知道" 衡量幻觉抑制是否到位

有了这套指标,调优就从"我觉得变好了"变成了"Recall@5 从 0.62 提到了 0.89"。每改一个参数(chunk_size、是否加 rerank、换 embedding 模型),都跑一遍评测集看指标涨没涨——这才是工程,而不是玄学。把那次调优的完整思路收个尾,下面这张决策树,是我沉淀下来的"RAG 答不好怎么查"速查表:

几条可以直接抄走的铁律

  1. RAG 答不好,先查检索,别急着换大模型。八成是喂错了资料。
  2. 按语义边界切分 + 留 overlap,别用固定字数硬切。切分是质量的天花板。
  3. embedding 模型要匹配语言和领域,中文用中文友好模型;且建库与检索必须同一个模型
  4. 向量粗召回 + rerank 精排,必要时叠加 BM25 混合检索,提纯 Top-K。
  5. Prompt 明确约束"只依据资料、没有就说不知道",给模型一条诚实的退路。
  6. 用评测集和量化指标驱动调优,拒绝"凭感觉"。先盯 Recall@K。
  7. 只把最相关的几条喂给 LLM,塞太多无关 chunk 反而稀释重点、增加幻觉。

顺带说说:用户的问题,往往也需要"翻译"一下

调优过程中我还发现一个反直觉的事:很多召回失败,不怪文档也不怪模型,而怪用户的问题问得太"口语"。真实用户不会像文档那样措辞——他们会问"这个咋报销啊",而文档里写的是"费用报销审批流程及标准"。两者字面差异巨大,向量相似度自然就低,该召回的内容沉了下去。

解法是在检索之前,先用大模型把用户的原始问题改写/扩展成更规整、更接近文档表述的查询,甚至一次生成多个不同角度的查询去并行检索(multi-query),再把结果合并:

# 检索前先用 LLM 把口语化问题改写成多个规整查询
REWRITE = """把下面的用户问题改写成 3 个不同角度、更正式书面的检索查询,每行一个:
用户问题:{q}"""

queries = llm.generate(REWRITE.format(q=user_input)).strip().split("\n")
# 多路检索 + 合并去重,显著提升口语化问题的召回率
all_hits = []
for q in queries:
    all_hits += vector_store.search(embed(q), top_k=20)
candidates = dedup(all_hits)

这一步常被新手忽略,但它的性价比极高:它承认了一个现实——用户不会按你文档的措辞提问,与其苛求用户,不如让系统主动去弥合"用户语言"和"文档语言"之间的鸿沟。查询改写、HyDE(用模型先生成一个假设答案再去检索)等技巧,本质都是在做同一件事:把检索的入口问题,变得离知识库的表达更近一点。

那些年我对 RAG 的几个误解

误解一:"RAG 效果差,就是大模型不够强。"——这是我最初最大的误判,也是最烧钱的弯路。换更贵的模型,在检索本身就喂错料的前提下,几乎是白花钱:模型再聪明,也只能在你给的错误资料里打转。RAG 的上限,在检索;大模型只负责把检索到的对的资料,组织成通顺的话。先把检索修对,再谈模型。

误解二:"chunk 切大一点,信息全,总没错。"——恰恰相反。大 chunk 看似信息全,实则把一堆不相关内容糊成一个"四不像"向量,既拉低检索精度,又在喂给模型时塞进大量噪声、稀释了真正有用的那几句。检索追求的是"精准命中",不是"宁可错杀";小而聚焦的 chunk,往往比又大又杂的强。

误解三:"上线前我自己测着挺好,应该没问题。"——这正是 demo 惊艳、生产翻车的根源。我精心挑选的演示问题,恰好都落在系统表现好的区间;真实用户五花八门的问法,瞬间就把短板暴露无遗。自测的"感觉良好"毫无统计意义;没有覆盖真实问法的评测集和量化指标,任何"看起来不错"都只是幸存者偏差。

一个延伸:知识库会过期,别建完就不管了

还有一个上线后才会暴露、却很要命的问题:知识库不是一次性建好就万事大吉的。公司的制度文档会修订、会作废、会新增——可你的向量库如果还停留在建库那天的快照,就会出现一种最尴尬的幻觉:模型言之凿凿地援引了一条已经被废止的旧规定,因为对它来说,那条旧 chunk 在向量库里依然"活着"。

所以一个能长期用的 RAG 系统,必须把"知识库更新"也当成工程的一部分来设计。关键有两点:一是给每个 chunk 带上元数据(来源文档、版本号、更新时间、是否生效),检索时可以据此过滤掉过期内容;二是建立增量更新机制,文档一变更,就重新切分、重新编码、替换掉库里对应的旧向量,而不是放任新旧混杂。

# 给每个 chunk 附带元数据,支持按时效/版本过滤,并能增量更新
vector_store.upsert(
    id=f"{doc_id}::{chunk_idx}",      # 用稳定 id,文档更新时按 id 覆盖旧块
    vector=vec,
    payload={
        "text": chunk,
        "doc_id": doc_id,
        "version": doc_version,
        "updated_at": "2026-05-29",
        "valid": True,                 # 文档作废时置 False,检索时过滤
    },
)

检索的准确,不只是"找得对",还包括"找得新"。一个返回了正确但已过期信息的 RAG,有时比直接答错更危险——因为它看起来同样可信。把知识库的生命周期管起来,让过期内容能被及时下架或标记,你的系统才能从一个"能用的 demo",变成一个"敢长期依赖的产品"。

写在最后

那次调优,我没有换任何一个大模型,从头到尾都用着最初那个。变的只是检索这条线:把固定字数硬切换成按语义切+overlap,把英文 embedding 换成中文模型并统一了库与查,加上 rerank 和混合检索,给 prompt 套上"只依据资料"的约束,最后建了个百来条的评测集盯着 Recall。改完再上线,那些"答非所问""援引不存在条款"的投诉,肉眼可见地消失了——而成本几乎没变。

这件事彻底改变了我看待 RAG(乃至大模型应用)的视角。在这之前,我以为做 AI 应用就是"调用一个足够强的大模型";在这之后我才明白,大模型只是整条流水线最后那个执行者,真正决定产品好坏的,是它之前那一长串不起眼的工程环节——数据怎么切、怎么编码、怎么检索、怎么排序、怎么约束。这些环节里没有什么"魔法",全是扎实的、可量化、可调试的工程。

所以,如果你也在做 RAG,或正准备做,请记住:当它胡说八道时,先别怪那个大模型,去翻一翻检索日志,看看你到底喂了什么给它。九成的"AI 不智能",根子都在那些被忽略的检索细节里。把检索这门功课做扎实,你手里那个"平平无奇"的模型,也能交出让人惊艳的答卷。会调 API 只是入门,懂得为模型准备好对的上下文,才是 AI 应用真正的手艺。

如果你手头正好有一个表现不稳的 RAG,今天就能做一个最简单的体检:随便挑十个真实用户问过的问题,把每个问题召回的 Top-K chunk 原文打印出来,逐条看一眼"它跟问题到底相不相关"。不用任何高深工具,光这一眼,就足以告诉你病到底出在检索还是生成——而九成的情况,你会在那堆召回片段里,当场找到模型胡说八道的真正原因。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

一次发布把服务全杀了:K8s 健康检查与滚动更新的坑

2026-5-29 22:25:19

技术教程

大促后对账发现重复扣款:一篇讲透接口幂等性设计

2026-5-29 22:37:19

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索