<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>LinkRag on 边个濑椰的博客</title><link>https://blog.kawausococo.top/tags/linkrag/</link><description>Recent content in LinkRag on 边个濑椰的博客</description><generator>Hugo -- gohugo.io</generator><language>zh-CN</language><lastBuildDate>Sun, 12 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://blog.kawausococo.top/tags/linkrag/index.xml" rel="self" type="application/rss+xml"/><item><title>LinkRag 的结构化与语义 Chunking 方法</title><link>https://blog.kawausococo.top/p/linkrag-%E7%9A%84%E7%BB%93%E6%9E%84%E5%8C%96%E4%B8%8E%E8%AF%AD%E4%B9%89-chunking-%E6%96%B9%E6%B3%95/</link><pubDate>Sun, 12 Jul 2026 00:00:00 +0800</pubDate><guid>https://blog.kawausococo.top/p/linkrag-%E7%9A%84%E7%BB%93%E6%9E%84%E5%8C%96%E4%B8%8E%E8%AF%AD%E4%B9%89-chunking-%E6%96%B9%E6%B3%95/</guid><description>&lt;p&gt;在 RAG 系统里，文档通常不能以完整文件的形态直接参与检索。它需要先被拆成若干 chunk，分别建立索引，再由查询召回其中最相关的部分。这个环节看起来只是数据准备，实际上会改变检索系统看到的知识形态。&lt;/p&gt;
&lt;p&gt;chunk 太大，召回结果会夹带许多与问题无关的内容，真正有用的信息反而不够突出；chunk 太小，标题、前提和结论又可能散落在不同位置。即使检索命中了其中一块，模型拿到的也可能只是一句失去上下文的话。为了缓和这种断裂，工程上经常会加入 overlap，让相邻 chunk 重复一小段内容，但 overlap 只能修补切点附近的连续性，不能替我们判断一篇文档究竟应该在哪里分开。&lt;/p&gt;
&lt;p&gt;Markdown 又让这个问题多了一层约束。它不是一串没有形状的纯文本。标题表达章节关系，列表组织并列信息，代码块和公式有完整边界，图片与表格往往还依赖前后的说明。splitter 是负责把文档组织成 chunk 的模块。若它在处理这些内容时只看 token 数，原文中已经存在的结构就会在进入向量库之前被抹平。&lt;/p&gt;
&lt;p&gt;LinkRag 的 chunking 正是在这些问题里逐步形成的。当前方法先利用 Markdown 的块级结构组织候选块，再处理其中仍然过长的正文，在可行的范围内寻找更合适的语义边界。后一部分受到 TextTiling 的启发，但项目没有照搬论文算法。原始 TextTiling 使用词项分布观察相邻文本的凝聚度变化；LinkRag 面对的是已经结构化的 Markdown 元素，并且使用 embedding、token 预算和受保护元素规则共同约束切分。这里的 embedding 指文本经向量模型转换后得到的语义表示，token 预算则是 splitter 自己用于计数和打包的尺度。受保护元素包括代码块、公式块、图片和表格；它们在普通打包时不会被从内部切开，但这种保护受 hard max 约束，并不是无条件的内容完整保证。&lt;/p&gt;
&lt;p&gt;先说明本文对应版本的状态。结构整理 &lt;code&gt;candidate_boundary&lt;/code&gt; 是当前分块链路的基础；语义细分 &lt;code&gt;semantic_depth_window&lt;/code&gt; 已经实现，也有阶段契约与异常路径测试，但系统默认仍然使用 &lt;code&gt;noop&lt;/code&gt; 透传，需要显式配置才会启用。项目尚未完成正式的检索与 RAG 对照实验，因此本文能够说明的是实现行为、结构与长度契约以及已知限制，不能据此声称召回或回答质量已经提高。&lt;/p&gt;
&lt;p&gt;文章开头和结尾使用的是同一份 JVM 文档的真实输入、输出与可视化报告；中间为了解释 atom、gap、cohesion 和 depth 使用的数字则是教学构造，并不是那次运行的内部 trace。下面先看这个真实案例，再依次说明文档结构怎样进入算法、长正文中的候选边界怎样计算，以及图片、表格、代码和极端长度如何处理。最后还需要回答一个更难的问题，也就是怎样判断一次分块究竟合理，还是仅仅满足了程序约束。&lt;/p&gt;
&lt;h2 id="从一个失败的-chunk-开始"&gt;从一个失败的 chunk 开始
&lt;/h2&gt;&lt;p&gt;这个问题并不是从算法论文开始的。开发过程中，我们曾把一份讲解 JVM 运行时数据区的 Markdown 文档送入完整解析流程，并将所有分片结果做成可视化报告。报告中有一个 chunk 很醒目。它的标题路径是“堆内存 &amp;gt; 堆内存分配与回收策略”，覆盖原文第 94 至 121 行，共 1383 个字符，经过当时使用的 tokenizer 统计后达到 1243 token。tokenizer 是把文本换算为 token 的计数工具；这里记录 token 数，是为了用实际模型输入尺度衡量 chunk 大小，而不是简单按字符数估算。&lt;/p&gt;
&lt;p&gt;单看 Markdown 结构，这个结果没有明显错误。原文从一个四级标题开始，后面没有新的子标题，内容也都与堆内存有关。按照标题边界聚合，它自然会被视为同一节。问题在于，这一节内部实际上连续讨论了几组不同的问题。&lt;/p&gt;
&lt;p&gt;原文先从标记清除、复制和标记整理讲到分代回收，随后解释 Eden 区与两个 Survivor 区的划分，以及常见的 8:1:1 比例。再往后，讨论转向对象进入老年代的条件，包括大对象、动态年龄判断和空间分配担保。最后一部分已经离开回收策略，开始解释对象分配时的并发安全，以及 TLAB 和 CAS 各自承担的作用。&lt;/p&gt;
&lt;p&gt;把原文压缩后，可以看到这个 chunk 的跨度。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-markdown" data-lang="markdown"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gu"&gt;#### 堆内存分配与回收策略
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;很多 JVM 采用分代思想划分堆区域……
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;新生代通常包含 Eden 区和两个 Survivor 区……
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;对象还可能因为大对象、动态年龄判断或空间分配担保进入老年代……
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;在 JVM 中为对象分配空间还会遇到并发安全问题……
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;-&lt;/span&gt; TLAB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;-&lt;/span&gt; CAS 失败重试
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;如果用户问“Survivor 区为什么常见 8:1:1”，真正相关的是中间几段；如果问“TLAB 如何减少对象分配时的锁竞争”，相关内容则在这个 chunk 的末尾。两个问题虽然都属于 JVM 堆，却不需要共享一整段 1243 token 的上下文。将它们放进同一个向量表示，会让多个局部主题共同影响这个 chunk 的语义；召回后，排序器和生成模型也需要在更长的内容里重新定位答案。这里不能断言检索一定失败，但它显然已经不是一个聚焦的检索单元。&lt;/p&gt;
&lt;p&gt;最直接的修补办法是重新按固定长度切开。例如每到 512 token 就生成一个 chunk，再给两侧补上一段 overlap。这样可以控制大小，却不知道第 512 个 token 落在什么位置。切点可能位于 Eden 与 Survivor 的解释中间，也可能落在一个列表、链接或长句内部。overlap 能让两边各保留一点重复内容，却无法判断一段论述是否已经完整，更无法识别下一段是否开始讨论新的问题。后文的结构化方法会优先使用 parser 已知的块级边界，但它也不能无条件保证所有列表和行内 Markdown 始终闭合。&lt;/p&gt;
&lt;p&gt;这个失败案例还揭示了另一件事。标题边界和语义边界并不总是重合。标题可以告诉 splitter 这里仍然属于“堆内存分配与回收策略”，但不能证明标题下面的所有内容都适合成为同一个检索单元。反过来，语义相似度也不能不受约束地决定一切。代码块、公式块、图片和表格即使与前后文本的向量相似度较低，也不应该仅凭这一点从内部断开。&lt;/p&gt;
&lt;p&gt;因此，我们后来关心的问题不再只是“一个 chunk 最多放多少 token”，而是两个更具体的问题。文档已经给出的结构应该怎样进入分块判断；当一个候选块仍然过长时，程序又该怎样优先沿可用结构边界寻找相对自然的切点。&lt;/p&gt;
&lt;p&gt;现有的分片可视化报告保留了这个案例的对照结果。仅做结构整理时，上述内容是一个 1243 token 的 mixed chunk。mixed 表示其中混合了标题、段落、列表等多种 Markdown 元素，而不是某一种专用内容。启用语义细分后，它被组织成多个相邻片段，并保留必要的邻接上下文。这个对照并不能单独证明后者具有更好的检索质量，却足以说明，结构正确不等于粒度合适，长度受控也不等于边界合理。接下来需要先把 LinkRag 的整个分块流程展开，才能看清这两类判断分别发生在哪里。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-01.png" alt="失败 chunk 的前后对照" loading="lazy" /&gt;&lt;/p&gt;
&lt;h2 id="分块流程"&gt;分块流程
&lt;/h2&gt;&lt;p&gt;上一节的失败 chunk 同时提出了结构和长度两个问题。如果把所有判断都塞进一个 splitter 函数，标题规则、token 预算、语义相似度和异常处理很快就会缠在一起。某一条规则被修改时，很难判断它究竟改变了文档结构，还是只改变了长文本的切点。&lt;/p&gt;
&lt;p&gt;LinkRag 因此把分块组织成前后两次处理。第一次根据 Markdown 元素整理文档结构，得到候选块；第二次接收这些候选块，只在配置允许且确有需要时继续细分。源码将它们称为 Stage 1 和 Stage 2。stage 是实现架构中的分层，不是本文要反复强调的算法概念。对使用者来说，更重要的是前一次处理留下了什么，后一次处理又被允许改变什么。&lt;/p&gt;
&lt;p&gt;完整链路如下。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-02.png" alt="LinkRag 分块主链路" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;这条链路的起点已经不是原始 Markdown。文档在进入 splitter 之前，会先由 Markdown parser 解析为 &lt;code&gt;ParseResult&lt;/code&gt;，其中包含按原文顺序排列的 &lt;code&gt;MarkdownElement&lt;/code&gt;。这里的 element 是块级结构，包括标题、段落、列表、引用、代码块、公式块、图片和表格等；inline code、普通链接、强调、HTML 标签和行内公式不会各自形成受保护 element。每个元素带有类型、内容和行号，标题还会影响后续的层级路径。splitter 也可以直接接收 &lt;code&gt;MarkdownElement[]&lt;/code&gt;，但无论使用哪个入口，&lt;code&gt;InputAdapter&lt;/code&gt; 都只负责把已有结构转换为内部统一使用的 &lt;code&gt;SplitInput&lt;/code&gt;，不会重新解析 Markdown 字符串。adapter 在这里就是输入适配器，用来消除两种调用入口之间的格式差异。&lt;/p&gt;
&lt;p&gt;这个边界很重要。若 parser 和 splitter 各自识别一遍标题、表格或代码块，两边的规则只要稍有差异，同一段原文就可能得到两套结构。LinkRag 让 parser 对“这是什么元素”负责，splitter 则在这个结果之上决定“这些元素怎样组成 chunk”。这样一来，标题层级、表格范围和图片位置只有一个事实来源。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;SplitInput&lt;/code&gt; 进入结构整理后，当前实际使用的算法是 &lt;code&gt;candidate_boundary&lt;/code&gt;。它的产物不是外部接口里的 &lt;code&gt;Chunk&lt;/code&gt;，而是只在 splitter 内部流转的 &lt;code&gt;CoarseChunkSet&lt;/code&gt;。coarse 在这里表示“粗分后的候选结果”，它已经有可靠的文档结构，但还不一定具有最终需要的长度和语义粒度。其中每个 &lt;code&gt;CoarseChunk&lt;/code&gt; 除了正文和行号，还保留 token 数、来源元素索引、元素类型、标题路径、受保护范围以及元素在正文中的位置视图。位置视图记录一个源元素在拼接后正文中的起止位置，供后续算法准确找回它。图片和表格还会生成 derived chunk，也就是从单个结构元素额外构造的检索块，并记录它来自哪个 source coarse chunk。&lt;/p&gt;
&lt;p&gt;这些名字在后文会反复出现，可以先用一张生命周期表固定它们的关系。&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;对象&lt;/th&gt;
					&lt;th&gt;产生和用途&lt;/th&gt;
					&lt;th&gt;生命周期与去向&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;MarkdownElement&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;parser 识别的块级元素&lt;/td&gt;
					&lt;td&gt;splitter 的结构事实输入，不是最终 chunk&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;SplitInput&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;adapter 统一后的元素序列&lt;/td&gt;
					&lt;td&gt;只在 splitter 调用期间存在&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;CoarseChunk&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;结构整理后的 source 或 derived 候选块&lt;/td&gt;
					&lt;td&gt;Stage 1 内部对象，交给 Stage 2&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;atom&lt;/td&gt;
					&lt;td&gt;从 source coarse 的 element view 构造的最小操作单元&lt;/td&gt;
					&lt;td&gt;只在一次语义细分运行中存在&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;segment&lt;/td&gt;
					&lt;td&gt;packer 临时组合的连续 atom&lt;/td&gt;
					&lt;td&gt;组装为 &lt;code&gt;FinalChunk&lt;/code&gt; 后消失&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;FinalChunk&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;Stage 2 完成后的 source final core 或 derived final&lt;/td&gt;
					&lt;td&gt;validator 和 exporter 使用的内部对象&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;Chunk&lt;/code&gt;&lt;/td&gt;
					&lt;td&gt;exporter 产生的稳定外部模型&lt;/td&gt;
					&lt;td&gt;持久化并参与索引；符合条件时，&lt;code&gt;content&lt;/code&gt; 还会在导出后加入 overlap&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;source 与 derived 是 coarse/final/chunk 的角色，不是两套继承关系。source 沿正文主线组织，derived 则从图片或表格额外构造。atom 和 segment 都只是算法内部的临时对象，不会写入数据库。&lt;/p&gt;
