在 RAG 系统里,文档通常不能以完整文件的形态直接参与检索。它需要先被拆成若干 chunk,分别建立索引,再由查询召回其中最相关的部分。这个环节看起来只是数据准备,实际上会改变检索系统看到的知识形态。
chunk 太大,召回结果会夹带许多与问题无关的内容,真正有用的信息反而不够突出;chunk 太小,标题、前提和结论又可能散落在不同位置。即使检索命中了其中一块,模型拿到的也可能只是一句失去上下文的话。为了缓和这种断裂,工程上经常会加入 overlap,让相邻 chunk 重复一小段内容,但 overlap 只能修补切点附近的连续性,不能替我们判断一篇文档究竟应该在哪里分开。
Markdown 又让这个问题多了一层约束。它不是一串没有形状的纯文本。标题表达章节关系,列表组织并列信息,代码块和公式有完整边界,图片与表格往往还依赖前后的说明。splitter 是负责把文档组织成 chunk 的模块。若它在处理这些内容时只看 token 数,原文中已经存在的结构就会在进入向量库之前被抹平。
LinkRag 的 chunking 正是在这些问题里逐步形成的。当前方法先利用 Markdown 的块级结构组织候选块,再处理其中仍然过长的正文,在可行的范围内寻找更合适的语义边界。后一部分受到 TextTiling 的启发,但项目没有照搬论文算法。原始 TextTiling 使用词项分布观察相邻文本的凝聚度变化;LinkRag 面对的是已经结构化的 Markdown 元素,并且使用 embedding、token 预算和受保护元素规则共同约束切分。这里的 embedding 指文本经向量模型转换后得到的语义表示,token 预算则是 splitter 自己用于计数和打包的尺度。受保护元素包括代码块、公式块、图片和表格;它们在普通打包时不会被从内部切开,但这种保护受 hard max 约束,并不是无条件的内容完整保证。
先说明本文对应版本的状态。结构整理 candidate_boundary 是当前分块链路的基础;语义细分 semantic_depth_window 已经实现,也有阶段契约与异常路径测试,但系统默认仍然使用 noop 透传,需要显式配置才会启用。项目尚未完成正式的检索与 RAG 对照实验,因此本文能够说明的是实现行为、结构与长度契约以及已知限制,不能据此声称召回或回答质量已经提高。
文章开头和结尾使用的是同一份 JVM 文档的真实输入、输出与可视化报告;中间为了解释 atom、gap、cohesion 和 depth 使用的数字则是教学构造,并不是那次运行的内部 trace。下面先看这个真实案例,再依次说明文档结构怎样进入算法、长正文中的候选边界怎样计算,以及图片、表格、代码和极端长度如何处理。最后还需要回答一个更难的问题,也就是怎样判断一次分块究竟合理,还是仅仅满足了程序约束。
从一个失败的 chunk 开始
这个问题并不是从算法论文开始的。开发过程中,我们曾把一份讲解 JVM 运行时数据区的 Markdown 文档送入完整解析流程,并将所有分片结果做成可视化报告。报告中有一个 chunk 很醒目。它的标题路径是“堆内存 > 堆内存分配与回收策略”,覆盖原文第 94 至 121 行,共 1383 个字符,经过当时使用的 tokenizer 统计后达到 1243 token。tokenizer 是把文本换算为 token 的计数工具;这里记录 token 数,是为了用实际模型输入尺度衡量 chunk 大小,而不是简单按字符数估算。
单看 Markdown 结构,这个结果没有明显错误。原文从一个四级标题开始,后面没有新的子标题,内容也都与堆内存有关。按照标题边界聚合,它自然会被视为同一节。问题在于,这一节内部实际上连续讨论了几组不同的问题。
原文先从标记清除、复制和标记整理讲到分代回收,随后解释 Eden 区与两个 Survivor 区的划分,以及常见的 8:1:1 比例。再往后,讨论转向对象进入老年代的条件,包括大对象、动态年龄判断和空间分配担保。最后一部分已经离开回收策略,开始解释对象分配时的并发安全,以及 TLAB 和 CAS 各自承担的作用。
把原文压缩后,可以看到这个 chunk 的跨度。
| |
如果用户问“Survivor 区为什么常见 8:1:1”,真正相关的是中间几段;如果问“TLAB 如何减少对象分配时的锁竞争”,相关内容则在这个 chunk 的末尾。两个问题虽然都属于 JVM 堆,却不需要共享一整段 1243 token 的上下文。将它们放进同一个向量表示,会让多个局部主题共同影响这个 chunk 的语义;召回后,排序器和生成模型也需要在更长的内容里重新定位答案。这里不能断言检索一定失败,但它显然已经不是一个聚焦的检索单元。
最直接的修补办法是重新按固定长度切开。例如每到 512 token 就生成一个 chunk,再给两侧补上一段 overlap。这样可以控制大小,却不知道第 512 个 token 落在什么位置。切点可能位于 Eden 与 Survivor 的解释中间,也可能落在一个列表、链接或长句内部。overlap 能让两边各保留一点重复内容,却无法判断一段论述是否已经完整,更无法识别下一段是否开始讨论新的问题。后文的结构化方法会优先使用 parser 已知的块级边界,但它也不能无条件保证所有列表和行内 Markdown 始终闭合。
这个失败案例还揭示了另一件事。标题边界和语义边界并不总是重合。标题可以告诉 splitter 这里仍然属于“堆内存分配与回收策略”,但不能证明标题下面的所有内容都适合成为同一个检索单元。反过来,语义相似度也不能不受约束地决定一切。代码块、公式块、图片和表格即使与前后文本的向量相似度较低,也不应该仅凭这一点从内部断开。
因此,我们后来关心的问题不再只是“一个 chunk 最多放多少 token”,而是两个更具体的问题。文档已经给出的结构应该怎样进入分块判断;当一个候选块仍然过长时,程序又该怎样优先沿可用结构边界寻找相对自然的切点。
现有的分片可视化报告保留了这个案例的对照结果。仅做结构整理时,上述内容是一个 1243 token 的 mixed chunk。mixed 表示其中混合了标题、段落、列表等多种 Markdown 元素,而不是某一种专用内容。启用语义细分后,它被组织成多个相邻片段,并保留必要的邻接上下文。这个对照并不能单独证明后者具有更好的检索质量,却足以说明,结构正确不等于粒度合适,长度受控也不等于边界合理。接下来需要先把 LinkRag 的整个分块流程展开,才能看清这两类判断分别发生在哪里。

分块流程
上一节的失败 chunk 同时提出了结构和长度两个问题。如果把所有判断都塞进一个 splitter 函数,标题规则、token 预算、语义相似度和异常处理很快就会缠在一起。某一条规则被修改时,很难判断它究竟改变了文档结构,还是只改变了长文本的切点。
LinkRag 因此把分块组织成前后两次处理。第一次根据 Markdown 元素整理文档结构,得到候选块;第二次接收这些候选块,只在配置允许且确有需要时继续细分。源码将它们称为 Stage 1 和 Stage 2。stage 是实现架构中的分层,不是本文要反复强调的算法概念。对使用者来说,更重要的是前一次处理留下了什么,后一次处理又被允许改变什么。
完整链路如下。

