解析 ‘Tokenization’ 陷阱:为什么不同的分词器(Tiktoken vs SentencePiece)会影响 RAG 的分割边界?

在构建基于检索增强生成 (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” 等前缀和后缀可以被识别为独立的子词,有助于模型理解词的形态变化。
  • 跨语言适用性: 对于没有明确单词边界的语言,子词分词表现出色。

主流的子词分词算法包括:

  1. Byte Pair Encoding (BPE) 及其变种 (如WordPiece, Tiktoken)。
  2. 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 最初是一种数据压缩算法,后来被应用于自然语言处理。其核心思想是迭代地合并文本中最频繁出现的字节对(或字符对),直到达到预设的词汇量大小或迭代次数。

基本步骤:

  1. 初始化: 将所有文本拆分成单个字符(或字节),并将每个字符视为一个初始的token。
  2. 统计频率: 统计所有相邻token对的出现频率。
  3. 合并: 找到频率最高的token对,将其合并成一个新的token。
  4. 重复: 重复步骤2和3,直到达到预设的词汇表大小或不再有符合合并条件的对。

示例: 假设我们有文本 "low lower lowest"

  1. 初始tokens: l o w </w> l o w e r </w> l o w e s t </w> (这里的 </w> 表示词尾)
  2. 最频繁对: l o -> lo
    lo w </w> lo w e r </w> lo w e s t </w>
  3. 最频繁对: lo w -> low
    low </w> low e r </w> low e s t </w>
  4. 继续合并 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_basep50k_base)对同一文本的分词结果和token数量会不同,这再次强调了选择正确编码器的重要性。

2.2 SentencePiece:灵活多样的多语言专家

SentencePiece 是 Google 开发的一个开源库,它提供了一种语言无关的、无监督的文本分词器。它不仅支持 BPE,还支持 Unigram Language Model (ULM) 模式,并在多语言处理中表现出色。

2.2.1 ULM算法原理回顾

与 BPE 从最频繁的字符对开始合并不同,ULM 的思路是:给定一个句子,存在多种可能的子词切分方式,我们希望找到一种切分方式,使得这些子词序列的概率最大化。它通过训练一个 Unigram 语言模型来计算每个子词的概率,然后使用 Viterbi 算法来找到最佳的切分路径。

基本步骤(高层次理解):

  1. 初始词汇表: 从所有输入文本中提取所有可能的子字符串作为初始候选词汇。
  2. 迭代优化:
    • 计算每个子词在训练数据中出现的概率。
    • 移除那些概率较低的子词(即如果移除它们对整体文本概率影响不大)。
    • 重复这个过程,直到词汇表达到预设大小。
  3. 分词: 对于新输入的句子,使用 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 的边界在 ization 之间切开,但实际的检索或生成过程依赖于 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")

代码输出分析:

  1. Token数量差异: 运行代码后,你会发现对于同一段文本,Tiktoken和SentencePiece给出的总token数量是不同的。这直接验证了“Token数量不匹配”的问题。Tiktoken通常会产生更多的token,尤其是在处理中文时,因为它倾向于更细粒度的拆分。
  2. 分块数量和边界差异: 尽管我们都设定了 max_chunk_tokens = 100,但由于底层分词器的差异,最终生成的块数量可能不同,每个块的实际字符长度也可能相差甚远。更重要的是,即使两个块的token数都接近100,它们在文本中的切割点会因为分词器的不同而落在不同的位置。例如,Tiktoken可能在一个英文单词的中间切开,而SentencePiece可能在一个中文词语的中间切开,或者反之。
  3. 语义中断的直观感受: 当你查看 tiktoken_chunkssp_chunks 的内容预览时,你会发现它们在相同的token预算下,包含的字符内容是不同的。如果一个重要的短语或概念正好跨越了其中一个分词器设定的边界,而另一个分词器却能将其完整保留,那么在RAG检索时,被“腰斩”的块就更难被准确匹配。

这种差异是分词器“陷阱”的核心。它意味着,如果你的RAG系统使用Tiktoken进行文档分块,但你最终使用的是一个基于SentencePiece的LLM(例如Llama系列模型),那么你的分块策略将与LLM的真实理解产生偏差,从而降低RAG的整体效果。

第四章:缓解策略——如何走出分词陷阱

理解了分词陷阱的危害,接下来就是如何有效地规避或缓解这些问题。

4.1 核心原则:保持RAG系统与目标LLM的分词器一致

这是最重要的原则。

  • 知道你的LLM用什么分词器: 如果你使用的是OpenAI的GPT系列模型,那么就应该使用 tiktoken 库中对应的编码器(例如 cl100k_base)。
  • 端到端一致性: 确保你的RAG系统在以下所有环节都使用与目标LLM相同的分词器:
    1. 文档预处理阶段: 计算文档块的token数量,决定分块边界时。
    2. 查询编码阶段: 将用户查询转换为token ID序列,用于向量嵌入。
    3. 结果生成阶段: 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系统在看似完美的设计下,却无法发挥其应有的潜力。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注