&lt;p&gt;这些字段看起来比最终入库所需的信息多，但后续语义细分正是依靠它们避免盲切。比如，一段代码在 coarse content 里只是若干字符；有了 &lt;code&gt;protected_ranges&lt;/code&gt; 和 &lt;code&gt;element_views&lt;/code&gt;，后续处理才能知道这些字符对应一个完整代码块，以及它在原始元素序列中的位置。等到分块全部完成，这些内部辅助字段便不再需要进入外部 &lt;code&gt;Chunk&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;结构整理结束后，&lt;code&gt;CoarseChunkSetValidator&lt;/code&gt; 会立即检查结果。它关注的不是分块“好不好”，而是输出有没有破坏阶段契约，包括 ID 是否唯一、可见 source element 是否进入 Stage 1 的规范化表示、source 与 derived 的关系是否成立、行号和受保护范围是否一致。这里的参照物已经不是 raw Markdown：水平分割线可能被过滤，图片和大表格也可能在 source 中变成引用或摘要，并同时产生 derived。把校验放在两次处理之间，可以更快地区分问题来自结构整理，还是来自后续的语义细分。&lt;/p&gt;
&lt;p&gt;接下来才到 Stage 2 路由。router 是按配置选择执行路径的组件。这里有一个容易误解的地方，它不会逐个查看 coarse chunk 的长度，再临时决定使用哪套算法，只会根据文档级配置选择一条路径。当前配置为 &lt;code&gt;semantic_depth_window&lt;/code&gt; 时，整份 &lt;code&gt;CoarseChunkSet&lt;/code&gt; 交给语义细分算法，由算法内部判断哪些 chunk 需要计算 embedding，哪些可以直接透传。配置为 &lt;code&gt;noop&lt;/code&gt; 时，则不做语义判断，只把 coarse 模型等价转换成 final 模型。&lt;/p&gt;
&lt;p&gt;源码中的 &lt;code&gt;NoopStageTwoAlgorithm&lt;/code&gt; 实现了与语义细分相同的阶段接口，这是为了让所有链路都能稳定地产生 &lt;code&gt;FinalChunkSet&lt;/code&gt;，并继续经过统一的校验和导出。它没有计算新的边界，所以在本文的语境里，&lt;code&gt;noop&lt;/code&gt; 只是透传行为，不与 &lt;code&gt;candidate_boundary&lt;/code&gt; 和 &lt;code&gt;semantic_depth_window&lt;/code&gt; 并列为一种有效的分块算法。当前默认值仍然是 &lt;code&gt;noop&lt;/code&gt;，语义细分需要显式启用。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;FinalChunkSet&lt;/code&gt; 表示内部切分工作已经完成。最终校验器检查的是一个更窄的“不丢不重”条件：对同一个 source coarse，未截断的 source final core 按字符区间顺序拼接后，应覆盖该 coarse 的 &lt;code&gt;content&lt;/code&gt;。它不承诺从 final 还原 raw Markdown，derived 不参与这条正文拼接，导出后才加入的 overlap 也不在校验范围内；带有 &lt;code&gt;truncated&lt;/code&gt; 诊断的来源组是显式例外。语义细分写入 derived 锚点声明时，校验器还会检查图片、表格等派生块能否在 source final 中找到对应元素。启用语义细分后，普通 mixed final 还会接受 hard max 校验。hard max 是普通 source core 不能超过的绝对 token 上限。&lt;code&gt;noop&lt;/code&gt; 保留原始 coarse 内容，不承诺满足这一上限，因此这条路径会跳过 hard max 检查。front matter 是 Markdown 文件开头的元数据块，它作为结构保护块保留例外，后文会单独解释。&lt;/p&gt;
&lt;p&gt;通过校验后，&lt;code&gt;ChunkExporter&lt;/code&gt; 才把内部模型收敛为下游长期使用的 &lt;code&gt;list[Chunk]&lt;/code&gt;。最终 &lt;code&gt;Chunk&lt;/code&gt; 保留正文、起止行号和 metadata。metadata 中会写入标题路径、chunk role、元素类型、顺序索引以及实际采用的 split strategy。derived chunk 还会映射到对应的 source chunk。当前解析链路会把 source 和 derived 都持久化并向量化，让它们作为不同 chunk 进入召回候选；splitter 只保证 derived 的 role、element ID 和 source 映射，不负责下游如何融合排序、去重或展示原始图片与表格。&lt;/p&gt;
&lt;p&gt;邻接 overlap 位于导出之后，不属于语义细分算法本身。普通 chunk 可以从前后相邻块补充少量上下文；derived chunk 和 front matter 不参与这一步，包含代码、公式、图片或表格等受保护元素的 chunk 默认也会跳过，除非配置明确允许。这样的顺序让算法先确定正文边界，再由统一组件处理检索所需的邻接上下文，避免 overlap 反过来干扰 source final core 的区间覆盖校验。&lt;/p&gt;
&lt;p&gt;到这里，整个流程的职责已经清楚了。parser 提供结构化事实，结构整理生成候选块，按需语义细分处理过长正文，validator 守住阶段契约，exporter 和 overlap 负责稳定的外部结果。下一步要看的，是这条链路中最先做出分块决定的 &lt;code&gt;candidate_boundary&lt;/code&gt;。它怎样利用标题，又怎样处理表格、图片和其他不能随意拆开的内容，决定了后面所有语义判断所站立的基础。&lt;/p&gt;
&lt;h2 id="文档结构先被保留下来"&gt;文档结构先被保留下来
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;candidate_boundary&lt;/code&gt; 这个名字很直接。它不把标题理解为必须执行的切点，而是把标题当作候选边界。候选是否真正生效，还要看当前 buffer 已经积累了多少内容，以及这个标题在整篇文档的层级里处于什么位置。&lt;/p&gt;
&lt;p&gt;算法开始时，输入已经是一组有序的 &lt;code&gt;MarkdownElement&lt;/code&gt;。水平分割线会被过滤，因为它只作为 parser 内部识别结构的信号，不需要形成最终 chunk。front matter 是 Markdown 文件开头由 &lt;code&gt;---&lt;/code&gt; 或 &lt;code&gt;+++&lt;/code&gt; 包围的元数据块。它既不应该和正文拼接，也不能被当作噪声丢弃，因此会在遇到时先清空当前 buffer，再独立生成一个 source chunk。buffer 是暂存已读元素的临时收集区，source chunk 则是沿着文档正文主线生成的来源块，与后面从图片或表格额外生成的派生块相对。&lt;/p&gt;
&lt;p&gt;其余可见元素按原文顺序进入这个 buffer，暂时不输出为 chunk。段落、列表、引用、图片、表格、代码块和公式块都不会单独触发 flush。flush 指把 buffer 中积累的元素组装成一个 coarse chunk，然后清空收集区，继续处理后面的内容。正常情况下，只有下一个标题到来时，算法才判断是否需要执行这一步。这样做是为了让标题之前的内容保持完整，也避免把图片、代码或一个普通段落误当成章节边界。&lt;/p&gt;
&lt;h3 id="token-软下限控制碎片"&gt;token 软下限控制碎片
&lt;/h3&gt;&lt;p&gt;标题很多的 Markdown 并不一定写得好。有些文档几乎每两三句话就加一个小标题，如果见到标题就切，最终会出现大量只有几十个 token 的碎片。它们虽然在结构上“正确”，却很难为检索提供完整上下文。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;candidate_boundary&lt;/code&gt; 因而维护了一个候选块软下限，默认值为 128 token。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-env" data-lang="env"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;CHUNKING_MIN_CANDIDATE_CHUNK_TOKENS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;128&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;当 buffer 已经达到这个大小，下一个标题会成为实际切点，已有内容先输出，新标题再进入新的 buffer。没有达到软下限时，算法通常会继续聚合。这不是一个最大长度配置，达到 128 token 并不会立刻切分；算法仍然要等待下一个标题。反过来，如果一篇长文完全没有标题，即使累积到上千 token，这一步也不会主动制造边界。&lt;/p&gt;
&lt;p&gt;这一点可以解释上一章的 JVM 案例。整个“堆内存分配与回收策略”小节后面没有新的标题，结构整理便没有可以采用的候选边界。1243 token 是结构信息不足时留下的结果，不是算法把最大长度误设成了 1243。&lt;/p&gt;
&lt;p&gt;第一步也不会读取语义细分使用的 &lt;code&gt;CHUNKING_MAX_CHUNK_TOKENS&lt;/code&gt; 和 &lt;code&gt;CHUNKING_HARD_MAX_TOKENS&lt;/code&gt; 来决定何时 flush。这几个配置服务于不同阶段。软下限避免结构分片过碎，后面的普通目标和硬上限才处理最终 chunk 的体积。把它们混成一个阈值，会让“尽量保留章节”和“绝不允许无限增长”两种目的互相干扰。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-21.png" alt="token 软下限的触发条件" loading="lazy" /&gt;&lt;/p&gt;
&lt;h3 id="标题层级保护较大的章节"&gt;标题层级保护较大的章节
&lt;/h3&gt;&lt;p&gt;只看 128 token 仍然不够。一个新章节的开头可能很短，却不应该因为没达到软下限而并入上一章。为此，算法会在遍历正文前预先看一遍可见标题，找出当前文档参与保护的最深标题层级。默认最多追踪到五级标题，六级标题仍作为普通内容进入 chunk，但不会参与动态边界判断或标题路径维护。&lt;/p&gt;
&lt;p&gt;未达到软下限时，算法比较 buffer 中最后一个受保护标题和即将到来的标题。进入更深一层，说明内容仍在当前章节下展开，不强制切分；回到更浅层，说明新的上级章节开始，需要保护这个边界。同级标题通常也会切开，不过当两边都是整篇文档最深的叶子标题时，算法允许它们继续合并，避免产生过多细碎小节。&lt;/p&gt;
&lt;p&gt;下面这段 Markdown 的最深层级是三级标题。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-markdown" data-lang="markdown"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gu"&gt;## A
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gu"&gt;### A.1
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;短内容。
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gu"&gt;### A.2
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;短内容。
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="gu"&gt;## B
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;B 的开头。
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;在 A.1 的内容还没有达到软下限时，A.2 与它同为最深叶子小节，两者可以留在同一个 coarse chunk。遇到 B 时，标题从三级回到二级，A 的 buffer 会提前输出，即使它仍然不足 128 token。这个取舍优先保护较大的章节转换，同时允许最细层的小节在内容较少时共享上下文。如果 A.1 本身已经超过软下限，那么 A.2 到来时仍会正常切开。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-03.png" alt="标题层级与候选边界" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;纯标题还有单独的处理。buffer 里如果只有连续标题，算法不会仅因 token 数或层级变化输出一个没有正文的 chunk。文档结尾若只剩一个标题，它会尽量并入前一个正文块。全文确实只有标题时，只能保留标题块；前一个块若是 front matter，也不会把尾部标题塞进元数据中。这些规则的目的都是避免生成来源不清晰的标题孤儿。&lt;/p&gt;
&lt;h3 id="图片和表格同时保留位置与检索入口"&gt;图片和表格同时保留位置与检索入口
&lt;/h3&gt;&lt;p&gt;结构整理并不只关心标题。代码块、公式块、表格和图片都被标记为 protected element。这里的 protected 是条件性保证：Stage 2 的普通 packer 不会在元素内部选择语义切点；元素不超过 hard max 时可以完整保留，单个元素超过 hard max 时仍会进入后文所述的受控截断。它们会和标题、段落等内容一起组成前文所说的 mixed chunk，但四类元素的表示方式并不完全相同。&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;元素&lt;/th&gt;
					&lt;th&gt;在 mixed chunk 中的处理&lt;/th&gt;
					&lt;th&gt;是否生成 derived chunk&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;代码块&lt;/td&gt;
					&lt;td&gt;普通路径保留代码围栏和内容；超 hard max 可截断&lt;/td&gt;
					&lt;td&gt;否&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;公式块&lt;/td&gt;
					&lt;td&gt;普通路径保留公式边界和内容；超 hard max 可截断&lt;/td&gt;
					&lt;td&gt;否&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;图片&lt;/td&gt;
					&lt;td&gt;写入稳定图片引用和可用描述&lt;/td&gt;
					&lt;td&gt;是&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;表格&lt;/td&gt;
					&lt;td&gt;小表格保留原文，长表格写入引用和摘要&lt;/td&gt;
					&lt;td&gt;是&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;代码和公式仍然属于所在章节的正文。算法记录它们的受保护范围，但不会额外复制一份用于独立召回；如果它们因超过 hard max 被截断，丢失的尾部没有 derived 副本兜底。图片和表格则有双重身份。它们既占据原文中的一个位置，又常常值得作为独立检索对象，因此会在 source mixed chunk 旁边生成前文所说的 derived chunk。它不是沿正文边界切出的新段落，而是从某个图片或表格元素额外构造、可供下游独立索引和召回的候选块。&lt;/p&gt;
