Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

分片实战:从“按长度切“到“按语义切“的改造记录

前两篇把 PDF 解析和表格还原折腾完,我手里有了一份 119 页腾讯中报的干净文本。分片的第一版配置,我抄的是教程默认值:按 512 token 切,重叠 50。这套参数在博客和 FAQ 文档上一直没出过事——段落独立成意,断在哪都不太疼。放进中报,两天之内就翻了车。

翻车的句子在管理层讨论与分析里:

截至 2025 年 6 月 30 日止六個月,本公司權益持有人應佔盈利為人民幣 1,150 億元,較去年同期增長 28%。增長主要由於增值服務及網絡廣告業務的收入貢獻增加。

按 512 一刀切下去,边界恰好落在“1,150 億元,“后面。前半段有数值,没说增长多少;后半段有增长原因,数值没了。用户问“腾讯上半年利润增长了多少”,两个 chunk 各含一半答案,拼不拼得起来全看检索运气。【待核实:本篇腾讯中报引文数字(1,150 億元、28% 等)沿用旧成稿,发布前需与真实中报原文核对;若为示例性改写需另作说明】

按长度切的三种死法

第一种死法就是开场这个:语义断点落在 chunk 边界上。它不是小概率事件。MD&A 里“数值+增长率+原因“是固定句式,长度又常和 chunk 边界量级相当,切在句子中间的次数多到我没法当意外处理。

第二种死法更贵:表格被切散。利润表数据块按 token 数切完,前几个 chunk 是“收入 184,504 成本 98,238 毛利 86,266“,后几个是“行政開支 18,431 研發支出 12,892 經營盈利 54,943“【待核实:利润表数字沿用旧成稿,发布前需与真实中报核对】。文本上看像两组独立数据,逻辑上是同一张利润表。用户问“经营盈利“,命中后一组,收入、成本、毛利的上下文全丢,LLM 只看到一个孤立数字——54,943 是什么科目、什么单位、什么期间,它一概不知道。

第三种死法最隐蔽:定义和它的数据被分开。会计政策的定义在上一段,具体数字在下一段,切割点落在中间。检索命中的 chunk 只剩“15.3% / 22.4% / 9.7%“一串裸数字,口径、单位、期间全无。用户不管问哪一项比率,命中的都是这串没法解释的数字。从模型角度看,一段话切成两半之后,两半都失去了独立可解释性。

顺带还有个体感问题:连续几页纯数据挤进一两个 chunk 时,这些 chunk 的向量表示会变“窄“——只有数字和指标名,没有分析文字去平衡。它们对任何带数字的 query 相似度都高,彼此拉不开区分度。

三种死法性质不同——断句、断表、断配对——但病根是同一个:边界位置由 token 数决定,而 token 数不知道语义在哪结束。把 512 调成 800 或者 300,只是换个位置继续切错。决定 chunk 质量的不是大小,是边界。

先换方法,不是调参:TextBlock 中转结构

所以第一步是否定“调 chunk_size“这条路。我真正动手做的,是在 raw text 和 chunk 之间插一个中间层:TextBlock。

TextBlock 的定义是语义上不可再切的最小文本单元。它可以是一个完整段落、一个表格行组、一个章节标题加引导语、一个列表组。每个 TextBlock 带一个类型标签:paragraph、table、header、list。

切出 TextBlock 靠的不是 token 数,是文档结构信号。我在这个项目里用的规则很朴素:

  • 双换行符 → 段落边界;
  • 连续缩进加数字、货币符号 → 可能的表格行;
  • “第 X 节”“X、”“(X)“开头且文本很短 → 标题;
  • 上一行冒号结尾、下一行缩进 → 列表。

走完这套规则,一份财报文本从一大段 raw text 变成大约 300 到 500 个带标签的 TextBlock 序列。这是我在 119 页中报上的实测范围,不是通用数字——文档越长块越多,别当成标准引用。

这套做法在分片策略分类里属于“文档结构驱动“,对财报这类自带层级的文档是主力策略。代价也明确:依赖上游解析质量。标题层级判错——比如加粗正文被当成小标题——后面所有边界跟着错。前两篇在 PDF 解析和表格上花的功夫,其实是在这里兑现。反过来说,文档解析和分片在财报场景里几乎是同一个问题:先有一棵正确的结构树,才谈得上按结构切。

合并而非切割:从 TextBlock 到 chunk

有了 TextBlock,分片的思路整个反过来:从“找位置切“变成“决定要不要把这个块加进来“。流程是从第一个 TextBlock 开始不断追加,直到当前 chunk 达到目标大小。

