ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

文档切分决定 RAG 上限:Chunk Size、Overlap 与语义切分实测方法

文档切分决定 RAG 上限:Chunk Size、Overlap 与语义切分实测方法 做 RAG 时很多人第一次感到挫败是在这里向量库明明已经有了文档提问也用了文档里的原话检索却命中了不相干的段落或者命中了正确章节却恰好漏掉一句决定性例外。于是开始换 embedding 模型、调提示词、加更大的 LLM。可这些动作经常绕开了真正的变量——切分决定了向量化的基本单位也决定了“一个结果”究竟代表什么。切分不是把一篇文章每 500 个字符砍一刀。它是在两个损失之间取舍块太短句子失去主语、条件和上下文块太长多个主题被压成一个向量检索得到了整段却难以精确回答。重叠也不是越多越保险它能保护跨边界的语义却会制造近重复候选、放大索引体积并让同一章节霸占前几名。本文不提供“万能 512”的口诀而是搭一套可重复的小型实验方法。用自己的问题集和文档比较固定长度、递归分隔、标题感知和语义边界四种方案关注的指标不仅是命中率还有上下文完整性、重复率、延迟和人工可读性。跑出结果后你才能知道该调大小、调 overlap还是首先修复文档解析。一、切分影响的是整个检索链路RAG 的常见链路可以抽象为文档被切成块每块产生 embedding问题也产生 embedding向量检索返回最近的若干块。真正进入模型上下文的是这些块而不是原始文件。换句话说切分的边界就是模型可被要求引用的最小证据边界。原始文档解析: 标题/段落/表格切分策略Chunk metadataEmbedding向量索引问题问题向量Top-k 候选块重排或生成如果一条制度写着“市内交通可凭票报销超过 500 元需附审批单”固定窗口刚好在“超过 500”处断开第二块可能只剩“元需附审批单”。它在向量空间中几乎没有“交通报销”的语义第一块又缺失审批条件。即便后续生成模型很聪明也没有完整的事实输入。反过来把整份十页制度作为一个块问“住宿上限”时向量只表示整份文件的平均主题细节会被淹没。所以切分目标不是让块大小看起来整齐而是尽可能让每个块成为自洽的、可被独立引用的证据。一个可读的 chunk 通常回答得出三个问题它讲的主题是什么适用条件是什么原文来自哪里第三项依赖 metadata第一、二项依赖边界。二、先明确实验对象避免把随机性当改进实验前先固定三样东西文档快照、评测问题和 embedding 模型。不要今天跑 A 用的是上周的文件明天跑 B 重新抓了网页也不要一边修改问题一边比较参数。每个策略对同一份资料、同一批问题生成独立索引查询时用同一k。如果调用远端 embedding还要记录模型名和请求日期因为服务端模型可能升级。问题集不需要一开始就很大但要有代表性。一个实用的起点是 40 到 80 个真实问题按类别标注精确事实“住宿上限是多少”、条件/例外“哪些情况可以不附发票”、流程顺序“提交申请后谁审批”、跨段归纳“出差前后各需要做什么”和无答案“公司是否报销家属行程”。每题至少标注一个“金标准证据位置”可以是文件名加标题或段落 ID它不是模型答案而是人工确认的依据。千万别只记录“答得好不好”。切分首先影响检索应该先看 evidence 是否被带回。以下是足够用的指标Recallk正确证据是否出现在前 k 个候选中。MRR正确证据排第几位第一名给 1第二名给 1/2。覆盖率候选文本是否同时覆盖答案所需的条件和结论。重复率前 k 个块有多少来自同一位置或高度相似。上下文预算拼接 k 个块后实际占用多少 token 或字符。Recallk 高不等于生成一定正确但 Recallk 低时不要优先怀疑大模型。它连证据都没看到再好的提示词也只能逼它更优雅地猜。三、四种常见策略分别在哪些地方会失败1. 固定字符窗口最直观的做法是每 800 字切一块向后重叠 100 字。优点是稳定、实现短对没有结构的日志、转写稿或 OCR 文本有用。缺点同样直接它不知道标题、句子和表格边界容易在关键处截断。固定窗口不是不能用关键是别把它当作所有格式的默认答案。2. 递归分隔切分递归策略按“章节标题 → 空行 → 换行 → 句号 → 字符”的优先级尝试分割尽量保留自然段。它是通用文档最值得先试的基线因为实现成本低通常比硬切可读。它仍然不了解语义一个很长的列表、一个跨很多段的流程说明、一个带有例外条款的章节仍可能被拆坏。3. 标题感知切分对于 Markdown、HTML、结构规范的 Word 转换文本标题是廉价且很有价值的结构信号。每个块带上祖先标题例如“第三章 报销标准 住宿费 一线城市”即使正文中不重复写“一线城市”embedding 也能理解范围。标题感知不表示每个标题下都只做一块长章节仍需要在段落级继续切分。4. 语义边界切分语义切分先按句子分段再计算相邻句子或小窗口的 embedding 相似度当相似度显著下降时切开。它特别适合会议纪要、教程、混合主题的长文因为它不依赖文件是否有漂亮标题。代价是需要额外 embedding阈值也会受文档类型影响。把它用在每份短制度上未必比标题感知更值用在十万份文档上还要考虑离线成本。四、从零写一个可检查的递归切分器下面代码不用框架便于看清变量。它将文本按优先分隔符拆成单位再尽可能贪心地装进目标窗口块尾会保留 overlap但 overlap 应当是完整尾部文字而不是强制拿到一个句子中间。示例用字符计数便于运行生产中可替换为目标 embedding tokenizer 的计数函数。# chunking.pyfrom__future__importannotationsimportrefromdataclassesimportdataclassdataclass(frozenTrue)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:int800,overlap:int100)-list[Chunk]:ifmax_chars0oroverlap0oroverlapmax_chars:raiseValueError(max_chars 必须大于 0overlap 必须在 [0, max_chars) 内)unitssplit_units(text)chunks:list[Chunk][]bufcursor0forunitinunits:unitunit[:max_chars]iflen(unit)max_charselseunit candidatef{buf}\n\n{unit}.strip()ifbufelseunitiflen(candidate)max_chars:bufcandidatecontinuestarttext.find(buf,cursor)ifstart0:# 处理原文出现重复段落的情况startcursor chunks.append(Chunk(buf,start,startlen(buf)))cursorstartlen(buf)tailbuf[-overlap:]ifoverlapelsebuff{tail}\n\n{unit}.strip()ifbuf:starttext.find(buf,cursor)ifstart0:startmax(0,len(text)-len(buf))chunks.append(Chunk(buf,start,startlen(buf)))returnchunksif__name____main__:demo范围本制度适用于正式员工。\n\n住宿费超过上限须先审批。\n\n发票应在十日内提交。resultrecursive_chunks(demo,max_chars26,overlap5)assertlen(result)2assertall(0len(x.text)26forxinresult)print(*result,sep\n)这段代码也暴露了一个常见误区只靠 overlap 不能修复坏边界。若第一个块塞了“适用范围、住宿、机票、餐费”四个主题重叠 200 个字符只是让重复更多不会使向量更聚焦。应当优先选择能识别章节/段落的边界再决定是否需要 overlap。五、标题要进入块而不是只留在原文件里人阅读目录时天然借助标题理解下方文字向量检索并不会自动继承这个上下文。把标题前缀写到 chunk 的 embedding 文本中是成本很低的改进。注意保存时应分开content与embedding_text前者给用户显示原文后者包含标题上下文。否则用户看到的引用片段会反复出现冗长标题阅读体验变差。以下例子面向 Markdown。它不试图写完整 Markdown 解析器只处理一到六级 ATX 标题遇到表格、代码块和 HTML建议由专门解析器在预处理阶段转成带结构的节点。最小方案的价值是明确边界而不是假装任何格式都能无损理解。# markdown_sections.pyfrom__future__importannotationsimportrefromdataclassesimportdataclass HEADINGre.compile(r^(#{1,6})\s(.?)\s*#*\s*$)dataclass(frozenTrue)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():matchHEADING.match(line)ifnotmatch:body.append(line)continueflush()body.clear()level,titlelen(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 元。sectionsmarkdown_sections(sample)assertsections[1].path(报销,住宿)assert章节报销 住宿inembedding_text(sections[1])标题路径还可以写入 metadata用于过滤和解释。比如用户问“差旅制度里的住宿”先检索doc_typepolicy然后让界面显示“第三章 住宿费”。不要把文件名当标题的替代品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 和按排名返回的证据 IDgold.jsonl每行包含id与正确证据 ID 列表。把检索过程与指标过程分开能避免实验脚本悄悄换了查询参数。# score_chunks.pyfrom__future__importannotationsimportjsonfrompathlibimportPathdefload_jsonl(path:str)-dict[str,dict]:return{row[id]:rowforrowinmap(json.loads,Path(path).read_text(encodingutf-8).splitlines())}defscore(gold_path:str,result_path:str,k:int5)-tuple[float,float]:gold,resultload_jsonl(gold_path),load_jsonl(result_path)recall_totalreciprocal_total0.0foritem_id,expectedingold.items():rankedresult.get(item_id,{}).get(evidence_ids,[])[:k]targetsset(expected[evidence_ids])positions[ifori,doc_idinenumerate(ranked,1)ifdoc_idintargets]recall_totalbool(positions)reciprocal_total1/positions[0]ifpositionselse0nlen(gold)returnrecall_total/n,reciprocal_total/nif__name____main__:recall,mrrscore(gold.jsonl,results-heading.jsonl)assert0recall1and0mrr1print(fRecall5{recall:.3f}, MRR5{mrr:.3f})报告实验结果时至少记录策略名称、最大块大小、overlap、实际块数、平均/中位块长度、embedding 模型、检索k、问题集版本、Recallk、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。若正确块在前几名但回答仍错则看上下文是否过长、指令是否约束引用、生成模型是否把多条证据混淆。这种分层诊断比“模型不够聪明”更节省时间。还应留意“过度切分”的假阳性把一个答案拆成两块时评测如果只标了其中一块可能显示召回成功但生成阶段仍没有完整条件。金标准最好允许一个证据集合或为跨段问题额外标记“需同时命中 AB”。切分的评测对象不是孤立向量相似度而是一个可支持回答的证据包。十、从字符数走向 token为什么同一个“800”并不等价示例为了透明使用字符数但模型真正受限的是 token。中文、英文、数字、代码、URL 在不同 tokenizer 下占用的 token 并不相同一个 800 字符的中文制度片段和一个 800 字符的 JSON 配置片段可能产生完全不同的 embedding 输入长度和生成上下文成本。模型还有最大序列长度超过部分有时会被静默截断这会造成“看起来写入成功关键条款实际没参与向量化”的隐蔽故障。因此当你已经选定 embedding 模型时应在离线构建阶段用它对应的 tokenizer 统计长度并记录截断数量。不要把每块硬凑到最大上限给标题前缀、特殊 token 和未来的 metadata 留余量。对于生成阶段还要计算top_n × 平均块长度 系统提示 用户问题是否稳定落在模型上下文预算内。上下文窗口很大也不代表应该塞满长而重复的证据会增加注意力分散和成本。可以把“字符窗口”当作第一轮工程基线但报告中要清楚说明它是字符而非 token。等基线确定后再替换长度函数并重新跑同一评测集不要把两种计数方式的结果直接放在一张表里比较。你真正关心的是证据完整性和检索表现而不是某个看起来整齐的数字。十一、语义切分的可控做法先找候选边界再做审计语义切分听起来像让模型“理解文章后自动分段”实际上也需要可复核的过程。一个朴素实现是先按句号、空行或标题得到原子单元再将相邻若干句组成窗口比较前后窗口的 embedding 相似度相似度明显下降的位置被视为候选边界。不要直接对每个字或每个 token 求向量那会带来高成本和噪声先使用人类可读的最小单元边界才能被人工检查。阈值选择不能凭感觉。可以先在十几份代表性文档上把相似度变化画出来人工标注明显换题的位置观察它们的分布。阈值太高会产生大量小块阈值太低又几乎不切不同类型文档的阈值很可能不同。合同、技术手册和会议纪要的主题转折密度并不一样强行一个全局阈值未必比标题切分好。语义切分还要有硬边界任何块不能超过模型输入上限也不应小到只剩一句“见附件”每个块应至少携带来源和标题路径在标题下发生语义变化时可以切但不应跨越不同权限、不同版本或不同表格行把文本拼在一起。语义模型只是提出边界的工具文档结构和权限仍是更高优先级的约束。下面给出不依赖具体 embedding 服务的伪实现骨架重点是记录边界理由。实际接入时用你的 embedding 函数替换similarity并把每次切分的版本写入 manifestfromitertoolsimportpairwisedefsemantic_boundaries(units:list[str],similarities:list[float],threshold:float)-list[int]:similarities[i] 表示 units[i] 与 units[i1] 的相似度。iflen(similarities)!max(0,len(units)-1):raiseValueError(相似度数量应比单元数少 1)cuts[0]forindex,scoreinenumerate(similarities,1):ifscorethreshold: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、页码、标题路径和原始偏移量。第二天找业务同事写出一批带金标准来源的问题先跑出当前 Recall5 与失败列表。第三天只比较三四组有意义的长度和 overlap不同时更换 embedding、重排和 prompt。这样每一轮结果都能解释。观察失败后再分流如果关键句经常被截断就缩小块或改自然边界如果同一主题因标题缺失而散落补结构解析如果正确块在 top-20 却排不进 top-5再评估 rerank如果文档根本没有答案则交给内容负责人补充而不是提高 chunk 数量。每个选择都由一个失败模式驱动避免陷入“看到一个参数就试一次”的无效劳动。最后保留一份面向维护者的样例报告。报告不必很漂亮但要能回答本次语料是什么、块数增长多少、哪些问题改善、哪些问题退化、是否超过延迟或成本预算、是否需要回滚。切分是离线任务最适合先在影子索引中验证确认结果后再切换线上有效索引而不是直接重建生产集合。这个流程比一次性追求所谓最佳算法慢一点却能把每一次改动变成可回退的知识。十五、别忽略用户看到的片段质量检索结果最终常会展示在答案下方因此 chunk 还是一种界面数据结构。一个只包含“如下”“除外”“详见附件”的片段即使向量距离很近用户也无法独立判断。抽样时应把候选直接放到阅读界面看标题是否足够、前后句是否自然、是否含有大量无意义重叠。必要时在展示层向前后扩展少量原文但扩展内容不能反过来参与生成否则评测时要重新计入证据范围。这个区分很实用索引块追求稳定检索与成本展示片段追求可读复核。二者可以共享定位信息却不必强迫同一段文本同时完成两件彼此冲突的事。当展示层扩展了原文还应清楚标识“检索命中片段”和“相邻阅读上下文”。前者是系统实际用来排序的证据后者只是帮助用户理解。这个小小的区分能减少排查时的误解用户看到完整段落并不表示模型在回答时一定看到了全部段落。十六、小结Chunk Size、Overlap 和语义切分没有脱离语料的标准答案。稳妥的做法是先用标题/段落感知的递归切分建立可读基线让标题路径进入 embedding 文本以小范围参数组合比较同一问题集用 Recallk、MRR、重复率和人工边界抽查共同判断从失败样本定位到解析、切分、检索或生成的具体层。如果你的文档短、结构好、问题主要问单条规则朴素的标题加段落切分往往已经够用。只有当数据表明自然边界缺失、主题频繁切换或长文本召回受损时再投入语义切分的 embedding 成本。先量出瓶颈再增加复杂度才不会把 RAG 调参变成一场没有终点的玄学。参考资料LangChain Text Splitters 文档ChromaAdding Data to CollectionsChromaIntroduction to RetrievalSentence Transformers 文档UnstructuredChunking
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进