&lt;p&gt;图片在 mixed chunk 中被渲染为稳定引用，例如下面的形式。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;[图片引用: image_001]
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;图片说明：……
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;与它对应的 derived chunk 会包含图片 ID、标题路径、图片说明、相邻上下文和原始 Markdown 引用。图片说明优先使用 parser 增强后写入的视觉描述；没有视觉描述时使用 alt，仍然没有才写入明确的缺省说明。这样既不会把缺失描述伪装成模型理解，也能保证输出结构稳定。&lt;/p&gt;
&lt;p&gt;表格无论大小都会生成 derived chunk，其中保留原始表格、表格 ID、标题路径和可用摘要。区别只在 source mixed chunk 中怎样表示。token 数不超过 256、非空行不超过 12 且最大列数不超过 5 的表格会直接保留 Markdown 原文。三项条件有任何一项超出，mixed chunk 里就改用表格引用和摘要，完整表格仍保存在 derived chunk 中。&lt;/p&gt;
&lt;p&gt;这项处理解决了两个相反的问题。小表格通常是附近论述的一部分，直接保留更容易读懂；大表格若完整塞进 mixed chunk，可能让正文的 token 数和向量语义都被表格内容主导。引用维持了原文位置，derived chunk 则提供独立召回入口，二者通过稳定的 &lt;code&gt;image_001&lt;/code&gt; 或 &lt;code&gt;table_001&lt;/code&gt; 一类 ID 建立联系。&lt;/p&gt;
&lt;p&gt;图片和表格的 derived chunk 还会截取前一个可用元素的末尾与后一个可用元素的开头，默认每侧最多 64 token。查找发生在完整 element 序列上，只跳过 front matter 和水平分割线，不检查标题路径或 coarse 边界；标题、代码、公式或另一个异构元素也可能成为邻居。因此 adjacent context 是一种物理位置启发式，可能跨章节，也不保证一定是最相关的自然语言语境。它是 derived chunk 自身内容的一部分，与后文的 neighbor overlap 不是同一个处理步骤。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-04.png" alt="source chunk 与 derived chunk" loading="lazy" /&gt;&lt;/p&gt;
&lt;h3 id="结构正确先于语义判断"&gt;结构正确先于语义判断
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;candidate_boundary&lt;/code&gt; 输出后，validator 会核对所有可见 source element 是否进入 Stage 1 的规范化表示，元素视图能否对齐到 coarse content，protected range 是否对应实际受保护元素，图片和表格是否都生成了可回溯的 derived chunk。任何一项不成立都会直接失败，而不是静默丢掉内容。&lt;/p&gt;
&lt;p&gt;经过这一步，我们得到的不是最终答案，而是一组具有块级来源信息的候选块。标题路径仍在，图片和表格仍能回到 source 位置，代码与公式的 protected range 已被记录，较大的章节边界也获得了优先保护。这些条件为后续语义细分划定了可操作范围；是否最终完整保留，还要经过 hard max 与截断规则。&lt;/p&gt;
&lt;p&gt;它也有清楚的能力边界。算法不会凭空补出原文没有的标题，也不会在没有标题的长正文中猜测话题何时发生变化。上一章的失败 chunk 正是这种边界的体现。要继续处理这类内容，就需要一种不依赖显式标题、而是观察相邻文本如何变化的方法。TextTiling 提供了我们后来采用的那条思路。&lt;/p&gt;
&lt;h2 id="texttiling-关心的是什么"&gt;TextTiling 关心的是什么
&lt;/h2&gt;&lt;p&gt;TextTiling 来自 Marti A. Hearst 在 1997 年发表的论文 &lt;em&gt;TextTiling: Segmenting Text into Multi-paragraph Subtopic Passages&lt;/em&gt;。论文研究的是说明性长文，例如科普文章或环境影响报告。此类文档围绕一个主话题展开，内部又连续出现若干子话题，但作者未必使用足够清楚的标题把它们标出来。&lt;/p&gt;
&lt;p&gt;TextTiling 希望把这类文本划分为连续、互不重叠的多段落片段，每个片段对应一段相对集中的子话题讨论。它做的是线性分段，也就是沿着原文从前往后确定边界，并不试图建立一棵完整的章节树。论文也有意不依赖标题等排版信息，因为它要解决的正是显式结构不足时怎样发现子话题变化。&lt;/p&gt;
&lt;p&gt;选择“多段落片段”作为单位并非偶然。单个句子通常太短，很难独立完成解释；整篇文档又太宽，会同时包含多个讨论焦点。段落看起来像一个自然选择，但真实文档中的段落长度和作用并不稳定。有些作者用段落表达完整观点，有些作者只是为了改善版面而频繁换行。TextTiling 寻找的是介于句子和全文之间的 passage，也就是能够承载一段相对完整讨论的文本片段。&lt;/p&gt;
&lt;p&gt;这与 RAG 的需求有相似之处。检索系统希望返回比完整文档更聚焦、又比孤立句子更完整的证据。不过，TextTiling 论文讨论的是通用文本分段，并没有 RAG、向量数据库或大模型上下文窗口这些后来的工程条件。我们可以借用它对边界的观察方式，不能把论文目标直接改写成现代 RAG chunking。&lt;/p&gt;
&lt;p&gt;论文把完整算法概括为三个部分。先把原文规整为可比较的词项单位，再为文本中每个候选位置计算 lexical score，最后根据分数曲线识别子话题边界。不同版本可以采用不同的 lexical score，后面的 boundary identification 则共享同一套 depth 思路。理解这一层分工很重要，因为 LinkRag 改变的主要是前两部分的输入单位和相似度来源，同时保留了低谷深度所表达的相对边界信号。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-05.png" alt="TextTiling 的完整处理过程" loading="lazy" /&gt;&lt;/p&gt;
&lt;h3 id="比主题更容易观察的是主题变化"&gt;比主题更容易观察的是主题变化
&lt;/h3&gt;&lt;p&gt;“主题”很容易理解，却很难写成一个稳定的计算定义。相邻两段都在谈 JVM，但一段解释垃圾回收，一段解释线程本地分配，它们究竟算同一个主题还是两个子话题，往往取决于当前任务需要多细的粒度。&lt;/p&gt;
&lt;p&gt;TextTiling 采用了一个更容易落地的角度。与其先判断每一段“属于什么主题”，不如寻找讨论内容发生明显转换的位置。这里的基本假设是，一段子话题持续展开时，会有一组词在局部范围内反复出现或共同活跃；当子话题改变，其中相当一部分词也会随之变化。&lt;/p&gt;
&lt;p&gt;论文把这种局部连续性称为 lexical cohesion，即词汇凝聚度。它不是要求左右两边逐字相同，而是观察一批词在相邻区域中的分布是否仍有较强联系。以 JVM 文档为例，Eden、Survivor、对象和回收可能在一段范围内密集出现；当正文转向 TLAB、线程、指针和 CAS，活跃词集合也会发生变化。词汇分布的变化由此成为子话题转换的可计算线索。&lt;/p&gt;
&lt;p&gt;这里观察的是一组词的共同分布，而不是追踪某一个关键词。贯穿全文的主话题词可能在每个子话题中都出现，因而无法区分边界；只出现一次的偶然词也不够稳定。真正有用的是若干局部词项在某一区域形成密集的“词汇团块”，随后一起减弱，另一组词又开始活跃。论文中的科普文章以生命、月球等词贯穿全文，但大陆、海岸线等词集中在一个子话题，恒星、双星和轨道等词则集中在另一个子话题。TextTiling 要找的正是这些团块之间的转换。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-06.png" alt="词汇团块与子话题变化" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;这也解释了为什么简单的关键词链不够。一个子话题往往同时展开多条概念线索，不同词未必在词典意义上直接相关，却会因为共同参与一段讨论而出现在同一区域。block comparison 不要求先知道哪些词属于同一个概念，只比较相邻文本区域整体的词频分布，因此能够同时吸收多组局部主题信号。&lt;/p&gt;
&lt;p&gt;这种方法特别适合说明文。说明文的组织往往由所讲的对象和概念推动，词汇变化能够反映讨论焦点的移动。论文也明确指出，叙事文本可能需要更复杂的信息，因为人物、时间和场景可以变化，而作者仍然使用代词或多样化表达维持同一条叙事线。TextTiling 并没有声称仅靠词项重复就能解释所有文体。&lt;/p&gt;
&lt;h3 id="把文本变成可比较的窗口"&gt;把文本变成可比较的窗口
&lt;/h3&gt;&lt;p&gt;若直接比较自然句子或段落，长度差异会造成麻烦。一边只有十几个词，另一边有上百个词，两者共享词的数量很难公平比较。为减少这种不平衡，原始 TextTiling 先把正文划分为固定长度的 token-sequence。这里的 token-sequence 可以理解为“伪句子”，它不要求符合语法，只要求包含近似相同数量的 token。&lt;/p&gt;
&lt;p&gt;tokenization 是这一步的预处理。论文中的实现把词转为小写，过滤高频功能词，并将屈折变化还原到词根。过滤的词通常被称为 stop words，例如英文中的冠词和介词；它们会计入 token-sequence 的长度，但不参与后面的相似度计算。词形还原则尽量让同一个词的单复数或动词变化落到共同形式，减少表面写法带来的干扰。程序同时记录原始段落边界，供最终边界回到可读的位置。&lt;/p&gt;
&lt;p&gt;token-sequence 的长度由参数 &lt;code&gt;w&lt;/code&gt; 控制。&lt;code&gt;w&lt;/code&gt; 太小，曲线会对个别用词非常敏感，产生大量尖锐波动；&lt;code&gt;w&lt;/code&gt; 太大，又会把局部变化平均掉。论文评测采用的一组默认值是每个 token-sequence 20 个 token。这个数字来自当时的小规模实验，并不是跨语言、跨文体都成立的常量。&lt;/p&gt;
&lt;p&gt;得到 token-sequence 后，block comparison 会在每个相邻位置放置一个 gap。gap 就是两个序列之间的空隙，也是一个潜在边界。算法从 gap 向左取若干 token-sequence 组成左 block，再向右取同样数量的序列组成右 block。block 是用于比较的一段局部窗口，并不等于最终分片。&lt;/p&gt;
&lt;p&gt;每侧取多少个 token-sequence 由参数 &lt;code&gt;k&lt;/code&gt; 控制。论文的默认评测配置使用 &lt;code&gt;k=10&lt;/code&gt;，意图让一个 block 大致接近平均段落尺度。窗口每次只向前移动一个 token-sequence，并不是算完一对 block 就跳到下一对完全不重叠的区域。因此，相邻 gap 的 block 会大量重叠，同一个 token-sequence 也会参与多次比较。这样得到的不是几次孤立判断，而是一条沿全文连续采样的相似度曲线。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-07.png" alt="moving block 怎样经过每个 gap" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;左右 block 分别表示为词频向量。向量中的每个维度对应一个词，数值是该词在 block 内出现的次数。随后用归一化内积计算相似度，也就是常见的 cosine similarity。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;score(i) = dot(left, right) / (norm(left) * norm(right))
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;由于词频都是非负数，这个分数落在 0 到 1 之间。接近 1 表示左右 block 的词汇分布方向接近，接近 0 表示两侧几乎没有共同的活跃词。它衡量的是分布相似程度，不是两个 block 的字数差，也不是对其中某个主题的分类概率。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;score(i)&lt;/code&gt; 表示第 i 个 gap 两侧的词汇凝聚度。左右窗口共享较多活跃词时，分数较高；两侧词汇分布明显变化时，分数降低。窗口沿全文逐个 gap 移动后，便得到一条凝聚度曲线。论文还讨论了 vocabulary introduction 等其他打分方式，但 LinkRag 后来借鉴的主要是相邻窗口比较与低谷深度，因此这里不展开另一条算法支线。&lt;/p&gt;
&lt;p&gt;论文中的 block comparison 使用 block 内词频作为向量权重。早期版本曾使用 &lt;code&gt;tf-idf&lt;/code&gt;，也就是降低全局常见词的权重、提高区分性词项的权重，但作者发现，在相邻小范围文本之间判断细微差异时，&lt;code&gt;tf-idf&lt;/code&gt; 的重要性估计没有带来足够稳定的收益，单纯使用 block 内词频反而更稳健。这一点很容易被今天的实现背景遮住，原始 TextTiling 并没有调用语义模型，它比较的仍是词项出现次数。&lt;/p&gt;
&lt;p&gt;除了 block comparison，论文还研究了 vocabulary introduction。该方法观察局部窗口中新词首次出现的比例，希望在大量新词开始出现的位置识别信息流变化。论文也讨论了 lexical chains，即追踪重复词形成的词汇链。三种方法都试图从词汇分布发现边界，但计算的信号不同。LinkRag 与其中的 block comparison 最接近，因为两者都在每个 gap 比较左右窗口；下一章提到“TextTiling 思路”时，主要指的就是这条路径与后续 depth score。&lt;/p&gt;
&lt;h3 id="从凝聚度曲线寻找低谷"&gt;从凝聚度曲线寻找低谷
&lt;/h3&gt;&lt;p&gt;凝聚度低的位置值得关注，却不能直接等同于边界。曲线可能因为一句短暂插话或少量生词产生小幅下降，也可能整段都处于偏低水平。只设置一个绝对阈值，会把这些情况混在一起。&lt;/p&gt;
&lt;p&gt;TextTiling 因此使用 depth score 衡量低谷相对于周围环境有多深。对某个 gap，算法从当前分数向左寻找局部峰值，再向右寻找局部峰值，将两侧峰值与当前低谷的落差相加。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;depth(i) = (left_peak - score(i)) + (right_peak - score(i))
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;寻找峰值时并不是扫描全文最高点。算法从当前 gap 开始向左移动，只要分数沿该方向继续升高，就继续走，直到到达左侧局部峰值；向右也采用同样过程。因此 depth 描述的是当前低谷相对于邻近高凝聚区域的深度，而不是它相对于全文极值的位置。&lt;/p&gt;
&lt;p&gt;下面是一组为了说明计算过程而构造的分数，不是论文实验数据。假设 moving block 经过某段 JVM 文档后得到五个连续 gap 的 cohesion score。&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;gap&lt;/th&gt;
					&lt;th style="text-align: right"&gt;g1&lt;/th&gt;
					&lt;th style="text-align: right"&gt;g2&lt;/th&gt;
					&lt;th style="text-align: right"&gt;g3&lt;/th&gt;
					&lt;th style="text-align: right"&gt;g4&lt;/th&gt;
					&lt;th style="text-align: right"&gt;g5&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;score&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.82&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.76&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.31&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.71&lt;/td&gt;
					&lt;td style="text-align: right"&gt;0.79&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;对 g3 来说，向左经过 g2 后到达局部峰值 0.82，向右经过 g4 后到达局部峰值 0.79。它的 depth 为 &lt;code&gt;(0.82 - 0.31) + (0.79 - 0.31) = 0.99&lt;/code&gt;。g3 的绝对分数较低，两侧又分别形成较高凝聚区域，因此它是一个很强的候选边界。相比之下，g2 虽然比 g1 低，但下降幅度很小，右侧马上还会继续下降，它并没有形成同样完整的“旧区域结束、新区域建立”信号。&lt;/p&gt;
