1. 当初把RAG的F1调到0.89,我做的第一件事不是调参数
半年前我接了一个企业知识库问答的项目,检索增强生成(RAG)系统上线后,业务方拿真实问题来测,结论只有四个字:答非所问。我拉了一下离线指标,整体F1大约在0.52,远谈不上可用。当时第一反应是调高向量检索的Top K、把温度降到0.1这种“玄学操作”,但真动手之前我强迫自己冷静了一天,因为我发现自己根本说不清楚“这个0.52到底是怎么算出来的”。
在RAG场景里,F1通常不是拿整段生成文本和标准答案做字符级对比,而是做关键点级别的对齐。做法是先把标准答案拆成若干个关键点(Key Points),比如一道题的标准答案有3个要点,模型生成的答案里命中了2个,另外还带错了1个,那精确率就是2除以3等于0.667,召回率是2除以3等于0.667,F1也是0.667。如果模型只答出1个点,没有额外错误,精确率是1,召回率是0.333,F1算出来是0.5左右。
这个定义直接决定了后续所有调参方向:F1低,只有两种可能,要么检索回来的片段里根本没有答案,要么片段里有答案但生成模型没把它正确提炼出来。这两种问题对应的旋钮完全不同。我后来把项目里所有可调的参数归成了五类:数据清洗类、分块索引类、检索召回类、生成策略类、评估回归类,每一类对应一个调优阶段,总共30多个能落地的旋钮。
如果你现在正准备调RAG的F1,别急着动模型或者堆GPU。先花两天时间把评测集建对、把指标口径统一,否则后面所有改动都是对着一个错误的靶子开枪。我见过太多人优化了一周,F1涨了0.1,结果只是评估代码里某个正则表达式改了,把本来算错的分“修正”成了想看到的分。
2. 五阶段调优地图:为什么用“旋钮”而不是“拍脑袋试参数”
把30多个旋钮全部铺开,人是非常容易懵的。所以我在项目启动时画了一张调优地图,明确五个阶段的顺序和每个阶段的退出条件,这个顺序本身也代表了RAG链路的数据流向:原始语料进来到切块,切块之后才能做向量化,检索回来之后才谈得上重排,最后才是交给生成模型做答案。
第一个阶段是语料清洗与分块,这个阶段我花费的时间最长,但对召回率的提升也是决定性的。第二个阶段是检索链路改造,主要做稀疏检索和稠密检索的混合以及重排序。第三个阶段是生成侧优化,重心在提示词结构、答案关键点覆盖和后处理抽取。第四个阶段是评测集校准和回归验证,保证调出来的分数不是因为偶然现象。第五个阶段是稳定性收尾,做多轮回归、边界样本测试和灰度上线。
这里多说一句为什么要用“旋钮”这个说法。因为RAG的每一个环节都有明确的参数边界,比如分块大小可以从64切到1024,Top K可以从3调到50,这些参数是离散的、可量化的、可以重复实验的。你不需要靠灵感去“感知”系统的瓶颈,只要把每个旋钮记录在案,每次只动一个变量,跑同一套评测集,记录前后两个F1,这个系统的行为规律就会逐渐浮出水面。
我习惯在项目目录下建一个实验记录表,格式很简单:日期、改了哪个旋钮、旧值、新值、检索召回率、生成F1、备注。别小看这个土办法,最后复盘的时候,它能帮你精确回答“这个0.05的涨幅到底是重排模型带来的,还是只是换了一批测试题”。我在这个项目里吃过亏:有一次同时改了分块重叠和向量检索权重,F1从0.61涨到0.68,我以为是权重的功劳,后来单独验证发现权重改动几乎没有影响,真正起作用的是重叠区间多覆盖了半个段落。
2.1 旋钮分类和它们影响的指标口径
这里把30多个旋钮先粗归一下类,后面每一阶段会展开讲。数据清洗类影响的是“进到索引里的内容干不干净”;分块索引类影响的是“答案片段能不能被完整切到一个块里”;检索召回类影响的是“候选片段排到前几名的概率”;生成策略类影响的是“从上下文里抽取关键点的质量”;评估回归类决定的是“你拿什么尺子量自己”。
一个值得记住的结论是:分块和检索阶段的旋钮,主要影响召回率;生成和后处理阶段的旋钮,主要影响精确率。F1是两者的调和平均,如果卡在0.6上不去,十有八九是召回侧出了问题,这时候去调提示词是无效的。我有很长一段时间陷入“完美主义提示词”的误区,写了十几个版本的system prompt,F1纹丝不动,后来检查检索结果才发现,正确答案压根没有被检索回来,提示词写得再漂亮也是无米之炊。
3. 阶段一:语料清洗与分块——召回率的天花板在这里
3.1 清洗环节的三个旋钮:去噪强度、段落完整性、长度过滤器
知识库的原始语料通常是从各处汇集来的,我手里的这批数据里有PDF导出的文本、网页爬下来的HTML、Word转出来的碎片,还有一堆表格被抽成了乱序字符串。如果这些脏数据直接进切分器,分出来的块就会包含页眉页脚、导航栏文字、乱码符号,向量化之后这些噪声块还会污染相似度排序。
清洗环节我推荐先跑一遍标准化流程:统一换行符、去掉不可见字符和零宽空格、剥离HTML标签、把连续空白字符压成一个空格。注意,这一步不是越狠越好,我在早期把标点符号全给规范成半角,结果把某些专业术语里的连字符也拆坏了,反而掉了两个点。所以清洗脚本里一定要加一个“保护词表”,把项目里出现频率高的复合术语、产品名、英文缩写先占位保护起来,再做符号清洗。
长度过滤器也很关键。我设置了两个阈值:最短保留长度是20个token,低于这个长度的片段大概率是残留的表格碎片或者标题残片,直接丢弃;最长保留长度是2048个token,超过这个长度说明可能是多篇文档被错误拼接,需要回到原始文档重新提取。这个过滤器的价值不只是清理垃圾,还能防止后面切分时出现超长异常块导致向量化模型截断,从而让一部分内容语义丢失。
清洗做完之后,可以做一个快速验证:随机抽20个切出来的块,人工读一下是否语义完整。我当时抽检发现一个问题,很多标题被单独切成了一块,后续检索时经常把标题块当作高相关片段返回,但对生成答案几乎没有帮助。所以我又加了一个规则:标题和正文如果属于同一语义单元,优先保留在一起,不要因为换行符就把它们拆散。
表:清洗阶段三个核心旋钮的推荐范围
| 旋钮 | 取值范围 | 最终采用值 | 影响维度 |
|---|---|---|---|
| 去噪正则强度 | 保守/标准/激进 | 标准 | 召回率 |
| 最小保留长度 | 10-50 token | 20 | 精确率 |
| 最大保留长度 | 1024-4096 token | 2048 | 召回率 |
3.2 分块参数:chunk_size、overlap 和切分策略的黄金三角
分块是整个RAG链路里最容易被低估的环节。很多人觉得分段嘛,拿个splitter按字符数切就行了,实际上分块大小对检索召回率的影响,比换一个向量模型还要明显。我做过一轮对照实验:固定其他条件,只改chunk_size,从1024降到512再降到256,Top 20召回率先上升后下降,最优值落在384附近。
为什么?块太大时,一个块里会混入多个子主题,向量化之后整个块的平均语义被稀释,查询向量和它做相似度计算时会产生偏差;块太小时,完整答案经常被拦腰切成两半,检索到一半也只能拿到半个答案,生成模型再厉害也补不齐。384这个值并不是普适的,它取决于你语料的段落长度和答案的平均跨度。我建议你先统计一下标准答案在原始文档中的平均字节长度,然后让chunk_size大致覆盖答案长度的1.5倍左右。
Overlap(重叠区间)的作用是给分块边界上保险。我最终设置的overlap是48个token,大概是每个块额外多保留了前一个块末尾的3到4行。这样即使一个答案恰好跨在两个块之间,检索到前一个块时,后一个块的起始内容也已经带上了。Overlap不是越大越好,过大的重叠会造成大量重复内容入库,增加检索噪声,我测试过128个token的重叠,召回率反而下降了两个多点。
切分策略上,不要用固定字符窗口,优先用“结构感知切分”。如果原始文档有标题层级,先按一级标题分出大节,再按二级标题和段落边界切到目标块大小。这样切出来的块内部主题一致,向量表达更有区分度。我还加了一个启发式规则:切分时优先在句号、问号、感叹号后断开,其次是逗号,再次是空格,尽量不要在一个句子中间拦腰截断。
3.3 向量化模型的选型和索引更新的坑
向量模型这个旋钮被很多人忽略,因为你可以在不换模型的情况下把F1从0.52调到0.7,但这个阶段的收益空间是独立存在的。我最初用的是通用中文问答向量模型,后来换成了在某垂直领域语料上微调过的向量模型,同一套评测集下,Top 20召回率从0.68提升到了0.78。这种提升在最终F1上大概贡献了4到5个点。
换模型的代价是必须全量重建索引。我踩过一个大坑:为了省事,我用新模型只对新增文档做了向量化,旧索引还是老模型生成的,结果检索模块在同一个向量空间里混用了两种分布不一致的向量,相似度排序完全乱套。正确做法是准备一个双索引灰度切换机制,新模型离线全量建好索引之后,线上通过开关切流量,先放5%的请求观察检索相关性,确认没有问题再全量切过去。
4. 阶段二:检索链路改造——从单一向量召回升级为稀疏与稠密混合
4.1 BM25 和向量检索怎么搭配才不浪费
纯向量检索有一个天然短板:对于产品型号、订单编号、法律条款这类专有名词和精确词,语义相似的向量往往排不上号,但BM25这种稀疏检索能通过精确词匹配把它们捞回来。所以我把检索链路改造成了两路并行:一路是稠密向量召回,一路是BM25关键词召回,两路结果做融合。
融合方式我试过两种。第一种是线性加权融合,给两路分数分别乘权重再加起来,优点是直观,缺点是向量分数和BM25分数分布差异大,权重很难调稳。第二种是RRF(倒数排名融合),不直接比分数,而是对两路结果的排名取倒数的和,这个公式对分数尺度不敏感,实际使用中稳定性好很多。我最终用的是RRF,其中的常数k取60。
权重设置上,我做了三组对比:稠密占0.7、稀疏占0.3的时候,F1最高。但这不意味着稀疏不重要,在特定类型的查询上,比如用户问“合同编号ABC-2024-001”,单纯靠向量几乎找不到,靠BM25能直接命中。所以在RRF之外,我还加了一条规则:如果查询里包含明显的编号模式(连续数字加连字符),则把BM25结果的权重临时提升到0.6以上。这个规则简单粗暴,但非常有效。
表:混合检索常见旋钮和我最终采用的参数
| 旋钮 | 取值范围 | 最终采用值 | 说明 |
|---|---|---|---|
| BM25的k1 | 1.0-2.0 | 1.5 | 词频饱和度 |
| BM25的b | 0.5-0.9 | 0.75 | 文档长度惩罚 |
| RRF常数k | 30-100 | 60 | 排名融合平滑 |
| 稠密/稀疏权重 | 0.3-0.7 | 0.7/0.3 | 业务可调 |
| 检索候选池大小 | 20-100 | 80 | 进入重排的数量 |
4.2 Top K 和阈值的正确姿势:候选池拉大、最终返回控制
Top K这个旋钮很有意思,改小了召不全,改大了噪声多。我在项目里把它拆成了两个参数:检索阶段返回给重排模型的候选池大小,以及重排之后最终进入生成模块的上下文片段数。
检索候选池我设成了80,也就是说混合检索从索引里先捞80个候选块。这个数字不能太小,因为重排的效果是建立在候选池包含正确答案的前提上的,如果候选池里根本没有目标块,重排器再强也白搭。重排后只保留10个片段给生成模型,上下文窗口不会撑爆,也给了生成模型足够的选择余地。
阈值方面,我给稠密向量召回设置了最低相似度0.35的过滤线,低于这个值的基本可以判断为无效召回。这个阈值不要太激进,我一开始设了0.5,结果很多正确答案因为表述差异被过滤掉了,F1不升反降。正确做法是把阈值设得宽松一些,宁可让后面重排器多负担一些,也不要在召回阶段就把正确答案毙掉。
重排模型的选择和顺序也很关键。我试用过两种路线:一种是用交叉编码器模型对80个候选块逐对打分,精度高但算力开销大;另一种是先用轻量模型粗排到20个,再用交叉编码器精排到10个。第二种方案在效果上几乎没有损失,耗时却减少了一半左右。对于线上服务来说,这是必须做的取舍。
5. 阶段三:生成端调优——为什么检索做好了,F1还是上不去
5.1 提示词的“结构化约束”比“道德说教”更管用
到了这个阶段,我的检索召回率已经从0.58提升到0.86左右,但生成F1只到0.74。剩下丢分主要在两个地方:一是生成模型没有把上下文里的关键点全部用起来,二是自作主张补充了上下文之外的知识,导致精确率下降。
提示词方面的核心改动是把以往那段比较长的“请基于以下上下文回答问题”改写成了结构化约束,格式大致是这样:
任务:回答用户问题。 约束1:只依据<context>中的内容作答,禁止使用外部知识。 约束2:如果上下文不足以回答问题,必须回答“上下文不足”,并列出缺失的信息点。 约束3:回答按要点列出,每个要点尽量对应上下文中的明确出处。 约束4:不要重复表述,不要补充与问题无关的背景。 <context>...</context> 问题:...这个改动看起来没有增加任何“魔法词”,但它把生成模型的注意力明确引向了关键点覆盖。实测效果是精确率上升了3个点,因为“禁止引入外部知识”这条约束显著减少了幻觉内容。
温度这个旋钮我也做了网格测试。温度在0到0.3之间,F1几乎没有变化;到0.7之后,精确率暴跌,因为输出变得发散,关键点里混入了大量修饰性语句。最终我固定在0.1,Top P取0.9。对于RAG这个场景,生成侧追求的是忠实提取而不是创意表达,所以采样参数整体走保守路线。
5.2 关键点抽取:让生成答案以结构化方式输出再逐点验真伪
整个生成阶段性价比最高的一个改动,是要求模型在输出答案的同时,额外输出一个“关键点清单”。具体做法是在提示词中要求模型先列要点,再给完整回答。通过这个形式,后处理脚本可以直接按照分隔符把要点拆出来,与标准答案的关键点做匹配,无需再依赖正则表达式去语义推断。
我后来还加了一个后处理校验函数:从生成答案里抽取每句话的核心成分,检查是否能在检索回来的上下文片段中找到对应证据。如果某句话在上下文中找不到任何可对齐的子串,就标记为“无证据陈述”,再判断它是否属于必要信息,如果可删则删掉,如果删掉会导致答案残缺,再人工判断是否属于外部常识。这个校验逻辑虽然简单,但把生成精确率提升了4个点,因为很多幻觉都是从改写开始的。
抽取和匹配还有两个细节:一是匹配时要做同义词归一化,否则“合同到期日期”和“合同有效期截止日”会误判为两个不同要点;二是要对数字格式做处理,比如“2024年”和“2024”不应该算不一致。这些细节如果不在后处理脚本里做归一化,评测时你都不知道分数是被哪个环节扣掉的。
6. 阶段四与阶段五:评测集校准、回归验证和防过拟合
6.1 评测集的三个质量门槛
没有一套可信的评测集,所有调参都是自我安慰。我这个项目里最初的评测集只有46条问答对,数量少不说,覆盖还偏斜,大部分是“文档在哪一章”这类定位型问题,真正需要跨段落合成的复杂问题几乎没有。这个评测集跑出来的F1其实是虚高的,换一批真实问题就原形毕露。
我在扩充评测集时设了三个质量门槛。第一个门槛是数量不能少于100条,且按问题类型分层:单跳检索题、多跳综合题、否定类问题、数值型问题、对比分析题,每一类至少占15%以上。第二个门槛是每一条评测样本都做关键点拆分,并交由第二个人复核,避免标注标准答案的人带有主观偏好看不到另一组关键点。第三个门槛是定期加入线上真实用户的badcase沉淀,把系统回答不好的问题补充进评测集,防止离线指标越来越好看、线上体验却依然糟糕。
校准评测集还有一个容易被忽略的点:LLM作为评测器是否稳定。我最初用一个大模型跑关键点匹配,后来发现它对长答案特别宽容,对短答案特别严格,导致同一组结果来回波动。后来我改用两段式评测:先用规则提取候选关键点,再用大模型做二分类判定,并且多次采样取多数结果。这个改动减少了很多“假涨分”的情况。
6.2 回归基线:每次实验前先跑一组固定样本
调参最容易犯的错误是盯着同一个评测集反复调,最后把评测集“背”下来了。我在项目里建立了一个固定回归基线:50条不进入日常调参循环的保密封样,每周跑一次,专门监测模型是否在没见过的样本上也稳定。日常调参用的是另外一个扩充评测集,两套集合并行。
这个机制帮我挡住了一次真实的过拟合事故。当时我把某个检索权重调到一个看起来很优的值,日常评测集F1从0.84涨到了0.87,但回归基线上不升反降。我仔细排查后发现,那个权重对评测集里一类共有的表述风格特别敏感,但对真实线上提问没有泛化价值。后来我把权重调回一个更保守的区间,日常集掉到0.86,回归基线却明显回升。真正可靠的涨分,应该是在两套数据集上同步体现的。
整个调优过程的节奏也很重要。我的习惯是每次只改一个旋钮,跑完评测和回归,记录结果,再动下一个。想在一天内把所有可能带来提升的改动都验证完,是最不切实际的想法,因为检索和生成链路之间的影响是串联的,多个变量同时改,出了问题根本分不清是哪个环节造成的。
7. 30+个可量化旋钮清单和我的最终推荐值
最后把这个项目里实际调过的旋钮整理成一张总表,分类、取值范围、最终取值和备注都列清楚。这份清单可以直接作为你动手调优时的起点,但不要照抄,因为最终取值高度依赖你的语料分布和业务场景,你需要做一遍同样的网格搜索。
| 分类 | 旋钮 | 默认范围 | 最终采用值 | 备注 |
|---|---|---|---|---|
| 数据清洗 | HTML标签剥离 | 开/关 | 开 | 保留保护词表 |
| 数据清洗 | 去零宽字符 | 开/关 | 开 | 预防PDF乱码 |
| 数据清洗 | 最短token过滤 | 10-50 | 20 | 丢弃碎片 |
| 数据清洗 | 最长token过滤 | 1024-4096 | 2048 | 异常长块拦截 |
| 数据清洗 | 重复段落去重 | 0.7-0.95 | 0.82 | 余弦相似度阈值 |
| 数据清洗 | 标题合并开关 | 开/关 | 开 | 避免标题单块 |
| 分块索引 | chunk_size | 128-1024 | 384 | 网格搜索最优 |
| 分块索引 | chunk_overlap | 0-256 | 48 | 约3-4行文本 |
| 分块索引 | 切分粒度 | 固定/结构感知 | 结构感知 | 按标题层级切 |
| 分块索引 | 断句符优先级 | 默认/自定义 | 句末优先 | 句号优先于逗号 |
| 分块索引 | 向量模型 | 通用/领域微调 | 领域微调 | 全量重建索引 |
| 分块索引 | 向量维度 | 256-1024 | 768 | 随模型固定 |
| 分块索引 | 相似度算法 | 内积/余弦 | 余弦 | 归一化后等价 |
| 检索召回 | BM25 k1 | 1.0-2.0 | 1.5 | 词频饱和调节 |
| 检索召回 | BM25 b | 0.5-1.0 | 0.75 | 长度惩罚 |
| 检索召回 | 稠密最低分 | 0.2-0.6 | 0.35 | 过滤无效召回 |
| 检索召回 | 混合算法 | 线性加权/RRF | RRF | 常数k=60 |
| 检索召回 | 稠密权重 | 0.3-0.7 | 0.7 | 偏好语义召回 |
| 检索召回 | 稀疏权重 | 0.3-0.7 | 0.3 | 精确词补充 |
| 检索召回 | 编号查询权重 | 关/开 | 开 | 提升编号类召回 |
| 检索召回 | 检索候选池 | 20-100 | 80 | 进重排数量 |
| 重排序 | 轻量粗排数量 | 10-50 | 20 | 第一级候选 |
| 重排序 | 交叉编码器精排数 | 5-20 | 10 | 进生成数量 |
| 重排序 | 重排最低分 | 0-1 | 0.2 | 低于则丢弃 |
| 上下文控制 | 最大上下文token数 | 512-4096 | 2048 | 防止截断 |
| 上下文控制 | 上下文去重 | 开/关 | 开 | MMR算法 |
| 生成参数 | temperature | 0-1.0 | 0.1 | 保守取值 |
| 生成参数 | top_p | 0.7-1.0 | 0.9 | 核采样 |
| 生成参数 | max_tokens | 256-2048 | 1024 | 防超长输出 |
| 生成策略 | 结构化提示词 | 开/关 | 开 | 四项约束 |
| 生成策略 | 关键点清单输出 | 开/关 | 开 | 配合后处理 |
| 生成策略 | 无证据语句过滤 | 开/关 | 开 | 对齐上下文 |
| 生成策略 | 上下文不足兜底 | 开/关 | 开 | 返回缺省文案 |
| 评估回归 | 日常评测集规模 | 50-300 | 120 | 分层采样 |
| 评估回归 | 回归集规模 | 30-100 | 50 | 密封样本 |
| 评估回归 | LLM评测器重复次数 | 1-5 | 3 | 多数表决 |
这份表里的所有值,都是我在这个项目上经过至少3组对比实验之后固定下来的。截止到收尾,日常评测集上的生成F1稳定在0.89,回归基线在0.86到0.88之间波动,线上badcase数量比初始版本减少了大约七成。
最后分享两个实操体会。第一,任何参数改动如果不能在真实抽取的10条badcase上解释得通,那就不算一次有效调优,数值涨了也只是运气。第二,RAG的F1不是靠某一个环节的“神操作”突然拉上去的,我仔细观察过,整个项目里没有任何一次改动让F1提升超过6个点,但十几个6个点以内的改进叠加起来,就是0.52到0.89的距离。今天看到0.89这个数字的人,往往只关心我用了哪个模型,但我真正想告诉你的,是这一张实验记录表上的每一次取舍。