RAG 项目做到第二个版本,十有八九会撞上同一堵墙:模型换了两轮,提示词改了十几版,检索出来的东西还是“擦边”——问的是某条规定的例外情形,召回的是这条规定的适用范围;问的是某个参数的默认值,召回的是这个参数的取值范围。很多人第一反应是嵌入模型不行、向量库不行、大模型不够聪明,于是开始换模型、加 rerank、上混合检索,钱和时间都砸下去,效果提升却像挤牙膏。
我处理过几轮这类问题,结论比较扫兴:大部分“检索不准”其实在文档切分那一步就注定了。切分是整条 RAG 链路里唯一一个“一错到底”的环节——上游切坏了,下游的嵌入、索引、召回、重排、生成全都在为这个错误买单,而且每一层都看起来“工作正常”,你很难从日志里直接看出病灶。这也是为什么这个环节最容易被跳过:它不像模型选型那样有明确的对比表,也不像提示词那样改一行就能看到变化,它更像打地基,塌的时候已经是三楼了。
这篇内容想解决的就是这件事:Chunk Size 到底怎么定、Overlap 到底要不要给、语义切分是刚需还是噱头、以及怎么用一套低成本、可复现的方法把这些参数实测出来,而不是拍脑袋。不管你是刚接手一个知识库问答项目,还是已经在调优阶段卡住,下面这些内容应该都能直接拿去用。前面会讲清机制和取舍逻辑,中间给可落地的切分实现路径,后面是一整套评测流程和工程细节,最后分享几个我自己踩过、且网上资料里很少提的坑。
1. 切分为什么是 RAG 里唯一“一错到底”的环节
1.1 解析错误还能人工兜底,切分错误连兜底的机会都没有
先说清楚一件事:文档进入 RAG 之前其实有两道关口,第一道是解析(PDF 转文本、表格还原、标题层级识别),第二道才是切分。解析出问题通常比较显眼——乱码、段落粘连、表格变成一堆数字,跑几篇文档肉眼就能发现,实在不行还能针对某类文档写定制解析规则,或者干脆人工校对一批。
切分不一样。它不改变文本内容,一个 500 页的手册切完之后,字符总数几乎不变,你抽样看几个块,每一块都是通顺的、有意义的、语法完整的。问题藏在“边界”里:某个块讲完了“适用范围”,下一个块才开始讲“例外情形”,而用户的问题恰好落在两者之间。这种缺陷在你的原文里根本不存在,是切分“制造”出来的。它不会被解析质检发现,也不会被嵌入模型报错,它只会安静地降低检索质量,然后你在评估报表上看到一句“准确率 61%”,完全不知道从哪下手。
我见过一个团队花了三周时间在这上面绕圈:先怀疑嵌入模型,换了一个更大的;再怀疑向量库,从本地部署换到托管服务;最后加了一层重排模型。三轮下来准确率从 58% 涨到 67%,大家都觉得“方向对了”,但实际上他们只是用重排把“已经召回到但排得靠后”的那部分样本救回来了。真正没被召回的那 33%,无论怎么重排都救不了,因为目标块压根没进候选集。
1.2 三种典型症状,对应三种不同的切分病灶
在实际排查里,我习惯先看“症状”反推切分问题,这比直接读切分代码快得多。大致能归成三类,每类的病灶位置完全不同。
| 症状表现 | 典型回答形态 | 大概率病灶 | 优先验证动作 |
|---|---|---|---|
| 答非所问,但召回内容确实相关 | 回答笼统、只讲通用规则 | 块太小,缺少完整语义单元 | 把命中块前后各扩 1 块,看答案是否补齐 |
| 答案“差一点”,关键条件缺失 | 回答正确一半,漏掉前提 | 块边界切断了条件与结论 | 定位原文,看条件句与结论句是否跨块 |
| 召回噪声大,多个块互相矛盾 | 回答自相矛盾或含糊 | Overlap 过大或结构切分重复 | 统计同一文档块在 top-k 中的占比 |
| 明明文档里有,就是召不回 | 回答“未找到相关信息” | 嵌入被静默截断,或标题信息丢失 | 单独把原文整段送去嵌入,看能否召回 |
第三行那个“top-k 里同一文档块占比过高”是个很实用的信号。正常情况下 top-5 应该来自不同章节,如果经常出现同一小节的三四个相邻块霸榜,基本可以确定 Overlap 给多了,或者切分粒度太碎导致信息密度过低,向量之间高度相似,挤掉了其他候选。
第四行是我认为最隐蔽、也最容易被忽略的一类:嵌入模型的输入长度限制导致静默截断。很多常用的句向量模型最大输入只有 512 token,你把 chunk size 设成 800 token 甚至 1000 token,嵌入接口不会报错,它只会默默把超出的部分丢掉。于是你的索引里存着 1000 token 的原文,向量却只表达了前 512 token 的语义。结果就是:一个块的后半段内容相当于“没被索引”,你能检索到的永远只有前半段的意思。这个坑我踩过一次,排查了整整两天,因为从任何日志里都看不出异常。
1.3 把切分问题当提示词问题修,是成本最高的一条路
这里有个很现实的经济账。一次切分策略调整,成本是“重新嵌入 + 重建索引”,如果文档量在几千页量级,用本地模型嵌入可能几十分钟就跑完;而一次提示词优化,成本是你自己、可能还有同事的时间,以及反复评测的模型调用费用。看起来提示词更便宜,所以大家的默认反应是先去改提示词。
但提示词优化的天花板由检索质量决定。检索给的上下文里如果没有答案,再强的模型也只能编或者拒答;上下文里有答案但被切碎了,模型最多能“拼”出一半。所以正确的顺序应该是:先确认“答案确实被召回到了候选集里”,再谈提示词和重排。验证方法很土但很有效——把标准答案对应的原文段落人工贴进上下文,看模型能不能答对。如果能答对,说明生成环节没问题,问题一定在检索;如果答不对,那才是提示词或模型的事。这一步能帮你省掉大量无效的提示词迭代。
2. Chunk Size 的真实权衡:不是“越小越准”
2.1 嵌入模型的输入上限是一道硬墙,必须先算再定
定 chunk size 的第一步不是想“多长比较合理”,而是先确认技术约束。你需要三个数字:嵌入模型的最大输入 token 数、向量库单条记录的长度限制、以及生成模型能接受的上下文预算。这三个里最小的那个,是 chunk size 的绝对上限。
这里有个容易被忽略的细节:token 和字符不是一回事。中文场景下,一个汉字大约对应 0.6~1 个 token(取决于分词器),一个英文单词大约 1.3 个 token,而代码因为符号多,token 密度更高。所以“500 字”不等于“500 token”。我的做法是先写个小脚本,对文档抽样 20 段,用实际的分词器统计 token/字符比。
# 抽样统计 token 与字符的比例,用于后续按字符估算 chunk size from transformers import AutoTokenizer tokenizer = AutoTokenizer.from_pretrained("your-embedding-model-name") samples = load_sample_paragraphs("manual.pdf", n=20) ratios = [] for s in samples: n_token = len(tokenizer.encode(s, add_special_tokens=False)) ratios.append(n_token / max(len(s), 1)) print(f"平均 token/字符 = {sum(ratios)/len(ratios):.3f}") print(f"最大值 = {max(ratios):.3f}") # 用最大值做保守估算如果统计出来是 0.75,而你希望块落在 400 token 以内,那按字符切分时目标就应该是 400 / 0.75 ≈ 530 字符,而且要用最大值 0.9 再算一次作为下限保护。别小看这个换算,我见过直接把“512 字符”当成“512 token”用的,实际跑出来块普遍在 350 token 左右,白白浪费了嵌入模型的容量,信息密度上不去。
还有一个更隐蔽的约束:很多模型不是“超长就截断”,而是“超长就报错”,但报错被你的批量处理脚本吞掉了。如果你的嵌入流程里有 try/except 并且 except 里只是 continue,那么超长块会被静默跳过,索引里根本没有它。这个一定要在日志里打出明确的超长告警,我一般会在切分后直接加一道断言,任何超过模型上限 90% 的块都打印出来。
2.2 块小提升定位精度,但会摧毁“可回答性”
为什么大家本能地觉得“块越小越准”?因为在小块上,向量表达的主题更聚焦,相似度计算的区分度更高,检索的“定位精度”确实会提升。一百字的块只讲一件事,向量自然干净;一千字的块可能讲了五件事,向量是五件事的平均,跟任何一个具体问题的相似度都不算高。
但检索的最终目的不是“定位到相关段落”,而是“让模型能答出问题”。这两件事在小块上会打架。举个具体的:某份技术文档里有一句“当环境温度低于 5℃ 时,需将预热时间延长至 30 分钟”。如果按 200 字切,这句话可能落在块 A 的末尾,而前面“适用于户外设备”这个前提落在块 B。用户问“户外设备冬天启动要多久”,你召回了块 A,“30 分钟”在里面,模型答得出来;但如果用户问“低温启动需要额外做什么”,块 A 只有结论没有条件,模型很可能答成“延长预热时间”却漏掉“仅限户外设备”,然后你得到一条“看起来对但有风险”的答案。
这就是我说的可回答性:一个块单独拿出来,能不能支撑一个完整、无歧义的回答。这个性质跟块大小不是线性关系,它取决于文档本身的“语义单元”结构。所以真正合理的做法不是盯着 token 数,而是先花半天时间读一遍目标文档,找出它的“最小完整论述单元”大概多长——有的 FAQ 一条就是 80 字,有的规章制度一个条款带例外说明能到 600 字。
2.3 top-k 与 chunk size 的乘积,才是你真正喂给模型的上下文预算
这个换算很多人不做,但它直接决定你的成本和质量。假设你的生成模型上下文窗口足够大,但你把 top-k 设成 8、chunk size 设成 1024 token,那你每次请求要塞进去 8000 多 token 的检索内容,其中大部分是噪声,而且模型的注意力会被稀释——这是实打实的“更多不等于更好”。
反过来,chunk 128 token、top-20,总预算 2560 token,看起来省,但 20 个碎块里有 15 个是不相关的,同样稀释注意力,还多花了 20 次向量检索。
我一般会用这张表来对齐参数,先把“有效上下文预算”定下来(比如 2000~3000 token 是很多场景的甜点区),再倒推组合:
| chunk size (token) | top-k | 检索上下文预算 | 适用情形 | 主要风险 |
|---|---|---|---|---|
| 128 | 20 | ~2560 | FAQ、术语表、字段说明 | 上下文碎片化,模型难拼完整逻辑 |
| 256 | 8 | ~2048 | 大多数结构化文档的默认起点 | 长条款被切断 |
| 512 | 5 | ~2560 | 手册、制度、说明书 | 单块内多主题,向量被平均 |
| 1024 | 3 | ~3072 | 长篇论述、会议纪要、报告 | 定位精度下降,需配合 rerank |
我的经验起点是 256~512 token、top-5 到 top-8,然后在这个区间内做小步调整。超过 1024 token 的块,除非你的文档本身就是长篇连续论述,否则基本都会遇到“向量被平均”的问题——这也是为什么很多团队用“小块检索 + 大块生成”(后面会讲)来绕开这个矛盾。
2.4 按文档类型给经验区间,比追求一个万能参数更实际
我特别不建议在整个知识库里用同一个 chunk size。真实项目里往往混着产品手册、FAQ、合同条款、会议纪要、代码文档,它们的语义结构差异极大。下面这张表是我实际用下来比较稳的初始值,可以直接当起点,然后再做小范围调优。
| 文档类型 | 建议 chunk size | 建议 overlap | 切分依据 | 说明 |
|---|---|---|---|---|
| FAQ / 问答对 | 100~200 token | 0 | 一问一答为单位 | 一条问答天然是一个语义单元,别硬切 |
| 产品手册 / 操作指引 | 300~500 token | 10% | 按步骤、按小节 | 步骤之间强依赖,overlap 要保住上下文 |
| 制度 / 合同条款 | 400~600 token | 5%~10% | 按条、按款 | 条款号必须随块保留,否则无法引用 |
| 技术报告 / 论文 | 500~800 token | 15% | 按段落、按小节 | 论述链长,块太小会失去论证完整性 |
| 会议纪要 | 300~400 token | 10% | 按议题、按发言人段落 | 议题边界比字数边界更重要 |
| 代码 / API 文档 | 按函数或类切 | 0 | 语法结构 | 绝对不能按字符切,会切碎函数体 |
| 表格密集型文档 | 整表或按行组 | 0 | 表格结构 | 单元格被切开等于信息全丢 |
表里最需要注意的是“表格密集型”那一行。表格是切分的重灾区,一个 20 行的参数对照表如果按 500 字硬切,会变成上半张表和下半张表两个块,每个块都缺列头,向量里全是孤立的数值。这种块召回去之后,模型根本不知道数字对应哪个参数。处理方式要么整表保留(哪怕超过目标大小),要么把每一行改写成“参数名:值,取值范围:……,默认值:……”这样的自然语言句子,彻底放弃表格形态。
3. Overlap 的边际收益与代价:什么时候它真的有用
3.1 Overlap 真正解决的是“边界截断”,不是“语义补全”
关于 overlap,网上流传最广的说法是“让相邻块有重叠,避免语义被切断”。这个说法对,但太模糊,容易让人以为 overlap 越大越安全。事实上 overlap 的作用范围非常有限:它只能保证“一个跨越边界的句子,在两个块里都完整出现”,它没法保证“一个跨越多个块的论证链”被完整保留。
举个能说明边界的例子。原文是:“设备支持三种工作模式。模式 A 适用于室内,功耗低。模式 B 适用于室外,需防水。模式 C 为待机模式,功耗最低。”如果按每块两句话切,块 1 是“设备支持三种模式 + 模式 A”,块 2 是“模式 B + 模式 C”。用户问“室外用哪个模式”,块 2 能答;用户问“三种模式分别适用什么场景”,任何一个块都答不全。这时候加 overlap 只是让块 2 多带上“模式 A”那半句,依然答不全,因为完整信息本来就需要三个块。
所以 overlap 的定位应该明确成:防止单句被腰斩。它对付的是那些“条件在前一句、结论在后一句”的紧邻结构。如果你的文档里这类结构很多(制度、合同、技术规范都是),给 10%~15% 的 overlap 是划算的。但如果你的文档是 FAQ 或者每条独立的条目式内容,overlap 基本是纯浪费。
3.2 10%~20% 是常见拐点,超过之后开始反噬
我给 overlap 的默认值是 chunk size 的 10%~15%,这个区间覆盖了绝大多数“句子被切断”的情况。之所以不往高了给,是因为 overlap 有三个不太直观的代价。
第一个代价是索引膨胀。overlap 20% 意味着文本总量增加 20%,嵌入成本、存储成本、检索耗时都按比例上升。文档量小的时候无所谓,几万页文档下这个成本很实在。
第二个代价更隐蔽,是召回冗余。重叠的块之间向量相似度天然很高,它们会成簇地出现在 top-k 里。假设 top-5 里有 3 个是同一段内容的连续切片,你等于只召回了 3 个有效信息源,另外 2 个名额浪费了。这个现象在 overlap 超过 30% 时会非常明显,表现出来就是“召回的东西翻来覆去都是那一段”。
第三个代价是生成端的重复信息。模型看到三个内容高度重叠的块,有时会误以为是不同来源在重复强调某件事,从而放大某个细节的权重;有时则会在回答里出现重复表述。这个我没法给出量化结论,但在实际输出里确实观察到过。
下面这组数据是我在一个中等规模手册文档集(大约 800 页)上跑出来的对比,用的是同一套 40 条金标问答,指标定义后面第 5 节会详细讲,这里只看趋势:
| Overlap 比例 | 索引文本量 | Hit@5 | MRR | 答案正确率 | 平均检索耗时 |
|---|---|---|---|---|---|
| 0 | 1.00x | 0.71 | 0.63 | 0.58 | 1.00x |
| 10% | 1.11x | 0.82 | 0.72 | 0.71 | 1.07x |
| 20% | 1.25x | 0.84 | 0.73 | 0.72 | 1.16x |
| 30% | 1.43x | 0.83 | 0.70 | 0.69 | 1.28x |
| 50% | 2.00x | 0.80 | 0.65 | 0.64 | 1.45x |
从 0 到 10% 是明显的跃升(Hit@5 从 0.71 到 0.82),10% 到 20% 收益已经很小,20% 之后开始回落。这个形状我在几个不同项目里都见过,拐点位置略有差异,但“10%~20% 是甜点区、30% 以上开始亏”这个判断基本稳定。当然这是针对手册类文档,如果你的文档句子特别长、一段就是一个完整论述,那 overlap 的合理值会更高一些。
3.3 结构切分叠加 Overlap,会制造三种重复污染
这是我觉得最值得单独说的一点:如果切分本身是按结构做的(按标题、按条款、按段落),再叠加 overlap,很可能产生语义上的重复块。原因是结构切分的边界本来就是语义边界,你在一个语义边界上再做字节级重叠,等于把上一节的结尾硬塞进下一节的开头。
具体会出现三种情况。第一种是标题污染:按 H2 切分时,块头通常是“## 3.2 参数配置”,如果加 overlap,下一个块的开头可能变成“……如上所述,参数配置分为三类。## 3.3 默认值”,这个块的主题就变得模糊了。
第二种是前后矛盾:文档里前一条规定“默认开启”,后一条修订为“默认关闭”。overlap 让这两句话出现在同一个块里,模型看到互相矛盾的表述,输出就会含糊其辞。这种情况在版本更新频繁的文档里很常见。
第三种是列表项粘连:一个 10 项的操作清单,如果每 300 字切一次并加 overlap,会出现“第 7、8 项在块 A 尾部,第 8、9 项在块 B 开头”的情况,检索时同时召回 A 和 B,模型看到第 8 项出现两次,会误以为它是重点。
我的处理原则是:结构切分不加 overlap,固定窗口切分加 10%~15% overlap。这两条策略不要混着用。如果你确实担心结构切分后某段太长导致信息被稀释,那应该做的是“二次切分”——先按结构切出 800 token 的大块作为父块,再在父块内按句子切成 200 token 的子块,检索用子块、生成用父块。这是第 4 节要讲的方案,它比加 overlap 有效得多。
4. 语义切分的三条实现路径与各自的翻车点
4.1 相邻句向量相似度断点法:原理简单,判据难定
语义切分最流行的实现是:把文档拆成句子,逐句算向量,相邻两句的相似度低于阈值就在中间断开。逻辑很直白——相似度高说明还在讲同一件事,相似度骤降说明话题变了。
实际跑起来,第一道坎是阈值怎么定。余弦相似度没有一个跨文档通用的绝对阈值,同样的 0.6,在技术文档里可能表示“相关”,在小说里可能表示“完全无关”。所以我更推荐用相对判据:先算出全部相邻相似度,取分布的某个百分位作为断点阈值。
import numpy as np def semantic_split(sentences, embed_fn, percentile=15, max_chunk_tokens=400): """基于相邻句相似度的百分位断点做语义切分""" vecs = embed_fn(sentences) # 一次批量嵌入,避免逐句调用 sims = np.array([ np.dot(vecs[i], vecs[i+1]) / (np.linalg.norm(vecs[i]) * np.linalg.norm(vecs[i+1]) + 1e-8) for i in range(len(vecs) - 1) ]) threshold = np.percentile(sims, percentile) # 最低的 15% 视为话题转折 chunks, buf = [], [sentences[0]] for i, sim in enumerate(sims): is_break = sim < threshold is_overflow = count_tokens(buf) > max_chunk_tokens if is_break or is_overflow: chunks.append("".join(buf)) buf = [sentences[i+1]] else: buf.append(sentences[i+1]) chunks.append("".join(buf)) return chunks这里有个必须加的保护:max_chunk_tokens上限。纯靠相似度断点,遇到通篇平铺直叙的文档(比如一个很长的操作步骤列表),相邻句子相似度都很高,你可能切出一个 3000 token 的巨块。加一道硬上限能兜住这种极端情况。
第二道坎是句向量模型和检索模型不是同一个。断点用的是句子级嵌入模型,检索用的是文档级嵌入模型,两者的语义空间不一致,这意味着“切得好”和“检索得好”是两个独立目标。我实测下来,用同一个模型做断点和检索会稍好一些,但也不能保证对齐。所以语义切分的效果必须用端到端指标验证,不能只看切出来的块“读起来顺不顺”。
第三道坎是短句噪音。目录页、图表标题、页眉页脚这些短句,向量本身就不稳定,跟任何句子的相似度都偏低,会制造大量假断点。我的做法是先把长度小于 8 个字符的句子合并到相邻句,并且在做断点检测前剔除掉页眉页脚模式的文本。
4.2 结构标记切分,往往比向量方法更稳更便宜
说个可能不太受欢迎的观点:如果你的文档有良好的结构标记(标题层级、编号、条款、列表),那么基于规则的切分在绝大多数情况下比语义切分更可靠,而且成本几乎为零。语义切分适合的是那些“一大段平铺文字、没有明显结构”的文档,比如访谈记录、转录稿、小说。
结构切分的实现很简单:先解析出标题树,然后按标题层级递归切分,超长的节点再降级到下一层。
import re HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.*)$") def split_by_heading(md_text, max_tokens=500): """按 Markdown 标题层级切分,超长节点继续下钻""" lines = md_text.splitlines() sections, cur_title, cur_body = [], "", [] def flush(): if not cur_body: return body = "\n".join(cur_body).strip() if not body: return if count_tokens(body) > max_tokens: # 超长小节:按空行分段,再按句子兜底 for para in re.split(r"\n\s*\n", body): if count_tokens(para) <= max_tokens: sections.append((cur_title, para)) else: sections.extend(split_by_sentence(para, max_tokens, cur_title)) else: sections.append((cur_title, body)) for line in lines: m = HEADING_PATTERN.match(line) if m: flush() cur_title, cur_body = m.group(2).strip(), [] else: cur_body.append(line) flush() return sections这段代码的关键不是切分本身,而是cur_title被保留了下来。标题路径是切分时最容易被丢掉、又极其值钱的信息。一个块的内容是“预热时间延长至 30 分钟”,如果不知道它属于“户外设备 > 低温启动”这一节,这个块几乎是不可检索的,因为“30 分钟”这个词在任何文档里都可能出现。把标题路径拼到块的开头(比如“户外设备 > 低温启动 > 预热时间延长至 30 分钟”),检索命中率会有肉眼可见的提升。这一点后面第 6 节还会展开。
结构切分的翻车点主要在解析质量上:PDF 转出来的标题可能带页码、带乱码,编号可能是“3.2.1”也可能是“三、(二)1.”,需要针对文档来源写规则。我的建议是宁可多写几条正则,也不要在解析不干净的文本上跑语义切分,因为脏标题会让向量断点判据彻底失效。
4.3 递归字符切分的中文分隔符优先级,是个细节活
固定长度切分里,递归字符切分(按分隔符列表依次尝试)是性价比最高的方案,它不需要模型、速度快、结果稳定。但默认的分隔符列表通常是给英文设计的["\n\n", "\n", " ", ""],直接用在中文上会把句子从中间切开——中文句子之间没有空格,最后会退化到按字符硬切。
中文场景的分隔符优先级我一般这样排:
SEPARATORS = [ "\n\n", # 段落 "\n", # 换行(列表项、条款) "。", "!", "?", ";", # 句末标点 ",", "、", # 句内停顿 "", # 最后兜底 ]这里有个细节:把句号、问号、感叹号放在同一优先级,切的时候要保留标点在块尾,而不是把标点丢到下一个块开头。否则下一个块会以“。”开头,向量质量会受影响。实现上就是切分后做一次“标点回粘”。
另一个细节是别在数字和英文单词中间切。像“版本 3.2.1”“API 调用超时 30s”这类内容,如果在“3.”或者“30”后面断开,块里就剩个“2.1”和“s”,完全是垃圾。我一般会在切分后做一个后处理:检测块尾是否是“数字+点”或块首是否是纯数字片段,如果是就把它和下一个块合并。
4.4 小块检索配父块生成,绕开“定位精度”和“回答完整性”的矛盾
前面第 2 节讲的矛盾——块小定位准但答不全,块大答得全但定位差——有一个很实用的解法:用小块做检索,用大块做生成。具体做法是维护两级块:子块 150~250 token 用于嵌入和召回,父块 600~1000 token 用于喂给模型。子块上存一个parent_id,命中子块后回溯取父块内容。
# 索引结构示意 { "child_id": "doc7_c012", "parent_id": "doc7_p004", "text": "当环境温度低于 5℃ 时,需将预热时间延长至 30 分钟。", "embedding": [...], "metadata": {"title_path": "户外设备 > 低温启动", "page": 42} }这个方案的收益很直接:检索阶段子块语义聚焦,MRR 通常比大块高;生成阶段父块信息完整,答案正确率上来了。代价是索引体积变大(子块数量是父块的 3~5 倍),以及需要多一次父块查询。在几千到几万块的规模下,这个代价完全可以接受。
有个坑要提醒:父块要按“一个完整的语义单元”来定,而不是简单地把 4 个子块拼起来。如果父块就是机械地把相邻子块连起来,那你可能把两节内容拼成一个父块,反而引入噪声。正确做法是父子两级都基于同一套结构切分,父块对应一个完整小节,子块是小节内的句子组。
5. 搭一套可复现的切分评测流程
5.1 先造 30~50 条金标问答,答案必须能锚定到原文
没有评测的调参就是玄学。但我也理解,很多项目没条件做完整的人工标注。折中方案是:从文档里反向造题。挑 30~50 个段落,每个段落人工写 1 个问题,问题的答案必须能在这一段里找到原文句子。这个过程一个熟悉文档的人大概两三个小时能完成,投入产出比极高。
关键在“锚定”二字。每条样本要记录三样东西:问题、标准答案(可以是原文句子)、答案所在的原文片段(用字符位置或文本片段标识)。有了“原文片段”,你就能在切分后自动判断“哪个块包含了答案”,从而计算检索命中,完全不需要人工判断。
举个样本的样子:
{ "q": "户外设备在低温环境下启动需要额外注意什么?", "a": "需将预热时间延长至 30 分钟", "anchor": "当环境温度低于 5℃ 时,需将预热时间延长至 30 分钟", "doc": "manual_v3", "type": "条件-结论型" }样本要覆盖不同类型的问法:事实型(某参数默认值是多少)、条件型(什么情况下会触发某行为)、对比型(A 和 B 有什么区别)、多跳型(需要两处内容才能回答)。其中条件型和多跳型最能暴露切分问题,所以我一般会让它们占到一半以上。
5.2 三层指标:先看召没召到,再看排得对不对,最后看答得好不好
评测指标我分三层,每层回答一个不同的问题,混在一起看会误判。
| 层级 | 指标 | 回答什么问题 | 达标参考 |
|---|---|---|---|
| 检索层 | Hit@k | 答案块有没有进候选集 | k=5 时 ≥ 0.85 |
| 排序层 | MRR / nDCG@k | 答案块排得够不够靠前 | MRR ≥ 0.70 |
| 生成层 | 答案正确率 | 最终输出对不对 | ≥ 0.75(人工或模型评判) |
这三层要按顺序看。如果 Hit@5 就很低,那排序和生成都不用看了,直接回去改切分或嵌入。如果 Hit@5 高但 MRR 低,说明答案块召回到了但排在后面,这时候加 rerank 或者调整 top-k 是有意义的。如果前两层都高但答案正确率低,那问题在生成侧,跟切分关系不大了。
我特别想强调 Hit@k 这个指标的价值:它把“切分问题”和“排序问题”彻底分开了。很多团队一上来就优化排序,但如果答案块压根没进候选集,排序优化是零收益。先看 Hit@k,能帮你省下大量无效工作。
5.3 跑批的组织方式:先粗筛再精调,别一上来就跑全网格
参数空间看着不大,但组合起来很吓人:4 种 chunk size × 3 种 overlap × 4 种切分策略 = 48 组,每组都要重新嵌入全量文档,如果文档量大,成本会很可观。我的做法是分两阶段。
第一阶段粗筛:只取 50~100 篇有代表性的文档(覆盖所有文档类型),chunk size 只跑 {192, 384, 768},overlap 只跑 {0, 15%},策略只跑 {固定递归, 结构切分},一共 12 组。这一阶段的目标不是找到最优解,而是快速排除明显差的组合。实测下来,12 组跑完通常能砍掉一半。
第二阶段精调:在粗筛胜出的 2~3 个组合附近做细粒度扫描,比如 chunk size 在 256~512 之间步长 64,overlap 在 5%~20% 之间步长 5%。这一阶段文档用全量,指标加测 MRR 和答案正确率。
跑批时有个工程细节要注意:固定随机种子、固定 top-k、固定嵌入模型版本。我见过一次因为嵌入模型悄悄升级了小版本,导致前后两批结果不可比,白跑了两天。还有一点,评测要跑两遍看波动,尤其是答案正确率这种依赖模型生成的指标,单次结果的偶然性不小,两遍差异超过 5 个百分点就说明样本量不够或者评测方式不稳定。
5.4 结果解读:什么时候该停手换策略,而不是继续调参
拿到一堆数字之后,最容易犯的错是盯着最高分那组死磕,继续微调参数。我的经验是设三条止损线。
第一条:如果最优组合的 Hit@5 仍然低于 0.8,别再调参数了,问题大概率在文档解析或者嵌入模型不匹配上。把出错的样本捞出来看原文,很多时候你会发现原文里那段话在解析阶段就已经坏了。
第二条:如果不同 chunk size 之间的指标差异小于 3 个百分点,说明这是一个平台区,参数已经不敏感了,继续调是浪费时间,应该转向别的优化方向(比如加 rerank、加元数据过滤、改混合检索)。
第三条:如果答案正确率明显低于 Hit@5 对应的预期(比如 Hit@5 是 0.9 但正确率只有 0.6),先检查上下文是否被切碎。具体做法是把参考块人工拼成完整段落再跑一遍,如果正确率一下子上到 0.85,那答案很明确——上“小块检索 + 父块生成”。
这套流程我自己跑过几轮,最大的价值不是找到某个“最佳参数”,而是把讨论从“我觉得应该切成 500 字”变成“数据说 384 字配 15% overlap 在这个文档集上 Hit@5 高 6 个点”。团队里再有人提“要不要换个模型”,你可以直接甩指标表。
6. 工程落地里几个容易被忽略的细节
6.1 表格、代码、公式这三类内容,必须走特殊通道
前面提过表格不能按字符切,这里展开说一下具体处理。表格的难点在于列头是隐式上下文,一个单元格脱离列头就没有意义。我的做法分两种:小表格(行数 ≤ 15)整表保留,并且把表头拼成一段描述放在前面;大表格按行分组,每组强制带上表头。
def table_row_to_text(headers, row): """把表格行转成自然语言,便于嵌入和生成""" pairs = [f"{h}:{v}" for h, v in zip(headers, row) if v.strip()] return ";".join(pairs)转成“参数名:值;取值范围:……;默认值:……”这样的句子之后,检索命中率会明显好于原始的竖线表格。代价是 token 数变多,所以只对参数对照类表格这么做,纯展示类表格(比如图片说明表)可以整表保留。
代码的处理原则是按语法结构切,函数、类、方法作为最小单位,并且每个块都要带上文件顶部的 import 和模块说明。这一点在 API 文档场景特别重要——一个函数块如果没有 import,模型生成调用示例时经常瞎编包名。公式的处理类似,公式本身要保留,同时把上下文里的符号说明带上,否则召回一个孤立的公式块毫无用处。
6.2 标题路径注入:一个改动小、收益大的动作
这个前面提过,单独再说一下实现细节。标题路径注入有几种做法,效果和成本不一样。
第一种是只用于嵌入:把“标题路径 + 正文”送去算向量,但返回给模型的是纯正文。这样不增加生成端的 token 消耗,又能让向量带上层级语义。这是我目前最推荐的,尤其在文档层级深、专业术语重复率高的场景。
第二种是嵌入和返回都带路径:好处是模型能知道这段话的出处,便于引用;坏处是每个块多几十个 token。
第三种是只存进 metadata 不做嵌入:用于过滤和展示,对召回没有直接帮助。
实测下来,第一种做法的性价比最高。在一个层级很深的规范文档集上,加上标题路径之后 Hit@5 提升了大概 8 个百分点,原因是很多术语在不同章节里含义不同,纯正文的向量区分不开,加上章节路径之后就区分开了。
6.3 重新切分等于重建索引,迁移方案要提前想好
这一点是运维层面的,但踩过的人不少。切分参数一改,所有块的 id、边界、数量都变了,旧的向量索引基本作废,必须全量重建。如果你是在线服务,重建期间怎么保证可用,要提前设计。
我的做法是双索引 + 别名切换:新索引写到index_v2,全量构建并验证通过之后,把查询别名从index_v1切到index_v2,保留index_v1一段时间以便回滚。这样整个过程对调用方是透明的。
还有个细节是块 id 的生成规则。如果 id 里带上了块的序号(比如chunk_0001),那重新切分之后同一个内容的 id 就变了,所有引用这些 id 的下游数据(比如用户收藏、反馈记录)都会失效。更稳的做法是用“文档 id + 内容哈希”作为块 id,内容不变则 id 不变,重新切分时只有内容真的变了的块才会换 id。
另外提醒一句:重新切分之后一定要重跑一遍评测。切分策略的改动对指标的影响有时候是反直觉的,我遇到过一次“看起来更合理的切分”让 Hit@5 掉了 5 个点,原因是新切分把小节标题单独切成了很短的块,这些块在向量空间里到处乱撞,挤占了 top-k 名额。后来加了“短块合并到下一块”的规则才修好。
最后分享一个我自己反复验证过的体会:切分这件事,优化顺序比优化幅度重要得多。先确认嵌入没有静默截断,再确认标题信息没丢,再定 chunk size 的合理区间,最后才轮到语义切分这种“高级玩法”。我见过太多项目直接从语义切分开始做,绕了一大圈又回到“按段落切 + 标题注入”,效果反而更好、成本还更低。真正决定 RAG 上限的往往不是那个最花哨的切分算法,而是你有没有把边界、层级和上下文这三件事老老实实地处理好。