&lt;p&gt;一个很低但只有单侧下降的位置，不一定代表完整的主题转换。深低谷则意味着左侧原本具有较强凝聚度，经过当前位置明显下降，右侧又形成新的高凝聚区域。这比单纯寻找全局最低分更接近“旧讨论结束，新讨论开始”的过程。&lt;/p&gt;
&lt;p&gt;depth 使用的是相对变化，而不是分数的绝对高低。某个 gap 的相似度即使处于全文中间水平，只要它被两侧明显更高的峰值包围，仍可能是重要边界。反过来，一段长平台上的多个低分位置并不一定都应切开。论文把这种平台视为模型不容易判断的情况，尤其当词汇变化是渐进发生时，边界本身就可能没有唯一答案。&lt;/p&gt;
&lt;p&gt;论文的 Figure 4 讨论了三类典型曲线。第一类是两侧峰值清楚、谷底集中的普通低谷，depth 很容易解释。第二类是在上升途中夹着一个小幅回落，如果把每个细小波动都当成边界，就会把一个连续主题切碎。第三类是两个峰值之间出现较长平台，此时平台两端都可能成为合理边界，单靠词汇凝聚度很难做出唯一选择。这三种情况说明，depth 比单点低分更稳健，但它仍然只是边界证据，而不是对主题结构的完美还原。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-08.png" alt="三类 cohesion 低谷" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;论文还比较了谷底深度与坡度。陡峭下降看起来像强信号，却可能只是一段很短的偏题；一个更深但坡度较缓的低谷，反而可能对应规模更大的渐进式话题转换。因此 TextTiling 更看重 valley depth，而不是只找变化最快的位置。&lt;/p&gt;
&lt;h3 id="论文怎样把低谷变成边界"&gt;论文怎样把低谷变成边界
&lt;/h3&gt;&lt;p&gt;原始算法并不是算完 depth 就结束。凝聚度曲线会先经过平均平滑，减少局部小波动制造的伪低谷。smoothing 即平滑处理，它用邻近若干 gap 的平均分替换当前分数，并可重复多轮。论文没有把这种平均滤波描述为唯一正确选择，只是用它降低噪声。&lt;/p&gt;
&lt;p&gt;平滑由宽度 &lt;code&gt;s&lt;/code&gt; 和执行轮数 &lt;code&gt;n&lt;/code&gt; 控制。论文给出的默认配置是 &lt;code&gt;s=2&lt;/code&gt;、&lt;code&gt;n=1&lt;/code&gt;，只做幅度有限的一轮平滑。平滑过强会把噪声压下去，也会把真正的转换位置抹平。窗口大小、token-sequence 长度和平滑程度彼此相关，较大的 block 本身就会让曲线更平滑，通常不需要再做太多滤波。&lt;/p&gt;
&lt;p&gt;接下来还要决定一篇文档应该切出多少段。论文没有采用固定 depth 阈值，而是参考当前文档全部 depth score 的均值和标准差选择较突出的边界。阈值可以调得宽松或保守，对应不同的召回和精确取舍。候选边界还会尽量对齐到原始段落位置，并限制两个边界不能靠得过近，避免短段落和偶发标题造成连续碎片。&lt;/p&gt;
&lt;p&gt;具体来说，论文先按 depth 从高到低考虑候选点，再用当前文档 depth 分布决定截止位置。较宽松的设置会接受更多低谷，得到更细的分段；较保守的设置只保留更突出的低谷，得到更少、更长的片段。由于相似度是在固定 token-sequence gap 上计算的，最终位置未必正好落在段落边界，算法会把判断移动到附近可用的段落 gap。两个已选边界之间还需要至少间隔一定数量的 token-sequence，默认间隔限制为 3，以减少过短片段。&lt;/p&gt;
&lt;p&gt;这些参数并非彼此独立。增大 &lt;code&gt;w&lt;/code&gt; 或 &lt;code&gt;k&lt;/code&gt; 会让一次比较覆盖更多文本，曲线通常更平缓，发现的边界也更少；缩小窗口则会提高局部敏感度，同时增加噪声和过度切分风险。论文在参数讨论中明确承认这一点，并指出默认配置只来自很小的评测集合。TextTiling 给出的是一套可运行的方法，不是无需适配语料的固定参数表。&lt;/p&gt;
&lt;p&gt;这些步骤说明，TextTiling 并非一句“相似度低就切开”可以概括。它先把文本规整为可比较单位，再计算每个 gap 两侧的局部凝聚度，从曲线中寻找相对深的低谷，最后结合平滑、文档内分数分布和段落位置确定边界。它所提供的核心价值，是把模糊的主题转换转化为一条可以计算和检查的局部变化曲线。&lt;/p&gt;
&lt;p&gt;LinkRag 保留了相邻窗口、凝聚度和 depth valley 这条思路，但没有复刻上述整套过程。我们没有使用固定 token-sequence 和词频向量，也没有照搬论文的平滑、边界数量阈值和段落对齐方式。Markdown 元素已经给出了更可靠的结构单位，embedding 可以提供词面重合之外的语义表示，RAG 又带来了明确的 token 上限。下一章要讨论的，正是这些条件怎样把 TextTiling 的思路改造成 &lt;code&gt;semantic_depth_window&lt;/code&gt;。&lt;/p&gt;
&lt;h2 id="长文本内部的边界怎样确定"&gt;长文本内部的边界怎样确定
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;semantic_depth_window&lt;/code&gt; 接收的不是原始文档，而是前面已经通过结构校验的 &lt;code&gt;CoarseChunkSet&lt;/code&gt;。它不再判断哪些行是标题、图片或代码，而是使用 &lt;code&gt;candidate_boundary&lt;/code&gt; 留下的 element view、标题路径和受保护范围。这样，语义算法面对的不是一串来源不明的字符，而是一条仍然能够回到 Markdown 元素的内容序列。&lt;/p&gt;
&lt;p&gt;它保留了 TextTiling 的一个核心判断。相邻内容仍然连贯时，gap 两侧的语义表示应该接近；讨论焦点转换时，相似度会下降；一个相对于左右区域都更深的低谷，比孤立的低分更值得作为边界。变化最大的是产生这条曲线的方式，以及最终怎样使用它。&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;比较维度&lt;/th&gt;
					&lt;th&gt;TextTiling block comparison&lt;/th&gt;
					&lt;th&gt;LinkRag &lt;code&gt;semantic_depth_window&lt;/code&gt;&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;基本单位&lt;/td&gt;
					&lt;td&gt;固定长度 token-sequence&lt;/td&gt;
					&lt;td&gt;由 Markdown element view 构造的 atom&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;语义证据&lt;/td&gt;
					&lt;td&gt;block 内词频向量&lt;/td&gt;
					&lt;td&gt;atom 的 embedding 向量&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;窗口宽度&lt;/td&gt;
					&lt;td&gt;每侧 &lt;code&gt;k&lt;/code&gt; 个 token-sequence&lt;/td&gt;
					&lt;td&gt;每侧约 128 个 display token&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;低谷计算&lt;/td&gt;
					&lt;td&gt;左峰、谷底和右峰形成 depth&lt;/td&gt;
					&lt;td&gt;保留同样的相对 depth 计算&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;边界选择&lt;/td&gt;
					&lt;td&gt;根据全文 depth 分布选择并对齐段落&lt;/td&gt;
					&lt;td&gt;token 预算触发后，在当前合法 gap 中选点&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;文档结构&lt;/td&gt;
					&lt;td&gt;原算法有意不依赖标题&lt;/td&gt;
					&lt;td&gt;heading、protected atom 和图片/表格 ID 共同约束&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;曲线平滑&lt;/td&gt;
					&lt;td&gt;原论文执行有限平均平滑&lt;/td&gt;
					&lt;td&gt;当前代码不做平滑，在可评分 gap 序列上直接计算 depth&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;这张表中的差异决定了项目不能被描述为“使用 embedding 复现 TextTiling”。embedding 只替换了语义证据的一部分，输入单位、边界触发方式和结构约束都已经改变。&lt;/p&gt;
&lt;h3 id="先判断是否真的需要语义计算"&gt;先判断是否真的需要语义计算
&lt;/h3&gt;&lt;p&gt;即使全局配置选择了 &lt;code&gt;semantic_depth_window&lt;/code&gt;，也不是每个 coarse chunk 都会调用 embedding。derived chunk 已经是为图片或表格生成的独立检索入口，不再继续细分；front matter 是文档元数据块，始终保持原样；普通 mixed chunk 若没有超过 &lt;code&gt;CHUNKING_MAX_CHUNK_TOKENS&lt;/code&gt;，也直接转换为 final chunk。这种不改变正文内容的等价转换在代码中称为 passthrough，即透传。&lt;/p&gt;
&lt;p&gt;默认配置下，这个普通目标是 512 token。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-env" data-lang="env"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;CHUNKING_MAX_CHUNK_TOKENS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;512&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;这里的 512 是软目标，不是这一章要讨论的 hard max。它首先是一道成本和必要性门槛。一个已经足够短的 chunk 没有必要构造 atom、请求 embedding、计算所有 gap，再得到与输入完全相同的结果。只有过长的 mixed coarse chunk 才进入完整算法。&lt;/p&gt;
&lt;p&gt;这个门控也使语义细分保持局部。算法不会把整篇 Markdown 重新拼成一个长序列，也不会跨 coarse chunk 推翻前面已经确定的章节边界。每个过长 coarse chunk 单独处理，输出仍然指向同一个 source coarse ID。&lt;/p&gt;
&lt;h3 id="atom-是结构与语义之间的接口"&gt;atom 是结构与语义之间的接口
&lt;/h3&gt;&lt;p&gt;进入完整算法后，&lt;code&gt;_AtomBuilder&lt;/code&gt; 按 element view 的原始顺序构造 atom timeline。timeline 就是 atom 沿原文顺序组成的序列，atom 则是语义细分阶段使用的最小临时单元。它只存在于一次运行中，不写入数据库，也不会成为外部 &lt;code&gt;Chunk&lt;/code&gt; 的新类型。&lt;/p&gt;
&lt;p&gt;每个 atom 记录自身在 coarse content 中的字符范围、token 数、元素类型、标题路径和来源元素索引。后续 packer 只能在相邻 atom 之间的 gap 切分，不能随意选择字符串中的某个字符位置。因此，atom timeline 同时承担两件事。它为 embedding 准备可比较文本，也把允许切分的位置限制在已知边界上。&lt;/p&gt;
&lt;p&gt;不同块级元素在这里得到的保证并不相同。&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;element view&lt;/th&gt;
					&lt;th&gt;atom 构造方式&lt;/th&gt;
					&lt;th&gt;最坏退化&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;heading&lt;/td&gt;
					&lt;td&gt;保持独立 atom，只参与结构归属&lt;/td&gt;
					&lt;td&gt;不参与语义评分&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;paragraph、list、blockquote&lt;/td&gt;
					&lt;td&gt;不超 512 时通常各自形成 text atom&lt;/td&gt;
					&lt;td&gt;过长时按换行、句末、token-safe 前缀继续细分&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;code、math、image、table&lt;/td&gt;
					&lt;td&gt;各自形成 protected atom&lt;/td&gt;
					&lt;td&gt;普通 packer 不内切；单 atom 超 hard max 时可受控截断&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;普通文本元素过长时，builder 先利用换行边界组织连续片段；某一行仍然太长，再寻找句末标点；单句依然超限，最后才由 tokenizer 找到不超过上限的最长安全前缀。每次降级都保持字符范围连续、无重叠、无缺口，但 token-safe 只保证计数和字符连续，不保证 inline code、链接、强调、HTML 标签或行内公式仍然语法闭合。protected atom 则限制 packer 只能在元素前后选择切点；超过绝对上限时如何处理，留到下一章讨论。&lt;/p&gt;
&lt;h3 id="不是每个-atom-都参与评分"&gt;不是每个 atom 都参与评分
&lt;/h3&gt;&lt;p&gt;atom 是否可以被切分，与它是否适合参与 cohesion 评分，是两个不同问题。所有 atom 都计入 display token 数。display text 是最终 chunk 中实际可见的内容，display token 就是这部分内容占用的 token 数。只有具备可靠语义文本的 atom 才会请求 embedding。&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;atom 内容&lt;/th&gt;
					&lt;th&gt;用于 embedding 的文本&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;普通段落、列表和引用&lt;/td&gt;
					&lt;td&gt;atom 自身的可见正文&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;heading&lt;/td&gt;
					&lt;td&gt;不参与评分，只保留归属和结构信息&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;image&lt;/td&gt;
					&lt;td&gt;有真实视觉描述时使用描述，否则不参与&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;table&lt;/td&gt;
					&lt;td&gt;有真实表格摘要时使用摘要，否则不参与&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;code block 和 math block&lt;/td&gt;
					&lt;td&gt;不参与评分，但仍计入 token 窗口和打包预算&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;标题不参与评分，是因为短标题与正文的向量关系未必稳定，而且标题已经通过 heading trail 参与结构归属。图片和表格不能直接拿 Markdown 引用或占位文字生成向量。只有 parser 或增强流程提供了真实的 visual description、table summary 时，它们才具有可用的语义代理。“未提供图片说明”与“未提供表格总结”这样的占位串会被明确排除。&lt;/p&gt;
&lt;p&gt;代码和公式被排除的理由略有不同。它们可能包含大量符号、标识符或公式变量，直接与自然语言窗口计算相似度，容易制造与文档主题无关的断点。当前实现选择让它们影响长度和合法边界，却不让它们投票决定语义低谷。这是一项工程取舍，并不表示代码与公式没有语义价值。&lt;/p&gt;
&lt;p&gt;为了直观看到 atom timeline，可以考虑下面这个教学示例。token 数只是示意，不来自实际运行报告。&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;atom&lt;/th&gt;
					&lt;th&gt;内容&lt;/th&gt;
					&lt;th style="text-align: right"&gt;token 数&lt;/th&gt;
					&lt;th&gt;是否评分&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;a0&lt;/td&gt;
					&lt;td&gt;“堆内存分配与回收策略”标题&lt;/td&gt;
					&lt;td style="text-align: right"&gt;10&lt;/td&gt;
					&lt;td&gt;否&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;a1&lt;/td&gt;
					&lt;td&gt;分代回收原理&lt;/td&gt;
					&lt;td style="text-align: right"&gt;180&lt;/td&gt;
					&lt;td&gt;是&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;a2&lt;/td&gt;
					&lt;td&gt;Eden 与 Survivor&lt;/td&gt;
					&lt;td style="text-align: right"&gt;200&lt;/td&gt;
					&lt;td&gt;是&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;a3&lt;/td&gt;
					&lt;td&gt;分代结构表格&lt;/td&gt;
					&lt;td style="text-align: right"&gt;40&lt;/td&gt;
					&lt;td&gt;有表格摘要时评分&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;a4&lt;/td&gt;
					&lt;td&gt;TLAB 与并发分配&lt;/td&gt;
					&lt;td style="text-align: right"&gt;190&lt;/td&gt;
					&lt;td&gt;是&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;a5&lt;/td&gt;
					&lt;td&gt;CAS 示例代码&lt;/td&gt;
					&lt;td style="text-align: right"&gt;160&lt;/td&gt;
					&lt;td&gt;否&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;packer 看到的是六个连续 atom，而不是一整段字符串。a3 即使不参与评分仍然保持为完整表格；a5 即使没有 embedding 也会占用 160 token，并限制切点只能落在它的前后。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-09.png" alt="atom timeline 与评分资格" loading="lazy" /&gt;&lt;/p&gt;
