一套合同问答系统上线两周,用户问“提前终止合同要怎么通知、逾期付款怎么算”,系统答“邮件通知即可,逾期按同期贷款利率计息”。原文写的是“书面通知”和“按日万分之五”。两个限定词都丢了。
翻召回日志,top-3 子块确实覆盖了这三句话所在的段落,但每个子块是固定 512 token 的滑窗,关键证据正好横跨两条边界:一块的结尾是“甲方应在终止后 30 日内”,另一块的开头是“以书面形式通知乙方”。向量检索命中了后一块,前一块排第 7,没进上下文。模型没有说“证据不足”,它用一个合理的默认值把空补上了。
团队的第一反应是把 chunk size 从 512 调到 1024。改完召回率反而降了:1024 的块把三句关键证据和另外五句关于不可抗力的内容压进同一个向量,查询“通知方式”时相似度被稀释,掉出了 top-10。
这两种失败方向相反,却来自同一个参数。块的尺寸同时承担了三个互相冲突的职责:被向量模型编码、被相似度排序、被塞进 prompt 当证据。这三件事的最优尺寸本来就不一样。
本文只讨论一件事:把这三个职责拆开之后,回填窗口该开多大。不讨论 embedding 模型选型,不讨论 rerank,不讨论 prompt 模板。给出的实现是一套可 assert 的双写索引加一个基于 gold span 的评测器,两条曲线分别是证据完整率和每答 token 成本。
1. 两个失败样本:为什么调 chunk size 调不出来
把开头那个故障拆成两个可以复现的形态,后面所有设计都是冲着这两个形态去的。
形态 A:证据被切散。 一段完整论证占 900 字符,被 512 字符的窗口切成两块。查询命中的是后半块,前半块里的主语和条件句丢失。模型看到的上下文是“……应在 30 日内以书面形式通知”,但“谁通知”和“从哪天起算”不在这块里。它不会报错,它会补一个默认值。这类错误在评测里表现为“答案通顺、引用存在、但与原文矛盾”,比拒答更难被发现。
形态 B:证据被稀释。 把窗口调到 1024,上面那段完整了,但同一节里另外 600 字符的无关内容也进来了。一个向量要概括的主题变多,单主题精确查询的区分度下降。块越大,向量越接近这一节的“平均语义”,而用户的查询通常只命中其中一个侧面。
这两个形态的代价方向相反:A 是上下文不足导致漏答和编造,B 是上下文过量导致召回下降和 token 浪费。调 chunk size 只能在两者之间移动,找不到同时最优的点——这是同一个参数的两个副作用,不是一个可优化目标的两种实现。
加大 overlap 能缓解 A:让边界附近的内容同时出现在两块里。代价有三层。第一层是索引体积按 overlap 比例增长。第二层是同一段话被多个块召回,挤占 top-k 名额,第九期第 3 篇讨论的近重复合并就是在补这一层。第三层最容易被忽略:overlap 是静态且位置无关的,它不知道这次查询命中的是块的中央还是边界,一律给同样的冗余。命中在中央时,overlap 纯粹是浪费。
还有一种更隐蔽的失败形态。形态 C:结构化内容被切碎。 表格、编号列表、代码块在按字符滑窗切分时会被拦腰截断,模型看到半张表,把列头和数值错配。这个形态和 A 同源,但用扩大窗口治不了——把整张表塞进一个块,向量会被表格里的数字噪声污染。它需要的是另一套边界规则,本文第 7 节会回到这一点。
值得注意的是,调 top-k 也救不了 A。top-k 决定的是“给模型看几个块”,不决定“每个块里有没有完整证据”。把 k 从 3 调到 10,只是把更多残缺的块一起送进去,token 成本上去了,缺失的主语还是缺失。
真正需要分开的是两个问题:用什么粒度做相似度比较,用什么粒度做证据呈现。第一个问题的答案偏向小——一句话、一个命题、一个表格行,语义集中,向量区分度高,Dense X Retrieval 那篇把检索单元下沉到 proposition 粒度就是这条路走到极致的形态。第二个问题的答案偏向大——模型需要主语、条件、例外和单位,一句孤立的话经常不足以回答问题。
父子块结构就是把这两个答案分别实现,再用一条映射关系连起来。它不是一个新算法,是一层组织方式;它的全部价值在于让“命中”和“作答”这两个动作各自拿到合适尺寸的输入。
2. 双粒度索引的不变量
先定义三个角色,后面所有设计都从它们推出来。
命中单元(hit unit):进向量索引、参与相似度计算的最小对象,本文里是子块。组装单元(assembly unit):最终进入 prompt 的文本片段,本文里是句窗或父块。存储单元(storage unit):原文只存一份,其余都是指向它的区间。
三者分离之后,索引里存的是子块向量,KV 或对象存储里存的是父块文本,prompt 里拼的是按需计算出来的区间。这里有一条容易被忽略的约束:组装单元必须是原文的连续区间,不能是拼接出来的。拼接会破坏语序,也会让“这句话来自第几页第几段”这类溯源信息丢失。所以组装单元用 (doc_id, start, end) 表示,取文本时直接从原文切片,而不是从若干块里拼字符串。
这套结构要成立,需要守住六条不变量。任何一条破了,后面调窗口都是在错误的地基上做实验。
不变量 1:子块区间必须落在它的父块区间内。 这是父子关系唯一的硬约束。它保证“回填到父块”永远是一个合法的放大操作,不会越界到别的章节,也让回填的上界可以被静态计算出来。
不变量 2:区间坐标用文档绝对偏移,不用块内相对偏移。 如果父块存相对偏移、子块再存相对父块的偏移,回填时要做两级换算;一旦某次切分逻辑改了而另一级没改,换算就静默错位,取出来的文本看起来正常,其实整体偏了几百字。用绝对偏移,任何一层都可以直接和原文比对,错了立刻 assert 得出来。这条不变量还有一个副产品:评测集里存 gold span 时也存绝对偏移,这样评测数据和切分版本解耦,重新切分不需要重标。
不变量 3:父块有硬上限。 没有上限的父块等于没有父子结构——回填到父块就等于回填全文。上限取多少取决于目标模型的上下文预算和单次问答允许的证据量,不是由文档结构决定的。一份没有小标题的万字文档,如果按标题切,整篇就是一个父块,硬上限是唯一的兜底。
不变量 4:子块不跨父块。 跨父块的子块会让“这个子块属于哪个父块”变成一对多,回填时要么重复要么漏。它还会让不变量 1 失效,因为一个跨界的子块不可能同时落在两个不相交的父块区间内。
不变量 5:父块之间不重叠,或只在明确允许的边界重叠。 父块重叠会让同一个子块区间落在两个父块里,回填去重变成必需品,而这又和不变量 4 冲突。
不变量 6:父块 id 与切分版本绑定。 重新切分后,同样的内容会得到不同的 id。如果 id 用递增序号,新旧索引之间无法对账;如果用内容哈希,编辑一个字符就变。实践里用 (doc_id, chunker_version, ordinal) 三元组,并在文档表里记一个 active_version,重建索引时整体切换而不是逐条覆盖。否则重建期间新旧块混在一起,同一段话会被召回两次。
这六条听起来像常识,但它们各自对应一种真实出现过的错。不变量 2 对应“切分脚本改了字符归一化规则,全库偏移错位”;不变量 3 对应“某份文档一整节一万二千字,回填等于全文,token 账单翻三倍”;不变量 6 对应“重建索引期间同一个问题返回了两条几乎一样的引用”。
下图是从入库到组装的数据血缘,以及两条曲线如何反过来影响回填策略。
#mermaid-svg-Ehnzp39GBnpY04hr{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Ehnzp39GBnpY04hr .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Ehnzp39GBnpY04hr .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Ehnzp39GBnpY04hr .error-icon{fill:#552222;}#mermaid-svg-Ehnzp39GBnpY04hr .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Ehnzp39GBnpY04hr .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Ehnzp39GBnpY04hr .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Ehnzp39GBnpY04hr .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Ehnzp39GBnpY04hr .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Ehnzp39GBnpY04hr .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Ehnzp39GBnpY04hr .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Ehnzp39GBnpY04hr .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Ehnzp39GBnpY04hr .marker.cross{stroke:#333333;}#mermaid-svg-Ehnzp39GBnpY04hr svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Ehnzp39GBnpY04hr p{margin:0;}#mermaid-svg-Ehnzp39GBnpY04hr .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-Ehnzp39GBnpY04hr .cluster-label text{fill:#333;}#mermaid-svg-Ehnzp39GBnpY04hr .cluster-label span{color:#333;}#mermaid-svg-Ehnzp39GBnpY04hr .cluster-label span p{background-color:transparent;}#mermaid-svg-Ehnzp39GBnpY04hr .label text,#mermaid-svg-Ehnzp39GBnpY04hr span{fill:#333;color:#333;}#mermaid-svg-Ehnzp39GBnpY04hr .node rect,#mermaid-svg-Ehnzp39GBnpY04hr .node circle,#mermaid-svg-Ehnzp39GBnpY04hr .node ellipse,#mermaid-svg-Ehnzp39GBnpY04hr .node polygon,#mermaid-svg-Ehnzp39GBnpY04hr .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Ehnzp39GBnpY04hr .rough-node .label text,#mermaid-svg-Ehnzp39GBnpY04hr .node .label text,#mermaid-svg-Ehnzp39GBnpY04hr .image-shape .label,#mermaid-svg-Ehnzp39GBnpY04hr .icon-shape .label{text-anchor:middle;}#mermaid-svg-Ehnzp39GBnpY04hr .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Ehnzp39GBnpY04hr .rough-node .label,#mermaid-svg-Ehnzp39GBnpY04hr .node .label,#mermaid-svg-Ehnzp39GBnpY04hr .image-shape .label,#mermaid-svg-Ehnzp39GBnpY04hr .icon-shape .label{text-align:center;}#mermaid-svg-Ehnzp39GBnpY04hr .node.clickable{cursor:pointer;}#mermaid-svg-Ehnzp39GBnpY04hr .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Ehnzp39GBnpY04hr .arrowheadPath{fill:#333333;}#mermaid-svg-Ehnzp39GBnpY04hr .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Ehnzp39GBnpY04hr .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Ehnzp39GBnpY04hr .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ehnzp39GBnpY04hr .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Ehnzp39GBnpY04hr .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ehnzp39GBnpY04hr .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Ehnzp39GBnpY04hr .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Ehnzp39GBnpY04hr .cluster text{fill:#333;}#mermaid-svg-Ehnzp39GBnpY04hr .cluster span{color:#333;}#mermaid-svg-Ehnzp39GBnpY04hr div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Ehnzp39GBnpY04hr .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Ehnzp39GBnpY04hr rect.text{fill:none;stroke-width:0;}#mermaid-svg-Ehnzp39GBnpY04hr .icon-shape,#mermaid-svg-Ehnzp39GBnpY04hr .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Ehnzp39GBnpY04hr .icon-shape p,#mermaid-svg-Ehnzp39GBnpY04hr .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Ehnzp39GBnpY04hr .icon-shape .label rect,#mermaid-svg-Ehnzp39GBnpY04hr .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Ehnzp39GBnpY04hr .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Ehnzp39GBnpY04hr .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Ehnzp39GBnpY04hr :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
查询与组装
入库
k 句
整块
跨节
重跑扫描
原文 doc_text
父块切分标题与段落硬上限
子块切分句子聚合文档绝对偏移
父块 KVdoc_id + span
向量索引只存子块
query
子块 top-k 召回
回填策略
句窗 span
父块 span
相邻父块 span
合并重叠按文档序重排
按 token 预算裁剪
LLM 作答
证据完整率
每答 token 成本
定 k 与扩展顺序
图里那条从 K 回到 W 的虚线是重点:回填参数不是一次调完就固定的配置,它依赖切分版本和 embedding 方案,任何一个变了都要重扫。
3. 两条曲线与三个待定参数
回填策略有三个待定参数:窗口大小 k、窗口之间的重叠、扩展顺序。这三个参数不能靠感觉定,需要两条曲线一起看。
证据完整率(evidence completeness) 的定义是:给定一个问题,标出回答所必需的最小原文区间集合 gold = {g1, g2, …};组装出来的上下文是 assembled = {a1, a2, …};完整率等于各个 gold 区间被 assembled 覆盖的字符数之和,除以 gold 区间的总字符数。
这里有两个刻意的选择。第一,按字符算交集长度,不按 span 命中算。一个 gold span 可能被覆盖了一半,按命中算会把它记成 0 或 1,曲线变成阶梯,看不出边际收益。第二,gold 用字符区间,不用 chunk_id。用 chunk_id 会把评测绑死在某一版切分上,换个切分脚本 gold 就全失效了;字符区间和切分无关,这也是不变量 2 的延伸。
gold span 怎么来?三条路,成本递减、可信度也递减。人工标注最准,但一个问题要标三到五个区间,标一百题就是几百个区间。用答案反推:先让模型带着全文答一遍,把答案里的每个事实句回溯到原文位置,人工确认。用 LLM 生成候选再抽检:让模型读原文和问题,输出“回答这个问题需要原文的哪几句”,人工抽 20% 校验。后两条路会引入标注偏差,如果偏差方向固定(比如总倾向于多标),完整率的绝对值不准,但不同 k 之间的相对比较仍然有效,而本文要的正是相对比较。
每答 token 成本(tokens per answer) 是组装上下文送进模型的平均 token 数。这里要注意分母:是“每次调用”还是“每个被作答的问题”。有拒答和重试的系统里两者不同,本文用后者,因为拒答也要付输入 token,重试要付两遍。用一个统一的分母,成本曲线才可比。
为什么必须两条一起看。只看完整率,最优解是把整篇文档塞进去——只要上下文窗口够长,完整率必然接近 1,连检索都可以省掉。只看 token 成本,最优解是每次只送一句话——但答对率会崩。两条曲线放在同一张图上,找的是边际完整率除以边际成本开始下降的那个点。
具体做法是对 k 做扫描:k=0 表示只送命中的子块本身,k=1 表示向命中位置两侧各扩一句,依此类推。每个 k 算一遍完整率和平均成本。正常情况下完整率随 k 单调上升、成本随 k 线性上升。拐点判据有两条,满足任一条就停。
饱和判据:完整率达到 k 很大时的渐近值的 95%。再往下扩,就是为最后 5% 的边角案例付成倍成本。
边际比判据:相邻两个 k 之间,完整率增量除以成本增量,跌破某个阈值。阈值取多少取决于业务:法务问答里漏一个限定词的代价远大于多花两千 token,阈值就该低;客服 FAQ 里答错的代价是多一轮对话,阈值可以高。这个阈值是一个业务参数,不是技术参数,应该由业务方签字而不是工程师拍。
这里要澄清一个高频混淆:证据完整率不是召回率。召回率衡量“正确的内容有没有被检索到”,是检索器的指标,分母是问题数。完整率衡量“答案需要的字符有没有全部进入上下文”,是组装器的指标,分母是 gold 字符数。一个系统可以召回率很高、完整率很低——块都命中了,但每块都被切掉一半。这两种病要用不同的药,前者修检索,后者修组装。
重叠的两种含义。 在父子结构里,“重叠”有两个完全不同的所指,经常被混为一谈。
索引重叠:相邻子块共享若干句子。它影响的是召回——边界上的证据至少能被一个子块完整覆盖。代价是索引体积和近重复召回,也就是传统 overlap 调参调的那个东西。
组装重叠:相邻两个命中子块的句窗在原文上重叠。它影响的是 token 成本——重叠部分需要去重,如果不去重就会重复计费。父子结构里这个重叠是计算出来的,不是预先存的,所以它不在索引里付代价。
区分这两者的实际意义在于:当你发现“边界证据丢失”时,可能应该加组装重叠(把 k 调大),而不是加索引重叠(把子块 overlap 调大)。前者只在该命中点附近多花 token,后者给全库每个子块都加冗余。选错了,成本翻倍而收益不变。
扩展顺序 是第三个参数,也是最容易被忽略的。给定一个命中子块,往外扩的方向有四个:向左(前文)、向右(后文)、到父块剩余部分、到相邻父块。这四个方向的边际信息密度不一样。
一个可用的默认顺序是:先命中子块本身;再左右各扩一句;继续扩到父块边界;父块仍不够,或答案明确需要跨节时,才扩到相邻父块。理由是同一段论证内部的句子互相依赖最强,同节的其他段落共享主题但不共享论证,邻节往往换了话题。
这个顺序不是定理,它依赖文档的写作结构。如果你的文档是“每节一个独立 FAQ”,第四步的收益接近零,那就应该在预算里禁掉它,而不是让它默默烧 token。如果文档是长篇论述,论证跨节延续,第四步的权重就很高。所以扩展顺序本身也应该进 sweep:固定 k,只改顺序,看完整率差多少。
4. 最小实现:双写索引与不变量检查
下面这段实现只做三件事:切分并双写、维护六条不变量、把子块和句子的坐标都钉在文档绝对偏移上。向量检索、rerank、prompt 模板都不在里面,它们不改变本文要定的参数。
from __future__ import annotations
import re
from dataclasses import dataclass
@dataclass(frozen=True)
class Span:
"""文档内的半开区间 [start, end),坐标是整篇文档的绝对字符偏移。"""
start: int
end: int
def __post_init__(self) –> None:
if self.start < 0 or self.end < self.start:
raise ValueError(f"非法区间: {self}")
@dataclass(frozen=True)
class Chunk:
chunk_id: str
doc_id: str
parent_id: str | None
level: str # "parent" | "child"
span: Span
order: int # 文档内全局顺序,用于组装时重排
SENT_END = re.compile(r"(?<=[。!?;!?;])")
HEADING = re.compile(r"(?m)^#{1,6} .*$")
def _split_long(text: str, a: int, b: int, limit: int):
"""在 [a,b) 内按空行找切点,保证每段不超过 limit;找不到就硬切。"""
if b – a <= limit:
yield a, b
return
cursor = a
while cursor < b:
hard = min(cursor + limit, b)
if hard == b:
yield cursor, b
return
window = text[cursor:hard]
cut = window.rfind("\\n\\n")
if cut <= 0:
cut = window.rfind("\\n")
if cut <= 0:
yield cursor, hard
cursor = hard
continue
yield cursor, cursor + cut
cursor = cursor + cut
def split_parents(doc_id: str, text: str, max_parent_chars: int = 1800) –> list[Chunk]:
"""先按标题切,超长小节再按空行切。父块绝不跨标题。"""
cuts = [0]
for m in HEADING.finditer(text):
if m.start() > cuts[–1]:
cuts.append(m.start())
cuts.append(len(text))
parents: list[Chunk] = []
for a, b in zip(cuts, cuts[1:]):
if b <= a:
continue
for seg_a, seg_b in _split_long(text, a, b, max_parent_chars):
if seg_b <= seg_a:
continue
ordinal = len(parents)
parents.append(Chunk(
chunk_id=f"{doc_id}:v1:p{ordinal:04d}",
doc_id=doc_id,
parent_id=None,
level="parent",
span=Span(seg_a, seg_b),
order=ordinal,
))
return parents
def split_sentences(text: str, span: Span) –> list[Span]:
"""父块内部的句子区间,坐标同样是文档绝对偏移。"""
seg = text[span.start:span.end]
out: list[Span] = []
cursor = 0
for m in SENT_END.finditer(seg):
if m.end() > cursor:
out.append(Span(span.start + cursor, span.start + m.end()))
cursor = m.end()
if cursor < len(seg):
out.append(Span(span.start + cursor, span.end))
return [s for s in out if text[s.start:s.end].strip()]
def split_children(
doc_id: str,
parent: Chunk,
sentences: list[Span],
target_chars: int = 360,
) –> list[Chunk]:
"""把句子贪心聚合成子块,子块不跨父块边界。"""
children: list[Chunk] = []
i = 0
while i < len(sentences):
j, length = i, 0
while j < len(sentences):
cur = sentences[j].end – sentences[j].start
if j > i and length + cur > target_chars:
break
length += cur
j += 1
span = Span(sentences[i].start, sentences[j – 1].end)
ordinal = len(children)
children.append(Chunk(
chunk_id=f"{parent.chunk_id}:c{ordinal:03d}",
doc_id=doc_id,
parent_id=parent.chunk_id,
level="child",
span=span,
order=parent.order * 1000 + ordinal,
))
i = j
return children
def build_index(doc_id: str, text: str, *, max_parent_chars: int = 1800,
target_chars: int = 360):
parents = split_parents(doc_id, text, max_parent_chars)
sentences: dict[str, list[Span]] = {}
children: list[Chunk] = []
for p in parents:
sents = split_sentences(text, p.span)
sentences[p.chunk_id] = sents
children.extend(split_children(doc_id, p, sents, target_chars))
return parents, children, sentences
def check_invariants(text: str, parents: list[Chunk], children: list[Chunk],
max_parent_chars: int = 1800) –> None:
by_id = {p.chunk_id: p for p in parents}
assert len(by_id) == len(parents), "父块 id 重复"
for a, b in zip(parents, parents[1:]): # 不变量 5
assert a.span.end <= b.span.start, (a.chunk_id, b.chunk_id)
for p in parents: # 不变量 3
assert p.span.end – p.span.start <= max_parent_chars
assert text[p.span.start:p.span.end].strip()
for c in children: # 不变量 1、4
p = by_id[c.parent_id]
assert p.span.start <= c.span.start < c.span.end <= p.span.end
assert text[c.span.start:c.span.end].strip()
by_parent: dict[str, list[Chunk]] = {}
for c in children:
by_parent.setdefault(c.parent_id, []).append(c)
for group in by_parent.values(): # 子块不丢字
for a, b in zip(group, group[1:]):
assert a.span.end == b.span.start
DEMO_DOC = """# 第三章 合同终止
甲方提前终止本合同的,应在终止后 30 日内以书面形式通知乙方。
通知自乙方签收之日起生效。
乙方对通知内容有异议的,应在收到通知后 10 日内书面提出。
## 3.1 逾期付款
乙方逾期付款的,每逾期一日按未付金额的万分之五支付违约金。
逾期超过 30 日的,甲方有权解除合同并要求赔偿损失。
"""
def demo_index() –> None:
parents, children, sentences = build_index("contract-001", DEMO_DOC)
check_invariants(DEMO_DOC, parents, children)
assert any("万分之五" in DEMO_DOC[c.span.start:c.span.end] for c in children)
assert all(len(sentences[p.chunk_id]) >= 1 for p in parents)
代码里几个决定值得单独交代,因为它们直接对应正文里的取舍。
为什么先按标题切、再按空行切。 父块要尽量对应一个语义单元,标题是最便宜的结构信号。但标题不保证粒度均匀:一份文档可能有一节两千字、另一节两百字。所以需要 _split_long 的第二级切分。这带来一个副作用:切完的父块可能不以标题开头,正文第 7 节会讲它如何影响引用展示。
为什么子块按句子聚合,而不是按字符滑窗。 中文没有词边界,按固定字符数滑窗会切断词,切出来的子块首尾都是残句,向量质量下降。句子是语义上最小的安全切割单位。代价是句子长度方差大:一个 200 字的长句会单独撑满一个子块,这在代码里表现为内层循环的 j > i 分支——至少要放一句,哪怕它超了目标长度。
target_chars=360 是怎么来的。 不是拍脑袋。中文句子的常见长度在 30 到 60 字,360 字大约六到十句,正好覆盖一段完整的小论证;再往上,一个向量要概括的命题数就超过“单主题”的范围,回到形态 B。这个值应该和你的 embedding 模型的有效输入长度一起调,而不是单独调;如果模型推荐输入是 512 token 而你的子块平均 800 字,池化会把后一半信息压掉。
为什么子块之间不设 overlap。 这是本文和传统切分最不一样的一处。索引重叠的收益是“边界证据至少被一块完整覆盖”,但父子结构已经用组装层解决了边界问题:命中任何一块,回填都会向两侧扩句。在索引里再加冗余,等于为同一个问题付两次钱。代码里 i = j 而不是 j – overlap,就是这个决定的落地。
为什么 order 用 parent.order * 1000 + ordinal。 组装时需要按文档顺序重排,而子块自己的序号只在父块内有意义。乘 1000 是一个足够大的步长,保证任意两个父块的所有子块顺序不交叉。真实系统里父块的子块数可能超过 1000,那就用元组 (parent.order, ordinal) 排序,不要用这个乘法技巧。
5. 回填器与两条曲线的可执行评测
回填器接收 (score, child) 列表,输出合并、去重、裁剪后的 span 序列。它要处理四件事:句窗计算、区间合并、文档序重排、预算裁剪。选择顺序按分数,输出顺序按文档序——这两个顺序不一样,是本节最容易被写错的地方。
from dataclasses import dataclass
from statistics import mean
@dataclass(frozen=True)
class Assembly:
spans: tuple[Span, ...]
char_cost: int
def child_sentence_range(child: Chunk, sentences: list[Span]) –> tuple[int, int]:
hit = [i for i, s in enumerate(sentences)
if s.start < child.span.end and s.end > child.span.start]
return min(hit), max(hit)
def sentence_window(sentences: list[Span], lo: int, hi: int, k: int) –> Span:
a = max(0, lo – k)
b = min(len(sentences) – 1, hi + k)
return Span(sentences[a].start, sentences[b].end)
def merge_spans(spans: list[Span]) –> list[Span]:
if not spans:
return []
ordered = sorted(spans, key=lambda s: (s.start, s.end))
out = [ordered[0]]
for s in ordered[1:]:
last = out[–1]
if s.start <= last.end: # 相交或相接就合并
out[–1] = Span(last.start, max(last.end, s.end))
else:
out.append(s)
return out
def assemble(
hits: list[tuple[float, Chunk]],
parents: dict[str, Chunk],
sentences: dict[str, list[Span]],
k: int,
budget_chars: int | None = None,
) –> Assembly:
"""hits 按分数降序传入;选择按分数,输出按文档序。"""
picked: list[tuple[int, Span]] = []
for score, child in sorted(hits, key=lambda x: –x[0]):
parent = parents[child.parent_id]
lo, hi = child_sentence_range(child, sentences[parent.chunk_id])
span = sentence_window(sentences[parent.chunk_id], lo, hi, k)
if budget_chars is not None:
used = sum(s.end – s.start for _, s in picked)
if used + (span.end – span.start) > budget_chars:
continue
picked.append((child.order, span))
spans = tuple(merge_spans([s for _, s in picked]))
return Assembly(spans=spans, char_cost=sum(s.end – s.start for s in spans))
@dataclass(frozen=True)
class QAItem:
qid: str
gold: tuple[Span, ...]
def union_len(intervals: list[tuple[int, int]]) –> int:
if not intervals:
return 0
intervals = sorted(intervals)
a, b = intervals[0]
total = 0
for x, y in intervals[1:]:
if x > b:
total += b – a
a, b = x, y
else:
b = max(b, y)
return total + (b – a)
def completeness(gold: tuple[Span, ...], assembled: tuple[Span, ...]) –> float:
need = sum(g.end – g.start for g in gold)
if need == 0:
return 1.0
got = 0
for g in gold:
clipped = [(max(g.start, a.start), min(g.end, a.end))
for a in assembled if max(g.start, a.start) < min(g.end, a.end)]
got += union_len(clipped)
return got / need
def oracle_hits(item: QAItem, children: list[Chunk], top_m: int = 5):
"""用 gold 重叠量模拟一个满分召回器,测的是组装上限,不是检索质量。"""
scored = []
for c in children:
overlap = sum(max(0, min(c.span.end, g.end) – max(c.span.start, g.start))
for g in item.gold)
if overlap:
scored.append((overlap, c))
scored.sort(key=lambda x: –x[0])
return [(float(s), c) for s, c in scored[:top_m]]
def sweep(items, children, parents, sentences, ks, budget=None):
rows = []
for k in ks:
comps, costs = [], []
for item in items:
asm = assemble(oracle_hits(item, children), parents, sentences, k, budget)
comps.append(completeness(item.gold, asm.spans))
costs.append(asm.char_cost)
rows.append((k, mean(comps), mean(costs)))
return rows
def knee(rows, saturation: float = 0.95) –> int:
best = max(c for _, c, _ in rows)
for k, c, _ in rows:
if c >= saturation * best:
return k
return rows[–1][0]
def span_of(text: str, needle: str) –> Span:
i = text.index(needle)
return Span(i, i + len(needle))
def demo_sweep() –> None:
parents, children, sentences = build_index("contract-001", DEMO_DOC)
parent_by_id = {p.chunk_id: p for p in parents}
items = [
QAItem("q1", (span_of(DEMO_DOC, "应在终止后 30 日内以书面形式通知乙方"),)),
QAItem("q2", (span_of(DEMO_DOC, "每逾期一日按未付金额的万分之五支付违约金"),)),
]
rows = sweep(items, children, parent_by_id, sentences, ks=[0, 1, 2, 4, 8, 16])
for (k1, c1, t1), (k2, c2, t2) in zip(rows, rows[1:]):
assert k2 > k1
assert c2 >= c1 – 1e-9, "k 变大完整率不该下降"
assert t2 >= t1, "k 变大成本不该下降"
assert rows[0][1] < 1.0, "k=0 就答全的话,父子结构的前提不成立"
assert rows[–1][1] > 0.99, "窗口足够大时应当覆盖全部 gold"
assert knee(rows) < rows[–1][0], "拐点不该落在扫描区间的末端之外"
几个实现决定需要说清楚。
为什么 assemble 先按分数选、再按文档序输出。 模型读的是一段连续文本,打乱顺序会让它误解因果,也会让引用难以回溯。但预算不够时要先保高分证据。这两个需求不冲突,只要把“选择”和“呈现”分成两步。代码里 picked 保留 child.order,merge_spans 内部按 start 排序,就是这个分离。
为什么 oracle_hits 是基于 gold 的而不是真检索器。 本文要定的是回填参数,不是检索质量。用 oracle 召回可以先把检索误差排除掉,测出“组装层最多能做到多完整”。这是一个上限,不是线上表现。真实系统里必须再用真检索器复跑一遍同样的 sweep,两条曲线的间距就是检索损失,这个差值本身是决定“先修检索还是先修组装”的依据。
为什么预算裁剪用合并前的长度估算。 合并后的长度一定小于等于合并前之和,所以这个估算是保守的:它会提前拒掉一些本来放得下的窗口。保守方向的误差可以接受——宁可少放一块,也不要超预算被模型端截断,截断是静默的,比拒掉更难发现。
为什么成本用字符数而不是 token 数。 字符数是 token 数的单调代理,用于比较不同 k 的相对大小足够,而且不引入 tokenizer 依赖。绝对成本必须用目标模型的 tokenizer 复算,尤其在中英混排和表格场景里,字符到 token 的比例并不稳定。
评测跑出来的 rows 应该落库,而不是打印完就丢。每一行是 (k, 完整率, 字符成本),加上切分版本、embedding 模型名、评测集版本,就是一个可对账的快照。下次改任何一项,重跑,看拐点往哪边移。如果拐点从 k=1 移到 k=3,说明新方案让召回变差了——本来一块就够,现在要三块才能覆盖同样的证据,这可能是 embedding 换了,也可能是文档结构变了。
6. 边界:和 Late Chunking、普通 overlap 调参不是一回事
这套结构经常被归到“切块技巧”里,和 Late Chunking、overlap 调参并列。它们确实都在处理切块带来的信息损失,但作用点完全不同,混着选会选错药。
普通 overlap 调参 在切分阶段让相邻块共享若干字符。它解决的是“边界证据被切断”,代价是索引体积和近重复召回。它的根本局限是静态且位置无关:不管查询命中在块的哪里,冗余都一样。父子结构把冗余从切分阶段移到组装阶段,是按命中位置付费。两者的关系不是替代,而是冗余放在哪一层的选择:如果检索之后没有组装环节(比如直接把 top-k 拼起来就送模型),overlap 是唯一能用的手段;如果有组装环节,把冗余放在组装层更省。
Late Chunking 是 Jina 在 2024 年 9 月给出的做法:先用长上下文 embedding 模型编码整篇文档,拿到每个 token 的上下文向量,再按切分边界做池化,得到块向量。它解决的是“块向量缺少全文语境”——让“它”“上述条款”这类指代在向量里带上先行词的信息。它不改变喂给 LLM 的文本量:切完还是那些块,只是向量更准。
所以两者的作用点不同:Late Chunking 在编码器里,父子结构在组装器里。它们可以叠加,而且叠加有协同:Late Chunking 让子块命中更准,top-1 更有可能是真正包含证据的那块,于是最优回填窗口 k 可能变小——因为“用扩大窗口来弥补召回不准”的动机减弱了。这个协同可以用本文的两条曲线验证:换 embedding 方案重跑一遍 sweep,看拐点往哪边移。如果拐点没动,说明你的瓶颈不在编码质量,而在组装策略或文档结构。
Anthropic Contextual Retrieval(2024 年 9 月)走的是另一条编码前的路:在每个块前面拼一段由模型生成的语境说明,再编码。它同样不改变组装粒度,但它的成本结构和父子结构有冲突:每个块一次 LLM 调用,块越小调用越多,而父子结构恰好把块切小了。上父子结构之前要算一下这部分增量——如果子块数翻三倍,语境化的成本也翻三倍,省下来的 token 可能抵不过新增的生成成本。
RAPTOR(ICLR 2024)是递归聚类加摘要,建一棵摘要树,检索时可以命中任意层。它是向上抽象——父节点是子节点的摘要,不是原文。父子块是向上扩展原文——父块是原文的连续区间。区别落在可溯源性上:摘要树上的节点无法指向原文的精确字符区间,回答里的引用只能到“某章某节”;父子结构可以给到 (doc_id, start, end),引用能精确到句。反过来,RAPTOR 能用远小于原文的 token 概括跨越多节的主题,父子结构做不到——它的父块有硬上限,跨节主题只能靠多父块合并,成本高得多。
这三个对比可以收成一句判断:先看失败样本长什么样。失败样本是“块向量不理解指代”,用 Late Chunking;是“边界证据被切断且没有组装环节”,用 overlap;是“命中对了但喂给模型的证据不全”,只有改变组装粒度的方案能治,父子结构是其中最简单的一种。三种病可以同时存在,但一次只治一种,否则改完不知道是哪一项起了作用。
7. 失败模式与生产加固
前面给的是能跑通的最小结构,这一节把它推到真实系统里。下面每一条都是这套结构在生产中出现过的具体故障形状。
父块无界,或者父块过小。 最常被忽略的一条,而且两个方向都会出问题。向上,切分脚本按标题切,遇到一份没有小标题的长文档,整篇就是一个父块,回填到父块等于回填全文,token 账单会直接体现。代码里的硬上限就是为这个准备的,但要注意它只在“按标题切完还超长”时生效。向下,如果标题切分本身把文档切成了五十个两百字的父块,硬上限永远不触发,看起来一切正常,实际上父子结构已经退化:父块和子块尺寸接近,回填到父块没有额外收益,你付了两套索引的钱,拿到一套的效果。所以除了上限,还要在监控里看父块尺寸分布,中位数低于子块目标尺寸的两倍就说明结构没建起来。
跨父块证据。 答案需要两个不相邻章节的证据时,单次回填只能覆盖一个。代码里 assemble 支持多个命中,但预算裁剪按分数贪心,可能把第二个父块的证据整个裁掉。更稳的做法是按父块覆盖度分配预算:先保证每个命中的父块至少拿到一个窗口,再按分数加码。这个改动不大,但它把预算分配从“按相关性”变成了“按相关性加覆盖”,代价是低分父块也会占坑,在证据本来就集中的问题上会浪费 token。要不要改,取决于你的评测集里跨节问题的占比。
结构化文本被句子切分器打碎。 表格行、代码块、编号列表在句子切分器眼里可能是一整段没有句号的文本,被当成一句塞进子块,或者被句号误切。处理方式是在切分前标记这些区域,让它们成为不可分割的原子单元:表格按行、代码块按整块、列表按项。这会破坏“子块按句子聚合”的简单性,也会让子块长度方差变大,但比让模型看到半张表要好。这类区域还有一个额外要求:回填时不能只回填半张表,需要整表扩展,所以它的回填单位不是句窗,是原子单元本身。
id 漂移与重建窗口。 重新切分后父块 id 变化,评测集里的 gold span 仍然有效(绝对偏移不变),但引用展示用的 chunk_id 对不上了。这也是不变量 2 用绝对坐标的额外收益:评测数据和切分版本解耦。重建期间还需要一个切换策略:整体切版本,而不是逐条覆盖。逐条覆盖会让新旧块在同一个索引里共存,同一段话被召回两次,用户会看到两条几乎一样的引用。
Lost in the middle。 回填把多个窗口拼起来之后,最相关的证据可能落在上下文中间。Liu 等人在 2023 年的实验里给出了位置效应的证据:模型对上下文中段的信息利用率低于首尾。缓解方式有两种,各有代价。按分数排序输出,把最相关的放头尾——但会破坏阅读顺序,可能让模型误解因果。保持文档序,但把窗口数控制在三个以内——牺牲覆盖。我的做法是保持文档序,把 char_cost 和窗口位置记进 trace;如果某个问题的完整率是 1 却答错了,先怀疑位置效应,再怀疑模型。
成本失控的隐性路径。 k 从 1 调到 3,单次上下文可能翻倍,但完整率只涨两三个点。这条路径不会报错,不会触发告警,只会在月底账单上出现。所以 sweep 的结果必须落库,每次改切分、换 embedding、改扩展顺序都重跑,把拐点当作一个需要 review 的配置项,而不是一次性的调参。配置项要有版本号,和 prompt 版本一样可回滚。
可观测性上要记什么。 线上至少记四个数:每次召回的命中子块数、回填后的 char_cost、组装窗口数、答案里的引用能否对齐到原文区间。最后一个需要抽检,但它能发现“完整率算出来很高、模型还是答错”的情况——那通常意味着回填带进来的文本里有干扰项,或者证据确实齐了但位置不对。前三个数是自动的,第四个是抽样的人工成本,两者要分开算。
8. 什么时候值得上,什么时候不值得
这套结构不是默认选项。它多一层映射、多一套索引、多一个需要定期重扫的参数。下面三个条件同时满足,才值得考虑。
文档有可用的层级结构。 标题、段落、条款编号,任何一种能让父块对应一个语义单元的信号都行。纯流水文本——聊天记录、ASR 转写、没有分段的长文——没有这个结构,硬造父块只会得到任意切分,父子关系和随机分组没有区别。这种情况下更该做的是先补结构:说话人分离、话题分割、时间窗切分。
失败样本里“证据不全”的比例明显高于“召回没命中”。 这两个比例可以从现有日志里估:抽一百个答错的问题,看正确内容在不在 top-10 里。如果在 top-10 里但没进上下文,或者进了上下文但被切碎了,那是组装问题,父子结构对症。如果 top-10 里根本没有,先修召回——换 embedding、加 BM25 混合、上 rerank,父子结构一点忙都帮不上,它只保证“命中之后,喂给模型的证据是完整的”。
token 成本是可见的成本项。 如果每次问答的输入只有一两千 token,回填窗口带来的增量无关痛痒,那用固定的大块加 overlap 更简单,少一层要维护的映射。反过来,如果输入 token 是账单的主要部分,或者你在按 token 计价的产品里,回填窗口的收益会直接体现在成本曲线上。
明确不值得上的三种情况:FAQ 型知识库,一条问答就是一个完整语义单元,子块和父块重合,双写只是重复存储;已经用长上下文模型并把 top-20 子块全塞进去且成本可接受,这时候父子结构省下的 token 抵不过多一层索引的复杂度;检索质量本身没调好,父子结构会把召回问题放大——命中错了,回填越大错得越远,因为你把更多不相关的内容包装成了“证据”。
如果决定上,落地顺序建议分两步,每一步都能单独上线和回滚。第一步只做双写索引加整父块回填(等价于 k 无穷大),跑一遍两条曲线,得到成本上界。这一步不改任何检索逻辑,只是把“命中子块、送父块”接上,风险最低。第二步把回填改成句窗,从 k=0 扫到饱和点,得到成本下界和拐点。两次之间的差值就是这个结构能省的钱,也是你说服别人继续投入的依据。不要一次把两个都改完——改完如果指标没动,你不知道是结构没用,还是参数没调对。
总结
父子双粒度索引解决的不是“切多大”的问题,是“命中和作答用同一个尺寸”的问题。它把 chunk size 这一个参数拆成三个:子块尺寸决定召回区分度,父块上限决定单次证据量的天花板,回填窗口 k 决定实际花多少 token。三者可以独立调,也就可以独立评测。
定 k 的方法不是拍脑袋,是两条曲线:证据完整率按 gold span 的字符覆盖率算,每答 token 成本按实际送进模型的上下文算,找饱和点或边际比跌破阈值的位置。扫描时先不设预算看纯曲线,再设预算看裁剪损失,两次的差值是扩容决策的依据。
它和 Late Chunking、overlap 调参不是替代关系:前两者改的是编码前的信息和索引层的冗余,父子结构改的是组装时的粒度。三者可以叠加,叠加后要重跑曲线,因为拐点会移动。
最后一条边界,也是最容易忘的一条:如果失败样本里召回本身就没命中,这套结构一点用都没有。它只保证命中之后,喂给模型的证据是完整的、可溯源的、按文档序排列的。剩下的部分,得靠检索器。
网硕互联帮助中心

![告别多工具混战|为什么越来越多毕业生选择 Paperxie 一站式方案[特殊字符]-网硕互联帮助中心](https://www.wsisp.com/helps/wp-content/uploads/2026/10/20261027155643-6ae0c9bb796f4.png)

评论前必须登录!
注册