TT Lab
开始
学习 学习路径 课程

LLM 工程

分块 — 定下检索单元就占一半

在 TT Lab 中继续学习

一句话总结

文本块既是检索的最小单位,也是放进上下文的单位。太大会混入噪声,太小会割断上下文。好的文本块只有一个标准:只读这一块,也能回答一个问题。

为什么需要它

假设把一本 40 页的运维手册整体作为一条记录建立索引。问“备份保留多久?”,手册排在第 1 位,检索成功了。可是把 40 页放进上下文就超出了 token 预算;即使放得下,唯一的答案也埋在长文中间,被模型漏掉。反过来,如果把手册按行切开建立索引,会检索出“该值为 30 天”这一行,但它说的是什么的值,写在上一行里,无从得知。

整篇文档建立索引会让两件事崩塌。第一,长文档包含多个主题,向量会被平均得模糊不清。第二,即使检索成功,也得把整篇文档放进上下文,token 随之暴涨。所以文档必须切分,而怎么切决定了检索质量。并且一旦定下的方式很难更改,因为改变分块策略就必须重建全部索引。

它如何工作

固定长度切分每 N 个字符或 N 个 token 切一次。实现简单、大小可预测,但会在句子中间切断,破坏语义。度量长度时 token 比字符更合适,因为嵌入模型和生成模型的上限都以 token 计。

基于分隔符的切分在段落或句子边界处切。能保留语义单元,但长度参差不齐。实践中常把两种方式结合:先按段落切,只把过长的段落再按句子切,过短的片段则与相邻片段合并。

重叠(overlap) 让相邻文本块共享一部分内容,缓解横跨边界的信息在哪一侧都检索不到的问题。代价是存储量和重复检索。400 token 的文本块重叠 50 token 时,每 350 token 就开始一个新块,块数约增加 14%,相同内容还可能两次出现在前列结果中,浪费上下文空间。

分层切分用小文本块做检索,再把它所属的大单元(段落或章节)放进上下文,是兼顾检索精度与上下文完整性的折中。

元数据弥补文本块欠缺的上下文。给文本块附上文档标题、章节路径和更新日期,既能为检索结果补充上下文,也能用于过滤。仅仅在文本块前面加上标题和章节名再建立索引,检索质量常常就会明显提升。

回到确定大小的实用标准:单独拿出一块来读,如果不知道“它”指的是什么,说明上下文不足;如果一块里塞了三个互不相关的主题,说明向量已经被平均模糊了。

实际项目中会出什么问题

按句号切分却切错了地方。 句号并不只出现在句尾。3.5、v1.2、域名、缩写、省略号都会把句子切断。反过来,列表、表格、代码这类没有句号的部分会连成一片,变成巨大的文本块。症状是文本块长度分布的两端同时出现几个字符的碎片和几千个字符的大块。

表格和代码块被撕裂。 按固定长度切分时,表头与表体被分开,两边都失去用处。只剩数字的文本块,没人知道那是什么数字。需要做例外处理:把表头重复放进每个片段,或把整张表作为一个文本块。

文本块的尾部被悄悄截掉。 嵌入模型有输入长度上限,超出的文本通常只保留前面部分。例如 sentence-transformers 对超过模型 max_seq_length 的输入只使用到该长度为止,BERT 系列常见的值是 512 token。不会报错。文本块后半部分的内容没有反映到向量里,永远不会被检索到。文本块的最大长度要设得比嵌入模型的上限小。

文本块编号整体错位。 如果文本块 id 只按“文档中的第几个片段”编号,文档前部只要加一句话,后面的编号就会全部顺延一位。评测集的正确标签、缓存、用户反馈记录都会指向错误的文本块。长期使用的系统应当用文档 id 加内容哈希生成稳定的 id。

如何确认

生成文本块后,在建立索引之前先看分布和样本。

import statistics, random

rows = [line.rstrip("\n").split("\t", 2)
        for line in open("chunks.tsv", encoding="utf-8")]
lens = [len(r[2]) for r in rows]
print(len(rows), min(lens), statistics.median(lens), max(lens))
print(sum(1 for n in lens if n < 15), "very short chunks")
for r in random.sample(rows, 5):
    print(r[0], r[1], r[2])

数字中先看最小值和最大值。极短的片段很多,说明切分规则在切不是句子的地方;有极长的片段,说明存在没有分隔符的区域。随机抽取的五个样本要读出声来。如果只看一个文本块,说不出“它能回答什么问题”,那么这个文本块即使被检索到也没有用。

如果有评测问题,还要多做一件事:确认每个问题的答案句是否完整地落在一个文本块里。答案跨在两个文本块上,这个问题用任何检索器都无法完整答对。最后,把同样的查询分别在文档级索引和文本块级索引上运行并比较 Recall@K,就能用数字说明分块是否真的有帮助。

下一个实验要做什么

把 30 篇文档按句号切成 90 多个文本块,遵守去掉首尾空白、丢弃空片段的规则保存下来。以这些文本块为单位建立 TF-IDF 索引,检索 8 个查询,用 Recall@3 和 MRR 衡量检索质量,再完成上下文组装、依据判定和拒答语料外问题,做出一整套 RAG 流水线。也要亲自看看按句号切分会在哪里切得别扭。