&lt;h3 id="embedding-窗口怎样形成-cohesion"&gt;embedding 窗口怎样形成 cohesion
&lt;/h3&gt;&lt;p&gt;可评分 atom 会按批次发送给配置好的 embedder。返回向量后，&lt;code&gt;_CohesionScorer&lt;/code&gt; 依次考察每个相邻 atom gap。当前代码把每侧 128 display token 设为内部窗口目标，不是可通过环境变量修改的 Settings。收集循环只有在“累计 token 已达到 128”并且“已经取得至少一个可评分向量”同时成立时才停止。&lt;/p&gt;
&lt;p&gt;这使 128 成为软目标。若前 100 token 是代码、接着 60 token 是标题，窗口仍然没有向量，便会继续纳入后面的可评分文本；一个 atom 本身跨过 128 时也会整体进入窗口。直到该侧边界仍找不到向量，窗口才返回不可评分。换言之，不可评分内容会拉大语义距离，但当前实现没有额外的最大扩张距离，窗口最坏可能延伸到整个 coarse chunk 的一侧。&lt;/p&gt;
&lt;p&gt;窗口 token 按所有 atom 累计，包括不参与 embedding 的标题、代码和公式；窗口向量则对其中取得 embedding 的 atom 等权平均。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;window_vector = (v1 + v2 + ... + vN) / N
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;这里没有按 display token 或 score text token 加权。一个 8-token atom 与一个 120-token atom 只要都可评分，对均值的贡献仍然各占一份。token 控制的是窗口跨度，atom 粒度控制的是语义投票权，因此 atom builder 的拆分方式本身也是评测变量。&lt;/p&gt;
&lt;p&gt;如果某一侧没有任何可评分向量，这个 gap 就没有 cohesion score，不会用零分伪造一个强低谷。其余 gap 的左右均值向量使用 cosine similarity 得到 cohesion。随后按 gap 的原始顺序排列这些分数，并在可评分 gap 组成的压缩序列上计算 depth。&lt;/p&gt;
&lt;p&gt;depth 公式与上一章相同。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;depth(i) = (left_peak - cohesion(i)) + (right_peak - cohesion(i))
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;不可评分 gap 会被跳过，但其原始 atom 位置不会消失。所谓“压缩序列”是按原 gap index 排序已有 cohesion，再让峰值搜索沿这些 scored gap 的名次移动。比如 g1 和 g4 有分数、g2 与 g3 没有分数时，g1 和 g4 会在评分序列中相邻；当前实现没有按中间相隔的 atom 或 token 数设置最大距离。因此这里的“局部峰值”是 scored-gap 轴上的局部，不是严格连续的原文邻域，长段不可评分内容可能让相距较远的分数互相影响。最终 depth 仍记录在原来的 gap index 上。当前代码没有执行 TextTiling 论文中的平均平滑，也没有用全文 depth 均值和标准差决定边界数量。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-10.png" alt="atom gap 两侧的 cohesion 窗口" loading="lazy" /&gt;&lt;/p&gt;
&lt;h3 id="token-触发切分depth-选择位置"&gt;token 触发切分，depth 选择位置
&lt;/h3&gt;&lt;p&gt;这是理解当前实现最关键的一点。&lt;code&gt;semantic_depth_window&lt;/code&gt; 不会扫描完整条 cohesion 曲线，然后把所有深低谷都切开。segment packer 是负责把连续 atom 组装为若干 segment 的组件，segment 即尚未导出的候选片段。它从左向右累积 atom，只要加入下一个 atom 后仍不超过普通目标，就继续装入。即将超出目标时，才在当前可行范围内查看合法 gap，并选择 depth 最高的位置。&lt;/p&gt;
&lt;p&gt;回到前面的教学示例。a0 到 a3 合计约 430 token，再加入 a4 会达到约 620 token，超过 512 的普通目标。此时 packer 不会机械地在 a3 后切，也不会回到全文最深的任意低谷，而是在当前 segment 已经覆盖的 gap 中比较 depth。如果 a3 与 a4 之间恰好对应“分代结构”转向“并发分配”的深低谷，它就成为优先切点。若更早的 gap 具有更高 depth，算法也可以提前切开，为下一段保留更多语义完整性。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-11.png" alt="token 触发与 depth 选点" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;合法 gap 还要满足基本结构条件。最直接的一条是不能让 segment 左侧只剩一个 heading atom。若多个 gap 的 depth 完全相同，当前实现优先选择位置更靠后的 gap，以便在不牺牲语义分数的情况下尽量装满当前 chunk。&lt;/p&gt;
&lt;p&gt;如果当前范围没有任何可用 depth，packer 不会把这视为运行错误。它退回到最靠后的合法 gap，让 segment 尽量接近普通目标。这种情况可能发生在可评分 atom 太少，或 gap 某一侧全是标题、代码与公式时。结构兜底只用于“没有语义证据”，embedding 请求本身失败则是另一类问题，不能静默退化，下一章会说明错误处理。&lt;/p&gt;
&lt;p&gt;这种做法和原始 TextTiling 的差别很明显。论文根据一篇文档的 depth 分布决定应该选出多少边界；LinkRag 先由 RAG 的 token 预算决定“何时必须考虑切分”，再让 depth 回答“当前范围内在哪里切更合适”。可以把它概括为 token 负责触发，语义负责选点。&lt;/p&gt;
&lt;h3 id="切点之后还要重新收口"&gt;切点之后还要重新收口
&lt;/h3&gt;&lt;p&gt;初次打包后，算法还要处理不适合直接输出的碎片。heading-only segment 会优先并入后面的正文，没有后段时再尝试并入前段。小于最小候选阈值的短 segment 会在不超过普通目标的前提下尝试合并。这里需要忠实说明当前代码的行为。短片段两侧都可合并时，当前实现默认并入前一段；按 cohesion 判断“更黏的一侧”仍是后续可增强点，不能写成已经完成的能力。&lt;/p&gt;
&lt;p&gt;标题与短片段合并后，packer 会再次按照 atom 边界检查 hard max，避免后处理把已经合规的 segment 重新推到绝对上限之外。单个 protected atom 自身超过 hard max 时，packer 仍保持其整体身份，交给最终 assembler 做受控截断和诊断记录。具体的容忍带、截断规则和 metadata 会在下一章展开。&lt;/p&gt;
&lt;p&gt;最后，assembler 负责把 packer 产生的 segment 组装成 &lt;code&gt;FinalChunk&lt;/code&gt;。它不会重新拼写正文，而是根据 segment 首尾 atom 在原 coarse content 中的位置取连续切片。未截断的相邻 source final core 按顺序覆盖这个 coarse content，元素之间的空行与分隔符也随切片保留下来。图片和表格的 &lt;code&gt;element_id&lt;/code&gt; 会写入包含它的 final metadata，供对应 derived chunk 找回新的 source chunk。&lt;/p&gt;
&lt;p&gt;语义细分到这里才真正完成。它没有重新解释 Markdown，也没有让 embedding 越过块级结构决定一切。atom 规定可操作的最小单位，128-token 软目标窗口产生 cohesion，TextTiling 式 depth 为合法 gap 排序，soft target 触发实际切分，hard max 约束普通 source mixed core。下一章要处理的是这套主流程留下的例外，包括超长 protected 内容、标题孤儿、front matter、embedding 故障和最终 overlap。&lt;/p&gt;
&lt;h2 id="真实文档里的约束"&gt;真实文档里的约束
&lt;/h2&gt;&lt;p&gt;如果输入永远是长度适中的普通段落，上一章的主流程已经足够。真实 Markdown 不会这么整齐。一个代码块可能比普通 chunk 目标还长，连续标题可能没有正文，表格可能独占数百行，embedding 服务也可能超时。此时算法不能只追求语义切点，还要明确哪些内容允许变长，哪些错误可以重试，哪些结果必须直接失败。&lt;/p&gt;
&lt;p&gt;这些规则看起来像算法外围的防御代码，实际上决定了分块结果能否稳定进入索引。一个漂亮的 depth 曲线无法修复被截成两半的代码块，也无法证明一次网络超时应该被当成正常结构切分。&lt;/p&gt;
&lt;h3 id="三个-token-阈值承担不同职责"&gt;三个 token 阈值承担不同职责
&lt;/h3&gt;&lt;p&gt;前文已经陆续出现三个长度配置。把它们放在一起看，可以发现它们并不是同一把尺子的三个刻度。&lt;/p&gt;
&lt;p&gt;这些数值都由 splitter factory 注入的同一个 &lt;code&gt;Tokenizer()&lt;/code&gt; 计数。当前默认使用 tiktoken 的 &lt;code&gt;cl100k_base&lt;/code&gt;；tiktoken 不可用或编码初始化失败时，代码会静默退回按中英文字符估算。后一种模式可能改变 128、512、1024 的判断、atom 边界和 overlap，而现有日志与 chunk metadata 没有记录实际采用了哪种模式。这是当前的复现性限制。正式实验应先确认 &lt;code&gt;cl100k_base&lt;/code&gt; 正常可用，并在外部 manifest 中冻结 tokenizer 版本与 encoding。embedding provider 和生成模型可能使用不同 tokenizer，所以这里的 hard max 也不是任意模型 API 都认可的绝对 token 保证。&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;阈值&lt;/th&gt;
					&lt;th style="text-align: right"&gt;默认值&lt;/th&gt;
					&lt;th&gt;约束对象&lt;/th&gt;
					&lt;th&gt;作用&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;CHUNKING_MIN_CANDIDATE_CHUNK_TOKENS&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;128&lt;/td&gt;
					&lt;td&gt;结构整理阶段的 buffer 或短 segment&lt;/td&gt;
					&lt;td&gt;避免过早切出碎片&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;CHUNKING_MAX_CHUNK_TOKENS&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;512&lt;/td&gt;
					&lt;td&gt;普通 final 的打包目标&lt;/td&gt;
					&lt;td&gt;触发语义选点并控制常规体积&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;&lt;code&gt;CHUNKING_HARD_MAX_TOKENS&lt;/code&gt;&lt;/td&gt;
					&lt;td style="text-align: right"&gt;1024&lt;/td&gt;
					&lt;td&gt;语义细分后的 source mixed core&lt;/td&gt;
					&lt;td&gt;阻止最终核心切片无限增长&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;128 token 是软下限，回答“当前内容是否已经足以独立成块”；512 token 是软目标，回答“是否应该开始寻找更合适的切点”；1024 token 是硬防线，回答“经过所有合并之后是否仍然可以接受”。如果只保留一个最大长度，就无法同时表达这三种不同意图。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-12.png" alt="三层 token 阈值" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;普通文本 atom 在 builder 阶段已经被拆到不超过 512 token，packer 也会尽量让常规 segment 留在这个目标内。不过，标题回并、元素之间的分隔符以及 protected 内容可能让最终切片略高于软目标。assembler 会把这种情况标记为 &lt;code&gt;oversized=true&lt;/code&gt;，并写入原因，而不是假装所有输出都严格等于或小于 512。&lt;/p&gt;