这条链路的起点已经不是原始 Markdown。文档在进入 splitter 之前,会先由 Markdown parser 解析为 ParseResult,其中包含按原文顺序排列的 MarkdownElement。这里的 element 是块级结构,包括标题、段落、列表、引用、代码块、公式块、图片和表格等;inline code、普通链接、强调、HTML 标签和行内公式不会各自形成受保护 element。每个元素带有类型、内容和行号,标题还会影响后续的层级路径。splitter 也可以直接接收 MarkdownElement[],但无论使用哪个入口,InputAdapter 都只负责把已有结构转换为内部统一使用的 SplitInput,不会重新解析 Markdown 字符串。adapter 在这里就是输入适配器,用来消除两种调用入口之间的格式差异。
这个边界很重要。若 parser 和 splitter 各自识别一遍标题、表格或代码块,两边的规则只要稍有差异,同一段原文就可能得到两套结构。LinkRag 让 parser 对“这是什么元素”负责,splitter 则在这个结果之上决定“这些元素怎样组成 chunk”。这样一来,标题层级、表格范围和图片位置只有一个事实来源。
SplitInput 进入结构整理后,当前实际使用的算法是 candidate_boundary。它的产物不是外部接口里的 Chunk,而是只在 splitter 内部流转的 CoarseChunkSet。coarse 在这里表示“粗分后的候选结果”,它已经有可靠的文档结构,但还不一定具有最终需要的长度和语义粒度。其中每个 CoarseChunk 除了正文和行号,还保留 token 数、来源元素索引、元素类型、标题路径、受保护范围以及元素在正文中的位置视图。位置视图记录一个源元素在拼接后正文中的起止位置,供后续算法准确找回它。图片和表格还会生成 derived chunk,也就是从单个结构元素额外构造的检索块,并记录它来自哪个 source coarse chunk。
这些名字在后文会反复出现,可以先用一张生命周期表固定它们的关系。
| 对象 | 产生和用途 | 生命周期与去向 |
|---|---|---|
MarkdownElement | parser 识别的块级元素 | splitter 的结构事实输入,不是最终 chunk |
SplitInput | adapter 统一后的元素序列 | 只在 splitter 调用期间存在 |
CoarseChunk | 结构整理后的 source 或 derived 候选块 | Stage 1 内部对象,交给 Stage 2 |
| atom | 从 source coarse 的 element view 构造的最小操作单元 | 只在一次语义细分运行中存在 |
| segment | packer 临时组合的连续 atom | 组装为 FinalChunk 后消失 |
FinalChunk | Stage 2 完成后的 source final core 或 derived final | validator 和 exporter 使用的内部对象 |
Chunk | exporter 产生的稳定外部模型 | 持久化并参与索引;符合条件时,content 还会在导出后加入 overlap |
source 与 derived 是 coarse/final/chunk 的角色,不是两套继承关系。source 沿正文主线组织,derived 则从图片或表格额外构造。atom 和 segment 都只是算法内部的临时对象,不会写入数据库。
这些字段看起来比最终入库所需的信息多,但后续语义细分正是依靠它们避免盲切。比如,一段代码在 coarse content 里只是若干字符;有了 protected_ranges 和 element_views,后续处理才能知道这些字符对应一个完整代码块,以及它在原始元素序列中的位置。等到分块全部完成,这些内部辅助字段便不再需要进入外部 Chunk。
结构整理结束后,CoarseChunkSetValidator 会立即检查结果。它关注的不是分块“好不好”,而是输出有没有破坏阶段契约,包括 ID 是否唯一、可见 source element 是否进入 Stage 1 的规范化表示、source 与 derived 的关系是否成立、行号和受保护范围是否一致。这里的参照物已经不是 raw Markdown:水平分割线可能被过滤,图片和大表格也可能在 source 中变成引用或摘要,并同时产生 derived。把校验放在两次处理之间,可以更快地区分问题来自结构整理,还是来自后续的语义细分。
接下来才到 Stage 2 路由。router 是按配置选择执行路径的组件。这里有一个容易误解的地方,它不会逐个查看 coarse chunk 的长度,再临时决定使用哪套算法,只会根据文档级配置选择一条路径。当前配置为 semantic_depth_window 时,整份 CoarseChunkSet 交给语义细分算法,由算法内部判断哪些 chunk 需要计算 embedding,哪些可以直接透传。配置为 noop 时,则不做语义判断,只把 coarse 模型等价转换成 final 模型。
源码中的 NoopStageTwoAlgorithm 实现了与语义细分相同的阶段接口,这是为了让所有链路都能稳定地产生 FinalChunkSet,并继续经过统一的校验和导出。它没有计算新的边界,所以在本文的语境里,noop 只是透传行为,不与 candidate_boundary 和 semantic_depth_window 并列为一种有效的分块算法。当前默认值仍然是 noop,语义细分需要显式启用。
FinalChunkSet 表示内部切分工作已经完成。最终校验器检查的是一个更窄的“不丢不重”条件:对同一个 source coarse,未截断的 source final core 按字符区间顺序拼接后,应覆盖该 coarse 的 content。它不承诺从 final 还原 raw Markdown,derived 不参与这条正文拼接,导出后才加入的 overlap 也不在校验范围内;带有 truncated 诊断的来源组是显式例外。语义细分写入 derived 锚点声明时,校验器还会检查图片、表格等派生块能否在 source final 中找到对应元素。启用语义细分后,普通 mixed final 还会接受 hard max 校验。hard max 是普通 source core 不能超过的绝对 token 上限。noop 保留原始 coarse 内容,不承诺满足这一上限,因此这条路径会跳过 hard max 检查。front matter 是 Markdown 文件开头的元数据块,它作为结构保护块保留例外,后文会单独解释。
通过校验后,ChunkExporter 才把内部模型收敛为下游长期使用的 list[Chunk]。最终 Chunk 保留正文、起止行号和 metadata。metadata 中会写入标题路径、chunk role、元素类型、顺序索引以及实际采用的 split strategy。derived chunk 还会映射到对应的 source chunk。当前解析链路会把 source 和 derived 都持久化并向量化,让它们作为不同 chunk 进入召回候选;splitter 只保证 derived 的 role、element ID 和 source 映射,不负责下游如何融合排序、去重或展示原始图片与表格。
邻接 overlap 位于导出之后,不属于语义细分算法本身。普通 chunk 可以从前后相邻块补充少量上下文;derived chunk 和 front matter 不参与这一步,包含代码、公式、图片或表格等受保护元素的 chunk 默认也会跳过,除非配置明确允许。这样的顺序让算法先确定正文边界,再由统一组件处理检索所需的邻接上下文,避免 overlap 反过来干扰 source final core 的区间覆盖校验。
到这里,整个流程的职责已经清楚了。parser 提供结构化事实,结构整理生成候选块,按需语义细分处理过长正文,validator 守住阶段契约,exporter 和 overlap 负责稳定的外部结果。下一步要看的,是这条链路中最先做出分块决定的 candidate_boundary。它怎样利用标题,又怎样处理表格、图片和其他不能随意拆开的内容,决定了后面所有语义判断所站立的基础。
文档结构先被保留下来
candidate_boundary 这个名字很直接。它不把标题理解为必须执行的切点,而是把标题当作候选边界。候选是否真正生效,还要看当前 buffer 已经积累了多少内容,以及这个标题在整篇文档的层级里处于什么位置。
算法开始时,输入已经是一组有序的 MarkdownElement。水平分割线会被过滤,因为它只作为 parser 内部识别结构的信号,不需要形成最终 chunk。front matter 是 Markdown 文件开头由 --- 或 +++ 包围的元数据块。它既不应该和正文拼接,也不能被当作噪声丢弃,因此会在遇到时先清空当前 buffer,再独立生成一个 source chunk。buffer 是暂存已读元素的临时收集区,source chunk 则是沿着文档正文主线生成的来源块,与后面从图片或表格额外生成的派生块相对。
其余可见元素按原文顺序进入这个 buffer,暂时不输出为 chunk。段落、列表、引用、图片、表格、代码块和公式块都不会单独触发 flush。flush 指把 buffer 中积累的元素组装成一个 coarse chunk,然后清空收集区,继续处理后面的内容。正常情况下,只有下一个标题到来时,算法才判断是否需要执行这一步。这样做是为了让标题之前的内容保持完整,也避免把图片、代码或一个普通段落误当成章节边界。
token 软下限控制碎片
标题很多的 Markdown 并不一定写得好。有些文档几乎每两三句话就加一个小标题,如果见到标题就切,最终会出现大量只有几十个 token 的碎片。它们虽然在结构上“正确”,却很难为检索提供完整上下文。
candidate_boundary 因而维护了一个候选块软下限,默认值为 128 token。
| |
当 buffer 已经达到这个大小,下一个标题会成为实际切点,已有内容先输出,新标题再进入新的 buffer。没有达到软下限时,算法通常会继续聚合。这不是一个最大长度配置,达到 128 token 并不会立刻切分;算法仍然要等待下一个标题。反过来,如果一篇长文完全没有标题,即使累积到上千 token,这一步也不会主动制造边界。
这一点可以解释上一章的 JVM 案例。整个“堆内存分配与回收策略”小节后面没有新的标题,结构整理便没有可以采用的候选边界。1243 token 是结构信息不足时留下的结果,不是算法把最大长度误设成了 1243。
第一步也不会读取语义细分使用的 CHUNKING_MAX_CHUNK_TOKENS 和 CHUNKING_HARD_MAX_TOKENS 来决定何时 flush。这几个配置服务于不同阶段。软下限避免结构分片过碎,后面的普通目标和硬上限才处理最终 chunk 的体积。把它们混成一个阈值,会让“尽量保留章节”和“绝不允许无限增长”两种目的互相干扰。

标题层级保护较大的章节
只看 128 token 仍然不够。一个新章节的开头可能很短,却不应该因为没达到软下限而并入上一章。为此,算法会在遍历正文前预先看一遍可见标题,找出当前文档参与保护的最深标题层级。默认最多追踪到五级标题,六级标题仍作为普通内容进入 chunk,但不会参与动态边界判断或标题路径维护。
未达到软下限时,算法比较 buffer 中最后一个受保护标题和即将到来的标题。进入更深一层,说明内容仍在当前章节下展开,不强制切分;回到更浅层,说明新的上级章节开始,需要保护这个边界。同级标题通常也会切开,不过当两边都是整篇文档最深的叶子标题时,算法允许它们继续合并,避免产生过多细碎小节。
下面这段 Markdown 的最深层级是三级标题。
| |
在 A.1 的内容还没有达到软下限时,A.2 与它同为最深叶子小节,两者可以留在同一个 coarse chunk。遇到 B 时,标题从三级回到二级,A 的 buffer 会提前输出,即使它仍然不足 128 token。这个取舍优先保护较大的章节转换,同时允许最细层的小节在内容较少时共享上下文。如果 A.1 本身已经超过软下限,那么 A.2 到来时仍会正常切开。

纯标题还有单独的处理。buffer 里如果只有连续标题,算法不会仅因 token 数或层级变化输出一个没有正文的 chunk。文档结尾若只剩一个标题,它会尽量并入前一个正文块。全文确实只有标题时,只能保留标题块;前一个块若是 front matter,也不会把尾部标题塞进元数据中。这些规则的目的都是避免生成来源不清晰的标题孤儿。
图片和表格同时保留位置与检索入口
结构整理并不只关心标题。代码块、公式块、表格和图片都被标记为 protected element。这里的 protected 是条件性保证:Stage 2 的普通 packer 不会在元素内部选择语义切点;元素不超过 hard max 时可以完整保留,单个元素超过 hard max 时仍会进入后文所述的受控截断。它们会和标题、段落等内容一起组成前文所说的 mixed chunk,但四类元素的表示方式并不完全相同。
| 元素 | 在 mixed chunk 中的处理 | 是否生成 derived chunk |
|---|---|---|
| 代码块 | 普通路径保留代码围栏和内容;超 hard max 可截断 | 否 |
| 公式块 | 普通路径保留公式边界和内容;超 hard max 可截断 | 否 |
| 图片 | 写入稳定图片引用和可用描述 | 是 |
| 表格 | 小表格保留原文,长表格写入引用和摘要 | 是 |
代码和公式仍然属于所在章节的正文。算法记录它们的受保护范围,但不会额外复制一份用于独立召回;如果它们因超过 hard max 被截断,丢失的尾部没有 derived 副本兜底。图片和表格则有双重身份。它们既占据原文中的一个位置,又常常值得作为独立检索对象,因此会在 source mixed chunk 旁边生成前文所说的 derived chunk。它不是沿正文边界切出的新段落,而是从某个图片或表格元素额外构造、可供下游独立索引和召回的候选块。
图片在 mixed chunk 中被渲染为稳定引用,例如下面的形式。
| |
与它对应的 derived chunk 会包含图片 ID、标题路径、图片说明、相邻上下文和原始 Markdown 引用。图片说明优先使用 parser 增强后写入的视觉描述;没有视觉描述时使用 alt,仍然没有才写入明确的缺省说明。这样既不会把缺失描述伪装成模型理解,也能保证输出结构稳定。
表格无论大小都会生成 derived chunk,其中保留原始表格、表格 ID、标题路径和可用摘要。区别只在 source mixed chunk 中怎样表示。token 数不超过 256、非空行不超过 12 且最大列数不超过 5 的表格会直接保留 Markdown 原文。三项条件有任何一项超出,mixed chunk 里就改用表格引用和摘要,完整表格仍保存在 derived chunk 中。
这项处理解决了两个相反的问题。小表格通常是附近论述的一部分,直接保留更容易读懂;大表格若完整塞进 mixed chunk,可能让正文的 token 数和向量语义都被表格内容主导。引用维持了原文位置,derived chunk 则提供独立召回入口,二者通过稳定的 image_001 或 table_001 一类 ID 建立联系。
图片和表格的 derived chunk 还会截取前一个可用元素的末尾与后一个可用元素的开头,默认每侧最多 64 token。查找发生在完整 element 序列上,只跳过 front matter 和水平分割线,不检查标题路径或 coarse 边界;标题、代码、公式或另一个异构元素也可能成为邻居。因此 adjacent context 是一种物理位置启发式,可能跨章节,也不保证一定是最相关的自然语言语境。它是 derived chunk 自身内容的一部分,与后文的 neighbor overlap 不是同一个处理步骤。

