令牌预算与语义边界:正确实现RAG分块划分
了解分词与切分之间的区别、它们在RAG流程中的位置,以及如何根据语义拆分文档,同时使用真正的分词器来控制长度。
来源: Chunking vs Tokenization: The Two Splits Every AI Engineer Keeps Confusing
一个RAG处理流程在各个可见层面看起来都很正常:嵌入向量合理、相似度得分可信、向量存储也足够完善,但却仍会向模型提供那些在句子中途就中断的片段。在很多这类情况下,问题出在数据摄取代码中,因为分块程序是基于对文本如何转换为标记的错误假设而设计的。本指南清晰地区分了这两种概念,说明了它们在处理流程中的位置,并介绍了一种既优先考虑语义又能够精确控制标记使用量的分割方法。
工程师们经常提到“将其拆分为512个token”或“基于token的拆分”这类说法,这些表述实际上将两种不同的操作混为一谈。它们解决的是不同的问题,在不同阶段执行,并以不同方式拆分文本。只有明确知道一种操作何时结束、另一种操作何时开始,才能合理设计检索系统,而非仅仅通过调整参数来提升评估分数。
两种操作,两位负责人
最简明的定义如下:
- 分词是将文本转换为模型实际能够处理的离散整数单元。它属于模型的范畴。
- 拆分则是将文档划分为可嵌入、可索引和可检索的独立部分。它属于你的处理流程与数据范畴。
无论你是否意识到,每当有字符串传入模型时都会进行分词处理。而分块则是你在模型看到语料库之前就需决定的、关于如何构建该语料库的结构方式。前者是固定且几乎不可见的;后者则需要你通过数周的时间来设计、评估并优化。
这两者始终处于相互作用之中。如果分割器忽视了分词器的行为特性,就会悄无声息地生成质量低下的分块。尽管如此,它们仍是不同的步骤,将二者混为一谈正是导致诸多隐蔽检索错误的根源。
模型如何读取文本
没有任何现成的变换器型大语言模型,无论是 GPT-4o、Claude、Llama 还是其他正在使用的模型,都是直接读取文字的。实际上它们读取的是一串整数,每个整数都对应着训练过程中学到的固定词汇表中的某个条目。分词器则是将原始文本转换为该整数序列的确定性算法,它在模型训练时就被确定下来,并作为模型的一部分被一同输出,无法根据不同请求进行更换。
子词词汇表:BPE、WordPiece、SentencePiece
目前大多数模型都采用子词分词技术,通常来自 BPE、WordPiece 或 SentencePiece 这几类算法。它们的共同原理都是从字符或原始字节开始,不断将出现频率最高的相邻字符对合并为新的词汇表条目,直到达到预定的词汇表规模,这一规模通常在 32k 到 128k 条目甚至更多。
这在实际工作中意味着:
- 常见的英语单词,如
the、is或model,通常每个都会对应一个标记。 - 出现频率较低的单词会被拆分成子词,因此
tokenization可能会变成token加上ization。 - 特殊的字符串,包括代码标识符、非拉丁字母脚本、表情符号以及虚构的产品名称,每个单词可能会拆分成许多个标记,有时甚至接近每个字符一个标记。
正因如此,含义相同的印地语或泰米尔语句子所需的标记数可能是英语句子的两到三倍,像 xgboost_lstm_ensemble_v2 这样的标识符原本预计需要两个标记,实际却可能消耗约八个标记。具体比例取决于分词器,而且较新的词汇表已缩小了某些语言之间的差距,因此需使用实际使用的模型进行测量。对于面向非英语市场的任何产品而言,比如用于总结印度证券交易所文件或经纪商订单日志的大型语言模型层,这确实是一项实际成本和资源预算项目,而非单纯的好奇现象。
为何标记数不仅关乎费用
标记数不仅仅决定请求的价格,还影响着三方面内容:
- 上下文窗口的使用量。128k的窗口是以标记数来计算的,而非单词或字符数。1,000个单词的文本并没有固定的成本;其标记数会随着词汇、语言和内容类型的不同而变化,因为代码、散文和数字的标记化方式差异很大。
- 注意力机制的作用范围。Transformer模型会关注所有标记位置,因此文本被分割成标记的方式会影响模型各部分之间的关联程度。虽然会损害理解能力的错误分割较为少见,但某些编码方式仍可能产生此类情况。
- 截断发生的位置。当输入内容超出窗口容量时,会在标记层面进行截断,而不会在句子或段落边界处截断,除非你自行处理。粗心大意地截断可能会导致某个单词被截断不全。
请查看一下你自己的文本字符串
如果你从未将实际的生产用字符串通过 OpenAI 的 tiktoken 或 Anthropic 的分词器处理过,不妨试一次。看到自己的提示模板被拆分成意想不到的片段,会让原本抽象的概念变得具体可感。Markdown 语法、JSON 大括号以及代码块尤其容易引发问题:它们的分词效率低于普通散文。
下面的示例加载了 cl100k_base 编码方式,并统计了一个典型 shell 命令中的分词数量。注意,各种标志符和文件扩展名往往各占用两到三个分词,这类内容正是会在无人察觉的情况下增加处理成本的因素。
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
tokens = enc.encode("pip-compile requirements.in --output-file requirements.txt")
print(len(tokens), tokens)
# CLI flags and file extensions often split into 2-3 tokens each —
# this is exactly the kind of string that silently inflates your token budget
如何决定模型能看到什么
分块技术位于更高层级,属于处理流程而非模型本身。它将大型文档——如PDF、Confluence页面、代码仓库或40页长的监管文件——拆分成更小的单元,这些单元可被独立嵌入、索引和检索。分块是检索层所使用的基本单位。
有两个因素使得分块成为必要,而这两者都与分词并无直接关系:
- 过长的嵌入向量会变得模糊不清。若将太多无关概念打包进一个向量中,该向量就会变成所有概念的平均值,无法精确匹配任何内容。一个良好的分块应包含一个连贯的概念,而非整份文档。
分块策略及其缺陷
固定大小窗口
最简单的方法是每隔N个令牌或字符对文本进行分割,相邻块之间通常会有一定重叠。大多数团队从这种方法开始,因为只需编写几行代码即可实现。下面的函数会对整个文本进行编码,将令牌列表分割成大小为chunk_size的窗口,再将每个窗口解码回文本,并向前移动chunk_size - overlap的距离,从而使连续的块之间保持一定间隔。
def fixed_chunk(text, chunk_size=512, overlap=50):
tokens = enc.encode(text)
chunks = []
start = 0
while start < len(tokens):
end = start + chunk_size
chunk_tokens = tokens[start:end]
chunks.append(enc.decode(chunk_tokens))
start += chunk_size - overlap
return chunks
该函数的缺陷在于没有句子概念。它会在分句中间设置分隔符,导致论点与支撑它的数据、代码示例与解释该示例的句子被分开。这些不完整的半句片段会被嵌入到主题之间的向量中,匹配度很低,这正是开头所描述的问题表现。还有两个需要注意的点:overlap 值必须低于 chunk_size,否则循环将永远无法继续;此外,对任意令牌片段进行解码时可能会将多字节字符一分为二,从而在片段边缘产生替换字符。
基于自然分界点的递归分割
递归分割器优先使用段落分隔,若不行则采用句子分隔,再考虑单词分隔,只有在其他方式均不可行时才会进行硬性切割。LangChain的RecursiveCharacterTextSplitter以及LlamaIndex中的节点解析器都遵循这一模式。对于以散文为主的文本集,这种默认方式要好得多,因为它会在考虑大小限制之前先尊重文档本身的结构。
语义分块
语义分块并非固定大小,而是将单个句子或少量句子组合起来,在相邻嵌入向量的余弦相似度下降、即主题发生变化的位置设置分隔符。这样生成的分块是依据语义而非长度联系在一起的。其代价是在处理每份文档时都需要额外进行一次嵌入计算。
结构感知分块
具有内置结构的内容,如带有标题的 Markdown、源文件、API 规范或架构文档,应按照该结构进行分割。在代码中,函数定义就是一个明显的边界;在 Markdown 知识库中,## 标签标识的章节也是一个边界。对于多智能体系统的 README 等内容,按照章节标题进行分割所能保留的有效信息量,远超过仅依据字符数来判断所能得到的结果。关于如何将相关证据集中保存的更深入探讨,可参阅保留证据的分块处理方法。
基于 LLM 的分块处理
在高端方案中,可以要求大型语言模型自行阅读文档并确定分割边界,使得每个片段都能独立存在且能回答合理的问题。虽然这种方式速度慢且成本高,但在监管文件或合同等高价值文档处理中却能发挥作用,因为检索失败会带来严重后果。
两种理念的碰撞点
有趣的是这些概念交汇的地方,可以用一句话概括:片段大小通常以标记数来表示,但片段边界应遵循语义逻辑,而这两个目标却往往相互矛盾。
使用chunk_size=512在512个标记处进行严格切割虽能控制长度,却会牺牲语义完整性。仅依据段落分隔进行分割则不会影响语义,但无法避免某个段落仅有40个标记而另一个段落却有4000个标记的情况。过长的段落可能超出嵌入模型的最大序列长度,从而导致各片段携带的信息量极不均衡。
因此,一个合理的分割器会按固定顺序同时解决这两个问题:
- 首先确定语义边界:章节、段落、句子、代码块。
- 将相邻的小单元合并,直到再添加一个单元就会超出标记限制为止。
下面的函数实现了这一逻辑。它需要已按段落或标题分割好的sections数据。函数会测量每个部分的实际分词长度;如果加入该部分会导致当前块超出容量,就会将已累积的部分作为一个整体输出。若某个部分单独就超过了max_tokens的限制,则会在分词边界处将其切分;其余部分则会被追加到当前块中。最后剩余的内容即为最终块。
def chunk_by_tokens_with_semantic_bias(sections, max_tokens=512):
chunks, current, current_len = [], [], 0
for section in sections: # pre-split by paragraph/header
section_len = len(enc.encode(section))
if current_len + section_len > max_tokens and current:
chunks.append("\n".join(current))
current, current_len = [], 0
if section_len > max_tokens:
# single section too big — hard split on token boundaries, not chars
section_tokens = enc.encode(section)
for i in range(0, len(section_tokens), max_tokens):
chunks.append(enc.decode(section_tokens[i:i+max_tokens]))
else:
current.append(section)
current_len += section_len
if current:
chunks.append("\n".join(current))
return chunks
关键的设计决策是:尺寸应使用目标嵌入模型的分词器来衡量,而非根据字符数估算;此外,强制分割只能发生在代码可控的分词边界上。值得考虑的一个优化点是:"\n".join中使用的换行符会在每个部分多生成一个左右的分词,而这些分词不会被当前计数所包含,因此应在模型的硬性限制之下留出一定的余量。
各步骤在处理流程中的位置
从处理流程来看,分块属于上游环节的问题。它在数据摄入阶段就发生了,此时尚未涉及任何模型,其处理结果会被存储下来。而分词则属于下游环节的问题。它在每次请求时都会在模型的输入层中进行,包括数据摄入阶段进行的嵌入操作以及查询时进行的生成操作。牢记这一点有助于在出现问题时确定该查看哪段代码。
区分这两种故障模式
在排查检索结果不佳的问题时,有两种表现相似但根源不同的症状:
- 检索到的内容被截断,或思维表达未完成就结束。这是分块环节出现的故障。边界判定逻辑错误地分割了语义单元,因此修复措施应针对分块逻辑,而非嵌入模型的选择。
len(text) / 4 来估算令牌数量,而实际计数更高。这类问题常常被归咎于“劣质的嵌入模型”,但那很少是真正的原因;几乎总是边界逻辑或令牌计数的问题。
生产环境检查清单
在构建或审查 RAG 或智能体流程时,请检查以下要点:
- 使用所调用模型的真实分词器来统计令牌数量。对于 OpenAI 模型可使用
tiktoken,对于开源模型则需使用该模型专用的分词器。切勿将len(text) // 4作为生产环境中的计数方式。 - 以嵌入模型中的标记数来衡量分块大小。嵌入模型通常使用与生成模型不同的分词器,并且会设定更严格的最大序列长度限制。
- 明确考虑重叠部分的影响。重叠会占用有效窗口的一部分空间,因此应将其纳入对嵌入模型输入限制的计算中,而非通过截断警告来间接了解这一影响。
- 优先考虑结构而非大小。默认采用考虑结构的拆分方式或递归拆分方法,仅将固定大小的窗口用于真正无结构的文本。
- 在数据输入过程中记录标记数及分割决策。当质量下降时,你需要立即知道是因大小限制还是结构原因导致某块数据被截断。
- 切换嵌入模型后需重新分词。为某一词汇表优化的策略无法直接迁移;在不同的分词器下,512个令牌所能覆盖的文本长度也有所不同。
总结
分词解决了模型如何解析字符串的问题;切分则回答了最小可独立检索单元是什么的问题。一旦选定模型,前者就已固定;后者则是完全由你掌控的设计要素,通常也是RAG系统中真正的工程优化点所在。
这两种错误的表现也各不相同。令牌计数错误会表现为异常的截断和预算超支,这些至少是可见的。而分块错误则会导致系统在毫无错误的情况下运行,却仍检索到正确句子的错误部分,这种问题更难被发现。应使用真正的分词器进行测试,先按语义划分内容,并记录每个分割点为何设置在该位置。