跳到主要内容

数据截至 (上游 commit d5827816baed)

03 · Encoding 结构与 offset 映射

这一章讲什么: tokenizers 最「值钱」的工程能力——offset 映射。归一化(小写化、NFKC、strip)会改变字符串长度和字节布局,但 encode 输出的每个 token 仍然能精确说出自己对应原文的哪几个字节。同时讲清最终产物 Encoding 的九个字段,以及截断时 overflowing 是怎么撑起「滑动窗口」用法的。


1. 它要解决的小问题

做抽取式问答、命名实体识别时,答案标注在原文上(第 x 到第 y 个字符),模型看到的却是 token 序列。训练时必须回答:「第 i 个 token 对应原文哪个区间?」

麻烦在于中间隔着一串会改写文本的操作:

原文: "Héllo WORLD"
NFKC: "Héllo WORLD" (可能把全角字符折叠,长度变了)
Lowercase: "héllo world" (长度不变但字节变了)
预分词: ["héllo", "world"]
模型: ["h", "él", "lo", "world"]

如果每一步只输出新字符串,「world 这个 token 在原文的第 8~13 字节」这个信息到第二步就丢了。要么全程背着一个映射,要么事后没法补算。


2. 直觉:给每个字节发一张「出生证」

tokenizers 的选择:归一化串的每个字节,都记着它来自原串的哪一段

  • 1 字节 → 1 字节:直接记 (start, end)
  • 一个字符展开成多个(如 NFKC 把 折成 fi):多个字节记同一段原串区间;
  • 多个字符压成一个:那一个字节记上跨多个原串字节的区间;
  • 被删除的字符:原串区间映射成零宽。

这张表叫 alignments。之后无论怎么切分、怎么合并,只要知道「token 在归一化串里的区间」,就能沿表反查原串区间。字符串可以随便改,出生证不能丢。


3. 图示:alignments 长什么样

原串: H é l l o
字节: 0 1 2 3 4 5 (é 占 2 字节)

归一化后: h e ◌́ l l o (NFD:é 拆成 e + 组合符)
字节: 0 1 2 3 4 5 6 (组合符 ◌́ 也占 2 字节,总长 7)

alignments(归一化串每个字节 → 原串区间):
0:h→(0,1) 1:e→(1,2) 2:◌́→(1,3) 3:◌́→(2,3) 4:l→(3,4) 5:l→(4,5) 6:o→(5,6)

token "él" 占归一化串区间 [1,5)
→ 合并 alignments[1..5] 的原串区间 = (1,2)∪(1,3)∪(2,3)∪(3,4) = 原串 [1,4) = "él"

查询方向是双向的:给归一化区间查原串区间(编码时用),给原串区间查归一化区间(get_range 取切片时用)。


4. 原理演示(示意代码)

# 示意,非源码
class NormalizedString:
original: str
normalized: str
alignments: list[(int, int)] # 每个 normalized 字节 → original 区间

def convert_offsets(self, norm_range):
# 把归一化串区间里每字节的原串区间取并集
covered = [self.alignments[b] for b in range(*norm_range)]
start = min(s for s, e in covered if e > s) # 跳过零宽
end = max(e for s, e in covered)
return (start, end)

真实实现还处理了「目标区间落在被删除字符上」「归一化串为空」等边界,但骨架就是这个。


5. 真实实现

5.1 NormalizedString:四字段结构

NormalizedStringtokenizers/src/tokenizer/normalizer.rs:105-117):

// 摘自 tokenizers/src/tokenizer/normalizer.rs:105-117(字段注释精简)
pub struct NormalizedString {
original: String, // 改之前的原串
normalized: String, // 改之后的串
alignments: Vec<(usize, usize)>,// normalized 每字节 → original 的 (start, end)
original_shift: usize, // 若本串是更大串的切片,记录起点偏移
}

original_shift 是关键配角:预分词把一个 NormalizedString 切成多个 Split 时,每个子串只需要记「我在父辈原串里从第几字节开始」,不需要复制父串——最终算原文 offset 时加上这个 shift 即可(offsets_original,同文件 145-150 行)。