结构正确先于语义判断
candidate_boundary 输出后,validator 会核对所有可见 source element 是否进入 Stage 1 的规范化表示,元素视图能否对齐到 coarse content,protected range 是否对应实际受保护元素,图片和表格是否都生成了可回溯的 derived chunk。任何一项不成立都会直接失败,而不是静默丢掉内容。
经过这一步,我们得到的不是最终答案,而是一组具有块级来源信息的候选块。标题路径仍在,图片和表格仍能回到 source 位置,代码与公式的 protected range 已被记录,较大的章节边界也获得了优先保护。这些条件为后续语义细分划定了可操作范围;是否最终完整保留,还要经过 hard max 与截断规则。
它也有清楚的能力边界。算法不会凭空补出原文没有的标题,也不会在没有标题的长正文中猜测话题何时发生变化。上一章的失败 chunk 正是这种边界的体现。要继续处理这类内容,就需要一种不依赖显式标题、而是观察相邻文本如何变化的方法。TextTiling 提供了我们后来采用的那条思路。
TextTiling 关心的是什么
TextTiling 来自 Marti A. Hearst 在 1997 年发表的论文 TextTiling: Segmenting Text into Multi-paragraph Subtopic Passages。论文研究的是说明性长文,例如科普文章或环境影响报告。此类文档围绕一个主话题展开,内部又连续出现若干子话题,但作者未必使用足够清楚的标题把它们标出来。
TextTiling 希望把这类文本划分为连续、互不重叠的多段落片段,每个片段对应一段相对集中的子话题讨论。它做的是线性分段,也就是沿着原文从前往后确定边界,并不试图建立一棵完整的章节树。论文也有意不依赖标题等排版信息,因为它要解决的正是显式结构不足时怎样发现子话题变化。
选择“多段落片段”作为单位并非偶然。单个句子通常太短,很难独立完成解释;整篇文档又太宽,会同时包含多个讨论焦点。段落看起来像一个自然选择,但真实文档中的段落长度和作用并不稳定。有些作者用段落表达完整观点,有些作者只是为了改善版面而频繁换行。TextTiling 寻找的是介于句子和全文之间的 passage,也就是能够承载一段相对完整讨论的文本片段。
这与 RAG 的需求有相似之处。检索系统希望返回比完整文档更聚焦、又比孤立句子更完整的证据。不过,TextTiling 论文讨论的是通用文本分段,并没有 RAG、向量数据库或大模型上下文窗口这些后来的工程条件。我们可以借用它对边界的观察方式,不能把论文目标直接改写成现代 RAG chunking。
论文把完整算法概括为三个部分。先把原文规整为可比较的词项单位,再为文本中每个候选位置计算 lexical score,最后根据分数曲线识别子话题边界。不同版本可以采用不同的 lexical score,后面的 boundary identification 则共享同一套 depth 思路。理解这一层分工很重要,因为 LinkRag 改变的主要是前两部分的输入单位和相似度来源,同时保留了低谷深度所表达的相对边界信号。

比主题更容易观察的是主题变化
“主题”很容易理解,却很难写成一个稳定的计算定义。相邻两段都在谈 JVM,但一段解释垃圾回收,一段解释线程本地分配,它们究竟算同一个主题还是两个子话题,往往取决于当前任务需要多细的粒度。
TextTiling 采用了一个更容易落地的角度。与其先判断每一段“属于什么主题”,不如寻找讨论内容发生明显转换的位置。这里的基本假设是,一段子话题持续展开时,会有一组词在局部范围内反复出现或共同活跃;当子话题改变,其中相当一部分词也会随之变化。
论文把这种局部连续性称为 lexical cohesion,即词汇凝聚度。它不是要求左右两边逐字相同,而是观察一批词在相邻区域中的分布是否仍有较强联系。以 JVM 文档为例,Eden、Survivor、对象和回收可能在一段范围内密集出现;当正文转向 TLAB、线程、指针和 CAS,活跃词集合也会发生变化。词汇分布的变化由此成为子话题转换的可计算线索。
这里观察的是一组词的共同分布,而不是追踪某一个关键词。贯穿全文的主话题词可能在每个子话题中都出现,因而无法区分边界;只出现一次的偶然词也不够稳定。真正有用的是若干局部词项在某一区域形成密集的“词汇团块”,随后一起减弱,另一组词又开始活跃。论文中的科普文章以生命、月球等词贯穿全文,但大陆、海岸线等词集中在一个子话题,恒星、双星和轨道等词则集中在另一个子话题。TextTiling 要找的正是这些团块之间的转换。

这也解释了为什么简单的关键词链不够。一个子话题往往同时展开多条概念线索,不同词未必在词典意义上直接相关,却会因为共同参与一段讨论而出现在同一区域。block comparison 不要求先知道哪些词属于同一个概念,只比较相邻文本区域整体的词频分布,因此能够同时吸收多组局部主题信号。
这种方法特别适合说明文。说明文的组织往往由所讲的对象和概念推动,词汇变化能够反映讨论焦点的移动。论文也明确指出,叙事文本可能需要更复杂的信息,因为人物、时间和场景可以变化,而作者仍然使用代词或多样化表达维持同一条叙事线。TextTiling 并没有声称仅靠词项重复就能解释所有文体。
把文本变成可比较的窗口
若直接比较自然句子或段落,长度差异会造成麻烦。一边只有十几个词,另一边有上百个词,两者共享词的数量很难公平比较。为减少这种不平衡,原始 TextTiling 先把正文划分为固定长度的 token-sequence。这里的 token-sequence 可以理解为“伪句子”,它不要求符合语法,只要求包含近似相同数量的 token。
tokenization 是这一步的预处理。论文中的实现把词转为小写,过滤高频功能词,并将屈折变化还原到词根。过滤的词通常被称为 stop words,例如英文中的冠词和介词;它们会计入 token-sequence 的长度,但不参与后面的相似度计算。词形还原则尽量让同一个词的单复数或动词变化落到共同形式,减少表面写法带来的干扰。程序同时记录原始段落边界,供最终边界回到可读的位置。
token-sequence 的长度由参数 w 控制。w 太小,曲线会对个别用词非常敏感,产生大量尖锐波动;w 太大,又会把局部变化平均掉。论文评测采用的一组默认值是每个 token-sequence 20 个 token。这个数字来自当时的小规模实验,并不是跨语言、跨文体都成立的常量。
得到 token-sequence 后,block comparison 会在每个相邻位置放置一个 gap。gap 就是两个序列之间的空隙,也是一个潜在边界。算法从 gap 向左取若干 token-sequence 组成左 block,再向右取同样数量的序列组成右 block。block 是用于比较的一段局部窗口,并不等于最终分片。
每侧取多少个 token-sequence 由参数 k 控制。论文的默认评测配置使用 k=10,意图让一个 block 大致接近平均段落尺度。窗口每次只向前移动一个 token-sequence,并不是算完一对 block 就跳到下一对完全不重叠的区域。因此,相邻 gap 的 block 会大量重叠,同一个 token-sequence 也会参与多次比较。这样得到的不是几次孤立判断,而是一条沿全文连续采样的相似度曲线。

左右 block 分别表示为词频向量。向量中的每个维度对应一个词,数值是该词在 block 内出现的次数。随后用归一化内积计算相似度,也就是常见的 cosine similarity。
| |
由于词频都是非负数,这个分数落在 0 到 1 之间。接近 1 表示左右 block 的词汇分布方向接近,接近 0 表示两侧几乎没有共同的活跃词。它衡量的是分布相似程度,不是两个 block 的字数差,也不是对其中某个主题的分类概率。
score(i) 表示第 i 个 gap 两侧的词汇凝聚度。左右窗口共享较多活跃词时,分数较高;两侧词汇分布明显变化时,分数降低。窗口沿全文逐个 gap 移动后,便得到一条凝聚度曲线。论文还讨论了 vocabulary introduction 等其他打分方式,但 LinkRag 后来借鉴的主要是相邻窗口比较与低谷深度,因此这里不展开另一条算法支线。
论文中的 block comparison 使用 block 内词频作为向量权重。早期版本曾使用 tf-idf,也就是降低全局常见词的权重、提高区分性词项的权重,但作者发现,在相邻小范围文本之间判断细微差异时,tf-idf 的重要性估计没有带来足够稳定的收益,单纯使用 block 内词频反而更稳健。这一点很容易被今天的实现背景遮住,原始 TextTiling 并没有调用语义模型,它比较的仍是词项出现次数。
除了 block comparison,论文还研究了 vocabulary introduction。该方法观察局部窗口中新词首次出现的比例,希望在大量新词开始出现的位置识别信息流变化。论文也讨论了 lexical chains,即追踪重复词形成的词汇链。三种方法都试图从词汇分布发现边界,但计算的信号不同。LinkRag 与其中的 block comparison 最接近,因为两者都在每个 gap 比较左右窗口;下一章提到“TextTiling 思路”时,主要指的就是这条路径与后续 depth score。
从凝聚度曲线寻找低谷
凝聚度低的位置值得关注,却不能直接等同于边界。曲线可能因为一句短暂插话或少量生词产生小幅下降,也可能整段都处于偏低水平。只设置一个绝对阈值,会把这些情况混在一起。
TextTiling 因此使用 depth score 衡量低谷相对于周围环境有多深。对某个 gap,算法从当前分数向左寻找局部峰值,再向右寻找局部峰值,将两侧峰值与当前低谷的落差相加。
| |
寻找峰值时并不是扫描全文最高点。算法从当前 gap 开始向左移动,只要分数沿该方向继续升高,就继续走,直到到达左侧局部峰值;向右也采用同样过程。因此 depth 描述的是当前低谷相对于邻近高凝聚区域的深度,而不是它相对于全文极值的位置。
下面是一组为了说明计算过程而构造的分数,不是论文实验数据。假设 moving block 经过某段 JVM 文档后得到五个连续 gap 的 cohesion score。
| gap | g1 | g2 | g3 | g4 | g5 |
|---|---|---|---|---|---|
| score | 0.82 | 0.76 | 0.31 | 0.71 | 0.79 |
对 g3 来说,向左经过 g2 后到达局部峰值 0.82,向右经过 g4 后到达局部峰值 0.79。它的 depth 为 (0.82 - 0.31) + (0.79 - 0.31) = 0.99。g3 的绝对分数较低,两侧又分别形成较高凝聚区域,因此它是一个很强的候选边界。相比之下,g2 虽然比 g1 低,但下降幅度很小,右侧马上还会继续下降,它并没有形成同样完整的“旧区域结束、新区域建立”信号。
一个很低但只有单侧下降的位置,不一定代表完整的主题转换。深低谷则意味着左侧原本具有较强凝聚度,经过当前位置明显下降,右侧又形成新的高凝聚区域。这比单纯寻找全局最低分更接近“旧讨论结束,新讨论开始”的过程。
depth 使用的是相对变化,而不是分数的绝对高低。某个 gap 的相似度即使处于全文中间水平,只要它被两侧明显更高的峰值包围,仍可能是重要边界。反过来,一段长平台上的多个低分位置并不一定都应切开。论文把这种平台视为模型不容易判断的情况,尤其当词汇变化是渐进发生时,边界本身就可能没有唯一答案。
论文的 Figure 4 讨论了三类典型曲线。第一类是两侧峰值清楚、谷底集中的普通低谷,depth 很容易解释。第二类是在上升途中夹着一个小幅回落,如果把每个细小波动都当成边界,就会把一个连续主题切碎。第三类是两个峰值之间出现较长平台,此时平台两端都可能成为合理边界,单靠词汇凝聚度很难做出唯一选择。这三种情况说明,depth 比单点低分更稳健,但它仍然只是边界证据,而不是对主题结构的完美还原。

