做了大半年 RAG 应用,我听到最多的一句话就是:为什么我的知识库问答答得这么“一本正经地胡说八道”?RAG(检索增强生成)本意是让大模型先翻资料再回答,可实际跑起来,检索不准、答案丢失、格式混乱,各种问题叠在一起,准确度很难看。这篇文章算是我自己项目里的调优笔记,重点讲检索、切片、重排、生成和评估这几条关键链路,适合已经开始做 RAG、却被准确度卡住的朋友,也适合刚准备上知识库问答、想少走弯路的新手。
1. RAG 问答不准的根源,先从链路里拆
RAG 的完整链路可以简单分成三块:文档索引、检索召回、生成回答。大多数人遇到“答得不准”,第一反应是换更大的模型或改提示词,但问题往往出在前面两块。索引和检索决定了大模型能不能“看到”正确的材料,生成环节决定它能不能“用好”这些材料。所以优化准确度,不能只盯着某一个环节,而是要先搞清楚错误到底从哪一段链路冒出来的。
1.1 检索阶段:拿回来的片段本身就不对
检索环节的任务是从知识库里捞回最相关的几个片段,再交给大模型。这里藏着 RAG 最典型的坑:检索结果里根本没有答案。我见过太多次返回的 Top 片段打开一看,只有一段背景介绍,真正回答用户问题的句子被切到了下一个 chunk 里。这不是模型能力问题,是召回精度出了问题。
向量检索的本质是把文本投影到高维语义空间,按余弦相似度排序,它并不保证“文本包含答案”,只保证“语义方向上相近”。比如用户问“某产品保修期是多久”,知识库里写“该产品保修期到 2026 年,但不包含电池故障”,如果这段文本被切成了两个片段,检索系统很可能只拿回前半句,后半句的“不包含”被落到另一个 chunk。模型看到前半句,自然会给出一个不完整的答案。这种问题在快餐式切分文档的项目里非常常见。
另外还有一类问题是相似文本干扰。像客服知识库中经常出现“退货流程”和“换货流程”,两个页面的开头几句几乎一样,向量检索可能同时召回了大量重复片段,真正涉及差异的部分反而排在后面。这类问题通常要靠重排和去重来解决,但根源还是在“召回粒度”和“文本结构”上没做好。如果 Top 片段本身就不相关,后面所有优化都会打折扣。
1.2 生成阶段:检索对了也能被模型答偏
检索结果勉强能用,不代表答案就准确。大模型面对多段文本时,并不知道哪段是权威答案,也不知道哪几段之间有时间先后或适用范围差异。比如同一份知识库里有两处对“退款周期”的描述,一处是旧流程说 7 天,一处是新流程说 3 天,模型很可能选择其中更显眼的一句来回答,结果就出现新旧信息冲突。
还有个常见现象是“lost in the middle”:当上下文超过一定长度后,模型对中间部分内容的关注度会下降。如果你把 Top 5 个片段全部塞给模型,而正确答案恰好排在第 3、第 4 位,模型可能只根据开头那两段不相干内容发挥。这种问题不是“检索没召回”,而是生成阶段没有对上下文做合理的排序和强调。
更现实的是,模型天生有迎合用户的倾向。用户问了一个知识库里没有答案的问题,提示词又没有明确允许它说“不知道”,模型为了不让用户失望,会基于训练记忆或上下文猜测出一个“看起来合理”的答案。所以说,RAG 准确度问题不是单一环节的问题,必须把检索和生成两边都管住。
2. 把“问对问题”做在前面:查询改写与多路召回
很多 RAG 项目把大量精力花在切分和向量模型上,却忽略了用户输入本身。用户问题往往是口语化、省略主语、带指代的。直接用原始 query 去检索,效果一定打折。优化准确度,第一件值得做的事就是让“问句”变得更适合检索。
2.1 查询改写:让 user query 变成会提问的人
我举一个真实场景。用户问“我想申请异地就医备案需要什么材料?”如果文档标题写的是“跨省异地就医直接结算办理须知”,句子之间没有连续关键词,向量相似度未必能排到前面。但如果先把问题改写为“异地就医备案需要哪些材料”,再把关键词“跨省异地就医直接结算办理须知”“备案材料”扩展进去,召回结果会明显更聚焦。
查询改写通常用 LLM 来做。我常用的改写方向有三个:
- 拆解多重意图:如果用户一句问了两件事,比如“申请流程是什么,多久能生效”,先拆成两个独立问题分别检索。
- 补全指代和缩写:多轮对话里,用户第二句说“那售后呢”,必须结合上一轮把“那售后呢”改写成“某产品的售后服务政策是什么”。
- 去掉口语噪音但保留限定条件:“赶紧告诉我退货行不行”改成“退货是否可以”,其中的“退货”要保留。
下面是一个简化版的改写提示词模板:
def rewrite_query(question, history_text, llm): prompt = f""" 你是一个检索助手。请把用户问题改写成更适合检索的独立问题。 多轮历史:{history_text} 当前问题:{question} 要求: 1. 补全指代和缺失实体; 2. 如果包含多个意图,拆成最多3个独立问题; 3. 不要新增原文没有的信息; 4. 直接输出改写后的问题,每行一个。 """ return llm(prompt)改写不是翻译,不要无中生有。我踩过的一个坑是,让模型“自由改写”之后,它把“退货”扩展成“七天无理由退货”,反而把“质量退货”这个核心场景漏掉了。所以提示词里必须限制:只能补全指代、拆分意图,不能引入新的假设。
2.2 多路召回:别把鸡蛋放在向量检索一个篮子里
向量检索擅长语义泛化,但精确匹配能力弱。比如用户输入“型号 ABC-100”,文档里写的是“ABC100”或者“ABC 100”,向量检索有可能因为 token 切分方式不同而匹配不上。这时候传统的关键词检索反而是强项。所以多路召回在 RAG 优化里几乎成了标配:一路用向量,一路用 BM25 或 Elasticsearch 的稀疏检索。
两类检索的优缺点大概是这样:
| 检索方式 | 擅长场景 | 明显短板 |
|---|---|---|
| 向量检索 | 同义词、口语化表达、语义相关 | 精确型号、编号、否定句容易失效 |
| BM25 关键词检索 | 编号、型号、专有名词精确命中 | 同义词和语义泛化基本无能为力 |
| 混合检索 | 两者互补,召回覆盖面更大 | 需要处理分数合并和权重调参 |
混合检索的实现并不复杂。一种方式是分别拿到两路的 Top 结果,再对分数做归一化合并。常见的做法是把每路结果的排名映射到 0 到 1,再用一个权重参数alpha做加权:
def merge_score(dense_rank, bm25_rank, alpha=0.5): dense_norm = 1 / (1 + dense_rank) # 排名越靠前,分数越高 bm25_norm = 1 / (1 + bm25_rank) return alpha * bm25_norm + (1 - alpha) * dense_norm这个权重需要在你的业务数据上试,通常是 0.3 到 0.7 之间。如果知识库里有大量规范编号、产品型号,关键词检索的权重可以高一点;如果用户问题普遍口语化,向量检索权重高一点。工程上,如果用的是 Elasticsearch、Weaviate 或 Qdrant,一些版本已经内置了混合检索能力,不必自己手写合并逻辑。
还有一种更“重”的检索增强方式叫 HyDE,思路是让 LLM 先针对用户问题生成一段“假设答案”,再用这段假设答案去检索,因为假设答案可能比原始问题更接近文档中的措辞。我在某些问答场景试过,确实能提升召回率,但多一次额外调用,延迟也会增加,通常放在查询改写和混合检索都做了之后,再决定要不要加。
3. 切片与索引优化:知识库的组织方式决定上限
检索再聪明,如果文档在索引阶段就被切得七零八落,后续优化都会受限制。切片是 RAG 项目里最基础也最容易被忽视的环节。很多人直接用固定长度切,512 个字符一刀切下去,看起来省事,实际上把大量语义完整的段落拦腰截断。后边的重排和生成再好,也很难修复“内容本身就不完整”的问题。
3.1 切片策略:固定大小、语义切分与 overlap
切片的目标不是“每片一样长”,而是“每片尽量表达一个完整意思”。固定长度可以作为兜底,但不能作为唯一策略。更好的做法是优先按文档结构切:识别标题、段落、列表和表格,在标题边界处分割,一个自然段如果太长,再往下切成句子级片段。
我常用的参数是:优先按二级标题分块,单块最大长度在 500 到 800 个中文字符之间,超过上限再按句切,同时保留前后一到两句话作为 overlap。overlap 的目的就是避免把前后有依赖关系的内容切散。比如条款里写“本政策适用于全体员工,但不包括临时工”,如果只在“全体员工”和“但不包括临时工”之间硬切,检索到前面那段就会给出错误答案。
切片时还要注意“排除条件”和“主体内容”尽量放在同一片段。规则类文档里,“适用场景”“申请条件”“除外情况”往往是同一个答案不可缺少的部分。我处理过一份售后政策文档,第一段写“支持退货”,第二段写“以下情况不支持退货”。如果不把这两段在同一片段内保留关联,模型很容易只看到“支持退货”就给出肯定回答。最简单的手段是,切片时把段落标题一起组装进 chunk,比如“售后服务政策 / 退货限制 / 不支持退货情况”。这样既保留了语义边界,也方便后续引用。
3.2 保留上下文结构:标题、父子块与元数据
给每个 chunk 加上元数据,是一项低成本高回报的优化。最基本的元数据包括文档标题、一级标题、二级标题、页码、更新时间。这些信息有两个用途:一是在检索后拼进提示词,让模型知道这段内容来自哪里;二是在答案展示时作为引用来源,帮助用户核验。
很多项目走到这一步还不够,因为“小 chunk 检索准,但上下文不足;大 chunk 上下文足,但检索噪声大”。一个成熟的解法是父子块结构:把文档切成较大的父块,再把父块内部拆成较细的子块。索引和检索时用子块,命中子块后返回其所属的父块给大模型。这样既保证了定位精准,又让生成阶段有足够的上下文。
# 文档索引阶段的关键字段 chunk = { "id": "parent_123_child_4", "text": "子块文本", "parent_id": "parent_123", "parent_text": "包含完整上下文的父块", "meta": { "doc_title": "售后政策", "section": "退货限制", "updated_at": "2025-06-01" } }如果知识库里有大量跨文档、跨章节的关系型知识,比如“研发部门提交申请后,销售部门需要在几个工作日内审批”,这类问题靠普通向量检索很难串联起来。社区现在讨论比较多的 GraphRAG、本体 RAG,本质上就是把实体和关系抽取成图结构,再从问题出发检索相关子图。这类方案对多跳问答确实有效,但建造成本很高,需要额外处理实体抽取和图查询。我个人建议先把父子块和混合检索做好,如果评测集里跨文档问题仍然大比例失败,再考虑引入关系图谱。
4. 精排与阈值控制:把准确度再往上顶一层
召回做完,候选片段通常有几十条,但大模型上下文有限,不能全塞进去。这时候需要在“再给模型更多内容”和“只给最相关内容”之间做取舍。靠向量相似度直接截取 Top 3,经常会把正确答案挤出去。重排这一步,就是为了在送入生成前再做一次精准筛选。
4.1 Cross-encoder 重排的性价比
向量检索用的双塔模型(bi-encoder)把 query 和文档分别编码成向量,计算速度快,但 query 和文档之间缺少深层交互。Cross-encoder 模型会把 query 和文档拼接在一起过一遍 Transformer,能看更细的 token 级关联,所以精度通常更高。代价是速度慢,不能用来扫全库。实际操作中,先用混合检索召回 Top 30 到 50 条,再用 Cross-encoder 重排,取前 3 到 5 条给模型,是精度和性能最平衡的组合。
中文场景我常用BAAI/bge-reranker-base,英文场景可以用cross-encoder/ms-marco-MiniLM-L-6-v2。用起来很简单:
from sentence_transformers import CrossEncoder reranker = CrossEncoder("BAAI/bge-reranker-base") pairs = [(query, doc["text"]) for doc in candidates] scores = reranker.predict(pairs) ranked = sorted( zip(candidates, scores), key=lambda x: x[1], reverse=True ) top_n = [doc for doc, score in ranked[:5]]在我自己测试的项目里,加入 Cross-encoder 重排后,答案命中率普遍能提升 10 个百分点以上,尤其在知识库内容相近的场景中提升更明显。但要注意,重排模型本身也会有偏差,不能把重排分数直接当成“置信度”。它只是一个排序工具,不是答案正确性的判定器。
4.2 阈值、去重和答案来源约束
重排之后还有一个容易被忽略的动作:设阈值。当所有候选片段的重排分数都很低时,宁可告诉用户“知识库中没有找到相关内容”,也不要强行拼一个答案。阈值需要基于自己的评测集去标定。我遇到过把 Top 1 分数 0.2 的内容塞给大模型,结果答出来完全跑偏的情况,后来设了 0.45 的阈值,低于这个值直接走兜底话术,准确度反而上去了。
去重也很关键。多路召回很容易带回同一篇文章的不同版本,或者同一段内容的不同切片。如果直接把 Top 5 都给模型,模型会被重复内容带偏。可以用向量相似度做一次粗过滤,两段文本的余弦相似度超过 0.95 就只保留其中一段。更高级的还有 MMR(最大边际相关性)去重,它在“相关”和“多样”之间做平衡,避免选出五段几乎一样的文本。
答案来源约束是必须做的。最简单的方式是要求模型在回答里标注引用编号,比如“根据[1]”和“根据[2]”,并在提示词里说明编号对应哪段资料。这样有两个好处:一是模型在生成时会更克制,因为它需要明确知道自己用了哪段内容;二是用户可以回查来源,发现错误时能定位到具体文档,而不是对着一个黑盒答案干瞪眼。
5. 生成侧优化:提示词与答案可验证
检索和重排都做对了,最后一步是大模型生成答案。很多人在这个环节犯的错误是:把检索到的片段一股脑塞给模型,然后只问一句“请回答”,其他什么都不约束。好的生成提示词,需要把“只能用什么信息、不能做什么、答不出来怎么办、怎么引用来源”全部写清楚。
5.1 提示词里绑定引用和“不确定就承认”
我常用的 RAG 生成提示词结构大概是这样的:
prompt = f""" 请根据以下资料回答问题。 资料: [1] {doc1} [2] {doc2} [3] {doc3} 问题:{question} 要求: 1. 只依据上述资料回答,不要使用你自己的知识; 2. 如果资料中没有答案,请直接回答“知识库中未找到相关答案”,不要推测; 3. 答案中每个关键句后标注资料编号,格式如“根据[1]”; 4. 不要回答与资料无关的内容。 """这里最关键的是第 2 条“允许模型说不知道”。我见过很多 RAG 项目,检索结果里根本没有答案,模型却依然硬着头皮给出一段从训练数据里“脑补”的内容。允许拒答会牺牲一点“回答率”,但能显著提升“准确率”,对于企业知识库场景来说,准确比全面重要得多。
生成参数也要配合调整。我一般把temperature调到 0.1,top_p调到 0.1,max_tokens根据答案长度限制一下。低随机性对事实型问答是安全的。另外,如果发现模型经常“受上下文干扰”,可以尝试调整片段顺序:把重排分数最高、最可能是正确答案的片段放在最前面,避免正确答案淹没在中间位置。
5.2 后验校验与自我反思
提示词约束得再好,也不能完全保证模型不犯错。另一个值得做的优化是加一层后验校验:先用一个轻量判断,检查生成的答案是否真的由检索片段支撑。最简单的做法是构造一个二分类提示词:
def validate_answer(question, contexts, answer, llm): prompt = f""" 你是一个审核员。请判断以下回答是否完全由给定资料支持。 资料:{contexts} 问题:{question} 回答:{answer} 请只输出 YES 或 NO,并附一句原因。 """ result = llm(prompt) return result.startswith("YES")如果校验结果是 NO,可以触发两种动作:一是直接拒答,返回“知识库中没有找到可靠答案”;二是带着校验结果重新检索一轮,也就是社区常说的 self-RAG 或 agentic RAG 思路。Agentic RAG 最近确实很火,核心就是让模型自己判断“当前召回内容够不够、要不要重新检索、要不要换一种检索方式”。但我个人觉得,如果你连基础检索和重排都没有调到稳定水平,不要急着上 agent 编排。错误会在多轮决策中被进一步放大,最后你可能连错误出在哪一步都找不到。
可以先从一个小循环开始:首次检索后,如果重排分数低于阈值,就调用查询改写,再检索一次,把两次结果合并。这个“两步检索”方案比完整 agent 框架简单,却已经能覆盖大量“换个问法就能找到答案”的场景。
6. 评估体系:没有指标,优化就是玄学
优化 RAG 准确度,最忌讳的是“今天改一下切分,明天换一下 embedding,后天觉得好像变好了”。如果手里没有一套可重复的评测集,你根本说不清楚哪个改动真正有效,最后只能靠感觉拍脑袋。建立评估体系,是优化工作中最值得提前投入的部分。
6.1 建一个最小评测集
不需要一开始就搞几百上千条,50 条高质量问题足够跑通第一轮。评测集应该覆盖几类典型问题:
- 简单事实型:答案在一段话中直接出现。
- 多跳关联型:需要综合两段或更多资料才能回答。
- 否定排除型:问题含有“不包括”“除外”等条件。
- 无答案型:知识库里确实没有相关内容,看系统会不会硬答。
每条评测样例最好都标注标准答案、答案来源文档、来源片段。没有标准答案只有“参考答案”也可以,但至少要有明确的评分规则:是看内容要点是否齐全,还是看结论是否正确。对无答案型样例,评分规则应该是“正确拒答才得分,硬答不得分”。
跑评测时,可以写一个脚本批量调用 RAG 接口,把检索到的片段、生成答案、耗时一起记录下来。如果想用现成框架,RAGAS 是不错的选择,装好之后按它的格式构造数据集就行:
pip install ragas但自动评估也有误差。我的习惯是让 RAGAS 当“筛选器”,先跑一遍自动指标,把低分样本挑出来人工看。这样不用每条都人工审,又能保证关键问题被覆盖。
6.2 指标解读和调优实验记录
不同指标对应不同问题,不要只看一个“最终答案对不对”。我常用的指标有四个:
| 指标 | 含义 | 主要优化手段 |
|---|---|---|
| Hit Rate | 标准答案是否出现在召回片段中 | 切片、多路召回、查询改写 |
| MRR | 第一个正确答案的排名是否靠前 | 重排、混合检索权重 |
| Faithfulness | 答案是否忠于检索上下文 | 提示词约束、后验校验 |
| Answer Relevancy | 答案是否切题、信息是否完整 | 查询改写、生成参数 |
每个改动只动一个变量,并记录前后对比。我项目里的实验记录大概长这样:
| 实验编号 | 改动内容 | Hit Rate | MRR | Faithfulness |
|---|---|---|---|---|
| Baseline | 512字符切片+向量检索+默认提示词 | 0.52 | 0.45 | 0.58 |
| Exp 1 | 语义边界切片+父子块 | 0.58 | 0.50 | 0.60 |
| Exp 2 | 再加BM25混合检索 | 0.66 | 0.53 | 0.62 |
| Exp 3 | 再加Cross-encoder重排 | 0.73 | 0.67 | 0.64 |
| Exp 4 | 再加引用式提示词 | 0.73 | 0.67 | 0.83 |
这是一组示意数据,能说明一个常见规律:Faithfulness 主要靠提示词约束和后验校验提升,Hit Rate 主要靠检索链路提升。如果 Hit Rate 已经很低,说明答案压根没被召回,你调多少提示词都没用。反过来,如果 Hit Rate 很高但 Faithfulness 低,说明答案找到了,但模型没有好好用,此时该去改生成侧。
线上运行后,还要有日志和人工抽查机制。每次 query 都记录当时的召回片段、重排分数、生成的答案,每周挑 20 条错误案例复盘。准确度不是上线后就不管了,知识库内容会更新,用户的问法也会变化,需要持续维护评测集,把新发现的问题补充进回归集,防止后续改动把之前修好的问题又弄坏。
最后说点个人体会。做 RAG 优化,最怕的不是模型不够强,而是没有基线、没有评测集,今天调一下切分,明天换个 embedding,后天又说“看起来变好了”,实际上谁也说不清改了什么。我现在接到新的 RAG 项目,第一件事永远是建 50 条评测题,第二件事才是动工程。另一个容易被忽略的小技巧是,把你的历史坏答案收集起来当回归集,每次改动后重跑一遍,防止优化了 A 问题却弄坏了 B。准确度是一点点抠出来的,先把基础链路上的问题清零,再去看那些更花哨的“新框架”,你会发现大部分收益其实早就藏在这些不起眼的细节里。