做 RAG 时,很多人第一次感到挫败是在这里:向量库明明已经有了文档,提问也用了文档里的原话,检索却命中了不相干的段落;或者命中了正确章节,却恰好漏掉一句决定性例外。于是开始换 embedding 模型、调提示词、加更大的 LLM。可这些动作经常绕开了真正的变量——切分决定了向量化的基本单位,也决定了“一个结果”究竟代表什么。
切分不是把一篇文章每 500 个字符砍一刀。它是在两个损失之间取舍:块太短,句子失去主语、条件和上下文;块太长,多个主题被压成一个向量,检索得到了整段却难以精确回答。重叠也不是越多越保险:它能保护跨边界的语义,却会制造近重复候选、放大索引体积,并让同一章节霸占前几名。
本文不提供“万能 512”的口诀,而是搭一套可重复的小型实验方法。用自己的问题集和文档,比较固定长度、递归分隔、标题感知和语义边界四种方案;关注的指标不仅是命中率,还有上下文完整性、重复率、延迟和人工可读性。跑出结果后,你才能知道该调大小、调 overlap,还是首先修复文档解析。
一、切分影响的是整个检索链路
RAG 的常见链路可以抽象为:文档被切成块,每块产生 embedding,问题也产生 embedding,向量检索返回最近的若干块。真正进入模型上下文的是这些块,而不是原始文件。换句话说,切分的边界就是模型可被要求引用的最小证据边界。
如果一条制度写着“市内交通可凭票报销;超过 500 元需附审批单”,固定窗口刚好在“超过 500”处断开,第二块可能只剩“元需附审批单”。它在向量空间中几乎没有“交通报销”的语义;第一块又缺失审批条件。即便后续生成模型很聪明,也没有完整的事实输入。反过来,把整份十页制度作为一个块,问“住宿上限”时向量只表示整份文件的平均主题,细节会被淹没。
所以切分目标不是让块大小看起来整齐,而是尽可能让每个块成为自洽的、可被独立引用的证据。一个可读的 chunk 通常回答得出三个问题:它讲的主题是什么?适用条件是什么?原文来自哪里?第三项依赖 metadata,第一、二项依赖边界。
二、先明确实验对象,避免把随机性当改进
实验前先固定三样东西:文档快照、评测问题和 embedding 模型。不要今天跑 A 用的是上周的文件,明天跑 B 重新抓了网页;也不要一边修改问题一边比较参数。每个策略对同一份资料、同一批问题生成独立索引,查询时用同一k。如果调用远端 embedding,还要记录模型名和请求日期,因为服务端模型可能升级。
问题集不需要一开始就很大,但要有代表性。一个实用的起点是 40 到 80 个真实问题,按类别标注:精确事实(“住宿上限是多少”)、条件/例外(“哪些情况可以不附发票”)、流程顺序(“提交申请后谁审批”)、跨段归纳(“出差前后各需要做什么”)和无答案(“公司是否报销家属行程”)。每题至少标注一个“金标准证据位置”,可以是文件名加标题或段落 ID;它不是模型答案,而是人工确认的依据。
千万别只记录“答得好不好”。切分首先影响检索,应该先看 evidence 是否被带回。以下是足够用的指标:
- Recall@k:正确证据是否出现在前 k 个候选中。
- MRR:正确证据排第几位,第一名给 1,第二名给 1/2。
- 覆盖率:候选文本是否同时覆盖答案所需的条件和结论。
- 重复率:前 k 个块有多少来自同一位置或高度相似。
- 上下文预算:拼接 k 个块后实际占用多少 token 或字符。
Recall@k 高不等于生成一定正确,但 Recall@k 低时,不要优先怀疑大模型。它连证据都没看到,再好的提示词也只能逼它更优雅地猜。
三、四种常见策略,分别在哪些地方会失败
1. 固定字符窗口
最直观的做法是每 800 字切一块,向后重叠 100 字。优点是稳定、实现短,对没有结构的日志、转写稿或 OCR 文本有用。缺点同样直接:它不知道标题、句子和表格边界,容易在关键处截断。固定窗口不是不能用,关键是别把它当作所有格式的默认答案。
2. 递归分隔切分
递归策略按“章节标题 → 空行 → 换行 → 句号 → 字符”的优先级尝试分割,尽量保留自然段。它是通用文档最值得先试的基线,因为实现成本低,通常比硬切可读。它仍然不了解语义:一个很长的列表、一个跨很多段的流程说明、一个带有例外条款的章节仍可能被拆坏。
3. 标题感知切分
对于 Markdown、HTML、结构规范的 Word 转换文本,标题是廉价且很有价值的结构信号。每个块带上祖先标题,例如“第三章 报销标准 > 住宿费 > 一线城市”,即使正文中不重复写“一线城市”,embedding 也能理解范围。标题感知不表示每个标题下都只做一块;长章节仍需要在段落级继续切分。
4. 语义边界切分
语义切分先按句子分段,再计算相邻句子或小窗口的 embedding 相似度;当相似度显著下降时切开。它特别适合会议纪要、教程、混合主题的长文,因为它不依赖文件是否有漂亮标题。代价是需要额外 embedding,阈值也会受文档类型影响。把它用在每份短制度上,未必比标题感知更值;用在十万份文档上,还要考虑离线成本。
四、从零写一个可检查的递归切分器
下面代码不用框架,便于看清变量。它将文本按优先分隔符拆成单位,再尽可能贪心地装进目标窗口;块尾会保留 overlap,但 overlap 应当是完整尾部文字,而不是强制拿到一个句子中间。示例用字符计数,便于运行;生产中可替换为目标 embedding tokenizer 的计数函数。
# chunking.pyfrom__future__importannotationsimportrefromdataclassesimportdataclass@dataclass(frozen=True)classChunk:text:strstart:intend:intdefsplit_units(text:str)->list[str]:"""先保留段落;长段落再按句末符号分为最小单元。"""paragraphs=[x.strip()forxinre.split(r"\n\s*\n+",text)ifx.strip()]units:list[str]=[]forparagraphinparagraphs:iflen(paragraph)<=300:units.append(paragraph)else:units.extend(x.strip()forxinre.split(r"(?<=[。!?;])",paragraph)ifx.strip())returnunitsdefrecursive_chunks(text:str,max_chars:int=800,overlap:int=100)->list[Chunk]:ifmax_chars<=0oroverlap<0oroverlap>=max_chars:raiseValueError("max_chars 必须大于 0,overlap 必须在 [0, max_chars) 内")units=split_units(text)chunks:list[Chunk]=[]buf=""cursor=0forunitinunits:unit=unit[:max_chars]iflen(unit)>max_charselseunit candidate=f"{buf}\n\n{unit}".strip()ifbufelseunitiflen(candidate)<=max_chars:buf=candidatecontinuestart=text.find(buf,cursor)ifstart<0:# 处理原文出现重复段落的情况start=cursor chunks.append(Chunk(buf,start,start+len(buf)))cursor=start+len(buf)tail=buf[-overlap:]ifoverlapelse""buf=f"{tail}\n\n{unit}".strip()ifbuf:start=text.find(buf,cursor)ifstart<0:start=max(0,len(text)-len(buf))chunks.append(Chunk(buf,start,start+len(buf)))returnchunksif__name__=="__main__":demo="范围:本制度适用于正式员工。\n\n住宿费超过上限须先审批。\n\n发票应在十日内提交。"result=recursive_chunks(demo,max_chars=26,overlap=5)assertlen(result)>=2assertall(0<len(x.text)<=26forxinresult)print(*result,sep="\n")这段代码也暴露了一个常见误区:只靠 overlap 不能修复坏边界。若第一个块塞了“适用范围、住宿、机票、餐费”四个主题,重叠 200 个字符只是让重复更多,不会使向量更聚焦。应当优先选择能识别章节/段落的边界,再决定是否需要 overlap。
五、标题要进入块,而不是只留在原文件里
人阅读目录时天然借助标题理解下方文字,向量检索并不会自动继承这个上下文。把标题前缀写到 chunk 的 embedding 文本中,是成本很低的改进。注意保存时应分开content与embedding_text:前者给用户显示原文,后者包含标题上下文。否则用户看到的引用片段会反复出现冗长标题,阅读体验变差。
以下例子面向 Markdown。它不试图写完整 Markdown 解析器,只处理一到六级 ATX 标题;遇到表格、代码块和 HTML,建议由专门解析器在预处理阶段转成带结构的节点。最小方案的价值是明确边界,而不是假装任何格式都能无损理解。
# markdown_sections.pyfrom__future__importannotationsimportrefromdataclassesimportdataclass HEADING=re.compile(r"^(#{1,6})\s+(.+?)\s*#*\s*$")@dataclass(frozen=True)classSection:path:tuple[str,...]body:strdefmarkdown_sections(text:str)->list[Section]:stack:list[str]=[]level_stack:list[int]=[]body:list[str]=[]result:list[Section]=[]defflush()->None:content="\n".join(body).strip()ifcontent:result.append(Section(tuple(stack),content))forlineintext.splitlines():match=HEADING.match(line)ifnotmatch:body.append(line)continueflush()body.clear()level,title=len(match.group(1)),match.group(2)whilelevel_stackandlevel_stack[-1]>=level:level_stack.pop()stack.pop()level_stack.append(level)stack.append(title)flush()returnresultdefembedding_text(section:Section)->str:prefix=" > ".join(section.path)returnf"章节:{prefix}\n内容:{section.body}"ifprefixelsesection.bodyif__name__=="__main__":sample="# 报销\n总则\n## 住宿\n上限为 500 元。"sections=markdown_sections(sample)assertsections[1].path==("报销","住宿")assert"章节:报销 > 住宿"inembedding_text(sections[1])标题路径还可以写入 metadata,用于过滤和解释。比如用户问“差旅制度里的住宿”,先检索doc_type=policy,然后让界面显示“第三章 > 住宿费”。不要把文件名当标题的替代品;v3_final_最终版.docx对人和模型都没有什么语义。
六、Overlap 到底该设多少
overlap 的功能是给相邻块提供共同上下文。它适合保护跨边界的因果、指代和列表项,例如“下列情形除外”在块末,“1. 紧急出差”在下一块开头;没有重叠时,任一块都不完整。但它也有明显成本:索引记录增多,向量计算与存储增加;相邻结果高度相似,Top-k 被同一段文本占满;传给 LLM 的上下文重复,浪费 token。
经验上不必从很大的重叠开始。对结构良好的制度文本,可以先试目标块长度的 10% 到 20%,再比较 0%、10%、20% 三组,而不是在 50 到 300 间逐个盲调。对问答对、日志等原本单元很短的文本,应该优先让一个自然记录成为一个块,overlap 反而会混入相邻记录。对合同条款,可按条号切分并把相邻条号作为可选扩展上下文,往往比全局字符 overlap 可解释。
一种实用的判断方式是观察 Top-5 的“源位置多样性”。若前五条全来自同一章节的相邻窗口,用户实际只获得了一个证据区域;这时可以在检索后按(source, section)做去重或提高候选池再重排,而不是无止境增大k。去重不是否定 overlap,而是承认 overlap 带来的重复必须在后续处理。
七、怎样进行一场不会自欺欺人的对比实验
下面脚本只演示指标计算。假设每种策略已经写出一份results-策略名.jsonl,每行包含问题 ID 和按排名返回的证据 ID;gold.jsonl每行包含id与正确证据 ID 列表。把检索过程与指标过程分开,能避免实验脚本悄悄换了查询参数。
# score_chunks.pyfrom__future__importannotationsimportjsonfrompathlibimportPathdefload_jsonl(path:str)->dict[str,dict]:return{row["id"]:rowforrowinmap(json.loads,Path(path).read_text(encoding="utf-8").splitlines())}defscore(gold_path:str,result_path:str,k:int=5)->tuple[float,float]:gold,result=load_jsonl(gold_path),load_jsonl(result_path)recall_total=reciprocal_total=0.0foritem_id,expectedingold.items():ranked=result.get(item_id,{}).get("evidence_ids",[])[:k]targets=set(expected["evidence_ids"])positions=[ifori,doc_idinenumerate(ranked,1)ifdoc_idintargets]recall_total+=bool(positions)reciprocal_total+=1/positions[0]ifpositionselse0n=len(gold)returnrecall_total/n,reciprocal_total/nif__name__=="__main__":recall,mrr=score("gold.jsonl","results-heading.jsonl")assert0<=recall<=1and0<=mrr<=1print(f"Recall@5={recall:.3f}, MRR@5={mrr:.3f}")报告实验结果时至少记录:策略名称、最大块大小、overlap、实际块数、平均/中位块长度、embedding 模型、检索k、问题集版本、Recall@k、MRR、失败样本。不要捏造一个漂亮的百分比,也不要把在十个问题上的变化表述为通用结论。特别是中文里“字符数”与 token 数并不等价,跨模型比较时必须说明计数单位。
人工抽查不可省略。随机挑选 10 个检索命中和 10 个未命中,阅读原文边界:块是否从表格中间开始?是否丢了否定词?是否把标题、页脚、免责声明反复混入?如果解析阶段把每页页眉“公司机密”当正文,任何精妙切分都只是在更快地检索噪声。
八、表格、代码、PDF 的特别处理
表格不是连续散文。把两列多行表格线性拼成“住宿 一线 500 二线 400”后,检索能否理解取决于解析质量;若行列关系丢失,模型很容易把数字配错城市。较可靠的办法是把每一行规范成一句带表头的事实,例如“报销标准:城市等级=一线;项目=住宿;上限=500 元/晚”,同时保存页码、表名和行号。行数很少的表可作为整体块,行数很大的表按行或逻辑分组切分。
代码也不应该按普通字符窗口乱断。至少按函数、类或 Markdown 代码块边界保留;如需切很长函数,要在块内附文件路径、语言和函数名。对于 API 文档,把请求参数、响应字段、错误码分为不同块时,要保证每个块都带端点和版本,否则“timeout”这一通用词会把不同接口的内容混在一起。
PDF 更棘手。文本型 PDF 要去掉重复页眉页脚、页码和目录;扫描 PDF 需要 OCR,而 OCR 的数字、单位、否定词都要抽样复核;双栏排版可能让抽取顺序交错。切分实验若没有记录解析器和文档版本,结论没有复现意义。很多“换切分策略提升”其实是换了 PDF 提取器带来的改善。
九、从失败样本反推该改什么
当具体问题没有命中时,先按以下顺序排查:原文里是否真的有答案;解析后的文本是否保留了关键句;金标准证据是否被切进了某个块;该块是否写入索引且 metadata 权限允许;embedding 对问题和块是否表达相近;正确块是否进入候选池但排名靠后。每一层都能用日志或离线脚本验证,别一上来就调生成提示词。
若正确块根本没有进 Top-20,可能是切分不完整、标题上下文缺失、查询表达不匹配或 embedding 不适合领域语料。若它进了 Top-20 却不在 Top-5,才考虑调k、做多路召回或引入 rerank。若正确块在前几名但回答仍错,则看上下文是否过长、指令是否约束引用、生成模型是否把多条证据混淆。这种分层诊断比“模型不够聪明”更节省时间。
还应留意“过度切分”的假阳性:把一个答案拆成两块时,评测如果只标了其中一块,可能显示召回成功,但生成阶段仍没有完整条件。金标准最好允许一个证据集合,或为跨段问题额外标记“需同时命中 A+B”。切分的评测对象不是孤立向量相似度,而是一个可支持回答的证据包。
十、从字符数走向 token:为什么同一个“800”并不等价
示例为了透明使用字符数,但模型真正受限的是 token。中文、英文、数字、代码、URL 在不同 tokenizer 下占用的 token 并不相同;一个 800 字符的中文制度片段和一个 800 字符的 JSON 配置片段,可能产生完全不同的 embedding 输入长度和生成上下文成本。模型还有最大序列长度,超过部分有时会被静默截断,这会造成“看起来写入成功,关键条款实际没参与向量化”的隐蔽故障。
因此,当你已经选定 embedding 模型时,应在离线构建阶段用它对应的 tokenizer 统计长度,并记录截断数量。不要把每块硬凑到最大上限:给标题前缀、特殊 token 和未来的 metadata 留余量。对于生成阶段,还要计算top_n × 平均块长度 + 系统提示 + 用户问题是否稳定落在模型上下文预算内。上下文窗口很大也不代表应该塞满,长而重复的证据会增加注意力分散和成本。
可以把“字符窗口”当作第一轮工程基线,但报告中要清楚说明它是字符而非 token。等基线确定后,再替换长度函数并重新跑同一评测集;不要把两种计数方式的结果直接放在一张表里比较。你真正关心的是证据完整性和检索表现,而不是某个看起来整齐的数字。
十一、语义切分的可控做法:先找候选边界,再做审计
语义切分听起来像让模型“理解文章后自动分段”,实际上也需要可复核的过程。一个朴素实现是先按句号、空行或标题得到原子单元,再将相邻若干句组成窗口,比较前后窗口的 embedding 相似度;相似度明显下降的位置被视为候选边界。不要直接对每个字或每个 token 求向量,那会带来高成本和噪声;先使用人类可读的最小单元,边界才能被人工检查。
阈值选择不能凭感觉。可以先在十几份代表性文档上把相似度变化画出来,人工标注明显换题的位置,观察它们的分布。阈值太高会产生大量小块,阈值太低又几乎不切;不同类型文档的阈值很可能不同。合同、技术手册和会议纪要的主题转折密度并不一样,强行一个全局阈值未必比标题切分好。
语义切分还要有硬边界:任何块不能超过模型输入上限,也不应小到只剩一句“见附件”;每个块应至少携带来源和标题路径;在标题下发生语义变化时可以切,但不应跨越不同权限、不同版本或不同表格行把文本拼在一起。语义模型只是提出边界的工具,文档结构和权限仍是更高优先级的约束。
下面给出不依赖具体 embedding 服务的伪实现骨架,重点是记录边界理由。实际接入时用你的 embedding 函数替换similarity,并把每次切分的版本写入 manifest:
fromitertoolsimportpairwisedefsemantic_boundaries(units:list[str],similarities:list[float],threshold:float)->list[int]:"""similarities[i] 表示 units[i] 与 units[i+1] 的相似度。"""iflen(similarities)!=max(0,len(units)-1):raiseValueError("相似度数量应比单元数少 1")cuts=[0]forindex,scoreinenumerate(similarities,1):ifscore<threshold:cuts.append(index)cuts.append(len(units))returncutsassertsemantic_boundaries(["甲","乙","丙"],[0.9,0.2],0.5)==[0,2,3]这个骨架特意不输出“语义分数一定正确”的结论。相似度只是模型空间中的距离,真正的质量仍要通过命中问题和人工抽查判断。若你不能说明一块为什么从这里开始、原文在哪、使用了哪个模型和阈值,未来就很难复现一次线上异常。
十二、检索前的去噪比优化 chunk 更划算
有些资料不应直接进入切分器。目录、封面、页眉页脚、修订记录、导航栏、版权声明和重复附录会形成大量高频噪声。它们常常与用户问题存在通用词重合,导致“公司名称、适用范围、联系方式”占据召回结果。先移除这些稳定噪声,再做任何 chunk 参数对比,结果通常更可信。
去噪不要走到删除有效信息的另一个极端。法律声明里的适用范围、版本页脚中的生效日期、表格脚注里的例外,可能正是答案的关键。安全的做法是对每类模板建立可审查的规则:例如只有当每页完全重复的字符串才视为页眉;只有文档开头连续出现且不含正文标题的目录项才删除;去除规则应用前后保留样本并做差异检查。不要用一个宽泛正则把所有带“注”的行删掉。
图片 OCR 结果也要标记来源质量。数字、表格、章号错误会直接污染分块。若文档里“500 元”被识别为“5000 元”,向量检索甚至可能更容易把它召回,随后生成模型会非常自信地复述错误。对包含金额、日期、型号、阈值的扫描件,应提供原图页码给复核者;高风险字段可以采用规则校验或人工抽样,不该只凭 OCR 文本通过。
十三、版本演进时,如何避免评测随手失效
切分策略一旦发布,文档和问题集也会继续变化。为了比较前后版本,应给每次实验一个可重复的标签:语料快照哈希、解析器版本、切分器版本、参数、embedding 模型、索引构建日期、查询模型与评测集版本。保存的不是一张截图,而是一份足够重新运行的配置。否则三个月后看到 Recall 下降,你无法判断是策略退化、资料更新,还是金标准路径已经变了。
评测题目也需要维护。制度修订后,旧问题的正确来源可能被撤销;产品功能下线后,正确的行为可能是拒答;新出现的缩写应加入真实用户表达。更新题目时保留变更原因与审核人,不要把旧结果覆盖掉。这样才能区分“系统对新知识更好”与“测试集变容易了”。
上线后可以从匿名化反馈中挑选候选问题进入评测集,但必须由业务人员确认期望证据,不能把用户点赞或模型答案直接当标签。高频无命中问题尤其值得审查:它可能提示切分问题,也可能暴露文档根本没有写清楚。后者是知识治理任务,不应该被调参掩盖。
十四、一个务实的落地顺序
如果要把这些原则变成一次实际迭代,可以按最短路径推进。第一天,不改模型,只抽取十份有代表性的文件,确认解析文本没有页眉、乱码和表格错序;选标题加段落的递归策略,保留 source、页码、标题路径和原始偏移量。第二天,找业务同事写出一批带金标准来源的问题,先跑出当前 Recall@5 与失败列表。第三天,只比较三四组有意义的长度和 overlap,不同时更换 embedding、重排和 prompt。这样每一轮结果都能解释。
观察失败后再分流:如果关键句经常被截断,就缩小块或改自然边界;如果同一主题因标题缺失而散落,补结构解析;如果正确块在 top-20 却排不进 top-5,再评估 rerank;如果文档根本没有答案,则交给内容负责人补充,而不是提高 chunk 数量。每个选择都由一个失败模式驱动,避免陷入“看到一个参数就试一次”的无效劳动。
最后保留一份面向维护者的样例报告。报告不必很漂亮,但要能回答:本次语料是什么、块数增长多少、哪些问题改善、哪些问题退化、是否超过延迟或成本预算、是否需要回滚。切分是离线任务,最适合先在影子索引中验证;确认结果后再切换线上有效索引,而不是直接重建生产集合。这个流程比一次性追求所谓最佳算法慢一点,却能把每一次改动变成可回退的知识。
十五、别忽略用户看到的片段质量
检索结果最终常会展示在答案下方,因此 chunk 还是一种界面数据结构。一个只包含“如下”“除外”“详见附件”的片段,即使向量距离很近,用户也无法独立判断。抽样时应把候选直接放到阅读界面看:标题是否足够、前后句是否自然、是否含有大量无意义重叠。必要时在展示层向前后扩展少量原文,但扩展内容不能反过来参与生成,否则评测时要重新计入证据范围。
这个区分很实用:索引块追求稳定检索与成本,展示片段追求可读复核。二者可以共享定位信息,却不必强迫同一段文本同时完成两件彼此冲突的事。
当展示层扩展了原文,还应清楚标识“检索命中片段”和“相邻阅读上下文”。前者是系统实际用来排序的证据,后者只是帮助用户理解。这个小小的区分能减少排查时的误解:用户看到完整段落,并不表示模型在回答时一定看到了全部段落。
十六、小结
Chunk Size、Overlap 和语义切分没有脱离语料的标准答案。稳妥的做法是:先用标题/段落感知的递归切分建立可读基线;让标题路径进入 embedding 文本;以小范围参数组合比较同一问题集;用 Recall@k、MRR、重复率和人工边界抽查共同判断;从失败样本定位到解析、切分、检索或生成的具体层。
如果你的文档短、结构好、问题主要问单条规则,朴素的标题加段落切分往往已经够用。只有当数据表明自然边界缺失、主题频繁切换或长文本召回受损时,再投入语义切分的 embedding 成本。先量出瓶颈,再增加复杂度,才不会把 RAG 调参变成一场没有终点的玄学。
参考资料
- LangChain Text Splitters 文档
- Chroma:Adding Data to Collections
- Chroma:Introduction to Retrieval
- Sentence Transformers 文档
- Unstructured:Chunking