论文还比较了谷底深度与坡度。陡峭下降看起来像强信号,却可能只是一段很短的偏题;一个更深但坡度较缓的低谷,反而可能对应规模更大的渐进式话题转换。因此 TextTiling 更看重 valley depth,而不是只找变化最快的位置。
论文怎样把低谷变成边界
原始算法并不是算完 depth 就结束。凝聚度曲线会先经过平均平滑,减少局部小波动制造的伪低谷。smoothing 即平滑处理,它用邻近若干 gap 的平均分替换当前分数,并可重复多轮。论文没有把这种平均滤波描述为唯一正确选择,只是用它降低噪声。
平滑由宽度 s 和执行轮数 n 控制。论文给出的默认配置是 s=2、n=1,只做幅度有限的一轮平滑。平滑过强会把噪声压下去,也会把真正的转换位置抹平。窗口大小、token-sequence 长度和平滑程度彼此相关,较大的 block 本身就会让曲线更平滑,通常不需要再做太多滤波。
接下来还要决定一篇文档应该切出多少段。论文没有采用固定 depth 阈值,而是参考当前文档全部 depth score 的均值和标准差选择较突出的边界。阈值可以调得宽松或保守,对应不同的召回和精确取舍。候选边界还会尽量对齐到原始段落位置,并限制两个边界不能靠得过近,避免短段落和偶发标题造成连续碎片。
具体来说,论文先按 depth 从高到低考虑候选点,再用当前文档 depth 分布决定截止位置。较宽松的设置会接受更多低谷,得到更细的分段;较保守的设置只保留更突出的低谷,得到更少、更长的片段。由于相似度是在固定 token-sequence gap 上计算的,最终位置未必正好落在段落边界,算法会把判断移动到附近可用的段落 gap。两个已选边界之间还需要至少间隔一定数量的 token-sequence,默认间隔限制为 3,以减少过短片段。
这些参数并非彼此独立。增大 w 或 k 会让一次比较覆盖更多文本,曲线通常更平缓,发现的边界也更少;缩小窗口则会提高局部敏感度,同时增加噪声和过度切分风险。论文在参数讨论中明确承认这一点,并指出默认配置只来自很小的评测集合。TextTiling 给出的是一套可运行的方法,不是无需适配语料的固定参数表。
这些步骤说明,TextTiling 并非一句“相似度低就切开”可以概括。它先把文本规整为可比较单位,再计算每个 gap 两侧的局部凝聚度,从曲线中寻找相对深的低谷,最后结合平滑、文档内分数分布和段落位置确定边界。它所提供的核心价值,是把模糊的主题转换转化为一条可以计算和检查的局部变化曲线。
LinkRag 保留了相邻窗口、凝聚度和 depth valley 这条思路,但没有复刻上述整套过程。我们没有使用固定 token-sequence 和词频向量,也没有照搬论文的平滑、边界数量阈值和段落对齐方式。Markdown 元素已经给出了更可靠的结构单位,embedding 可以提供词面重合之外的语义表示,RAG 又带来了明确的 token 上限。下一章要讨论的,正是这些条件怎样把 TextTiling 的思路改造成 semantic_depth_window。
长文本内部的边界怎样确定
semantic_depth_window 接收的不是原始文档,而是前面已经通过结构校验的 CoarseChunkSet。它不再判断哪些行是标题、图片或代码,而是使用 candidate_boundary 留下的 element view、标题路径和受保护范围。这样,语义算法面对的不是一串来源不明的字符,而是一条仍然能够回到 Markdown 元素的内容序列。
它保留了 TextTiling 的一个核心判断。相邻内容仍然连贯时,gap 两侧的语义表示应该接近;讨论焦点转换时,相似度会下降;一个相对于左右区域都更深的低谷,比孤立的低分更值得作为边界。变化最大的是产生这条曲线的方式,以及最终怎样使用它。
| 比较维度 | TextTiling block comparison | LinkRag semantic_depth_window |
|---|---|---|
| 基本单位 | 固定长度 token-sequence | 由 Markdown element view 构造的 atom |
| 语义证据 | block 内词频向量 | atom 的 embedding 向量 |
| 窗口宽度 | 每侧 k 个 token-sequence | 每侧约 128 个 display token |
| 低谷计算 | 左峰、谷底和右峰形成 depth | 保留同样的相对 depth 计算 |
| 边界选择 | 根据全文 depth 分布选择并对齐段落 | token 预算触发后,在当前合法 gap 中选点 |
| 文档结构 | 原算法有意不依赖标题 | heading、protected atom 和图片/表格 ID 共同约束 |
| 曲线平滑 | 原论文执行有限平均平滑 | 当前代码不做平滑,在可评分 gap 序列上直接计算 depth |
这张表中的差异决定了项目不能被描述为“使用 embedding 复现 TextTiling”。embedding 只替换了语义证据的一部分,输入单位、边界触发方式和结构约束都已经改变。
先判断是否真的需要语义计算
即使全局配置选择了 semantic_depth_window,也不是每个 coarse chunk 都会调用 embedding。derived chunk 已经是为图片或表格生成的独立检索入口,不再继续细分;front matter 是文档元数据块,始终保持原样;普通 mixed chunk 若没有超过 CHUNKING_MAX_CHUNK_TOKENS,也直接转换为 final chunk。这种不改变正文内容的等价转换在代码中称为 passthrough,即透传。
默认配置下,这个普通目标是 512 token。
| |
这里的 512 是软目标,不是这一章要讨论的 hard max。它首先是一道成本和必要性门槛。一个已经足够短的 chunk 没有必要构造 atom、请求 embedding、计算所有 gap,再得到与输入完全相同的结果。只有过长的 mixed coarse chunk 才进入完整算法。
这个门控也使语义细分保持局部。算法不会把整篇 Markdown 重新拼成一个长序列,也不会跨 coarse chunk 推翻前面已经确定的章节边界。每个过长 coarse chunk 单独处理,输出仍然指向同一个 source coarse ID。
atom 是结构与语义之间的接口
进入完整算法后,_AtomBuilder 按 element view 的原始顺序构造 atom timeline。timeline 就是 atom 沿原文顺序组成的序列,atom 则是语义细分阶段使用的最小临时单元。它只存在于一次运行中,不写入数据库,也不会成为外部 Chunk 的新类型。
每个 atom 记录自身在 coarse content 中的字符范围、token 数、元素类型、标题路径和来源元素索引。后续 packer 只能在相邻 atom 之间的 gap 切分,不能随意选择字符串中的某个字符位置。因此,atom timeline 同时承担两件事。它为 embedding 准备可比较文本,也把允许切分的位置限制在已知边界上。
不同块级元素在这里得到的保证并不相同。
| element view | atom 构造方式 | 最坏退化 |
|---|---|---|
| heading | 保持独立 atom,只参与结构归属 | 不参与语义评分 |
| paragraph、list、blockquote | 不超 512 时通常各自形成 text atom | 过长时按换行、句末、token-safe 前缀继续细分 |
| code、math、image、table | 各自形成 protected atom | 普通 packer 不内切;单 atom 超 hard max 时可受控截断 |
普通文本元素过长时,builder 先利用换行边界组织连续片段;某一行仍然太长,再寻找句末标点;单句依然超限,最后才由 tokenizer 找到不超过上限的最长安全前缀。每次降级都保持字符范围连续、无重叠、无缺口,但 token-safe 只保证计数和字符连续,不保证 inline code、链接、强调、HTML 标签或行内公式仍然语法闭合。protected atom 则限制 packer 只能在元素前后选择切点;超过绝对上限时如何处理,留到下一章讨论。
不是每个 atom 都参与评分
atom 是否可以被切分,与它是否适合参与 cohesion 评分,是两个不同问题。所有 atom 都计入 display token 数。display text 是最终 chunk 中实际可见的内容,display token 就是这部分内容占用的 token 数。只有具备可靠语义文本的 atom 才会请求 embedding。
| atom 内容 | 用于 embedding 的文本 |
|---|---|
| 普通段落、列表和引用 | atom 自身的可见正文 |
| heading | 不参与评分,只保留归属和结构信息 |
| image | 有真实视觉描述时使用描述,否则不参与 |
| table | 有真实表格摘要时使用摘要,否则不参与 |
| code block 和 math block | 不参与评分,但仍计入 token 窗口和打包预算 |
标题不参与评分,是因为短标题与正文的向量关系未必稳定,而且标题已经通过 heading trail 参与结构归属。图片和表格不能直接拿 Markdown 引用或占位文字生成向量。只有 parser 或增强流程提供了真实的 visual description、table summary 时,它们才具有可用的语义代理。“未提供图片说明”与“未提供表格总结”这样的占位串会被明确排除。
代码和公式被排除的理由略有不同。它们可能包含大量符号、标识符或公式变量,直接与自然语言窗口计算相似度,容易制造与文档主题无关的断点。当前实现选择让它们影响长度和合法边界,却不让它们投票决定语义低谷。这是一项工程取舍,并不表示代码与公式没有语义价值。
为了直观看到 atom timeline,可以考虑下面这个教学示例。token 数只是示意,不来自实际运行报告。
| atom | 内容 | token 数 | 是否评分 |
|---|---|---|---|
| a0 | “堆内存分配与回收策略”标题 | 10 | 否 |
| a1 | 分代回收原理 | 180 | 是 |
| a2 | Eden 与 Survivor | 200 | 是 |
| a3 | 分代结构表格 | 40 | 有表格摘要时评分 |
| a4 | TLAB 与并发分配 | 190 | 是 |
| a5 | CAS 示例代码 | 160 | 否 |
packer 看到的是六个连续 atom,而不是一整段字符串。a3 即使不参与评分仍然保持为完整表格;a5 即使没有 embedding 也会占用 160 token,并限制切点只能落在它的前后。