5.2 alignments 怎么被维护

归一化器不直接改字符串,而是产出「变换序列」交给 NormalizedString::transform / transform_rangetokenizers/src/tokenizer/normalizer.rs:317 起)执行:每个目标字符附带一个 isize 偏移,表示「我相对原位置的位移」,transform 据此重建 alignments(同文件 345-414 行,含 1→n 展开时为同一原串区间生成多条记录的逻辑,见 407 行 alignments.extend((0..c.len_utf8()).map(|_| align)))。

所有内置归一化操作(lowercase、strip、NFD/NFC/NFKC/NFKD、replace)都走这套 API,所以对齐表是自动维护的;自定义 Normalizer 若绕过 API 直接改串,对齐表就静默失效——这是本库文档里反复强调的纪律。

5.3 convert_offsets:两个方向的查询

convert_offsetstokenizers/src/tokenizer/normalizer.rs:156-214)按 Range::Original / Range::Normalized 分两个方向:

  • Original → Normalized(同文件 185-209 行):线性扫描 alignments,找覆盖目标区间的归一化字节范围;目标完全落在「被删除」区间时返回零宽区间((Some(s), None) => Some(s..s) 这类分支)。
  • Normalized → Original(同文件 213 行):alignments.get(target).and_then(expand_alignments)——expand_alignments(同文件 905 行)把相邻同源的记录并成一个区间,即 §4 伪代码的「取并集」。

5.4 into_encoding:offset 的终点站

编码主线的第 ⑤ 步 PreTokenizedString::into_encodingtokenizers/src/tokenizer/pre_tokenizer.rs:198-259)把每个 token 的「词内 offset」逐级换算回原文:

// 摘自 tokenizers/src/tokenizer/pre_tokenizer.rs:234-250(注释精简)
let mut offsets = normalized
.convert_offsets(Range::Normalized(token.offsets.0..token.offsets.1))
.map_or(token.offsets, |range| {
(offsets.0 + range.start, offsets.0 + range.end) // 加上本 Split 的 original_shift
});
if let Some(converter) = offset_converter {
offsets = converter.convert(offsets).unwrap_or(offsets); // 字节 → 字符
}

三步:词内 → Split 归一化串 →(+shift)原文字节 →(可选)原文字符

OffsetType 三态(tokenizers/src/tokenizer/pre_tokenizer.rs:8-13):Byte 是 Rust 默认;CharBytesToCharOffsetConverter(同文件 329-360 行)——预建「字节位置 → 字符下标」的哈希表逐点换算,右端点落在串尾时用末字节 +1 兜底(同文件 352-357 行);None 直接不存 offset(encode_fast 用,tokenizers/src/tokenizer/mod.rs:838-851,少算一堆映射换速度)。

5.5 Encoding:九组平行向量

最终产物 Encodingtokenizers/src/tokenizer/encoding.rs:11-29):

字段含义谁写入
idstoken idmodel / added vocab
type_ids第几句话(pair 时区分 0/1)PostProcessor::process 默认实现
tokenstoken 字符串model / added vocab
words所属词的序号(word_ids),特殊 token 为 Noneinto_encoding / 后处理
offsets原文字节(或字符)区间into_encoding
special_tokens_mask是否特殊 token(1/0)后处理
attention_maskpadding 位为 0,其余 1pad_encodings
overflowing截断溢出的后续窗口(递归的 Vec<Encoding>)truncate
sequence_ranges每条序列在 ids 里的覆盖范围后处理合并时

所有向量等长、按下标对齐——所以 token_to_wordchar_to_tokenword_to_tokens 这些查询(同文件 230-305 行)都是 O(1)~O(n) 的数组下标换算,不需要任何搜索结构。

5.6 stride 与 overflowing:滑动窗口的物质基础

Encoding::truncatetokenizers/src/tokenizer/encoding.rs:307-389)不是简单砍尾巴,而是按 max_lenstride(重叠长度)把原序列切成一串窗口:

原序列: |-------------------- 46 tokens --------------------|
max_len=16, stride=4 → 步长 offset = 16-4 = 12