&lt;h3 id="protected-内容允许经过一段容忍带"&gt;protected 内容允许经过一段容忍带
&lt;/h3&gt;&lt;p&gt;对普通文本来说，句子或 token-safe 边界总能提供进一步切分的位置。protected atom 不具备这个条件。代码、公式、图片和表格要么整体保留，要么不得不采取有损处理。&lt;/p&gt;
&lt;p&gt;当单个 protected atom 大于 512、但不超过 1024 token 时，它可以作为 oversized final 保留。代码或公式前面若有一段引导说明，packer 还允许二者在不超过 hard max 的前提下留在一起。这样做的理由很实际。一段“下面的代码演示重试过程”若与代码分离，两个 chunk 都会变得难以理解。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;oversized&lt;/code&gt; 不是报错，而是一条诊断信息。metadata 会区分单个 protected 元素、protected 与上下文组合，以及少量 token-safe 残余等原因。调用方可以据此观察真实文档中哪些结构经常迫使 chunk 超出普通目标，而不需要从正文内容反推。&lt;/p&gt;
&lt;p&gt;合并标题和短片段之后，packer 会再次按 atom 边界执行 hard max 收口。能在 atom 之间切开的 segment 会被拆开，普通 source mixed final 因而不会仅凭后处理越过 1024 token。真正棘手的是单个 protected atom 自身已经超过 hard max，此时再找 gap 已经没有意义，因为元素内部不存在合法 atom 边界。&lt;/p&gt;
&lt;p&gt;当前实现对此采取受控截断。assembler 先逐行累积内容，保留 hard max 以内最后一个完整行边界；如果第一行本身就超过上限，才退回 tokenizer 的安全前缀。输出会记录 &lt;code&gt;truncated=true&lt;/code&gt;、具体 &lt;code&gt;truncated_reason&lt;/code&gt; 和 &lt;code&gt;original_token_count&lt;/code&gt;。截断意味着 coarse content 的尾部没有进入任何 source final，因此 validator 会对带有 truncated 标记的来源组放宽完整覆盖检查，但仍要求每个输出片段来自原 coarse content 的有序子串。代码和公式没有 derived 副本，不能假设丢失尾部会从另一条路径找回。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-13.png" alt="protected 内容的 oversized 与 truncated 路径" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;这种策略把有损行为暴露出来，却不会让丢失的尾部自动进入其他 chunk。它是一条最后防线，不应被描述为理想的代码或表格切分方案。若某类超大结构在业务文档中频繁出现，更合理的改进可能是为该元素设计专门的分解与重组方式，而不是长期依赖截断。&lt;/p&gt;
&lt;p&gt;这里还要区分 source mixed 与 derived chunk。hard max validator 跳过 derived chunk，因为派生块可能需要保存完整原始表格等专用内容。它也跳过 front matter。换言之，1024 token 的硬保证约束的是启用 &lt;code&gt;semantic_depth_window&lt;/code&gt; 后的普通 source mixed core，不是所有导出对象不加区分的一刀切规则，也不包含稍后追加的 overlap。&lt;/p&gt;
&lt;h3 id="front-matter-是明确保留的例外"&gt;front matter 是明确保留的例外
&lt;/h3&gt;&lt;p&gt;front matter 在结构整理时已经独立成块。语义细分识别到它后会直接 passthrough，不构造 atom、不请求 embedding，也不执行截断。即使元数据块超过 hard max，它仍然保持原样。&lt;/p&gt;
&lt;p&gt;这不是遗漏，而是结构保护决策。front matter 可能包含 YAML-like 或 TOML-like 元数据，从中间截断容易产生不可解析的内容，也可能把文档级字段错误地拼进正文。它通常不承担检索语义，因此没有必要参与 cohesion。最终 overlap 同样永远跳过 front matter，不受 protected overlap 开关影响。&lt;/p&gt;
&lt;p&gt;这项例外也意味着调用方不能把 hard max 理解成整个 pipeline 对任意 chunk 的绝对承诺。当前解析链路仍会持久化并向量化 front matter 与 derived，向量化入口没有按 role 做统一长度预检或截断；超大的 derived、front matter，以及 overlap 后的 content 都可能超过具体 embedding provider 的输入限制。启用语义细分只能收口普通 source mixed core，不能保证每一个索引输入都安全。&lt;/p&gt;
&lt;h3 id="没有语义证据和-embedding-失败不是一回事"&gt;没有语义证据和 embedding 失败不是一回事
&lt;/h3&gt;&lt;p&gt;某个 coarse chunk 可能包含大量代码、公式和标题，真正可评分的自然语言 atom 很少。此时部分 gap 的一侧没有向量，无法得到 cohesion；整个窗口没有合法 depth 时，packer 就退回最靠后的结构边界。这是正常退化，因为输入本身没有提供足够语义证据。&lt;/p&gt;
&lt;p&gt;embedding 服务返回错误则属于运行失败。算法不会捕获异常后假装“没有语义证据”，更不会静默切换到 &lt;code&gt;noop&lt;/code&gt;。否则同一份文档会因为一次网络抖动得到不同分块结果，metadata 却无法说明变化来自哪里。&lt;/p&gt;
&lt;p&gt;当前实现把可评分文本按最多 10 条分成 batch。这个 10 是 Stage 2 模块内部常量，不是索引 embedding 的 &lt;code&gt;CHUNK_INDEX_EMBED_BATCH_SIZE&lt;/code&gt;。如果前面的 batch 已经成功，后面的 batch 超时，在同一次 Stage 2 执行内只重试失败的那一组，不重复请求已完成部分。超时、连接传输错误、HTTP 429 和 5xx 被视为瞬时错误，每个失败 batch 最多额外重试两次；两次和退避时间同样是内部常量。&lt;/p&gt;
&lt;p&gt;重试仍然失败时，算法抛出 &lt;code&gt;RetriableError&lt;/code&gt;，交给 parse task 做任务级重试。成功 batch 的向量只保存在这次调用的内存中，没有跨任务缓存；任务重跑会从 atom 构造开始，重新请求全部评分 batch。其他 4xx 通常表示参数、权限或模型配置有问题，重复请求没有意义，因此直接上抛。响应中的 embedding 数量与请求文本数量不一致也属于契约错误，不执行重试，以免掩盖 provider 或适配层缺陷。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-14.png" alt="正常退化与 embedding 错误分流" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;这套错误语义让“结构兜底”与基础设施故障保持不同的程序路径。不过，文中这次 embedding 只用于 atom 边界评分。分块完成后，向量索引还会对最终 source 和 derived &lt;code&gt;Chunk.content&lt;/code&gt; 再做一次 embedding，两次调用不复用向量。Stage 2 当前按发起用户的默认 embedding 配置解析模型，dense 索引则按数据集绑定配置解析，二者可能使用不同 provider 或模型；前者失败发生在 chunking 阶段，后者失败发生在 vectorizing 阶段。&lt;/p&gt;
&lt;p&gt;现有 chunk metadata 会记录 &lt;code&gt;candidate_boundary + semantic_depth_window&lt;/code&gt; 这样的算法名，却不记录 Stage 2 scorer 的 provider、model、config ID、tokenizer 模式或参数快照。因此“算法步骤可解释”和“某次运行可完全复现”不是一回事。正式实验需要在 chunk 之外保存这两次 embedding 的配置、tokenizer、阈值、代码版本和随机性设置。&lt;/p&gt;
&lt;h3 id="derived-chunk-要重新找到-source"&gt;derived chunk 要重新找到 source
&lt;/h3&gt;&lt;p&gt;结构整理阶段，一个表格 derived chunk 只需要指向它所在的 coarse chunk。语义细分后，这个 coarse chunk 可能变成多个 final chunk，原来的 coarse ID 已经不足以说明表格最终落在哪一块。&lt;/p&gt;
&lt;p&gt;assembler 因此会在包含图片或表格 atom 的 final metadata 中写入 &lt;code&gt;contained_element_ids&lt;/code&gt;。exporter 处理 derived chunk 时，优先用它自己的 &lt;code&gt;element_id&lt;/code&gt; 查找对应 final，再写入新的 &lt;code&gt;source_chunk_index&lt;/code&gt;。这里的 anchor 即锚点，是把派生检索入口重新连接到包含其原文位置的 source final 的稳定标识。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;noop&lt;/code&gt; 路径没有进行语义细分，也不一定写入同样的 contained 声明。为兼容这条路径，exporter 在找不到 element anchor 时，会回退到 &lt;code&gt;source_coarse_chunk_id&lt;/code&gt; 对应的第一个 final。由于 noop 下一个 source coarse 只转换为一个 source final，这个回退仍然能保持关系正确。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-15.png" alt="derived anchor 的重新映射" loading="lazy" /&gt;&lt;/p&gt;
&lt;h3 id="overlap-最后才进入正文"&gt;overlap 最后才进入正文
&lt;/h3&gt;&lt;p&gt;语义细分输出的 final core 之间是无重叠切片。validator 正是依靠这一点检查它们能否按顺序还原 coarse content。等 exporter 生成稳定的 &lt;code&gt;list[Chunk]&lt;/code&gt; 后，pipeline 才向符合条件的 chunk 添加 neighbor overlap。&lt;/p&gt;
&lt;p&gt;默认每侧最多取 64 splitter token。overlapper 先保存所有 chunk 的原始 core content，再从符合条件的前一个 chunk 取末尾、从后一个 chunk 取开头，拼到当前正文两侧。实际添加的 token 数会写入 &lt;code&gt;context_prev_tokens_applied&lt;/code&gt; 和 &lt;code&gt;context_next_tokens_applied&lt;/code&gt;，并标记 &lt;code&gt;context_overlap_mode=neighbor&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;derived chunk 不参与 neighbor overlap，因为它已经拥有自己的 adjacent context。front matter 永远跳过。含图片、表格、代码或公式等 protected element 的 source chunk 默认也跳过，只有显式设置 &lt;code&gt;CHUNKING_PROTECTED_NEIGHBOR_OVERLAP=true&lt;/code&gt; 才允许参与。实现会先过滤出具备 overlap 资格的索引，再在这个序列中取前后项。因此这里的 neighbor 不是严格的原文邻居：跳过 derived 通常仍沿 source 主线，但跳过一个 protected source 或 front matter 时，也可能把两侧并非直接连续的普通 source 接起来。这是当前实现的限制，不能把 neighbor overlap 当作语义连续性的保证。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-16.png" alt="core slice 与 neighbor overlap" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;由于 hard max validator 在 overlap 之前运行，1024 token 约束的是 source final core。导出后的 &lt;code&gt;Chunk.content&lt;/code&gt; 若再加入前后上下文，长度可能高于这个核心上限。&lt;code&gt;start_line&lt;/code&gt;、&lt;code&gt;end_line&lt;/code&gt;、标题路径和 source 映射仍描述 core，不会扩展到 overlap；metadata 只保存前后追加的 token 数，没有保存对应 source ID、字符区间或精确长度，因此无法仅凭最终 &lt;code&gt;Chunk.content&lt;/code&gt; 无歧义地剥离 core。正式评测若要计算 source 覆盖、证据 token 和重复比例，必须在 overlap 前保存 core/source span，不能从入库 content 反推。&lt;/p&gt;
&lt;p&gt;至此，结构、语义和异常路径形成了一个完整闭环。正常文本尽量靠近 soft target，protected 内容获得有限容忍，极端元素留下可审计的截断记录，模型故障不会伪装成正常退化，derived chunk 与 source 保持可追溯关系，overlap 则在所有边界确定后补充上下文。下一章将回到配置和使用范围，说明当前版本为什么保留这两套算法，以及在什么条件下值得启用语义细分。&lt;/p&gt;
&lt;h2 id="当前版本的取舍"&gt;当前版本的取舍
&lt;/h2&gt;&lt;p&gt;从代码结构看，LinkRag 为两次处理都留出了算法路由。路由的意义是让阶段之间通过稳定的数据模型连接，未来增加实现时不必改写整个 pipeline。它不表示当前已经准备了许多算法供人挑选。到本文对应的版本为止，结构整理只有 &lt;code&gt;candidate_boundary&lt;/code&gt;，长文本的语义细分只有 &lt;code&gt;semantic_depth_window&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;这个范围是有意收住的。结构整理直接关系到元素覆盖、标题归属和 derived anchor，一套新算法若不能满足同样的契约，就不能只因为“切出来的长度差不多”而接入。语义细分则多出 embedding 调用、重试和参数组合，保留多套未经同一文档集评测的实现，只会让配置项看起来丰富，却说不清什么时候应该选哪一个。&lt;/p&gt;
&lt;h3 id="默认先使用结构结果"&gt;默认先使用结构结果
&lt;/h3&gt;&lt;p&gt;系统默认的第二阶段配置是 &lt;code&gt;noop&lt;/code&gt;。它接收 &lt;code&gt;CoarseChunkSet&lt;/code&gt;，把每个 coarse chunk 转成对应的 final chunk，不再判断内部语义边界。这里的 noop 是架构上的透传实现，不是与 &lt;code&gt;semantic_depth_window&lt;/code&gt; 并列的另一种语义算法。&lt;/p&gt;
&lt;p&gt;这条默认路径适合两类情况。一类是文档自身标题清楚，而且实际 coarse token 分布已经确认不会触碰下游 provider 限制；另一类是还没有为目标数据准备好 Stage 2 embedding 成本和检索评测。此时保留结构结果，比默认引入一层未经验证的语义判断更容易解释。它并不自动等于“长度安全”或“跨环境可复现”。&lt;/p&gt;
&lt;p&gt;需要注意的是，noop 不会替结构结果补做 512 token 的软目标或 1024 token 的硬收口，对 final 长度没有上限承诺。如果一篇无标题文档在结构整理后形成数万 token 的 coarse chunk，它仍会完整传给后续向量化；当前索引 embedding 入口没有统一预检或截断，具体 provider 可能因输入过长而拒绝请求。开篇的 1243 token 只是这个风险的较小实例，不是 noop 的最大长度。&lt;/p&gt;
&lt;h3 id="启用不等于每块都计算-embedding"&gt;启用不等于每块都计算 embedding
&lt;/h3&gt;&lt;p&gt;要启用语义细分，最小配置可以写成下面这样。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-text" data-lang="text"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;CHUNKING_STAGE_ONE_ALGORITHM=candidate_boundary
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;CHUNKING_STAGE_TWO_ALGORITHM=semantic_depth_window
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;CHUNKING_MIN_CANDIDATE_CHUNK_TOKENS=128
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;CHUNKING_MAX_CHUNK_TOKENS=512
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;CHUNKING_HARD_MAX_TOKENS=1024
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;第一行在当前版本只能取 &lt;code&gt;candidate_boundary&lt;/code&gt;。第二行将透传切换为 &lt;code&gt;semantic_depth_window&lt;/code&gt;。后三行分别控制结构边界的软下限、普通 final 的软目标和 source mixed core 的硬上限，它们在前一章已经分别解释过。&lt;/p&gt;
&lt;p&gt;文章出现的数字并不都能通过环境变量调整。&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;数值或开关&lt;/th&gt;
					&lt;th&gt;性质&lt;/th&gt;
					&lt;th&gt;当前调整方式&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;128/512/1024 三个阈值&lt;/td&gt;
					&lt;td&gt;Settings，也可由数据集分块配置覆盖&lt;/td&gt;
					&lt;td&gt;环境变量或数据集配置&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;neighbor overlap 64、protected overlap&lt;/td&gt;
					&lt;td&gt;Settings，也可由数据集配置覆盖&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;CHUNKING_OVERLAP_TOKENS&lt;/code&gt;、&lt;code&gt;CHUNKING_PROTECTED_NEIGHBOR_OVERLAP&lt;/code&gt;&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;cohesion 窗口目标 128&lt;/td&gt;
					&lt;td&gt;Stage 2 内部常量&lt;/td&gt;
					&lt;td&gt;需要修改代码&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;Stage 2 batch 10、额外重试 2 次、0.5 秒退避&lt;/td&gt;
					&lt;td&gt;Stage 2 内部常量&lt;/td&gt;
					&lt;td&gt;需要修改代码&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;索引 embedding batch 与任务重试&lt;/td&gt;
					&lt;td&gt;向量化阶段的另一组 Settings&lt;/td&gt;
					&lt;td&gt;&lt;code&gt;CHUNK_INDEX_*&lt;/code&gt;，不控制 Stage 2 评分&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;即使第二行已经启用，语义算法也不会处理所有 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 限制。&lt;/p&gt;