embedding 窗口怎样形成 cohesion
可评分 atom 会按批次发送给配置好的 embedder。返回向量后,_CohesionScorer 依次考察每个相邻 atom gap。当前代码把每侧 128 display token 设为内部窗口目标,不是可通过环境变量修改的 Settings。收集循环只有在“累计 token 已达到 128”并且“已经取得至少一个可评分向量”同时成立时才停止。
这使 128 成为软目标。若前 100 token 是代码、接着 60 token 是标题,窗口仍然没有向量,便会继续纳入后面的可评分文本;一个 atom 本身跨过 128 时也会整体进入窗口。直到该侧边界仍找不到向量,窗口才返回不可评分。换言之,不可评分内容会拉大语义距离,但当前实现没有额外的最大扩张距离,窗口最坏可能延伸到整个 coarse chunk 的一侧。
窗口 token 按所有 atom 累计,包括不参与 embedding 的标题、代码和公式;窗口向量则对其中取得 embedding 的 atom 等权平均。
| |
这里没有按 display token 或 score text token 加权。一个 8-token atom 与一个 120-token atom 只要都可评分,对均值的贡献仍然各占一份。token 控制的是窗口跨度,atom 粒度控制的是语义投票权,因此 atom builder 的拆分方式本身也是评测变量。
如果某一侧没有任何可评分向量,这个 gap 就没有 cohesion score,不会用零分伪造一个强低谷。其余 gap 的左右均值向量使用 cosine similarity 得到 cohesion。随后按 gap 的原始顺序排列这些分数,并在可评分 gap 组成的压缩序列上计算 depth。
depth 公式与上一章相同。
| |
不可评分 gap 会被跳过,但其原始 atom 位置不会消失。所谓“压缩序列”是按原 gap index 排序已有 cohesion,再让峰值搜索沿这些 scored gap 的名次移动。比如 g1 和 g4 有分数、g2 与 g3 没有分数时,g1 和 g4 会在评分序列中相邻;当前实现没有按中间相隔的 atom 或 token 数设置最大距离。因此这里的“局部峰值”是 scored-gap 轴上的局部,不是严格连续的原文邻域,长段不可评分内容可能让相距较远的分数互相影响。最终 depth 仍记录在原来的 gap index 上。当前代码没有执行 TextTiling 论文中的平均平滑,也没有用全文 depth 均值和标准差决定边界数量。

token 触发切分,depth 选择位置
这是理解当前实现最关键的一点。semantic_depth_window 不会扫描完整条 cohesion 曲线,然后把所有深低谷都切开。segment packer 是负责把连续 atom 组装为若干 segment 的组件,segment 即尚未导出的候选片段。它从左向右累积 atom,只要加入下一个 atom 后仍不超过普通目标,就继续装入。即将超出目标时,才在当前可行范围内查看合法 gap,并选择 depth 最高的位置。
回到前面的教学示例。a0 到 a3 合计约 430 token,再加入 a4 会达到约 620 token,超过 512 的普通目标。此时 packer 不会机械地在 a3 后切,也不会回到全文最深的任意低谷,而是在当前 segment 已经覆盖的 gap 中比较 depth。如果 a3 与 a4 之间恰好对应“分代结构”转向“并发分配”的深低谷,它就成为优先切点。若更早的 gap 具有更高 depth,算法也可以提前切开,为下一段保留更多语义完整性。

合法 gap 还要满足基本结构条件。最直接的一条是不能让 segment 左侧只剩一个 heading atom。若多个 gap 的 depth 完全相同,当前实现优先选择位置更靠后的 gap,以便在不牺牲语义分数的情况下尽量装满当前 chunk。
如果当前范围没有任何可用 depth,packer 不会把这视为运行错误。它退回到最靠后的合法 gap,让 segment 尽量接近普通目标。这种情况可能发生在可评分 atom 太少,或 gap 某一侧全是标题、代码与公式时。结构兜底只用于“没有语义证据”,embedding 请求本身失败则是另一类问题,不能静默退化,下一章会说明错误处理。
这种做法和原始 TextTiling 的差别很明显。论文根据一篇文档的 depth 分布决定应该选出多少边界;LinkRag 先由 RAG 的 token 预算决定“何时必须考虑切分”,再让 depth 回答“当前范围内在哪里切更合适”。可以把它概括为 token 负责触发,语义负责选点。
切点之后还要重新收口
初次打包后,算法还要处理不适合直接输出的碎片。heading-only segment 会优先并入后面的正文,没有后段时再尝试并入前段。小于最小候选阈值的短 segment 会在不超过普通目标的前提下尝试合并。这里需要忠实说明当前代码的行为。短片段两侧都可合并时,当前实现默认并入前一段;按 cohesion 判断“更黏的一侧”仍是后续可增强点,不能写成已经完成的能力。
标题与短片段合并后,packer 会再次按照 atom 边界检查 hard max,避免后处理把已经合规的 segment 重新推到绝对上限之外。单个 protected atom 自身超过 hard max 时,packer 仍保持其整体身份,交给最终 assembler 做受控截断和诊断记录。具体的容忍带、截断规则和 metadata 会在下一章展开。
最后,assembler 负责把 packer 产生的 segment 组装成 FinalChunk。它不会重新拼写正文,而是根据 segment 首尾 atom 在原 coarse content 中的位置取连续切片。未截断的相邻 source final core 按顺序覆盖这个 coarse content,元素之间的空行与分隔符也随切片保留下来。图片和表格的 element_id 会写入包含它的 final metadata,供对应 derived chunk 找回新的 source chunk。
语义细分到这里才真正完成。它没有重新解释 Markdown,也没有让 embedding 越过块级结构决定一切。atom 规定可操作的最小单位,128-token 软目标窗口产生 cohesion,TextTiling 式 depth 为合法 gap 排序,soft target 触发实际切分,hard max 约束普通 source mixed core。下一章要处理的是这套主流程留下的例外,包括超长 protected 内容、标题孤儿、front matter、embedding 故障和最终 overlap。
真实文档里的约束
如果输入永远是长度适中的普通段落,上一章的主流程已经足够。真实 Markdown 不会这么整齐。一个代码块可能比普通 chunk 目标还长,连续标题可能没有正文,表格可能独占数百行,embedding 服务也可能超时。此时算法不能只追求语义切点,还要明确哪些内容允许变长,哪些错误可以重试,哪些结果必须直接失败。
这些规则看起来像算法外围的防御代码,实际上决定了分块结果能否稳定进入索引。一个漂亮的 depth 曲线无法修复被截成两半的代码块,也无法证明一次网络超时应该被当成正常结构切分。
三个 token 阈值承担不同职责
前文已经陆续出现三个长度配置。把它们放在一起看,可以发现它们并不是同一把尺子的三个刻度。
这些数值都由 splitter factory 注入的同一个 Tokenizer() 计数。当前默认使用 tiktoken 的 cl100k_base;tiktoken 不可用或编码初始化失败时,代码会静默退回按中英文字符估算。后一种模式可能改变 128、512、1024 的判断、atom 边界和 overlap,而现有日志与 chunk metadata 没有记录实际采用了哪种模式。这是当前的复现性限制。正式实验应先确认 cl100k_base 正常可用,并在外部 manifest 中冻结 tokenizer 版本与 encoding。embedding provider 和生成模型可能使用不同 tokenizer,所以这里的 hard max 也不是任意模型 API 都认可的绝对 token 保证。
| 阈值 | 默认值 | 约束对象 | 作用 |
|---|---|---|---|
CHUNKING_MIN_CANDIDATE_CHUNK_TOKENS | 128 | 结构整理阶段的 buffer 或短 segment | 避免过早切出碎片 |
CHUNKING_MAX_CHUNK_TOKENS | 512 | 普通 final 的打包目标 | 触发语义选点并控制常规体积 |
CHUNKING_HARD_MAX_TOKENS | 1024 | 语义细分后的 source mixed core | 阻止最终核心切片无限增长 |
128 token 是软下限,回答“当前内容是否已经足以独立成块”;512 token 是软目标,回答“是否应该开始寻找更合适的切点”;1024 token 是硬防线,回答“经过所有合并之后是否仍然可以接受”。如果只保留一个最大长度,就无法同时表达这三种不同意图。

普通文本 atom 在 builder 阶段已经被拆到不超过 512 token,packer 也会尽量让常规 segment 留在这个目标内。不过,标题回并、元素之间的分隔符以及 protected 内容可能让最终切片略高于软目标。assembler 会把这种情况标记为 oversized=true,并写入原因,而不是假装所有输出都严格等于或小于 512。
protected 内容允许经过一段容忍带
对普通文本来说,句子或 token-safe 边界总能提供进一步切分的位置。protected atom 不具备这个条件。代码、公式、图片和表格要么整体保留,要么不得不采取有损处理。
当单个 protected atom 大于 512、但不超过 1024 token 时,它可以作为 oversized final 保留。代码或公式前面若有一段引导说明,packer 还允许二者在不超过 hard max 的前提下留在一起。这样做的理由很实际。一段“下面的代码演示重试过程”若与代码分离,两个 chunk 都会变得难以理解。
oversized 不是报错,而是一条诊断信息。metadata 会区分单个 protected 元素、protected 与上下文组合,以及少量 token-safe 残余等原因。调用方可以据此观察真实文档中哪些结构经常迫使 chunk 超出普通目标,而不需要从正文内容反推。
合并标题和短片段之后,packer 会再次按 atom 边界执行 hard max 收口。能在 atom 之间切开的 segment 会被拆开,普通 source mixed final 因而不会仅凭后处理越过 1024 token。真正棘手的是单个 protected atom 自身已经超过 hard max,此时再找 gap 已经没有意义,因为元素内部不存在合法 atom 边界。
当前实现对此采取受控截断。assembler 先逐行累积内容,保留 hard max 以内最后一个完整行边界;如果第一行本身就超过上限,才退回 tokenizer 的安全前缀。输出会记录 truncated=true、具体 truncated_reason 和 original_token_count。截断意味着 coarse content 的尾部没有进入任何 source final,因此 validator 会对带有 truncated 标记的来源组放宽完整覆盖检查,但仍要求每个输出片段来自原 coarse content 的有序子串。代码和公式没有 derived 副本,不能假设丢失尾部会从另一条路径找回。

