在构建基于检索增强生成 (RAG) 系统的过程中,我们常常关注检索模块的效率、生成模型的性能,以及如何设计高效的向量数据库。然而,一个看似基础却极易被忽视的环节——分词 (Tokenization),却可能成为影响整个RAG系统性能的关键“陷阱”。不同的分词器,如OpenAI模型家族使用的Tiktoken和广泛应用于多语言场景的SentencePiece,其工作原理和分词结果的差异,将直接影响我们的文档分割边界,进而对RAG的检索准确性和生成质量产生深远影响。
今天,我们将深入探讨这一陷阱,剖析不同分词器的工作机制,并通过代码实例展示它们如何导致RAG系统中的分割边界问题,并提出相应的缓解策略。
第一章:分词的基石——为什么我们需要Tokenization?
在深入探讨分词器的差异之前,我们首先要理解分词在自然语言处理(NLP)特别是大型语言模型(LLM)中的核心作用。
1.1 什么是分词?
分词是将连续的文本序列切分成更小、有意义的单元(tokens)的过程。这些单元可以是单词、子词(subword)、字符或字节。LLM不能直接处理原始文本,它们需要将文本转换为模型能够理解的数值表示,即向量。分词是这个转换过程的第一步。
为什么不能直接用字符?
虽然字符是最细粒度的单元,但直接使用字符作为token会导致:
- 词汇量过大或过小: 如果仅使用字符,对于西方语言,词汇量太小(26个字母),无法捕捉语义信息;对于中文、日文等,常用汉字可能成千上万,但每个字符的语义贡献有限。
- 序列过长: 一个句子可能包含几十个字符,使得模型的输入序列过长,增加计算负担,并可能超出模型的上下文窗口限制。
为什么不能直接用单词?
传统的基于空格和标点符号的单词分词在西方语言中相对有效,但在以下情况下会遇到问题:
- 形态变化: "run", "running", "ran" 都是同一个词根,但被视为不同单词。
- 新词和罕见词 (OOV – Out-Of-Vocabulary): 模型在训练时未见过的词无法被编码。
- 复合词: "firefighter", "laptop" 等。
- 非西方语言: 像中文、日文等没有明确的单词边界,需要更复杂的分词算法。例如,“我爱北京天安门”,如果按字分,每个字一个token;如果按词分,“我/爱/北京/天安门”。
1.2 子词分词的崛起
为了克服纯字符和纯单词分词的局限性,子词分词 (Subword Tokenization) 应运而生,并成为现代LLM的主流分词方法。子词分词旨在在字符和单词之间找到一个平衡点,它将单词拆分为更小的、有意义的片段,这些片段通常出现在多个单词中。
子词分词的优势包括:
- 有效处理OOV词: 即使模型从未见过一个完整的单词,它也可以通过将其分解为已知的子词来处理。例如,“unbelievable” 可以分解为 “un”, “believe”, “able”。
- 更紧凑的词汇表: 相比于纯单词分词,子词分词的词汇表更小,但仍能有效表达大量单词。
- 捕捉形态信息: 像“ing”, “ed”, “un” 等前缀和后缀可以被识别为独立的子词,有助于模型理解词的形态变化。
- 跨语言适用性: 对于没有明确单词边界的语言,子词分词表现出色。
主流的子词分词算法包括:
- Byte Pair Encoding (BPE) 及其变种 (如WordPiece, Tiktoken)。
- Unigram Language Model (ULM) (SentencePiece 的一种模式)。
第二章:分词器家族——Tiktoken vs SentencePiece
现在我们来详细了解两种代表性的分词器:Tiktoken和SentencePiece。它们虽然都属于子词分词的范畴,但在实现细节和分词策略上存在显著差异。
2.1 Tiktoken:OpenAI模型的专属利器
Tiktoken 是 OpenAI 开发并开源的分词库,专门用于其系列模型(如GPT-3.5, GPT-4, Embeddings v2等)。它基于 字节对编码 (Byte Pair Encoding, BPE) 算法。
2.1.1 BPE算法原理回顾
BPE 最初是一种数据压缩算法,后来被应用于自然语言处理。其核心思想是迭代地合并文本中最频繁出现的字节对(或字符对),直到达到预设的词汇量大小或迭代次数。
基本步骤:
- 初始化: 将所有文本拆分成单个字符(或字节),并将每个字符视为一个初始的token。
- 统计频率: 统计所有相邻token对的出现频率。
- 合并: 找到频率最高的token对,将其合并成一个新的token。
- 重复: 重复步骤2和3,直到达到预设的词汇表大小或不再有符合合并条件的对。
示例: 假设我们有文本 "low lower lowest"
- 初始tokens:
l o w </w> l o w e r </w> l o w e s t </w>(这里的</w>表示词尾) - 最频繁对:
l o->lo
lo w </w> lo w e r </w> lo w e s t </w> - 最频繁对:
lo w->low
low </w> low e r </w> low e s t </w> - 继续合并
e r->er,e s->es等,直到词汇量饱和。
Tiktoken 实际上是对原始BPE的优化和扩展,它在字节级别进行操作,而非字符级别。这意味着它能更好地处理各种编码和未知字符,因为所有文本都可以表示为字节序列。
2.1.2 Tiktoken的特点
- 字节级BPE: Tiktoken处理的是原始字节序列,而不是Unicode字符序列。这使得它能够无缝处理任意文本,包括那些包含非标准编码或罕见字符的文本,而无需担心编码问题或OOV。
- 模型专用: 不同的OpenAI模型使用不同的Tiktoken编码。例如,
cl100k_base用于gpt-4,gpt-3.5-turbo,text-embedding-ada-002。这意味着它的词汇表和合并规则是针对特定模型训练数据优化的。 - 高效: 旨在提供快速的分词和反分词操作,以满足LLM推理的性能要求。
- 不可训练性(对用户而言): 作为用户,我们通常直接加载预训练好的Tiktoken编码器,而无法自定义训练新的Tiktoken模型。
2.1.3 Tiktoken 代码示例
import tiktoken
# 1. 加载OpenAI模型对应的编码器
# cl100k_base 适用于 gpt-4, gpt-3.5-turbo, text-embedding-ada-002
enc_cl100k = tiktoken.get_encoding("cl100k_base")
# p50k_base 适用于 text-davinci-003, text-davinci-002
enc_p50k = tiktoken.get_encoding("p50k_base")
print(f"Loaded Tiktoken encoder for cl100k_base: {enc_cl100k.name}")
print(f"Loaded Tiktoken encoder for p50k_base: {enc_p50k.name}n")
text_en = "Hello, world! This is a test sentence for tokenization."
text_zh = "你好,世界!这是一个用于分词的测试句子。"
text_mixed = "RAG系统在处理中文和英文混合文本时,分词器选择至关重要。Tiktoken和SentencePiece各有千秋。"
# 2. 编码文本并获取token IDs
tokens_en_cl100k = enc_cl100k.encode(text_en)
tokens_zh_cl100k = enc_cl100k.encode(text_zh)
tokens_mixed_cl100k = enc_cl100k.encode(text_mixed)
print(f"英文文本: '{text_en}'")
print(f"Tiktoken (cl100k_base) Tokens: {tokens_en_cl100k}")
print(f"Tiktoken (cl100k_base) Token count: {len(tokens_en_cl100k)}n")
print(f"中文文本: '{text_zh}'")
print(f"Tiktoken (cl100k_base) Tokens: {tokens_zh_cl100k}")
print(f"Tiktoken (cl100k_base) Token count: {len(tokens_zh_cl100k)}n")
print(f"中英文混合文本: '{text_mixed}'")
print(f"Tiktoken (cl100k_base) Tokens: {tokens_mixed_cl100k}")
print(f"Tiktoken (cl100k_base) Token count: {len(tokens_mixed_cl100k)}n")
# 3. 解码token IDs回文本
decoded_en = enc_cl100k.decode(tokens_en_cl100k)
decoded_zh = enc_cl100k.decode(tokens_zh_cl100k)
decoded_mixed = enc_cl100k.decode(tokens_mixed_cl100k)
print(f"英文文本解码: '{decoded_en}' (与原始文本{'相同' if decoded_en == text_en else '不同'})n")
print(f"中文文本解码: '{decoded_zh}' (与原始文本{'相同' if decoded_zh == text_zh else '不同'})n")
print(f"中英文混合文本解码: '{decoded_mixed}' (与原始文本{'相同' if decoded_mixed == text_mixed else '不同'})n")
# 4. 展示具体的token字符串
def display_tokens(encoder, text):
token_ids = encoder.encode(text)
token_strings = [encoder.decode([token_id]) for token_id in token_ids]
print(f"原始文本: '{text}'")
print(f"分词结果 ({encoder.name}):")
print(" | ".join(token_strings))
print(f"总 token 数: {len(token_ids)}n")
display_tokens(enc_cl100k, text_en)
display_tokens(enc_cl100k, text_zh)
display_tokens(enc_cl100k, text_mixed)
# 比较不同编码器的分词
print("--- 比较不同Tiktoken编码器对同一文本的分词 ---")
text_compare = "tokenization"
tokens_compare_cl100k = enc_cl100k.encode(text_compare)
tokens_compare_p50k = enc_p50k.encode(text_compare)
print(f"文本: '{text_compare}'")
print(f"Tiktoken (cl100k_base) Tokens: {tokens_compare_cl100k}, count: {len(tokens_compare_cl100k)}")
print(f"Tiktoken (p50k_base) Tokens: {tokens_compare_p50k}, count: {len(tokens_compare_p50k)}n")
display_tokens(enc_cl100k, text_compare)
display_tokens(enc_p50k, text_compare)
代码输出分析:
- 我们可以看到,即使是英文字符串,Tiktoken也会将其拆分为子词,例如 "tokenization" 在
cl100k_base下可能被拆成多个token。 - 对于中文,Tiktoken倾向于将每个汉字或汉字的一部分作为一个或多个token,标点符号也可能独立成token。
- 不同版本的Tiktoken编码器(如
cl100k_base和p50k_base)对同一文本的分词结果和token数量会不同,这再次强调了选择正确编码器的重要性。
2.2 SentencePiece:灵活多样的多语言专家
SentencePiece 是 Google 开发的一个开源库,它提供了一种语言无关的、无监督的文本分词器。它不仅支持 BPE,还支持 Unigram Language Model (ULM) 模式,并在多语言处理中表现出色。
2.2.1 ULM算法原理回顾
与 BPE 从最频繁的字符对开始合并不同,ULM 的思路是:给定一个句子,存在多种可能的子词切分方式,我们希望找到一种切分方式,使得这些子词序列的概率最大化。它通过训练一个 Unigram 语言模型来计算每个子词的概率,然后使用 Viterbi 算法来找到最佳的切分路径。
基本步骤(高层次理解):
- 初始词汇表: 从所有输入文本中提取所有可能的子字符串作为初始候选词汇。
- 迭代优化:
- 计算每个子词在训练数据中出现的概率。
- 移除那些概率较低的子词(即如果移除它们对整体文本概率影响不大)。
- 重复这个过程,直到词汇表达到预设大小。
- 分词: 对于新输入的句子,使用 Viterbi 算法,结合训练好的子词概率,找到概率最高的子词序列作为分词结果。
2.2.2 SentencePiece的特点
- 语言无关和无监督: 不依赖于任何语言特定的预处理(如词典、规则)。它直接从原始文本中学习分词规则。
- 支持多种算法: 主要支持 BPE 和 Unigram 语言模型。ULM模式尤其擅长处理分词歧义。
- 端到端处理: SentencePiece 将空格也视为普通字符进行编码,并在解码时恢复。这使得它能够避免预处理中的空格问题,并能处理所有字符。
- 可训练性: 用户可以根据自己的数据集训练定制的SentencePiece模型,这对于特定领域或特定语言的分词非常有用。
- 处理OOV: 通过回退到更小的子词或单个字符,能够有效处理未知词。
- 广泛应用: 许多流行的Transformer模型(如BERT, XLNet, T5, LLaMA等)都使用或借鉴了SentencePiece的分词方法。
2.2.3 SentencePiece 代码示例
由于SentencePiece需要先训练模型,我们会演示如何训练一个简单的模型,然后使用它进行分词。
import sentencepiece as spm
import os
# 1. 准备训练数据
# 实际应用中,训练数据应该非常大且多样化
training_data_path = "sentencepiece_training_data.txt"
with open(training_data_path, "w", encoding="utf-8") as f:
f.write("Hello, world! This is a test sentence for tokenization.n")
f.write("你好,世界!这是一个用于分词的测试句子。n")
f.write("RAG系统在处理中文和英文混合文本时,分词器选择至关重要。Tiktoken和SentencePiece各有千秋。n")
f.write("Tokenization is a fundamental step in NLP pipelines.n")
f.write("分词是NLP管道中的基础步骤。n")
f.write("The quick brown fox jumps over the lazy dog.n")
f.write("快速的棕色狐狸跳过了懒惰的狗。n")
# 2. 训练SentencePiece模型
# model_prefix: 模型的名称前缀,会生成 .model 和 .vocab 文件
# vocab_size: 词汇表大小
# model_type: 可以是 'bpe' 或 'unigram'
# character_coverage: 字符覆盖率,对于多语言尤其是中文很重要
# input_sentence_size: 限制输入到训练器中的句子数量(对于大型数据集有用)
# max_sentence_length: 限制输入句子的最大长度
spm.SentencePieceTrainer.train(
f'--input={training_data_path} '
f'--model_prefix=my_sp_model '
f'--vocab_size=8000 ' # 设定一个相对较大的词汇量,以便捕捉更多子词
f'--model_type=unigram ' # 使用Unigram模型
f'--character_coverage=0.9995 ' # 确保覆盖大部分字符
f'--split_by_unicode_script=true ' # 按Unicode脚本分割,有助于多语言
f'--add_dummy_prefix=true ' # 添加一个虚拟前缀,处理句首空格
f'--max_sentence_length=1000' # 限制最大句子长度
)
# 3. 加载训练好的SentencePiece模型
sp = spm.SentencePieceProcessor()
sp.load("my_sp_model.model")
print(f"Loaded custom SentencePiece model: my_sp_model.model (vocab size: {sp.get_piece_size()})n")
text_en = "Hello, world! This is a test sentence for tokenization."
text_zh = "你好,世界!这是一个用于分词的测试句子。"
text_mixed = "RAG系统在处理中文和英文混合文本时,分词器选择至关重要。Tiktoken和SentencePiece各有千秋。"
# 4. 编码文本并获取token IDs
tokens_en_sp = sp.encode_as_ids(text_en)
tokens_zh_sp = sp.encode_as_ids(text_zh)
tokens_mixed_sp = sp.encode_as_ids(text_mixed)
print(f"英文文本: '{text_en}'")
print(f"SentencePiece Tokens (IDs): {tokens_en_sp}")
print(f"SentencePiece Token count: {len(tokens_en_sp)}n")
print(f"中文文本: '{text_zh}'")
print(f"SentencePiece Tokens (IDs): {tokens_zh_sp}")
print(f"SentencePiece Token count: {len(tokens_zh_sp)}n")
print(f"中英文混合文本: '{text_mixed}'")
print(f"SentencePiece Tokens (IDs): {tokens_mixed_sp}")
print(f"SentencePiece Token count: {len(tokens_mixed_sp)}n")
# 5. 解码token IDs回文本
decoded_en_sp = sp.decode_ids(tokens_en_sp)
decoded_zh_sp = sp.decode_ids(tokens_zh_sp)
decoded_mixed_sp = sp.decode_ids(tokens_mixed_sp)
print(f"英文文本解码: '{decoded_en_sp}' (与原始文本{'相同' if decoded_en_sp == text_en else '不同'})n")
print(f"中文文本解码: '{decoded_zh_sp}' (与原始文本{'相同' if decoded_zh_sp == text_zh else '不同'})n")
print(f"中英文混合文本解码: '{decoded_mixed_sp}' (与原始文本{'相同' if decoded_mixed_sp == text_mixed else '不同'})n")
# 6. 展示具体的token字符串
def display_sp_tokens(processor, text):
token_strings = processor.encode_as_pieces(text)
print(f"原始文本: '{text}'")
print(f"分词结果 (SentencePiece):")
print(" | ".join(token_strings))
print(f"总 token 数: {len(token_strings)}n")
display_sp_tokens(sp, text_en)
display_sp_tokens(sp, text_zh)
display_sp_tokens(sp, text_mixed)
# 清理训练数据和模型文件
os.remove(training_data_path)
os.remove("my_sp_model.model")
os.remove("my_sp_model.vocab")
代码输出分析:
- SentencePiece 的分词结果在英语中通常会在单词前添加一个下划线
_,表示这是一个词的开头,并且是无空格处理的结果。例如_Hello。 - 对于中文,SentencePiece 的 Unigram 模型倾向于将常用词(如“世界”、“分词器”)合并成单个token,而将不常见的组合拆得更细。这与Tiktoken的字节级BPE策略有所不同。
- 由于我们训练的SentencePiece模型是基于一个很小的语料库,其分词效果可能不如大型预训练模型(如Tiktoken)那么精细和高效。但在实际应用中,SentencePiece通常是基于数TB的文本数据训练的。
2.3 Tiktoken vs SentencePiece 核心区别总结
| 特征 | Tiktoken (BPE-based) | SentencePiece (BPE/Unigram) |
|---|---|---|
| 基础算法 | 字节级BPE (Byte Pair Encoding) | BPE 或 Unigram Language Model |
| 处理单位 | 字节 (bytes),再映射到token ID | 字符 (characters),但内部处理可回退到字节 |
| 主要应用 | OpenAI系列模型 (GPT-3.5, GPT-4, Embeddings) | 广泛应用于Google模型 (BERT, T5),以及 LLaMA 等开源模型 |
| 训练方式 | 用户通常加载预训练好的模型,不可自定义训练 | 用户可以根据自己的数据训练定制模型 |
| 语言特性 | 字节级处理使其对所有文本(包括非UTF-8)具有鲁棒性 | 语言无关,无监督,特别适用于多语言和无空格语言 (如中文) |
| 空格处理 | 将空格视作普通字符,但分词边界可能与空格相关 | 将空格也视为可分词的字符 (通常用 _ 表示空格) |
| 分词结果 | 倾向于将英文单词分割得更细,中文以字或小词块为主 | 英文通常保留单词完整性,中文根据词频合并更长的词块 |
| 词汇表 | 针对特定LLM优化,通常较大 | 可自定义大小,从几千到几十万不等 |
| 优势 | 紧密集成OpenAI生态,效率高,对非标准字符鲁棒性好 | 灵活性高,可定制,多语言支持优秀,处理OOV能力强 |
第三章:分词陷阱——RAG中的分割边界问题
现在我们来到了问题的核心:为什么不同的分词器会影响RAG的分割边界,以及这会带来什么问题。
3.1 RAG系统中的文档分割 (Chunking)
在RAG系统中,原始文档通常过长,无法直接作为LLM的输入。因此,我们需要将文档分割成更小的、可管理的“块”(chunks)。这些块被索引到向量数据库中。当用户提出查询时,RAG系统会检索与查询最相关的若干块,并将它们作为上下文传递给LLM以生成答案。
文档分割的策略有很多,常见的包括:
- 固定大小分割 (Fixed-size chunking): 按固定字符数或固定token数分割。
- 重叠分割 (Overlapping chunking): 分割时允许相邻块之间有一定字符或token的重叠,以保留上下文连贯性。
- 语义分割 (Semantic chunking): 尝试根据文档的语义结构(如段落、章节、标题)进行分割。
- 递归分割 (Recursive chunking): 尝试多种分割策略,直到块大小合适。
无论采用哪种策略,核心目标都是:在保证块内容语义完整性的前提下,使其大小符合LLM的上下文窗口限制。
3.2 陷阱的产生:分词器不一致导致的边界错位
当RAG系统中的分词器与LLM实际使用的分词器不一致时,就会产生一系列问题。
3.2.1 Token数量不匹配
最直接的问题是token数量的计算不准确。
假设我们设定RAG系统的块大小为 N 个token。
- 如果RAG系统使用
Tokenizer A来计算N个token,但实际的LLM(或用于嵌入的LLM)使用的是Tokenizer B。 - 那么一个被
Tokenizer A认定为N个token的块,在Tokenizer B眼中,可能只有N-k个token,也可能有N+k个token。
后果:
- 截断风险 (Truncation Risk): 如果
Tokenizer B认为块的token更多 (N+k),它可能会截断这个块,导致重要的信息丢失。 - 上下文浪费 (Context Window Waste): 如果
Tokenizer B认为块的token更少 (N-k),那么传递给LLM的上下文窗口就没有被充分利用,可以容纳更多信息。
3.2.2 分割边界的语义中断
更隐蔽且更具破坏性的是分割边界的语义中断。
不同的分词器,由于其算法和词汇表的差异,会在文本的不同位置产生子词边界。
示例: 考虑短语 "tokenization pitfalls"
- Tiktoken (cl100k_base): 可能分词为
tok | en | iz | ation | | pit | falls - SentencePiece (自定义模型): 可能分词为
_token | ization | _pit | falls
如果RAG系统在计算固定token数的块时,根据 Tiktoken 的边界在 iz 和 ation 之间切开,但实际的检索或生成过程依赖于 SentencePiece,那么 SentencePiece 可能会将 ization 视为一个完整单位,或者其内部对 ization 的编码方式与 Tiktoken 完全不同。
后果:
- 语义完整性受损: 一个完整的词语、短语甚至是一个重要概念可能被“腰斩”在两个块之间,导致语义信息被分散。
- 检索质量下降: 如果查询词被分词器A和分词器B以不同方式分割,那么即使文本中存在精确匹配,也可能因为token表示不一致而导致检索失败或不准确。例如,查询
tokenization,如果RAG嵌入器将它分为token | ization,但文档中它被分为了tok | en | ization,那么相似度计算就会受到影响。 - 生成质量降低: LLM接收到的上下文片段可能因为边界错位而缺乏连贯性,导致生成答案时出现逻辑跳跃、信息缺失或不准确。
3.2.3 跨语言处理的复杂性
在多语言RAG系统中,这个问题尤为突出。
- Tiktoken 虽然能处理多语言,但其词汇表和合并规则主要针对英文及其训练语料优化。
- SentencePiece 的 Unigram 模型在处理中文、日文等无空格语言时,能更好地学习词的边界,倾向于合并更长的、有语义的中文词。
这意味着,一个中文句子,在Tiktoken下可能被拆分成更多、更细碎的token,而在SentencePiece下可能被拆分成更少、更长的token。这种差异会导致中文文档的块大小和边界问题更加显著。
3.3 实际案例分析 (代码演示)
我们将使用之前定义的中英文混合文本,比较Tiktoken和我们自定义的SentencePiece模型在token数量和分词边界上的差异,并模拟一个基于token数量的RAG分割场景。
import tiktoken
import sentencepiece as spm
import os
# 确保SentencePiece模型已训练并加载
# 为了演示,这里再次执行SP模型训练和加载,实际应用中只需加载一次
training_data_path = "sentencepiece_training_data.txt"
with open(training_data_path, "w", encoding="utf-8") as f:
f.write("Hello, world! This is a test sentence for tokenization.n")
f.write("你好,世界!这是一个用于分词的测试句子。n")
f.write("RAG系统在处理中文和英文混合文本时,分词器选择至关重要。Tiktoken和SentencePiece各有千秋。n")
f.write("Tokenization is a fundamental step in NLP pipelines.n")
f.write("分词是NLP管道中的基础步骤。n")
f.write("The quick brown fox jumps over the lazy dog.n")
f.write("快速的棕色狐狸跳过了懒惰的狗。n")
spm.SentencePieceTrainer.train(
f'--input={training_data_path} '
f'--model_prefix=my_sp_model '
f'--vocab_size=8000 '
f'--model_type=unigram '
f'--character_coverage=0.9995 '
f'--split_by_unicode_script=true '
f'--add_dummy_prefix=true '
f'--max_sentence_length=1000'
)
sp = spm.SentencePieceProcessor()
sp.load("my_sp_model.model")
# 加载Tiktoken编码器
enc_cl100k = tiktoken.get_encoding("cl100k_base")
# 待分析文本
long_text_mixed = """
在构建基于检索增强生成 (RAG) 系统的过程中,我们常常关注检索模块的效率、生成模型的性能,以及如何设计高效的向量数据库。然而,一个看似基础却极易被忽视的环节——分词 (Tokenization),却可能成为影响整个RAG系统性能的关键“陷阱”。不同的分词器,如OpenAI模型家族使用的Tiktoken和广泛应用于多语言场景的SentencePiece,其工作原理和分词结果的差异,将直接影响我们的文档分割边界,进而对RAG的检索准确性和生成质量产生深远影响。
今天,我们将深入探讨这一陷阱,剖析不同分词器的工作机制,并通过代码实例展示它们如何导致RAG系统中的分割边界问题,并提出相应的缓解策略。
"""
print(f"--- 原始文本长度 (字符): {len(long_text_mixed)} ---n")
# --- Tiktoken 分词 ---
tokens_tiktoken_ids = enc_cl100k.encode(long_text_mixed)
tokens_tiktoken_str = [enc_cl100k.decode([tid]) for tid in tokens_tiktoken_ids]
print(f"Tiktoken (cl100k_base) Token Count: {len(tokens_tiktoken_ids)}")
print("Tiktoken 分词预览:")
print("|".join(tokens_tiktoken_str[:20]) + "..." + "|".join(tokens_tiktoken_str[-20:]))
print("-" * 50)
# --- SentencePiece 分词 ---
tokens_sp_ids = sp.encode_as_ids(long_text_mixed)
tokens_sp_str = sp.encode_as_pieces(long_text_mixed)
print(f"SentencePiece (Custom) Token Count: {len(tokens_sp_ids)}")
print("SentencePiece 分词预览:")
print("|".join(tokens_sp_str[:20]) + "..." + "|".join(tokens_sp_str[-20:]))
print("-" * 50)
# --- 比较Token数量 ---
print(f"Token 数量对比:")
print(f" Tiktoken: {len(tokens_tiktoken_ids)}")
print(f" SentencePiece: {len(tokens_sp_ids)}")
print(f"差异: {abs(len(tokens_tiktoken_ids) - len(tokens_sp_ids))} tokensn")
# --- 模拟RAG分块:固定Token数分块 ---
def chunk_by_tokens(text, tokenizer, max_tokens, overlap_tokens=0):
if isinstance(tokenizer, tiktoken.Encoding):
encode_func = tokenizer.encode
decode_func = tokenizer.decode
elif isinstance(tokenizer, spm.SentencePieceProcessor):
encode_func = tokenizer.encode_as_ids
decode_func = tokenizer.decode_ids
else:
raise ValueError("Unsupported tokenizer type")
all_token_ids = encode_func(text)
chunks = []
current_start = 0
while current_start < len(all_token_ids):
# 确保不会超出文本范围
chunk_end = min(current_start + max_tokens, len(all_token_ids))
chunk_ids = all_token_ids[current_start:chunk_end]
chunks.append(decode_func(chunk_ids))
# 计算下一个块的起始位置,考虑重叠
next_start = current_start + max_tokens - overlap_tokens
if next_start >= len(all_token_ids): # 如果下一个块起始点已经超出总长度,则停止
break
current_start = next_start
if current_start == chunk_end: # 避免无限循环,如果重叠导致起始点不变
break
return chunks
max_chunk_tokens = 100
overlap_tokens = 20
print(f"--- 模拟RAG分块 (Max Tokens: {max_chunk_tokens}, Overlap: {overlap_tokens}) ---")
# Tiktoken 分块
tiktoken_chunks = chunk_by_tokens(long_text_mixed, enc_cl100k, max_chunk_tokens, overlap_tokens)
print(f"nTiktoken 分块结果 ({len(tiktoken_chunks)} 块):")
for i, chunk in enumerate(tiktoken_chunks[:3]): # 只显示前3块
print(f" 块 {i+1} (长度 {len(enc_cl100k.encode(chunk))} tokens / {len(chunk)} chars):")
print(f" '{chunk[:100]}...'") # 预览部分内容
# SentencePiece 分块
sp_chunks = chunk_by_tokens(long_text_mixed, sp, max_chunk_tokens, overlap_tokens)
print(f"nSentencePiece 分块结果 ({len(sp_chunks)} 块):")
for i, chunk in enumerate(sp_chunks[:3]): # 只显示前3块
print(f" 块 {i+1} (长度 {len(sp.encode_as_ids(chunk))} tokens / {len(chunk)} chars):")
print(f" '{chunk[:100]}...'") # 预览部分内容
print("n--- 分块边界差异分析 ---")
print("以第一个块为例,比较其在两种分词器下的实际字符长度和内容:")
# 获取Tiktoken第一个块的实际字符长度
if tiktoken_chunks:
tiktoken_first_chunk = tiktoken_chunks[0]
print(f"Tiktoken 第一个块 (Token count: {len(enc_cl100k.encode(tiktoken_first_chunk))}):")
print(f" 实际字符长度: {len(tiktoken_first_chunk)}")
print(f" 内容预览: '{tiktoken_first_chunk[:150]}...'")
# 获取SentencePiece第一个块的实际字符长度
if sp_chunks:
sp_first_chunk = sp_chunks[0]
print(f"SentencePiece 第一个块 (Token count: {len(sp.encode_as_ids(sp_first_chunk))}):")
print(f" 实际字符长度: {len(sp_first_chunk)}")
print(f" 内容预览: '{sp_first_chunk[:150]}...'")
# 再次清理文件
os.remove(training_data_path)
os.remove("my_sp_model.model")
os.remove("my_sp_model.vocab")
代码输出分析:
- Token数量差异: 运行代码后,你会发现对于同一段文本,Tiktoken和SentencePiece给出的总token数量是不同的。这直接验证了“Token数量不匹配”的问题。Tiktoken通常会产生更多的token,尤其是在处理中文时,因为它倾向于更细粒度的拆分。
- 分块数量和边界差异: 尽管我们都设定了
max_chunk_tokens = 100,但由于底层分词器的差异,最终生成的块数量可能不同,每个块的实际字符长度也可能相差甚远。更重要的是,即使两个块的token数都接近100,它们在文本中的切割点会因为分词器的不同而落在不同的位置。例如,Tiktoken可能在一个英文单词的中间切开,而SentencePiece可能在一个中文词语的中间切开,或者反之。 - 语义中断的直观感受: 当你查看
tiktoken_chunks和sp_chunks的内容预览时,你会发现它们在相同的token预算下,包含的字符内容是不同的。如果一个重要的短语或概念正好跨越了其中一个分词器设定的边界,而另一个分词器却能将其完整保留,那么在RAG检索时,被“腰斩”的块就更难被准确匹配。
这种差异是分词器“陷阱”的核心。它意味着,如果你的RAG系统使用Tiktoken进行文档分块,但你最终使用的是一个基于SentencePiece的LLM(例如Llama系列模型),那么你的分块策略将与LLM的真实理解产生偏差,从而降低RAG的整体效果。
第四章:缓解策略——如何走出分词陷阱
理解了分词陷阱的危害,接下来就是如何有效地规避或缓解这些问题。
4.1 核心原则:保持RAG系统与目标LLM的分词器一致
这是最重要的原则。
- 知道你的LLM用什么分词器: 如果你使用的是OpenAI的GPT系列模型,那么就应该使用
tiktoken库中对应的编码器(例如cl100k_base)。 - 端到端一致性: 确保你的RAG系统在以下所有环节都使用与目标LLM相同的分词器:
- 文档预处理阶段: 计算文档块的token数量,决定分块边界时。
- 查询编码阶段: 将用户查询转换为token ID序列,用于向量嵌入。
- 结果生成阶段: LLM内部处理检索到的上下文和查询时。
例如,如果你使用OpenAI的text-embedding-ada-002模型生成嵌入向量,并使用gpt-4进行生成,那么整个RAG流程(包括分块、查询嵌入、LLM上下文)都应使用tiktoken.get_encoding("cl100k_base")。如果你使用Hugging Face上的Llama 2模型,那么你需要加载该模型对应的SentencePiece tokenizer。
4.2 鲁棒性策略:重叠分块 (Overlapping Chunks)
即使分词器完全一致,完全固定大小的分割也可能在语义上切断重要信息。重叠分块是提高RAG鲁棒性的常用方法。
- 目的: 确保即使一个关键概念的边界被切断,其相邻的部分也能在下一个或上一个块中被保留,从而减少信息丢失的风险。
- 实现: 在创建文档块时,让相邻的块之间共享一部分文本。例如,如果一个块是100个token,下一个块可以从前一个块的第80个token开始,这样就有了20个token的重叠。
- Token-Aware Overlap: 计算重叠时,要使用与目标LLM相同的分词器来计算重叠的token数量,而不是简单的字符数。
# 示例:Token-Aware Overlap Chunking
def chunk_by_tokens_with_overlap(text, tokenizer, max_tokens, overlap_tokens):
if isinstance(tokenizer, tiktoken.Encoding):
encode_func = tokenizer.encode
decode_func = tokenizer.decode
elif isinstance(tokenizer, spm.SentencePieceProcessor):
encode_func = tokenizer.encode_as_ids
decode_func = tokenizer.decode_ids
else:
raise ValueError("Unsupported tokenizer type")
all_token_ids = encode_func(text)
chunks = []
current_start_idx = 0
while current_start_idx < len(all_token_ids):
chunk_end_idx = min(current_start_idx + max_tokens, len(all_token_ids))
chunk_ids = all_token_ids[current_start_idx:chunk_end_idx]
chunks.append(decode_func(chunk_ids))
# 计算下一个块的起始点,确保有足够的重叠
# 如果当前块的长度小于max_tokens(即已到文本末尾),则停止
if chunk_end_idx == len(all_token_ids):
break
# 确保不会因为重叠过大而导致 current_start_idx 不变或回退
current_start_idx = chunk_end_idx - overlap_tokens
if current_start_idx < 0: # 避免负数索引
current_start_idx = 0
# 避免无限循环,如果下一个起始点和当前块的起始点相同
if current_start_idx == chunk_end_idx - max_tokens + overlap_tokens:
break
return chunks
# 再次加载Tiktoken编码器和SentencePiece处理器
enc_cl100k = tiktoken.get_encoding("cl100k_base")
# (spm 已经在前面的代码中训练和加载)
long_text_example = """
这是一个很长的例子文本,用于演示带重叠的分块策略。分块在RAG系统中至关重要,它决定了信息如何被检索。
准确的分词器选择和合理的重叠策略能够大大提升RAG系统的性能。
我们希望通过这种方式,即使某个关键信息被分块器在不理想的位置切断,相邻的块也能提供足够的上下文,
从而让LLM能够理解并生成准确的回答。重叠分块是处理分词边界不确定性的有效手段之一。
"""
max_tokens_per_chunk = 50
overlap_tokens_count = 10
print(f"n--- 演示带重叠的Token-Aware分块 (Max Tokens: {max_tokens_per_chunk}, Overlap: {overlap_tokens_count}) ---")
# Tiktoken 分块 (带重叠)
tiktoken_chunks_overlap = chunk_by_tokens_with_overlap(long_text_example, enc_cl100k, max_tokens_per_chunk, overlap_tokens_count)
print(f"nTiktoken (cl100k_base) 带重叠分块结果 ({len(tiktoken_chunks_overlap)} 块):")
for i, chunk in enumerate(tiktoken_chunks_overlap):
print(f" 块 {i+1} (长度 {len(enc_cl100k.encode(chunk))} tokens / {len(chunk)} chars):")
print(f" '{chunk}'n")
# SentencePiece 分块 (带重叠)
sp_chunks_overlap = chunk_by_tokens_with_overlap(long_text_example, sp, max_tokens_per_chunk, overlap_tokens_count)
print(f"nSentencePiece (Custom) 带重叠分块结果 ({len(sp_chunks_overlap)} 块):")
for i, chunk in enumerate(sp_chunks_overlap):
print(f" 块 {i+1} (长度 {len(sp.encode_as_ids(chunk))} tokens / {len(chunk)} chars):")
print(f" '{chunk}'n")
4.3 智能分块策略:结合语义和结构
纯粹基于固定token数的分割,即使有重叠,也可能在句子中间或段落中间进行切割。更高级的策略是结合文本的语义和结构信息。
- 基于句子的分割: 首先将文档分割成句子,然后将多个句子组合成块,直到接近
max_tokens。 - 基于段落/章节的分割: 优先保持段落或章节的完整性,只有当段落过大时才进行内部切割。
- 递归文本分割器 (Recursive Text Splitter): LangChain等库提供了这种工具,它会尝试多种分割策略,例如,先按
nn(段落) 分割,如果块仍然太大,再按n(行) 分割,如果还太大,就按空格分割,最后按字符分割。在每一步分割时,都使用目标LLM的分词器来计算token数。
4.4 评估与迭代
没有任何一种分块策略是完美的,尤其是在面对多样化的文档内容和查询模式时。
- 离线评估: 使用数据集评估不同分块策略和分词器组合下的检索准确率(例如,召回率和精确率)。
- 在线A/B测试: 在实际生产环境中,小流量测试不同的RAG配置,观察用户满意度、LLM响应质量等指标。
- 监控成本: 不同的分词器和分块策略会导致不同的token使用量,直接影响API成本。
4.5 利用现有RAG框架
像LangChain、LlamaIndex这样的RAG框架通常提供了内置的文本分割器,这些分割器允许你指定要使用的分词器。
# 示例:LangChain的RecursiveCharacterTextSplitter与Tiktoken结合
# pip install langchain tiktoken
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings # 用于模拟LLM的分词器
# 加载Tiktoken编码器(LangChain会内部使用它来计算token数)
# text-embedding-ada-002 使用 cl100k_base
tokenizer_name = "cl100k_base"
encoding = tiktoken.get_encoding(tokenizer_name)
def tiktoken_len(text):
tokens = encoding.encode(text)
return len(tokens)
long_text_example_lc = """
这是一个很长的例子文本,用于演示LangChain中的分块策略。LangChain的RecursiveCharacterTextSplitter
是一个非常强大的工具,它允许我们定义多种分隔符,并智能地在这些分隔符处进行切割。
更重要的是,我们可以通过传入一个自定义的长度函数,使其感知到特定LLM的token长度。
这样,我们就可以确保分块过程与目标LLM的分词器保持一致,从而避免分词陷阱。
在实际应用中,这种与LLM分词器感知的文本分割是构建高效RAG系统的关键一步。
"""
# 初始化RecursiveCharacterTextSplitter
# chunk_size 和 chunk_overlap 都是以 token 为单位
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=100,
chunk_overlap=20,
length_function=tiktoken_len, # 传入自定义的token长度计算函数
separators=["nn", "n", " ", ""] # 优先按段落、行、空格分割,最后按字符
)
# 分割文本
lc_chunks = text_splitter.split_text(long_text_example_lc)
print(f"n--- LangChain RecursiveCharacterTextSplitter (Tiktoken-aware) 分块 ---")
print(f"总块数: {len(lc_chunks)}n")
for i, chunk in enumerate(lc_chunks):
token_count = tiktoken_len(chunk)
print(f" 块 {i+1} (长度 {token_count} tokens / {len(chunk)} chars):")
print(f" '{chunk}'n")
# 注意:LlamaIndex 也有类似的 TextSplitter,例如 TokenTextSplitter
# from llama_index.text_splitter import TokenTextSplitter
# splitter = TokenTextSplitter(chunk_size=100, chunk_overlap=20, tokenizer=encoding)
# (LlamaIndex的TextSplitter通常可以直接接收tokenizer对象)
通过length_function=tiktoken_len,我们告诉LangChain的分割器,在计算块大小时,要使用tiktoken来测量文本的token长度,而不是简单的字符长度。这极大地提高了分块的准确性,使其与OpenAI模型的需求保持一致。
结语
分词是RAG系统中的一个基础但至关重要的环节。不同的分词器(如Tiktoken和SentencePiece)在原理、分词结果和token计数上存在显著差异,这些差异会直接导致文档分割边界的错位,进而影响RAG的检索准确性、上下文利用率和最终的生成质量。理解这些差异,并采取一致的分词策略、利用重叠分块、结合语义结构以及借助RAG框架提供的智能工具,是构建高效、鲁棒RAG系统的关键。忽视分词这一“陷阱”,可能导致你的RAG系统在看似完美的设计下,却无法发挥其应有的潜力。