&lt;p&gt;这层 gate 很重要。一个文档可能有二十个结构清楚的小节，只有其中一个长章节缺少内部标题。让十九个本来合格的 chunk 重新参与语义计算，不仅增加调用量，也可能改变原本清晰的结构边界。当前实现只处理那一个确实过长的 mixed chunk，把语义判断限制在它能解决的问题上。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-17.png" alt="语义细分的实际运行范围" loading="lazy" /&gt;&lt;/p&gt;
&lt;h3 id="参数不是越小越精细"&gt;参数不是越小越精细
&lt;/h3&gt;&lt;p&gt;三个 token 阈值之间存在联动。把 512 改成 256，会更早触发切分，也会让 packer 每次可比较的合法 gap 变少。chunk 数和 overlap 重复量通常随之增加，短问题可能更容易命中局部内容，依赖上下文的问题却可能丢失支撑。把 512 提高到 1024 可以降低索引条目和 embedding 计算，但检索返回的单块内容更宽，主题混合的风险也随之上升。&lt;/p&gt;
&lt;p&gt;128 同样不能脱离另外两个值单独理解。它过高时，结构整理会跨过更多标题边界，语义细分接收到的 coarse chunk 更大；它过低时，短标题和短段落更容易提前组成独立候选，后续又需要反复合并碎片。配置校验因此要求普通目标不小于最小候选阈值，hard max 不小于普通目标，但这种数值顺序只保证配置合法，不保证它适合某类文档。&lt;/p&gt;
&lt;p&gt;开启语义细分还会增加索引前处理成本。普通文本 atom 先为边界评分请求一次 embedding；分块完成后，final chunk 又会为向量索引请求第二次 embedding，两者不复用，模型配置也可能不同。另一方面，最终的 source chunk 可能变多，neighbor overlap 会增加实际索引 token。成本不能只按“调用了一次模型”估算，而应同时记录参与评分的文本数、输入 token、请求批次、任务重投、最终 chunk 数和索引 token。当前索引阶段已有 usage 汇总，Stage 2 scorer 尚未累计 provider usage，正式实验需要增加独立 instrumentation。&lt;/p&gt;
&lt;p&gt;因此，当前版本把结构整理作为基础能力，把语义细分作为长 mixed 文本的可选增强。是否启用，不应只看文档里有没有长章节，也要看这些章节是否真的包含多个可分辨的话题，以及新的边界能否改善目标检索。要回答后一个问题，就需要一套比“chunk 看起来顺眼”更可靠的评判方法。&lt;/p&gt;
&lt;h2 id="怎么判断分块的质量"&gt;怎么判断分块的质量
&lt;/h2&gt;&lt;p&gt;分块结果很容易得到一种直观评价。标题没有落单、长度大致整齐、读起来也像几个完整段落，于是我们倾向于说它“切得不错”。这种判断有用，却没有一个公认的单一分数可以替代它。文本分段研究关心边界与人工分段是否接近，信息检索研究关心相关内容能否排进前几名，RAG 评测还会继续追问取回的上下文是否足以支撑答案。这三件事相关，但不是同一件事。&lt;/p&gt;
&lt;p&gt;当前项目还没有正式运行过 chunking 对照实验。单元与验收测试覆盖了 source final core 对 coarse content 的区间覆盖、protected 处理、hard max、derived anchor 等契约，它们能证明实现有没有破坏既定规则，不能推出召回或回答质量有所改善。下面这套指标因此分成三层。第一层检查共同正确性与各方案声明的结构能力；第二层采用文本分段与信息检索领域已有的通用指标；第三层才借助 RAG 评测方法观察生成端。没有跑出的分数仍然不会写成结果。&lt;/p&gt;
&lt;p&gt;本节采用的指标并非自行命名。文本分段部分主要参考 ACL Anthology 收录的 &lt;a class="link" href="https://aclanthology.org/J02-1002/" target="_blank" rel="noopener"
 &gt;WindowDiff 论文&lt;/a&gt; 和 &lt;a class="link" href="https://aclanthology.org/P13-1167/" target="_blank" rel="noopener"
 &gt;Boundary Similarity 论文&lt;/a&gt;；检索指标的定义参考 Stanford 在线版 &lt;a class="link" href="https://nlp.stanford.edu/IR-book/html/htmledition/evaluation-of-ranked-retrieval-results-1.html" target="_blank" rel="noopener"
 &gt;《Introduction to Information Retrieval》评测章节&lt;/a&gt;，并对照 &lt;a class="link" href="https://datasets-benchmarks-proceedings.neurips.cc/paper/2021/hash/65b9eea6e1cc6bb9f0cd2a47751a186f-Abstract-round2.html" target="_blank" rel="noopener"
 &gt;BEIR&lt;/a&gt; 与 arXiv 上的 &lt;a class="link" href="https://arxiv.org/abs/2409.04701" target="_blank" rel="noopener"
 &gt;Late Chunking&lt;/a&gt; 如何使用 &lt;code&gt;nDCG@10&lt;/code&gt;。RAG 下游部分参考 ACL Anthology 的 &lt;a class="link" href="https://aclanthology.org/2024.eacl-demo.16/" target="_blank" rel="noopener"
 &gt;RAGAS&lt;/a&gt; 与 &lt;a class="link" href="https://aclanthology.org/2024.naacl-long.20/" target="_blank" rel="noopener"
 &gt;ARES&lt;/a&gt; 论文，以及 Ragas 官方文档中的 &lt;a class="link" href="https://docs.ragas.io/en/stable/concepts/metrics/available_metrics/context_precision/" target="_blank" rel="noopener"
 &gt;Context Precision&lt;/a&gt; 和 &lt;a class="link" href="https://docs.ragas.io/en/stable/concepts/metrics/available_metrics/context_recall/" target="_blank" rel="noopener"
 &gt;Context Recall&lt;/a&gt;。这些来源解决的是不同层面的问题，正文不会把它们合并成一个来源不明的“chunk score”。&lt;/p&gt;
&lt;h3 id="边界本身有一套评测传统"&gt;边界本身有一套评测传统
&lt;/h3&gt;&lt;p&gt;TextTiling 论文让七名读者分别为 12 篇杂志文章标记段落间的话题变化。这些文章大多在 1800 到 2500 词之间，而且刻意选择了结构标记较少的文本。论文用边界 precision、recall 和读者一致性分析结果。此后的文本分段研究又形成了几项更专门的指标，其中最常见的是 &lt;code&gt;Pk&lt;/code&gt; 和 &lt;code&gt;WindowDiff&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Pk&lt;/code&gt; 最早用于比较参考分段与算法分段。它在文本上移动一个固定宽度的窗口，观察窗口两端是否应当属于同一 segment。如果参考答案认为两端同属一段，算法却把它们分开，或者情况相反，就记一次错误。最终分数是错误窗口所占的比例，越低越好。窗口宽度通常取参考 segment 平均长度的一半。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Pk&lt;/code&gt; 的优点是比逐个边界做完全匹配更能容忍邻近偏移，问题是它对漏切和多切的惩罚并不对称，也会受 segment 长度分布影响。&lt;a class="link" href="https://aclanthology.org/J02-1002/" target="_blank" rel="noopener"
 &gt;Pevzner 与 Hearst 在 2002 年的论文&lt;/a&gt;系统分析了这些问题，并提出 &lt;code&gt;WindowDiff&lt;/code&gt;。它仍然移动固定宽度窗口，但不再只比较窗口两端是否同段，而是比较窗口内的边界数量。参考答案与算法给出的数量不同，这个窗口才记错。&lt;code&gt;WindowDiff&lt;/code&gt; 同样越低越好，通常比 &lt;code&gt;Pk&lt;/code&gt; 更对称地处理漏切和多切，也能让只偏移少量位置的边界获得较小惩罚。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;WindowDiff&lt;/code&gt; 也不是最后答案。窗口类指标会低估文档开头和结尾附近的错误，对边界很少或挤在一起的分段也可能产生偏好。&lt;a class="link" href="https://aclanthology.org/P13-1167/" target="_blank" rel="noopener"
 &gt;Fournier 2013 年提出的 Boundary Similarity&lt;/a&gt;改用边界编辑距离，把差异解释为新增、删除和邻近位置移动。完全漏掉一个边界需要新增或删除；切点只偏了一两个单位则可以看作移动，因此得到部分分。它还能导出 B-Precision 和 B-Recall，分别观察多切与漏切。&lt;/p&gt;
&lt;p&gt;这些指标都建立在同一条前提上，即参考分段和算法分段必须落在同一组原子位置上。对 LinkRag 来说，不能一份结果按段落计数，另一份按字符计数。正式评测会冻结 tokenizer 实现、tiktoken 与 &lt;code&gt;cl100k_base&lt;/code&gt; 版本，并在运行前确认没有退回字符估算；当前 chunk metadata 无法替我们完成这项追溯。parser 元素、句子拆分和 token-safe 位置统一映射到 source token offset。人工标注只在 atom builder 允许的 gap 上选择边界，固定长度基线的切点也映射到同一 token 轴。&lt;code&gt;Pk&lt;/code&gt; 与 &lt;code&gt;WindowDiff&lt;/code&gt; 的窗口宽度按人工分段的平均 source token 长度计算。&lt;/p&gt;
&lt;p&gt;标注者仍然需要表达粒度上的不确定性。每个候选 gap 使用“应当分开”“应当保留”“两者皆可”三个标签。至少三名标注者独立判断，彼此看不到算法结果。两人以上选择“应当分开”的位置组成严格 gold；把两人以上选择“应当分开”或“两者皆可”的位置纳入后，可以再形成一份宽松 gold。两份结果同时报告，比事后调大容忍距离更诚实。&lt;/p&gt;
&lt;p&gt;人工一致性也不再只按完全相同的 gap 计算 kappa。TextTiling 当年的 kappa 能反映总体分歧，但两个读者若把同一次换题标在相邻 gap，会被视为彻底不一致。这里更适合报告标注者两两 Boundary Similarity，并辅以基于边界相似度的机会校正一致性。若人工之间本来就难以达成一致，算法与单一 gold 的差异也不应被过度解释。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-18.png" alt="Pk、WindowDiff 与 Boundary Similarity" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;正式报告中，&lt;code&gt;WindowDiff&lt;/code&gt; 与 Boundary Similarity 作为并列的主要边界指标。前者观察滑动窗口里的分段数量是否一致，后者观察边界经过多少编辑才能对齐；&lt;code&gt;Pk&lt;/code&gt; 用于与既有文本分段工作保持可比，B-Precision 和 B-Recall 则进一步解释错误究竟是漏切还是多切。它们都不能回答检索是否变好，因此还需要另一套指标。&lt;/p&gt;
&lt;h3 id="ragas-类指标放在更后面"&gt;RAGAS 类指标放在更后面
&lt;/h3&gt;&lt;p&gt;检索命中证据之后，RAG 还可能因为上下文冗余、提示词或生成模型而失败。&lt;a class="link" href="https://aclanthology.org/2024.eacl-demo.16/" target="_blank" rel="noopener"
 &gt;RAGAS 论文&lt;/a&gt;把评测拆成 context relevance、faithfulness 和 answer relevance。当前 Ragas 文档又提供了更接近传统 IR 命名的 &lt;a class="link" href="https://docs.ragas.io/en/stable/concepts/metrics/available_metrics/context_precision/" target="_blank" rel="noopener"
 &gt;Context Precision&lt;/a&gt; 与 &lt;a class="link" href="https://docs.ragas.io/en/stable/concepts/metrics/available_metrics/context_recall/" target="_blank" rel="noopener"
 &gt;Context Recall&lt;/a&gt;。前者判断相关上下文是否排在前面，后者可以把参考答案拆成 claims，检查取回内容支撑了其中多少。&lt;/p&gt;
&lt;p&gt;这些名称与普通 Precision@k、Recall@k 相似，计算过程却不一定相同。传统 IR 指标依赖预先固定的 relevance judgment；Ragas 可以让 LLM 根据问题、参考答案和 retrieved context 临时做判断。它降低了人工标注成本，也引入了 judge 模型、prompt、版本和随机性的影响。RAGAS 原论文专门讨论了重复运行的一致性，并承认长上下文的 context relevance 更难判断。&lt;a class="link" href="https://aclanthology.org/2024.naacl-long.20/" target="_blank" rel="noopener"
 &gt;ARES&lt;/a&gt;也没有把自动 judge 当作无条件真值，而是使用少量人工标注校准评估。&lt;/p&gt;