这种策略把有损行为暴露出来,却不会让丢失的尾部自动进入其他 chunk。它是一条最后防线,不应被描述为理想的代码或表格切分方案。若某类超大结构在业务文档中频繁出现,更合理的改进可能是为该元素设计专门的分解与重组方式,而不是长期依赖截断。
这里还要区分 source mixed 与 derived chunk。hard max validator 跳过 derived chunk,因为派生块可能需要保存完整原始表格等专用内容。它也跳过 front matter。换言之,1024 token 的硬保证约束的是启用 semantic_depth_window 后的普通 source mixed core,不是所有导出对象不加区分的一刀切规则,也不包含稍后追加的 overlap。
front matter 是明确保留的例外
front matter 在结构整理时已经独立成块。语义细分识别到它后会直接 passthrough,不构造 atom、不请求 embedding,也不执行截断。即使元数据块超过 hard max,它仍然保持原样。
这不是遗漏,而是结构保护决策。front matter 可能包含 YAML-like 或 TOML-like 元数据,从中间截断容易产生不可解析的内容,也可能把文档级字段错误地拼进正文。它通常不承担检索语义,因此没有必要参与 cohesion。最终 overlap 同样永远跳过 front matter,不受 protected overlap 开关影响。
这项例外也意味着调用方不能把 hard max 理解成整个 pipeline 对任意 chunk 的绝对承诺。当前解析链路仍会持久化并向量化 front matter 与 derived,向量化入口没有按 role 做统一长度预检或截断;超大的 derived、front matter,以及 overlap 后的 content 都可能超过具体 embedding provider 的输入限制。启用语义细分只能收口普通 source mixed core,不能保证每一个索引输入都安全。
没有语义证据和 embedding 失败不是一回事
某个 coarse chunk 可能包含大量代码、公式和标题,真正可评分的自然语言 atom 很少。此时部分 gap 的一侧没有向量,无法得到 cohesion;整个窗口没有合法 depth 时,packer 就退回最靠后的结构边界。这是正常退化,因为输入本身没有提供足够语义证据。
embedding 服务返回错误则属于运行失败。算法不会捕获异常后假装“没有语义证据”,更不会静默切换到 noop。否则同一份文档会因为一次网络抖动得到不同分块结果,metadata 却无法说明变化来自哪里。
当前实现把可评分文本按最多 10 条分成 batch。这个 10 是 Stage 2 模块内部常量,不是索引 embedding 的 CHUNK_INDEX_EMBED_BATCH_SIZE。如果前面的 batch 已经成功,后面的 batch 超时,在同一次 Stage 2 执行内只重试失败的那一组,不重复请求已完成部分。超时、连接传输错误、HTTP 429 和 5xx 被视为瞬时错误,每个失败 batch 最多额外重试两次;两次和退避时间同样是内部常量。
重试仍然失败时,算法抛出 RetriableError,交给 parse task 做任务级重试。成功 batch 的向量只保存在这次调用的内存中,没有跨任务缓存;任务重跑会从 atom 构造开始,重新请求全部评分 batch。其他 4xx 通常表示参数、权限或模型配置有问题,重复请求没有意义,因此直接上抛。响应中的 embedding 数量与请求文本数量不一致也属于契约错误,不执行重试,以免掩盖 provider 或适配层缺陷。

这套错误语义让“结构兜底”与基础设施故障保持不同的程序路径。不过,文中这次 embedding 只用于 atom 边界评分。分块完成后,向量索引还会对最终 source 和 derived Chunk.content 再做一次 embedding,两次调用不复用向量。Stage 2 当前按发起用户的默认 embedding 配置解析模型,dense 索引则按数据集绑定配置解析,二者可能使用不同 provider 或模型;前者失败发生在 chunking 阶段,后者失败发生在 vectorizing 阶段。
现有 chunk metadata 会记录 candidate_boundary + semantic_depth_window 这样的算法名,却不记录 Stage 2 scorer 的 provider、model、config ID、tokenizer 模式或参数快照。因此“算法步骤可解释”和“某次运行可完全复现”不是一回事。正式实验需要在 chunk 之外保存这两次 embedding 的配置、tokenizer、阈值、代码版本和随机性设置。
derived chunk 要重新找到 source
结构整理阶段,一个表格 derived chunk 只需要指向它所在的 coarse chunk。语义细分后,这个 coarse chunk 可能变成多个 final chunk,原来的 coarse ID 已经不足以说明表格最终落在哪一块。
assembler 因此会在包含图片或表格 atom 的 final metadata 中写入 contained_element_ids。exporter 处理 derived chunk 时,优先用它自己的 element_id 查找对应 final,再写入新的 source_chunk_index。这里的 anchor 即锚点,是把派生检索入口重新连接到包含其原文位置的 source final 的稳定标识。
noop 路径没有进行语义细分,也不一定写入同样的 contained 声明。为兼容这条路径,exporter 在找不到 element anchor 时,会回退到 source_coarse_chunk_id 对应的第一个 final。由于 noop 下一个 source coarse 只转换为一个 source final,这个回退仍然能保持关系正确。

overlap 最后才进入正文
语义细分输出的 final core 之间是无重叠切片。validator 正是依靠这一点检查它们能否按顺序还原 coarse content。等 exporter 生成稳定的 list[Chunk] 后,pipeline 才向符合条件的 chunk 添加 neighbor overlap。
默认每侧最多取 64 splitter token。overlapper 先保存所有 chunk 的原始 core content,再从符合条件的前一个 chunk 取末尾、从后一个 chunk 取开头,拼到当前正文两侧。实际添加的 token 数会写入 context_prev_tokens_applied 和 context_next_tokens_applied,并标记 context_overlap_mode=neighbor。
derived chunk 不参与 neighbor overlap,因为它已经拥有自己的 adjacent context。front matter 永远跳过。含图片、表格、代码或公式等 protected element 的 source chunk 默认也跳过,只有显式设置 CHUNKING_PROTECTED_NEIGHBOR_OVERLAP=true 才允许参与。实现会先过滤出具备 overlap 资格的索引,再在这个序列中取前后项。因此这里的 neighbor 不是严格的原文邻居:跳过 derived 通常仍沿 source 主线,但跳过一个 protected source 或 front matter 时,也可能把两侧并非直接连续的普通 source 接起来。这是当前实现的限制,不能把 neighbor overlap 当作语义连续性的保证。

由于 hard max validator 在 overlap 之前运行,1024 token 约束的是 source final core。导出后的 Chunk.content 若再加入前后上下文,长度可能高于这个核心上限。start_line、end_line、标题路径和 source 映射仍描述 core,不会扩展到 overlap;metadata 只保存前后追加的 token 数,没有保存对应 source ID、字符区间或精确长度,因此无法仅凭最终 Chunk.content 无歧义地剥离 core。正式评测若要计算 source 覆盖、证据 token 和重复比例,必须在 overlap 前保存 core/source span,不能从入库 content 反推。
至此,结构、语义和异常路径形成了一个完整闭环。正常文本尽量靠近 soft target,protected 内容获得有限容忍,极端元素留下可审计的截断记录,模型故障不会伪装成正常退化,derived chunk 与 source 保持可追溯关系,overlap 则在所有边界确定后补充上下文。下一章将回到配置和使用范围,说明当前版本为什么保留这两套算法,以及在什么条件下值得启用语义细分。
当前版本的取舍
从代码结构看,LinkRag 为两次处理都留出了算法路由。路由的意义是让阶段之间通过稳定的数据模型连接,未来增加实现时不必改写整个 pipeline。它不表示当前已经准备了许多算法供人挑选。到本文对应的版本为止,结构整理只有 candidate_boundary,长文本的语义细分只有 semantic_depth_window。
这个范围是有意收住的。结构整理直接关系到元素覆盖、标题归属和 derived anchor,一套新算法若不能满足同样的契约,就不能只因为“切出来的长度差不多”而接入。语义细分则多出 embedding 调用、重试和参数组合,保留多套未经同一文档集评测的实现,只会让配置项看起来丰富,却说不清什么时候应该选哪一个。
默认先使用结构结果
系统默认的第二阶段配置是 noop。它接收 CoarseChunkSet,把每个 coarse chunk 转成对应的 final chunk,不再判断内部语义边界。这里的 noop 是架构上的透传实现,不是与 semantic_depth_window 并列的另一种语义算法。
这条默认路径适合两类情况。一类是文档自身标题清楚,而且实际 coarse token 分布已经确认不会触碰下游 provider 限制;另一类是还没有为目标数据准备好 Stage 2 embedding 成本和检索评测。此时保留结构结果,比默认引入一层未经验证的语义判断更容易解释。它并不自动等于“长度安全”或“跨环境可复现”。
需要注意的是,noop 不会替结构结果补做 512 token 的软目标或 1024 token 的硬收口,对 final 长度没有上限承诺。如果一篇无标题文档在结构整理后形成数万 token 的 coarse chunk,它仍会完整传给后续向量化;当前索引 embedding 入口没有统一预检或截断,具体 provider 可能因输入过长而拒绝请求。开篇的 1243 token 只是这个风险的较小实例,不是 noop 的最大长度。
启用不等于每块都计算 embedding
要启用语义细分,最小配置可以写成下面这样。
| |
第一行在当前版本只能取 candidate_boundary。第二行将透传切换为 semantic_depth_window。后三行分别控制结构边界的软下限、普通 final 的软目标和 source mixed core 的硬上限,它们在前一章已经分别解释过。
文章出现的数字并不都能通过环境变量调整。
| 数值或开关 | 性质 | 当前调整方式 |
|---|---|---|
| 128/512/1024 三个阈值 | Settings,也可由数据集分块配置覆盖 | 环境变量或数据集配置 |
| neighbor overlap 64、protected overlap | Settings,也可由数据集配置覆盖 | CHUNKING_OVERLAP_TOKENS、CHUNKING_PROTECTED_NEIGHBOR_OVERLAP |
| cohesion 窗口目标 128 | Stage 2 内部常量 | 需要修改代码 |
| Stage 2 batch 10、额外重试 2 次、0.5 秒退避 | Stage 2 内部常量 | 需要修改代码 |
| 索引 embedding batch 与任务重试 | 向量化阶段的另一组 Settings | CHUNK_INDEX_*,不控制 Stage 2 评分 |
即使第二行已经启用,语义算法也不会处理所有 coarse chunk。derived chunk 直接保留;front matter 直接保留;token 数不超过 512 的 mixed coarse chunk 同样直接保留。只有超过普通目标的 mixed 内容才会构造 atom、请求 embedding、计算 cohesion 和 depth。换句话说,配置开关决定这项能力是否可用,chunk 自身的角色与长度决定它是否真的运行。由于 derived、front matter 与 overlap 后 content 不受 hard max 约束,启用开关也不代表所有最终索引输入都已满足 provider 限制。
这层 gate 很重要。一个文档可能有二十个结构清楚的小节,只有其中一个长章节缺少内部标题。让十九个本来合格的 chunk 重新参与语义计算,不仅增加调用量,也可能改变原本清晰的结构边界。当前实现只处理那一个确实过长的 mixed chunk,把语义判断限制在它能解决的问题上。

