数据截至 (上游 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折成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:四字段结构
NormalizedString(tokenizers/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_range(tokenizers/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_offsets(tokenizers/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_encoding(tokenizers/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 默认;Char 走 BytesToCharOffsetConverter(同文件 329-360 行)——预建「字节位置 → 字符下标」的哈希表逐点换算,右端点落在串尾时用末字节 +1 兜底(同文件 352-357 行);None 直接不存 offset(encode_fast 用,tokenizers/src/tokenizer/mod.rs:838-851,少算一堆映射换速度)。
5.5 Encoding:九组平行向量
最终产物 Encoding(tokenizers/src/tokenizer/encoding.rs:11-29):
| 字段 | 含义 | 谁写入 |
|---|---|---|
ids | token id | model / added vocab |
type_ids | 第几句话(pair 时区分 0/1) | PostProcessor::process 默认实现 |
tokens | token 字符串 | model / added vocab |
words | 所属词的序号(word_ids),特殊 token 为 None | into_encoding / 后处理 |
offsets | 原文字节(或字符)区间 | into_encoding |
special_tokens_mask | 是否特殊 token(1/0) | 后处理 |
attention_mask | padding 位为 0,其余 1 | pad_encodings |
overflowing | 截断溢出的后续窗口(递归的 Vec<Encoding>) | truncate |
sequence_ranges | 每条序列在 ids 里的覆盖范围 | 后处理合并时 |
所有向量等长、按下标对齐——所以 token_to_word、char_to_token、word_to_tokens 这些查询(同文件 230-305 行)都是 O(1)~O(n) 的数组下标换算,不需要任何搜索结构。
5.6 stride 与 overflowing:滑动窗口的物质基础
Encoding::truncate(tokenizers/src/tokenizer/encoding.rs:307-389)不是简单砍尾巴,而是按 max_len 和 stride(重叠长度)把原序列切成一串窗口:
原序列: |-------------------- 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_encodings(tokenizers/src/utils/truncation.rs:70)在 pair 场景按三种策略分配额度:LongestFirst(谁长砍谁,逐轮交替)、OnlyFirst / OnlySecond。注意第 1 章提过:这一步发生在 post_process 内部、特殊 token 插入之前,额度已预扣。
pad_encodings(tokenizers/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_offsets(bindings/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_ranges(tokenizers/src/tokenizer/encoding.rs:322-323)。截断后想按「每条序列的范围」切分 ids,信息已不在。 - 特殊 token 的 offsets 是
(0,0)(如BertProcessing里写死,tokenizers/src/processors/bert.rs:70),words是None——下游别拿它们当「对应原文开头」。 - 归一化删除字符处 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.rs | NormalizedString、alignments、original_shift |
| 对齐表维护 | tokenizers/src/tokenizer/normalizer.rs | transform、transform_range、filter、slice |
| 双向换算 | tokenizers/src/tokenizer/normalizer.rs | convert_offsets、expand_alignments、get_range_original |
| offset 终点 | tokenizers/src/tokenizer/pre_tokenizer.rs | PreTokenizedString::into_encoding、BytesToCharOffsetConverter、OffsetType |
| 产物结构 | tokenizers/src/tokenizer/encoding.rs | Encoding、token_to_word、char_to_token、word_to_tokens |
| 截断/溢出窗 | tokenizers/src/tokenizer/encoding.rs、tokenizers/src/utils/truncation.rs | Encoding::truncate、overflowing、truncate_encodings |
| 填充 | tokenizers/src/utils/padding.rs | pad_encodings、PaddingStrategy |
| 合并 | tokenizers/src/tokenizer/encoding.rs | Encoding::merge、merge_with |