&lt;p&gt;因此，LinkRag 不应拿一个 LLM-as-judge 分数替代确定性的检索指标。正式评测先用 source evidence 和固定 relevance grade 计算 Evidence Recall、MRR 与 nDCG；生成模型、prompt、top-k 和 reranker 全部冻结后，再把 Context Precision、Context Recall 与 faithfulness 作为第二层观察。自动评审需要记录模型与 prompt 版本，并抽样与人工判断对照。它能回答“这些 chunk 最后有没有被模型用好”，却不能把所有变化都归因到 chunking。&lt;/p&gt;
&lt;h3 id="linkrag-的正式指标组"&gt;LinkRag 的正式指标组
&lt;/h3&gt;&lt;p&gt;综合文本分段、信息检索与 RAG 评测，LinkRag 的第一版正式评测不采用一个总分，而是保留下面这组指标。共同正确性属于所有方案的 gate；protected、derived 和标题等则是结构化方案的能力与契约指标。固定长度基线可以在这些结构指标上失败，但仍应进入检索与成本比较，否则实验只是在定义上淘汰基线。&lt;/p&gt;
&lt;table&gt;
	&lt;thead&gt;
			&lt;tr&gt;
					&lt;th&gt;层面&lt;/th&gt;
					&lt;th&gt;正式指标&lt;/th&gt;
					&lt;th&gt;简单计算&lt;/th&gt;
					&lt;th&gt;首轮用途&lt;/th&gt;
			&lt;/tr&gt;
	&lt;/thead&gt;
	&lt;tbody&gt;
			&lt;tr&gt;
					&lt;td&gt;共同正确性&lt;/td&gt;
					&lt;td&gt;规范化 source 覆盖率、顺序与区间可映射率、未声明丢失率&lt;/td&gt;
					&lt;td&gt;在 overlap 前保存 core/source span；覆盖率为 core 来源区间并集的 token 数除以规范化有效 source token 数，顺序与区间必须能够回到同一 source 轴，任何未带诊断的缺失单独计错&lt;/td&gt;
					&lt;td&gt;三种方案都必须使用同一 parser 输出并可生成 qrels；固定长度也不得静默丢失内容&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;结构能力&lt;/td&gt;
					&lt;td&gt;protected 内切率与截断率、derived 锚定正确率、标题正文连接率、非法 hard-max 比例&lt;/td&gt;
					&lt;td&gt;protected 内切与受控截断分开统计；锚定正确率为正确指向 source 的 derived 数除以 derived 总数；标题连接率为与所属首段正文同块的标题数除以适用标题数；非法长度率只统计没有允许例外却越界的对象&lt;/td&gt;
					&lt;td&gt;LinkRag 两条结构化路径必须满足各自声明的契约；固定长度基线允许表现较差但不因此退出检索比较&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;人工边界&lt;/td&gt;
					&lt;td&gt;WindowDiff、Pk、Boundary Similarity、B-Precision、B-Recall&lt;/td&gt;
					&lt;td&gt;窗口宽度取人工平均 segment 长度的一半；&lt;code&gt;Pk&lt;/code&gt; 是窗口两端“是否同段”判断不一致的窗口比例；&lt;code&gt;WindowDiff&lt;/code&gt; 是窗口内边界数量不一致的比例；Boundary Similarity 为 1 减去归一化后的最小边界编辑代价；B-Precision 与 B-Recall 分别用匹配边界的得分除以算法边界数和人工边界数&lt;/td&gt;
					&lt;td&gt;WindowDiff 与 Boundary Similarity 并列报告，Pk 保持历史可比性，B-Precision/B-Recall 解释漏切和多切&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;排序检索&lt;/td&gt;
					&lt;td&gt;Evidence Recall@5、Evidence Recall@10、MRR@10、nDCG@10、Precision@5&lt;/td&gt;
					&lt;td&gt;Recall@k 为 top-k 覆盖的不同 gold 证据单元数除以证据单元总数；MRR@10 对每个查询取首个相关结果名次的倒数再求平均，top-10 未命中记 0；&lt;code&gt;DCG@10 = Σ(2^rel - 1) / log2(rank + 1)&lt;/code&gt;，nDCG 再除以理想排序的 DCG；Precision@5 为 top-5 中相关结果数除以 5&lt;/td&gt;
					&lt;td&gt;Recall 看证据覆盖，MRR 看首个命中，nDCG 看分级相关性与排序&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;上下文预算&lt;/td&gt;
					&lt;td&gt;2048 token 内的 Evidence Recall、证据 token 比例、重复来源 token 比例&lt;/td&gt;
					&lt;td&gt;按排名依次加入 chunk，完整内容累计到 2048 token 为止，再计算证据覆盖率；证据 token 比例为取回的不同 gold source token 数除以实际返回 token 数；重复比例为已在更高排名内容中出现过的 source token 数除以实际返回 token 数&lt;/td&gt;
					&lt;td&gt;防止通过更长 chunk 或大量 overlap 虚增命中&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;RAG 下游&lt;/td&gt;
					&lt;td&gt;Context Precision、Context Recall、faithfulness，辅以人工答案支撑判断&lt;/td&gt;
					&lt;td&gt;Context Precision 对每个被判相关的排名位置计算当时的 Precision@k，再取平均；Context Recall 为取回内容能够支撑的参考答案 claim 数除以参考 claim 总数；faithfulness 为能由 retrieved context 推出的回答 claim 数除以回答 claim 总数&lt;/td&gt;
					&lt;td&gt;只在 generator、prompt、retriever 与 reranker 冻结后作为第二层指标&lt;/td&gt;
			&lt;/tr&gt;
			&lt;tr&gt;
					&lt;td&gt;成本&lt;/td&gt;
					&lt;td&gt;每万源 token 的 chunk 数与索引 token、Stage 2 embedding token 和请求数、p50/p95 时延、重试率、失败率&lt;/td&gt;
					&lt;td&gt;chunk 或索引 token 数除以源 token 数后乘 10000；embedding 成本直接累计评分请求的输入 token 与批次数；p50/p95 取文档处理时延的第 50、95 百分位；重试率和失败率分别用重试 batch、失败文档除以各自总数&lt;/td&gt;
					&lt;td&gt;与质量指标一起形成成本收益曲线&lt;/td&gt;
			&lt;/tr&gt;
	&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;表中 nDCG 公式里的 &lt;code&gt;rel&lt;/code&gt; 是当前排名位置的相关性等级。首轮实验采用 0、1、2 三级，分别表示没有证据、只有部分必要证据、单个 chunk 已足以支撑整个查询。所谓有效源 token，则排除水平分割线等 pipeline 明确过滤的内容；若发生受控截断，缺失部分单独计入 truncated 统计，不能用覆盖率掩盖。&lt;/p&gt;
&lt;p&gt;共同正确性不能被较高的 Recall@10 抵消。任何方案若在自己的输入表示上出现未声明丢失、顺序错乱或 qrels 无法映射，比较就不成立。protected 内切、derived 锚定和标题连接则要按能力范围解释：它们是 LinkRag 结构化路径必须守住的契约，也是固定长度基线可以如实失败的质量指标。后者不会因此被禁止进入检索比较。WindowDiff 很低同样不能证明检索一定更好，真实用户的问题可能需要跨越人工标出的两个 segment。&lt;/p&gt;
&lt;h3 id="一次可以正式执行的对照实验"&gt;一次可以正式执行的对照实验
&lt;/h3&gt;&lt;p&gt;第一轮正式实验可以使用 60 篇允许公开或内部合规使用的真实 Markdown，每篇约 1500 到 5000 token。文档按主要困难分成五组，每组至少 12 篇，分别覆盖标题清楚、标题稀疏、完全无标题、protected 元素密集和含超长元素的情况。20 篇作为 development set，用于确定 token 阈值和标注规范；余下 40 篇作为一次性 test set。参数不能在看过 test 分数后继续调整。&lt;/p&gt;
&lt;p&gt;同一批文档运行三种方案。第一种使用同一 tokenizer 做 512 token 固定长度切分，并保留 64 token overlap；第二种是 &lt;code&gt;candidate_boundary + noop&lt;/code&gt;；第三种是 &lt;code&gt;candidate_boundary + semantic_depth_window&lt;/code&gt;，先以本文所列的 128、512、1024 作为默认参数。三种方案共享 parser 产出的规范化来源文本、tokenizer、最终索引模型、向量索引、查询、reranker 和 top-k。第三种方案还要额外冻结并记录 Stage 2 scorer 的 provider、model 与 config ID；它可以与最终索引模型不同，但实验结论只对这组组合成立。固定长度基线只是不使用元素边界，不能换一份更干净或更脏的输入制造差异。&lt;/p&gt;
&lt;p&gt;边界标注使用前述三人独立流程，并同时产出严格与宽松 gold。检索任务在查看任何方案的 chunk 之前制作，每篇至少五个问题，总数不少于 300。问题应包含局部事实、需要连续两三段说明的解释、标题层级限定的问题，以及针对表格、图片说明或代码的查找。每个问题保存一至多个最小证据单元，证据天然跨段时不得为了迎合某个 chunk 大小而缩短。&lt;/p&gt;
&lt;p&gt;评测程序根据 source 区间动态生成每次运行的 qrels。qrels 是 query relevance judgments，也就是每个查询与候选检索项之间的相关性标注。一个 chunk 单独包含回答查询所需的全部证据时记 2，只覆盖部分必要证据、仍需与其他 chunk 组合时记 1，没有证据时记 0。core/source span 必须在 overlap 前保存，因为当前入库 &lt;code&gt;Chunk.content&lt;/code&gt; 只有前后 overlap token 数，无法无歧义恢复 core。这个规则要在 development set 上由人工抽查，确认 derived、overlap 和 truncated 内容都被正确映射，再冻结到 test set。每个证据单元是否被找回仍然单独记录，供 Evidence Recall 使用，不能从 0/1/2 的单块 relevance 反推。&lt;/p&gt;
&lt;p&gt;检索结果需要保存 rerank 前后两个版本。原始 top-10 反映 chunk 表示与一阶段 retriever 的效果，rerank 后 top-5 和 top-10 反映真实消费链路。这样即使最终结果相近，也能看出是 chunking 本身已经把证据排到前面，还是 reranker 修复了较差的初始排序。&lt;/p&gt;
&lt;p&gt;统计时按文档做 paired bootstrap。paired 表示同一次抽样中三个方案始终使用相同文档，从而比较同一批样本上的差异；bootstrap 则通过多次有放回抽样估计置信区间。总体指标之外还要给出五类文档的分组结果，因为“无标题文档改善、标题清楚文档退化”在总平均里很容易被抵消。&lt;/p&gt;
&lt;p&gt;成本记录来自同一次实验运行，但不能假设现有 pipeline 日志已经包含全部字段。索引 embedding 有 usage 汇总，Stage 2 scorer 目前没有累计 provider usage，需要额外 instrumentation 保存参与评分的 atom 数、输入 token、batch、batch 内重试和任务重投。还要记录 source 与 derived chunk 数、加入 overlap 前后的索引 token。若 Recall@10 提高的同时索引 token 翻倍，报告必须把两者放在一起，而不是只展示前一个数字。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-19.png" alt="正式评测的证据链" loading="lazy" /&gt;&lt;/p&gt;
&lt;h3 id="结果不应被压成一个总分"&gt;结果不应被压成一个总分
&lt;/h3&gt;&lt;p&gt;正式结论先看共同 gate，再看结构能力与成对指标。规范化 source 出现未声明丢失或区间无法映射时先修实验；LinkRag 路径的 protected、derived anchor 或 hard-max 契约不合格时先修实现；固定长度在相同指标上的失败则作为比较结果保留。进入效果比较后，Evidence Recall@10 需要与 Precision@5、nDCG@10 和 2048-token 预算下的 recall 同看；WindowDiff 需要与 Boundary Similarity 同看；RAGAS 类分数则必须带上人工校准结果。首轮实验没有跑完之前，不预设一个看似精确的总阈值。&lt;/p&gt;
&lt;p&gt;这也解释了为什么 &lt;code&gt;Recall@k&lt;/code&gt; 不能单独成为 chunking 的最终分数。增大 k、切出更多 chunk 或加入更多 overlap，都可能把 Recall@k 推高，同时增加噪声和成本。相反，一种方法的边界与人工标注不完全一致，却可能在真实查询上取得更高 nDCG，因为用户问题的证据粒度本来就不等同于文章子话题的粒度。&lt;/p&gt;
&lt;p&gt;更有用的结论是找出适用范围。若语义细分只在无标题和标题稀疏文档中稳定改善 Evidence Recall 与 nDCG，在标题清楚的文档中没有收益，那么路由条件应继续细化；若 256 token 的目标提高 Hit@5，却降低 budgeted recall 和 context precision，就不能把它简单称为更精细的配置。评测的任务不是替算法盖章，而是说明它在哪些文档和问题上值得付出额外成本。&lt;/p&gt;
&lt;p&gt;目前我们能严格声称的是，代码已经实现了本文明确限定对象后的结构、长度和异常契约。它还没有解决所有 pipeline 风险：tokenizer fallback 不可观察，Stage 2 运行配置没有写进 chunk metadata，derived/front matter 缺少统一 provider 长度预检，overlap 也不能从最终 content 精确反解。人工边界、检索排序和 RAG 下游收益仍需要按上述方法取得证据。这个区分让文章最后回到开篇样例时，不必用一个好看的可视化替代正式结论。&lt;/p&gt;
&lt;h2 id="回到最开始的文档"&gt;回到最开始的文档
&lt;/h2&gt;&lt;p&gt;开篇那段“堆内存分配与回收策略”在结构上没有错误。它属于同一个四级标题，内部没有新的标题可供 &lt;code&gt;candidate_boundary&lt;/code&gt; 使用，因此在 noop 路径中完整保留为一个 1243 token 的 mixed chunk。标题和正文没有被拆散，代价是垃圾回收算法、Eden 与 Survivor、对象晋升、空间分配担保以及 TLAB/CAS 同时占据一个检索单元。&lt;/p&gt;
&lt;p&gt;启用 &lt;code&gt;semantic_depth_window&lt;/code&gt; 后，同一个 coarse chunk 才进入第二次判断。现有可视化报告中，它被整理为四个 source chunk，导出后计入 neighbor overlap 的长度分别为 265、479、641 和 308 token。它们大致对应分代回收的引入、Eden/Survivor 的分区与比例、对象晋升及空间担保、TLAB/CAS 并发分配。641 token 高于 512，不代表 core 的软目标失效；报告展示的是已经附加前后上下文的 exported &lt;code&gt;Chunk.content&lt;/code&gt;，而 512 本来也是普通打包目标，不是导出内容的硬上限。&lt;/p&gt;
&lt;p&gt;&lt;img src="https://blog.kawausococo.top/uploads/posts/linkrag-chunking/figure-20.png" alt="开篇样例的前后对照" loading="lazy" /&gt;&lt;/p&gt;
&lt;p&gt;这四块是那次运行的真实输出，但现有报告没有保存 JVM 案例的 atom timeline、各 gap 的 cohesion/depth 和每次 packer 选点原因。文章中间的 a0–a5 和分数只是教学构造，不能充当这次运行的 trace。因此这个结果只能说明实现产生了怎样的候选输出，还不能证明四个边界最优，更不能单独证明检索变好。真正的判断要回到上一章，让人工边界、证据召回、答案支撑和成本共同说话。&lt;/p&gt;
&lt;p&gt;LinkRag 在这里没有寻找一个对所有文本都适用的切分尺度。标题层级决定哪些块级结构优先留下，cohesion 的低谷帮助过长正文选择边界，token 阈值约束普通 source mixed core，protected、derived 和 overlap 则各自带有前文说明的例外。TextTiling 提供了观察相邻上下文变化的起点，最后落进代码的是一套受 Markdown 块级结构和 RAG 预算约束的实现。&lt;/p&gt;
&lt;p&gt;这套实现已经能把开篇的问题从“一个标题下只有一个超长块”推进到一组按明确规则产生的候选边界，但现有 chunk metadata 还不足以复现每次评分配置。它是否适合某个知识库，仍要由那个知识库里的文档和问题来回答。下一步不是再为算法换一个名字，而是补齐实验记录并把评测跑起来。&lt;/p&gt;</description></item></channel></rss>