参数不是越小越精细
三个 token 阈值之间存在联动。把 512 改成 256,会更早触发切分,也会让 packer 每次可比较的合法 gap 变少。chunk 数和 overlap 重复量通常随之增加,短问题可能更容易命中局部内容,依赖上下文的问题却可能丢失支撑。把 512 提高到 1024 可以降低索引条目和 embedding 计算,但检索返回的单块内容更宽,主题混合的风险也随之上升。
128 同样不能脱离另外两个值单独理解。它过高时,结构整理会跨过更多标题边界,语义细分接收到的 coarse chunk 更大;它过低时,短标题和短段落更容易提前组成独立候选,后续又需要反复合并碎片。配置校验因此要求普通目标不小于最小候选阈值,hard max 不小于普通目标,但这种数值顺序只保证配置合法,不保证它适合某类文档。
开启语义细分还会增加索引前处理成本。普通文本 atom 先为边界评分请求一次 embedding;分块完成后,final chunk 又会为向量索引请求第二次 embedding,两者不复用,模型配置也可能不同。另一方面,最终的 source chunk 可能变多,neighbor overlap 会增加实际索引 token。成本不能只按“调用了一次模型”估算,而应同时记录参与评分的文本数、输入 token、请求批次、任务重投、最终 chunk 数和索引 token。当前索引阶段已有 usage 汇总,Stage 2 scorer 尚未累计 provider usage,正式实验需要增加独立 instrumentation。
因此,当前版本把结构整理作为基础能力,把语义细分作为长 mixed 文本的可选增强。是否启用,不应只看文档里有没有长章节,也要看这些章节是否真的包含多个可分辨的话题,以及新的边界能否改善目标检索。要回答后一个问题,就需要一套比“chunk 看起来顺眼”更可靠的评判方法。
怎么判断分块的质量
分块结果很容易得到一种直观评价。标题没有落单、长度大致整齐、读起来也像几个完整段落,于是我们倾向于说它“切得不错”。这种判断有用,却没有一个公认的单一分数可以替代它。文本分段研究关心边界与人工分段是否接近,信息检索研究关心相关内容能否排进前几名,RAG 评测还会继续追问取回的上下文是否足以支撑答案。这三件事相关,但不是同一件事。
当前项目还没有正式运行过 chunking 对照实验。单元与验收测试覆盖了 source final core 对 coarse content 的区间覆盖、protected 处理、hard max、derived anchor 等契约,它们能证明实现有没有破坏既定规则,不能推出召回或回答质量有所改善。下面这套指标因此分成三层。第一层检查共同正确性与各方案声明的结构能力;第二层采用文本分段与信息检索领域已有的通用指标;第三层才借助 RAG 评测方法观察生成端。没有跑出的分数仍然不会写成结果。
本节采用的指标并非自行命名。文本分段部分主要参考 ACL Anthology 收录的 WindowDiff 论文 和 Boundary Similarity 论文;检索指标的定义参考 Stanford 在线版 《Introduction to Information Retrieval》评测章节,并对照 BEIR 与 arXiv 上的 Late Chunking 如何使用 nDCG@10。RAG 下游部分参考 ACL Anthology 的 RAGAS 与 ARES 论文,以及 Ragas 官方文档中的 Context Precision 和 Context Recall。这些来源解决的是不同层面的问题,正文不会把它们合并成一个来源不明的“chunk score”。
边界本身有一套评测传统
TextTiling 论文让七名读者分别为 12 篇杂志文章标记段落间的话题变化。这些文章大多在 1800 到 2500 词之间,而且刻意选择了结构标记较少的文本。论文用边界 precision、recall 和读者一致性分析结果。此后的文本分段研究又形成了几项更专门的指标,其中最常见的是 Pk 和 WindowDiff。
Pk 最早用于比较参考分段与算法分段。它在文本上移动一个固定宽度的窗口,观察窗口两端是否应当属于同一 segment。如果参考答案认为两端同属一段,算法却把它们分开,或者情况相反,就记一次错误。最终分数是错误窗口所占的比例,越低越好。窗口宽度通常取参考 segment 平均长度的一半。
Pk 的优点是比逐个边界做完全匹配更能容忍邻近偏移,问题是它对漏切和多切的惩罚并不对称,也会受 segment 长度分布影响。Pevzner 与 Hearst 在 2002 年的论文系统分析了这些问题,并提出 WindowDiff。它仍然移动固定宽度窗口,但不再只比较窗口两端是否同段,而是比较窗口内的边界数量。参考答案与算法给出的数量不同,这个窗口才记错。WindowDiff 同样越低越好,通常比 Pk 更对称地处理漏切和多切,也能让只偏移少量位置的边界获得较小惩罚。
WindowDiff 也不是最后答案。窗口类指标会低估文档开头和结尾附近的错误,对边界很少或挤在一起的分段也可能产生偏好。Fournier 2013 年提出的 Boundary Similarity改用边界编辑距离,把差异解释为新增、删除和邻近位置移动。完全漏掉一个边界需要新增或删除;切点只偏了一两个单位则可以看作移动,因此得到部分分。它还能导出 B-Precision 和 B-Recall,分别观察多切与漏切。
这些指标都建立在同一条前提上,即参考分段和算法分段必须落在同一组原子位置上。对 LinkRag 来说,不能一份结果按段落计数,另一份按字符计数。正式评测会冻结 tokenizer 实现、tiktoken 与 cl100k_base 版本,并在运行前确认没有退回字符估算;当前 chunk metadata 无法替我们完成这项追溯。parser 元素、句子拆分和 token-safe 位置统一映射到 source token offset。人工标注只在 atom builder 允许的 gap 上选择边界,固定长度基线的切点也映射到同一 token 轴。Pk 与 WindowDiff 的窗口宽度按人工分段的平均 source token 长度计算。
标注者仍然需要表达粒度上的不确定性。每个候选 gap 使用“应当分开”“应当保留”“两者皆可”三个标签。至少三名标注者独立判断,彼此看不到算法结果。两人以上选择“应当分开”的位置组成严格 gold;把两人以上选择“应当分开”或“两者皆可”的位置纳入后,可以再形成一份宽松 gold。两份结果同时报告,比事后调大容忍距离更诚实。
人工一致性也不再只按完全相同的 gap 计算 kappa。TextTiling 当年的 kappa 能反映总体分歧,但两个读者若把同一次换题标在相邻 gap,会被视为彻底不一致。这里更适合报告标注者两两 Boundary Similarity,并辅以基于边界相似度的机会校正一致性。若人工之间本来就难以达成一致,算法与单一 gold 的差异也不应被过度解释。

