1. RAG 问答准确度为什么总卡在“最后一公里”
做过 RAG 应用的人大概都有过这种体验:demo 阶段效果惊艳,一旦上线面对真实用户,准确度就断崖式下跌。用户问“我们公司差旅报销标准是多少”,系统检索回来一堆《员工手册》里关于考勤的段落;用户问“这个接口的超时时间怎么配置”,返回的却是三年前某篇技术博客里已经废弃的参数说明。检索增强生成这套架构本身没问题,问题出在链路的每一环都在悄悄丢信息。
RAG 的全称是 Retrieval-Augmented Generation,检索增强生成。它的核心逻辑很朴素:大模型本身的知识有截止日期,也不掌握你的私有数据,那就在回答问题之前,先从你的知识库里把相关片段找出来,塞进提示词里让模型基于这些片段作答。听起来简单,但“找得准”和“答得好”之间隔着一条巨大的鸿沟。
我接手过一个企业内部的制度问答系统,知识库大概两千多份文档,涵盖人事、财务、IT 运维三个大类。第一版上线后,人工抽检一百个问题的回答准确率只有 54%。用户反馈集中在三类:答非所问、答案不完整、以及最致命的——编造。编造这种情况最危险,模型用流畅的语言给出了一个看起来很像那么回事但完全错误的答案,用户如果不去核对原文根本发现不了。
这篇文章我会把优化过程完整拆开讲。从检索效果诊断、文档切分策略、向量模型选型、混合检索、重排序、到提示词工程和评估体系,每一步都给出我实际用过的参数和踩过的坑。适合正在做 RAG 应用但效果不理想的开发者,也适合刚接触 RAG 想少走弯路的朋友。我不会只讲“应该怎么做”,而是把“为什么这么做”和“我试过什么不行”都摊开说。
2. 先搞清楚问题出在检索还是生成
优化之前必须做一件事:定位瓶颈。很多人一上来就换更大的模型、换更贵的向量数据库,结果钱花了效果没变。RAG 的准确度问题,八成出在检索环节,而不是生成环节。
2.1 用命中率指标把问题量化
我用的方法很直接:准备一百个标注好标准答案的问题,对每个问题记录三个指标。第一个是Hit Rate,检索回来的前 K 个片段里有没有包含能回答问题的内容。第二个是MRR(Mean Reciprocal Rank),正确片段排在第几位,排第一得分 1,排第二得分 0.5,以此类推。第三个是Answer Accuracy,最终生成的答案对不对。
这三个指标一跑,问题立刻清晰了。我那个系统 Hit Rate 只有 62%,意味着将近四成的问题,检索阶段就没找到正确内容,后面生成环节再强也没用。MRR 只有 0.38,说明即使找到了,也经常排在很后面,被无关内容挤下去了。Answer Accuracy 54%,比 Hit Rate 还低,说明生成环节也有问题,模型有时候拿到了正确片段还是答错。
提示:Hit Rate 和 MRR 是诊断 RAG 检索质量最实用的两个指标,建议在优化前先跑一遍基线,后续每次改动都对比这两个数字,避免凭感觉调优。
2.2 检索失败的四种典型原因
定位到检索是瓶颈之后,我逐个排查了失败案例,归纳出四类原因。
第一类是切分粒度不对。早期我按固定 512 个 token 切分文档,结果一个完整的制度条款被切成两半,前半段在片段 A,后半段在片段 B。用户问这个条款,检索只召回了片段 A,模型看到的是半句话,自然答不全。
第二类是向量模型不适配。我最初用的是某个通用英文向量模型,对中文语义的捕捉能力很弱。“报销标准”和“费用额度”在它眼里相似度很低,但人类一看就知道是一回事。
第三类是查询和文档的语义鸿沟。用户问的是口语化的“出差吃饭能报多少”,文档里写的是“差旅期间伙食补助标准为每人每日 XX 元”。字面重叠度极低,纯向量检索很难匹配上。
第四类是缺乏重排序。向量检索返回的 Top 20 里其实有正确片段,但它排在第十五位,而我只取了前 5 个送给模型,正确内容被截断了。
这四类原因对应四套解法,下面逐个展开。
3. 文档切分:RAG 效果的地基
切分策略是 RAG 里最被低估的环节。很多人随便按字数一切就完事了,结果后面怎么调都调不好。切分做得好,检索效果能直接提升一大截。
3.1 固定长度切分的致命缺陷
固定长度切分的问题在于它完全无视文档的语义结构。一份制度文档里,“适用范围”和“报销标准”是两个独立的章节,固定切分可能把“适用范围”的结尾和“报销标准”的开头切进同一个片段。检索“报销标准”时,这个片段因为包含关键词被召回,但里面一半内容是无关的适用范围,稀释了有效信息。
我实测过,纯固定长度切分在制度类文档上的 Hit Rate 只有 58% 左右。换成按语义结构切分后,同样的向量模型和检索参数,Hit Rate 直接跳到 79%。
3.2 按文档结构切分的实操方法
我的做法是先用解析工具把文档转成结构化格式,再按标题层级切分。PDF 用pdfplumber或PyMuPDF提取文本和层级信息,Word 用python-docx,Markdown 直接按标题解析。
import re def split_by_heading(text, max_chunk_size=800, overlap=100): # 按 Markdown 标题切分 sections = re.split(r'\n(?=#{1,4}\s)', text) chunks = [] for section in sections: if len(section) <= max_chunk_size: chunks.append(section) else: # 超长段落再按句子切分,保留重叠 sentences = re.split(r'(?<=[。!?])', section) current = "" for sent in sentences: if len(current) + len(sent) > max_chunk_size: chunks.append(current) current = current[-overlap:] + sent else: current += sent if current: chunks.append(current) return chunks这里有几个参数需要根据实际情况调。max_chunk_size我一般设在 600 到 1000 个字符之间。太小了语义不完整,太大了噪声多。overlap设 100 到 150 个字符,保证跨片段的句子不会被完全切断。
注意:切分后的每个片段最好在前面拼接上它所属的章节标题路径,比如“员工手册 > 第三章 差旅管理 > 3.2 报销标准”。这样即使片段本身没提到“报销”二字,检索时也能通过标题路径匹配上。
3.3 表格和图片内容的特殊处理
热词里有人问“rag知识库能存储图片嘛”,这个问题很实际。纯文本 RAG 确实处理不了图片,但有两种变通方案。
一种是用多模态模型对图片生成文字描述,把描述文本存入向量库。比如一张报销流程图,用视觉模型生成“该图展示了差旅报销的五个步骤:填写申请单、部门审批、财务审核、出纳付款、归档”这样的描述,检索时就能命中。
另一种是表格转 Markdown 或自然语言。表格直接转文本会丢失结构,我一般转成 Markdown 表格格式,或者用“列名:值”的形式逐行描述。比如“报销项目:交通费,标准:实报实销,上限:无”这样的格式,比原始表格更容易被向量模型理解。
4. 向量模型与检索策略的选型实战
向量模型决定了语义匹配的上限,检索策略决定了能不能把上限发挥出来。这两块我踩过的坑最多。
4.1 中文向量模型怎么选
通用英文模型在中文场景下基本不能用。我对比过几个主流的中文向量模型,在自建的一百个问题测试集上的 Hit Rate 表现如下。
| 模型 | Hit Rate | MRR | 推理速度(条/秒) | 备注 |
|---|---|---|---|---|
| text-embedding-ada-002 | 61% | 0.35 | 依赖 API | 中文语义捕捉弱 |
| BGE-large-zh-v1.5 | 82% | 0.61 | 约 200 | 中文效果好,本地可跑 |
| M3E-base | 78% | 0.55 | 约 350 | 速度快,效果略逊 |
| text2vec-large-chinese | 76% | 0.52 | 约 180 | 老牌模型,稳定 |
最终我选了 BGE-large-zh-v1.5。它的中文语义匹配能力明显强于其他几个,而且支持本地部署,数据不出内网。如果对速度要求极高、可以接受一点效果损失,M3E-base 也是不错的选择。
选型时有一个容易被忽略的点:查询和文档要用同一个模型编码。我见过有人查询用 A 模型、文档用 B 模型,结果向量空间都不一致,检索效果惨不忍睹。
4.2 混合检索:向量加关键词的双保险
纯向量检索有个天然短板:对专有名词、编号、代码这类精确匹配不敏感。用户问“IT-2024-017 号工单的处理进度”,向量模型很难把“IT-2024-017”这个编号准确匹配上,但关键词检索(BM25)一抓一个准。
我的方案是向量检索和 BM25 各取 Top 20,然后用 RRF(Reciprocal Rank Fusion)融合。RRF 的公式很简单:每个文档的得分等于它在各检索结果中排名的倒数之和。
def rrf_fusion(vector_results, bm25_results, k=60): scores = {} for rank, doc_id in enumerate(vector_results): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1) for rank, doc_id in enumerate(bm25_results): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1) return sorted(scores.items(), key=lambda x: x[1], reverse=True)k值我设 60,这是 RRF 原论文的推荐值,实测下来也比较稳。融合之后取 Top 10 进入下一步。混合检索把我那个系统的 Hit Rate 从 82% 拉到了 89%。
4.3 重排序:把正确内容顶到最前面
混合检索解决了“找得到”的问题,重排序解决“排得前”的问题。我用的重排序模型是 BGE-reranker-large,它会对每个候选片段和查询做精细的交叉编码打分,比向量点积准确得多。
流程是这样的:混合检索取 Top 20,重排序模型对这 20 个逐个打分,取分数最高的 5 个送给大模型。这一步把 MRR 从 0.61 提升到了 0.79,效果非常明显。
重排序的代价是延迟。20 个片段的重排序大概增加 200 到 400 毫秒,取决于硬件。如果对延迟极其敏感,可以只对 Top 10 做重排序,效果损失不大。
实操心得:重排序模型和向量模型最好用同一系列的,比如都用 BGE 系列,它们的训练数据分布比较一致,配合起来效果更好。我试过向量用 BGE、重排序用别的模型,效果反而不如单独用 BGE 向量加 BGE 重排序。
5. 提示词工程与生成环节的优化
检索把正确内容找回来了,生成环节如果提示词写得不好,模型照样答错。这一块我总结了几个关键原则。
5.1 让模型“有据可依”的提示词结构
早期我的提示词很随意,就是“根据以下内容回答问题”。结果模型经常忽略检索内容,凭自己的知识瞎答。后来改成结构化提示词,明确告诉模型该怎么做。
你是一个严谨的问答助手。请严格根据以下【参考资料】回答用户问题。 规则: 1. 如果参考资料中没有相关信息,直接回答“根据现有资料无法回答该问题”,不要编造。 2. 回答时引用具体的资料片段编号,如 [1]、[2]。 3. 如果多个资料片段有冲突,指出冲突并说明。 4. 回答要简洁,直接给出答案,不要重复问题。 【参考资料】 [1] {chunk_1} [2] {chunk_2} [3] {chunk_3} 【用户问题】 {question}这个提示词里最关键的是第一条和第二条。第一条给了模型“拒答”的选项,大幅降低了编造率。第二条要求引用来源,一方面方便用户核对,另一方面也迫使模型真的去看参考资料。
实测下来,加上这两条规则后,编造率从 18% 降到了 4% 左右。
5.2 上下文窗口的取舍
检索回来的片段不是越多越好。我试过把 Top 10 全塞进去,结果模型反而更容易被无关内容干扰,准确率下降。原因是上下文太长时,模型对中间部分的注意力会衰减,而正确内容往往不在最前面。
最终我固定送 Top 5,并且按重排序分数从高到低排列。如果某个片段的分数明显低于其他片段(比如低于最高分的 60%),直接丢弃,不送进模型。
5.3 温度参数和输出格式控制
生成环节的温度参数我设 0.1,几乎接近确定性输出。RAG 问答不需要创造性,需要的是稳定和准确。温度高了模型容易自由发挥,反而坏事。
输出格式上,我要求模型用 JSON 返回,包含answer和citations两个字段。这样前端可以结构化展示,也方便后续做自动化评估。
{ "answer": "差旅伙食补助标准为每人每日 100 元。", "citations": ["[1]"] }6. 评估体系与持续迭代
没有评估体系的 RAG 优化就是盲人摸象。我建了一套自动化评估流程,每次改动都能快速看到效果变化。
6.1 构建测试集的实用方法
测试集不需要很大,一百到两百个问题就够。关键是覆盖要全:事实型问题、比较型问题、多跳问题、无法回答的问题,各占一定比例。
我构建测试集的方法是:先从真实用户日志里采样高频问题,再人工补充边界案例。每个问题标注标准答案和对应的正确文档片段 ID。这样既能算 Answer Accuracy,也能算 Hit Rate 和 MRR。
注意:测试集要定期更新。用户的问题分布会变化,半年前的测试集可能已经不能反映当前的真实场景。我一般每季度补充一批新问题,淘汰过时的。
6.2 自动化评估流水线
评估流水线跑一遍大概十分钟,包含四个步骤:对每个测试问题执行完整 RAG 流程、记录检索结果和生成答案、用规则加人工抽检的方式打分、输出指标报告。
打分环节我用了一个取巧的办法:对于事实型问题,用另一个大模型做裁判,判断生成答案和标准答案是否语义一致。对于无法回答的问题,检查模型是否正确拒答。人工只抽检 10% 的结果做校准。
这套流水线让我能在半小时内完成一次完整的优化迭代验证,效率比纯人工高太多了。
6.3 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 答非所问 | 检索没找到正确内容 | 看 Hit Rate | 优化切分、换向量模型、加混合检索 |
| 答案不完整 | 片段被切断 | 检查切分边界 | 调整切分粒度、增加重叠 |
| 编造答案 | 提示词没约束 | 看生成日志 | 加拒答规则、要求引用来源 |
| 正确内容排后面 | 缺乏重排序 | 看 MRR | 加重排序模型 |
| 专有名词匹配不上 | 纯向量检索短板 | 测试编号类问题 | 加 BM25 混合检索 |
| 响应太慢 | 重排序或模型推理慢 | 分环节计时 | 减少候选数量、换更快的模型 |
7. 我踩过的几个大坑
最后分享几个实际踩过的坑,都是文档里不会写但实际会遇到的。
第一个坑是向量数据库的索引类型选错。我一开始用暴力检索,数据量小的时候没问题,涨到十万条以后延迟飙升。后来换成 HNSW 索引,查询速度从 800 毫秒降到 50 毫秒,召回率只损失了不到 2%。选型时如果数据量会增长,一开始就上 HNSW 或 IVF 索引,别等出问题了再迁移。
第二个坑是文档更新后没有重建索引。有次制度文档更新了报销标准,但向量库里还是旧版本,用户问新标准,系统答的是旧标准,差点造成实际损失。后来我加了一个文档变更监听,文件一改就自动重新切分和编码,保证索引和源文档同步。
第三个坑是忽略了查询改写。用户的问题往往很口语化,直接拿去检索效果不好。我加了一个查询改写步骤,用大模型把用户问题改写成更适合检索的形式。比如“出差吃饭能报多少”改写成“差旅伙食补助标准 报销额度”,Hit Rate 又提升了 5 个百分点。
查询改写的提示词很简单:“请将以下用户问题改写成适合文档检索的关键词形式,保留核心语义,去除口语化表达。”这一步增加了一次模型调用,延迟增加约 300 毫秒,但效果提升值得。
这套优化方案跑下来,我那个制度问答系统的 Answer Accuracy 从 54% 提到了 87%,Hit Rate 从 62% 提到了 91%。整个过程大概花了三周,其中切分策略和混合检索贡献最大,重排序和提示词优化次之。如果你也在做 RAG 优化,建议先从切分和检索入手,这两块的投入产出比最高。生成环节的优化更多是锦上添花,检索没做好,生成再强也救不回来。