目标大小不再是 512 教条。这个项目里我落在 800~1200 tokens 的区间,同样是单一项目经验,不是标准答案,也不是一次定出来的——先用 800 起跑,再按后面第五节的验证指标往两边调。太小(300 以下)丢上下文,“应收账款余额 50 亿元“单独成一个 chunk,看不出是哪家公司哪一年;太大(2000 以上)embedding 被稀释,一大块里只有一句话相关,向量却融合了整块的无关内容,相似度被拉低。

追加时守三条类型规则:

  • header 永远作为 chunk 开头,不在 chunk 中间插入新标题;
  • table 尽量不切,本身超大就宁可独占一个 chunk——表格内部怎么序列化、行数太多怎么按科目拆行组,第 3 篇讲表格时展开过,这里不重复;
  • paragraph 可以切,但切点选在句子边界(句号、分号),不是 token 刚好到线的位置。

重叠设计也跟着变。不再是固定 50 token,而是把上一个 chunk 的最后一个完整 TextBlock 作为下一个 chunk 的开头。财报里要保住的是“上一段定义、上一行口径“,不是机械重复几十个字符,所以按语义单元做重叠比按字符数合理;默认重叠 1 个 block 就够用。

还有一条财报专属规则:period_type 切换强制 flush。累积过程中,如果当前 block 的期间口径和 chunk 里已有的不一致,立刻 flush 当前 chunk、开一个新的。财报里“二零二五年第二季度“和“二零二五年上半年“的措辞模板高度相似,混在一个 chunk 里,模型回答“二季度收入“时可能拿成上半年的数——数字格式全对,错得特别隐蔽。

合并策略最省事的地方是:你不需要猜“在哪切“,只需要决定“这个块要不要加进去“。决策空间从连续位置变成了离散选择。

一个顺序问题:先标注 metadata 还是先切 chunk

改到这里,撞上一个先有鸡还是先有蛋的问题。很多系统的顺序是:抽文本 → 按 token 切 chunk → 再调 LLM 给每个 chunk 打 metadata。这个顺序在 demo 里看不出问题——FAQ 文本怎么切都语义完整;换到财报才暴露,而且暴露得很间接:切坏的 chunk 会污染下游标注。

还是用开场那个例子。如果 chunk 只含“增長 28%,主要由於……“,丢掉了“截至 2025 年 6 月 30 日止六個月,本公司權益持有人應佔盈利為人民幣 1,150 億元,較去年同期“这半句,LLM 连“28% 对应什么指标、什么期间“都判不出来,标注准确率直接受损。

正确顺序反过来:先在 TextBlock 层级打 metadata,再合并成 chunk,chunk 的 metadata 取其 TextBlock 的并集。TextBlock 语义完整,标注精度更高;合并时一个表格块加一个段落块,各自标了 {period: 2025H1},chunk 就继承这个字段。

至于标哪些字段、规则标注和 LLM 标注怎么分工,是第 5 篇的主菜。这里只把顺序定下来:先标注,后合并。

切完了怎么验证

分片质量不能靠感觉。我分两层查:肉眼抽查是下限,指标化才能每次改规则之后横向比较。

肉眼三条。随机抽 20 个 chunk 读原文,确认语义完整;看 chunk 大小分布——不是追求均匀,而是极端值都要能解释,3 token 的和 2000 token 的都得说得出为什么存在;再拿几个已知答案的 query 手动检索,看返回的 chunk 是否包含完整答案。

指标五个,也是这个项目的实测口径:同一 chunk 内出现多个时间口径的占比;缺 unit metadata 的 chunk 占比;出现孤立数字(数字旁边没有指标名或单位)的占比;过短 chunk(100 tokens 以下)的占比;过长 chunk(超过 max_chunk_size 1.5 倍)的占比。五个都压低了,chunking 才算稳。每次调整规则跑一遍,改得好不好可度量、可回归,不用靠“我感觉顺了“。

边界对齐业务语义

全篇收下来就一个判断:chunking 不是文本分段,是检索单元设计。边界应当对齐业务语义单元——一个小节、一段指标说明、一张表、一个完整口径——而不是一个固定 token 数。调 chunk_size 是表面优化。这个结论收在财报这类结构密集、口径多变的文档里;结构松散的纯叙述文本怎么切,是另一套取舍,不在本篇范围内。

还有一个方向这里只点个名:检索粒度和上下文粒度其实是两件事,可以分开设计——小 chunk 负责被检索命中,大 chunk 负责给 LLM 完整上下文,也就是所谓父子分片。它直接化解“精度和完整性不可兼得“的矛盾,实现细节这篇不展开。

最后一个遗留问题。到这里,每个 chunk 语义完整了,可它还不知道自己是谁:哪一年、什么口径、什么单位。切好了的 chunk 还是个“黑户“。下一篇《Metadata 设计:比模型大小更重要的东西》,给它发身份证——文档级和 chunk 级 metadata 分别标什么、规则标注和 LLM 标注怎么分工。