正式报告中,WindowDiff 与 Boundary Similarity 作为并列的主要边界指标。前者观察滑动窗口里的分段数量是否一致,后者观察边界经过多少编辑才能对齐;Pk 用于与既有文本分段工作保持可比,B-Precision 和 B-Recall 则进一步解释错误究竟是漏切还是多切。它们都不能回答检索是否变好,因此还需要另一套指标。
RAGAS 类指标放在更后面
检索命中证据之后,RAG 还可能因为上下文冗余、提示词或生成模型而失败。RAGAS 论文把评测拆成 context relevance、faithfulness 和 answer relevance。当前 Ragas 文档又提供了更接近传统 IR 命名的 Context Precision 与 Context Recall。前者判断相关上下文是否排在前面,后者可以把参考答案拆成 claims,检查取回内容支撑了其中多少。
这些名称与普通 Precision@k、Recall@k 相似,计算过程却不一定相同。传统 IR 指标依赖预先固定的 relevance judgment;Ragas 可以让 LLM 根据问题、参考答案和 retrieved context 临时做判断。它降低了人工标注成本,也引入了 judge 模型、prompt、版本和随机性的影响。RAGAS 原论文专门讨论了重复运行的一致性,并承认长上下文的 context relevance 更难判断。ARES也没有把自动 judge 当作无条件真值,而是使用少量人工标注校准评估。
因此,LinkRag 不应拿一个 LLM-as-judge 分数替代确定性的检索指标。正式评测先用 source evidence 和固定 relevance grade 计算 Evidence Recall、MRR 与 nDCG;生成模型、prompt、top-k 和 reranker 全部冻结后,再把 Context Precision、Context Recall 与 faithfulness 作为第二层观察。自动评审需要记录模型与 prompt 版本,并抽样与人工判断对照。它能回答“这些 chunk 最后有没有被模型用好”,却不能把所有变化都归因到 chunking。
LinkRag 的正式指标组
综合文本分段、信息检索与 RAG 评测,LinkRag 的第一版正式评测不采用一个总分,而是保留下面这组指标。共同正确性属于所有方案的 gate;protected、derived 和标题等则是结构化方案的能力与契约指标。固定长度基线可以在这些结构指标上失败,但仍应进入检索与成本比较,否则实验只是在定义上淘汰基线。
| 层面 | 正式指标 | 简单计算 | 首轮用途 |
|---|---|---|---|
| 共同正确性 | 规范化 source 覆盖率、顺序与区间可映射率、未声明丢失率 | 在 overlap 前保存 core/source span;覆盖率为 core 来源区间并集的 token 数除以规范化有效 source token 数,顺序与区间必须能够回到同一 source 轴,任何未带诊断的缺失单独计错 | 三种方案都必须使用同一 parser 输出并可生成 qrels;固定长度也不得静默丢失内容 |
| 结构能力 | protected 内切率与截断率、derived 锚定正确率、标题正文连接率、非法 hard-max 比例 | protected 内切与受控截断分开统计;锚定正确率为正确指向 source 的 derived 数除以 derived 总数;标题连接率为与所属首段正文同块的标题数除以适用标题数;非法长度率只统计没有允许例外却越界的对象 | LinkRag 两条结构化路径必须满足各自声明的契约;固定长度基线允许表现较差但不因此退出检索比较 |
| 人工边界 | WindowDiff、Pk、Boundary Similarity、B-Precision、B-Recall | 窗口宽度取人工平均 segment 长度的一半;Pk 是窗口两端“是否同段”判断不一致的窗口比例;WindowDiff 是窗口内边界数量不一致的比例;Boundary Similarity 为 1 减去归一化后的最小边界编辑代价;B-Precision 与 B-Recall 分别用匹配边界的得分除以算法边界数和人工边界数 | WindowDiff 与 Boundary Similarity 并列报告,Pk 保持历史可比性,B-Precision/B-Recall 解释漏切和多切 |
| 排序检索 | Evidence Recall@5、Evidence Recall@10、MRR@10、nDCG@10、Precision@5 | Recall@k 为 top-k 覆盖的不同 gold 证据单元数除以证据单元总数;MRR@10 对每个查询取首个相关结果名次的倒数再求平均,top-10 未命中记 0;DCG@10 = Σ(2^rel - 1) / log2(rank + 1),nDCG 再除以理想排序的 DCG;Precision@5 为 top-5 中相关结果数除以 5 | Recall 看证据覆盖,MRR 看首个命中,nDCG 看分级相关性与排序 |
| 上下文预算 | 2048 token 内的 Evidence Recall、证据 token 比例、重复来源 token 比例 | 按排名依次加入 chunk,完整内容累计到 2048 token 为止,再计算证据覆盖率;证据 token 比例为取回的不同 gold source token 数除以实际返回 token 数;重复比例为已在更高排名内容中出现过的 source token 数除以实际返回 token 数 | 防止通过更长 chunk 或大量 overlap 虚增命中 |
| RAG 下游 | Context Precision、Context Recall、faithfulness,辅以人工答案支撑判断 | Context Precision 对每个被判相关的排名位置计算当时的 Precision@k,再取平均;Context Recall 为取回内容能够支撑的参考答案 claim 数除以参考 claim 总数;faithfulness 为能由 retrieved context 推出的回答 claim 数除以回答 claim 总数 | 只在 generator、prompt、retriever 与 reranker 冻结后作为第二层指标 |
| 成本 | 每万源 token 的 chunk 数与索引 token、Stage 2 embedding token 和请求数、p50/p95 时延、重试率、失败率 | chunk 或索引 token 数除以源 token 数后乘 10000;embedding 成本直接累计评分请求的输入 token 与批次数;p50/p95 取文档处理时延的第 50、95 百分位;重试率和失败率分别用重试 batch、失败文档除以各自总数 | 与质量指标一起形成成本收益曲线 |
表中 nDCG 公式里的 rel 是当前排名位置的相关性等级。首轮实验采用 0、1、2 三级,分别表示没有证据、只有部分必要证据、单个 chunk 已足以支撑整个查询。所谓有效源 token,则排除水平分割线等 pipeline 明确过滤的内容;若发生受控截断,缺失部分单独计入 truncated 统计,不能用覆盖率掩盖。
共同正确性不能被较高的 Recall@10 抵消。任何方案若在自己的输入表示上出现未声明丢失、顺序错乱或 qrels 无法映射,比较就不成立。protected 内切、derived 锚定和标题连接则要按能力范围解释:它们是 LinkRag 结构化路径必须守住的契约,也是固定长度基线可以如实失败的质量指标。后者不会因此被禁止进入检索比较。WindowDiff 很低同样不能证明检索一定更好,真实用户的问题可能需要跨越人工标出的两个 segment。
一次可以正式执行的对照实验
第一轮正式实验可以使用 60 篇允许公开或内部合规使用的真实 Markdown,每篇约 1500 到 5000 token。文档按主要困难分成五组,每组至少 12 篇,分别覆盖标题清楚、标题稀疏、完全无标题、protected 元素密集和含超长元素的情况。20 篇作为 development set,用于确定 token 阈值和标注规范;余下 40 篇作为一次性 test set。参数不能在看过 test 分数后继续调整。
同一批文档运行三种方案。第一种使用同一 tokenizer 做 512 token 固定长度切分,并保留 64 token overlap;第二种是 candidate_boundary + noop;第三种是 candidate_boundary + semantic_depth_window,先以本文所列的 128、512、1024 作为默认参数。三种方案共享 parser 产出的规范化来源文本、tokenizer、最终索引模型、向量索引、查询、reranker 和 top-k。第三种方案还要额外冻结并记录 Stage 2 scorer 的 provider、model 与 config ID;它可以与最终索引模型不同,但实验结论只对这组组合成立。固定长度基线只是不使用元素边界,不能换一份更干净或更脏的输入制造差异。
边界标注使用前述三人独立流程,并同时产出严格与宽松 gold。检索任务在查看任何方案的 chunk 之前制作,每篇至少五个问题,总数不少于 300。问题应包含局部事实、需要连续两三段说明的解释、标题层级限定的问题,以及针对表格、图片说明或代码的查找。每个问题保存一至多个最小证据单元,证据天然跨段时不得为了迎合某个 chunk 大小而缩短。
评测程序根据 source 区间动态生成每次运行的 qrels。qrels 是 query relevance judgments,也就是每个查询与候选检索项之间的相关性标注。一个 chunk 单独包含回答查询所需的全部证据时记 2,只覆盖部分必要证据、仍需与其他 chunk 组合时记 1,没有证据时记 0。core/source span 必须在 overlap 前保存,因为当前入库 Chunk.content 只有前后 overlap token 数,无法无歧义恢复 core。这个规则要在 development set 上由人工抽查,确认 derived、overlap 和 truncated 内容都被正确映射,再冻结到 test set。每个证据单元是否被找回仍然单独记录,供 Evidence Recall 使用,不能从 0/1/2 的单块 relevance 反推。
检索结果需要保存 rerank 前后两个版本。原始 top-10 反映 chunk 表示与一阶段 retriever 的效果,rerank 后 top-5 和 top-10 反映真实消费链路。这样即使最终结果相近,也能看出是 chunking 本身已经把证据排到前面,还是 reranker 修复了较差的初始排序。
统计时按文档做 paired bootstrap。paired 表示同一次抽样中三个方案始终使用相同文档,从而比较同一批样本上的差异;bootstrap 则通过多次有放回抽样估计置信区间。总体指标之外还要给出五类文档的分组结果,因为“无标题文档改善、标题清楚文档退化”在总平均里很容易被抵消。
成本记录来自同一次实验运行,但不能假设现有 pipeline 日志已经包含全部字段。索引 embedding 有 usage 汇总,Stage 2 scorer 目前没有累计 provider usage,需要额外 instrumentation 保存参与评分的 atom 数、输入 token、batch、batch 内重试和任务重投。还要记录 source 与 derived chunk 数、加入 overlap 前后的索引 token。若 Recall@10 提高的同时索引 token 翻倍,报告必须把两者放在一起,而不是只展示前一个数字。

结果不应被压成一个总分
正式结论先看共同 gate,再看结构能力与成对指标。规范化 source 出现未声明丢失或区间无法映射时先修实验;LinkRag 路径的 protected、derived anchor 或 hard-max 契约不合格时先修实现;固定长度在相同指标上的失败则作为比较结果保留。进入效果比较后,Evidence Recall@10 需要与 Precision@5、nDCG@10 和 2048-token 预算下的 recall 同看;WindowDiff 需要与 Boundary Similarity 同看;RAGAS 类分数则必须带上人工校准结果。首轮实验没有跑完之前,不预设一个看似精确的总阈值。
这也解释了为什么 Recall@k 不能单独成为 chunking 的最终分数。增大 k、切出更多 chunk 或加入更多 overlap,都可能把 Recall@k 推高,同时增加噪声和成本。相反,一种方法的边界与人工标注不完全一致,却可能在真实查询上取得更高 nDCG,因为用户问题的证据粒度本来就不等同于文章子话题的粒度。
更有用的结论是找出适用范围。若语义细分只在无标题和标题稀疏文档中稳定改善 Evidence Recall 与 nDCG,在标题清楚的文档中没有收益,那么路由条件应继续细化;若 256 token 的目标提高 Hit@5,却降低 budgeted recall 和 context precision,就不能把它简单称为更精细的配置。评测的任务不是替算法盖章,而是说明它在哪些文档和问题上值得付出额外成本。
目前我们能严格声称的是,代码已经实现了本文明确限定对象后的结构、长度和异常契约。它还没有解决所有 pipeline 风险:tokenizer fallback 不可观察,Stage 2 运行配置没有写进 chunk metadata,derived/front matter 缺少统一 provider 长度预检,overlap 也不能从最终 content 精确反解。人工边界、检索排序和 RAG 下游收益仍需要按上述方法取得证据。这个区分让文章最后回到开篇样例时,不必用一个好看的可视化替代正式结论。
回到最开始的文档
开篇那段“堆内存分配与回收策略”在结构上没有错误。它属于同一个四级标题,内部没有新的标题可供 candidate_boundary 使用,因此在 noop 路径中完整保留为一个 1243 token 的 mixed chunk。标题和正文没有被拆散,代价是垃圾回收算法、Eden 与 Survivor、对象晋升、空间分配担保以及 TLAB/CAS 同时占据一个检索单元。
启用 semantic_depth_window 后,同一个 coarse chunk 才进入第二次判断。现有可视化报告中,它被整理为四个 source chunk,导出后计入 neighbor overlap 的长度分别为 265、479、641 和 308 token。它们大致对应分代回收的引入、Eden/Survivor 的分区与比例、对象晋升及空间担保、TLAB/CAS 并发分配。641 token 高于 512,不代表 core 的软目标失效;报告展示的是已经附加前后上下文的 exported Chunk.content,而 512 本来也是普通打包目标,不是导出内容的硬上限。

这四块是那次运行的真实输出,但现有报告没有保存 JVM 案例的 atom timeline、各 gap 的 cohesion/depth 和每次 packer 选点原因。文章中间的 a0–a5 和分数只是教学构造,不能充当这次运行的 trace。因此这个结果只能说明实现产生了怎样的候选输出,还不能证明四个边界最优,更不能单独证明检索变好。真正的判断要回到上一章,让人工边界、证据召回、答案支撑和成本共同说话。
LinkRag 在这里没有寻找一个对所有文本都适用的切分尺度。标题层级决定哪些块级结构优先留下,cohesion 的低谷帮助过长正文选择边界,token 阈值约束普通 source mixed core,protected、derived 和 overlap 则各自带有前文说明的例外。TextTiling 提供了观察相邻上下文变化的起点,最后落进代码的是一套受 Markdown 块级结构和 RAG 预算约束的实现。
这套实现已经能把开篇的问题从“一个标题下只有一个超长块”推进到一组按明确规则产生的候选边界,但现有 chunk metadata 还不足以复现每次评分配置。它是否适合某个知识库,仍要由那个知识库里的文档和问题来回答。下一步不是再为算法换一个名字,而是补齐实验记录并把评测跑起来。