主窗口: [0,16)
overflowing[0]: [12,28)
overflowing[1]: [24,40)
overflowing[2]: [36,46)

实现上就是把各字段按 (start, stop) 切片装成新的 Encoding 推进 overflowing(同文件 355-388 行),自己保留第一窗。**「上一窗的尾巴在下一窗重复出现」**就是 stride 的全部含义——问答任务里答案跨窗时不至于被一切两半。overflowing 是递归结构,且 merge_with(同文件 408 行起)会把双方窗口做笛卡尔积式合并(注释里明说「多数情况 pair.overflowing.len() == 0」)。

5.7 管线的收尾:truncate_encodings 与 pad_encodings

truncate_encodingstokenizers/src/utils/truncation.rs:70)在 pair 场景按三种策略分配额度:LongestFirst(谁长砍谁,逐轮交替)、OnlyFirst / OnlySecond。注意第 1 章提过:这一步发生在 post_process 内部、特殊 token 插入之前,额度已预扣。

pad_encodingstokenizers/src/utils/padding.rs:50)支持定长与 BatchLongest(批内最长),可选 pad_to_multiple_of 对齐到倍数(Tensor Core 友好的 8/16 对齐);写入 attention_mask=0 的位、并把 pad token 的 type_ids/special_tokens_mask 一并设置。


6. 坑与注意点

  • Python 拿到的 offsets 是「字符」不是「字节」。 Python 绑定调 encode_char_offsetsbindings/python/src/tokenizer.rs:1212),与 Rust 默认的 encode(字节)差一个 BytesToCharOffsetConverter。混用两种语言的结果对 offset 时会莫名错位——先确认坐标系。
  • encode_fast 没有 offsets。 OffsetType::None 分支直接填 (0,0) 和空字符串(tokenizers/src/tokenizer/pre_tokenizer.rs:213-222)。拿它做问答后处理会得到全零区间。
  • 截断会清空 sequence_rangestokenizers/src/tokenizer/encoding.rs:322-323)。截断后想按「每条序列的范围」切分 ids,信息已不在。
  • 特殊 token 的 offsets 是 (0,0)(如 BertProcessing 里写死,tokenizers/src/processors/bert.rs:70),wordsNone——下游别拿它们当「对应原文开头」。
  • 归一化删除字符处 offset 可能是零宽或合并区间(§5.3 的 (Some(s), None) => Some(s..s))。做字符级可视化高亮时要把零宽区间过滤掉。
  • word_ids 在 pair 编码里各自从 0 起,跨序列定位「同一个词」要配合 sequence_id/type_id 用。
  • stride 必须 < max_len,否则 assert! panic(tokenizers/src/tokenizer/encoding.rs:319)。
  • 自定义 Normalizer 的最大坑:绕过 transform/filter 等 API 直接改 normalized 字符串,alignments 不失效于编译期而失效于运行时——表现为 offsets 静默漂移。写自定义归一化器前先读 tokenizers/src/tokenizer/normalizer.rs 顶部 98-103 行的结构文档。

7. 代码地图

主题文件路径符号名
对齐表结构tokenizers/src/tokenizer/normalizer.rsNormalizedStringalignmentsoriginal_shift
对齐表维护tokenizers/src/tokenizer/normalizer.rstransformtransform_rangefilterslice
双向换算tokenizers/src/tokenizer/normalizer.rsconvert_offsetsexpand_alignmentsget_range_original
offset 终点tokenizers/src/tokenizer/pre_tokenizer.rsPreTokenizedString::into_encodingBytesToCharOffsetConverterOffsetType
产物结构tokenizers/src/tokenizer/encoding.rsEncodingtoken_to_wordchar_to_tokenword_to_tokens
截断/溢出窗tokenizers/src/tokenizer/encoding.rstokenizers/src/utils/truncation.rsEncoding::truncateoverflowingtruncate_encodings
填充tokenizers/src/utils/padding.rspad_encodingsPaddingStrategy
合并tokenizers/src/tokenizer/encoding.rsEncoding::mergemerge_with