A tokenizer’s vocabulary is learned, not configured — and when it is learned from too little text, it fails in a way you can read off the output.
nlp
huggingface
tokenization
Author
Ravi Kalia
Published
March 20, 2025
Hugging Face Tokenizers Library
Train a byte-pair tokenizer on a few lines of text, ask it to encode "Hello world", and it returns ['[UNK]', 'e', 'l', 'l', 'o', 'wor', 'l', 'd']. Nothing errored. The vocabulary simply never contained a capital H, or the word hello, or anything close enough to help — so the model fell back to characters and then gave up on the first one.
That output is worth reading closely, because it is the whole subject of this post. A tokenizer is not a configuration; it is a small model trained on a corpus, and the tokenizers library’s four-stage pipeline exists so that when the result is wrong you can tell which stage is responsible. Normalizer, pre-tokenizer, model, post-processor — the [UNK] above indicts exactly one of them.
The pipeline is four stages, and failures localise to one
Every tokenizer in the library is the same pipeline, and it helps to know what each stage can and cannot be blamed for:
Stage
Job
Failure it causes
Normalizer
Lowercase, NFD/NFKC, strip accents
Case- and accent-sensitive [UNK]s
PreTokenizer
Split raw text into word-ish chunks
Punctuation glued to words; no cross-word merges
Model
The learned vocabulary (BPE, WordPiece, Unigram)
Over-fragmentation; [UNK] on unseen characters
PostProcessor
Add [CLS], [SEP], type ids
Missing or misplaced special tokens
Decoder
Reassemble text from tokens
Lost spacing on round-trip
A pretrained tokenizer arrives with all four already set. Loading one shows what a well-trained vocabulary looks like, and the offsets are the library’s distinguishing feature — every token carries the character span it came from:
Code
from tokenizers import Tokenizerbert = Tokenizer.from_pretrained("google-bert/bert-base-uncased")out = bert.encode("Hugging Face is creating a tool.")print("tokens :", out.tokens)print("ids :", out.ids[:8], "...")print("offsets:", out.offsets[:5], "...")
Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.
hugging maps back to characters 0–7 of the input string. That mapping survives the whole pipeline, which is what makes token-level predictions (named entities, extractive answers) reportable as spans of the user’s original text rather than of some normalised intermediate.
The vocabulary comes from data, and too little data shows
Now build one from scratch. The corpus is data.txt beside this post: fifteen hand-written lines of NLP terminology, about 340 bytes — synthetic, and deliberately far too small, standing in for the “quick test corpus” that a first attempt usually reaches for.
Code
from tokenizers import Tokenizerfrom tokenizers.models import BPEfrom tokenizers.trainers import BpeTrainerfrom tokenizers.pre_tokenizers import Whitespacedef train_bpe(corpus, vocab_size):"""Train a byte-pair tokenizer over an iterable of strings.""" tok = Tokenizer(BPE(unk_token="[UNK]")) tok.pre_tokenizer = Whitespace() trainer = BpeTrainer( vocab_size=vocab_size, special_tokens=["[UNK]", "[CLS]", "[SEP]"] ) tok.train_from_iterator(corpus, trainer)return tokwithopen("data.txt") as fh: tiny_corpus = fh.read().splitlines()tiny = train_bpe(tiny_corpus, vocab_size=1000)print(f"requested vocab: 1000")print(f"actual vocab : {tiny.get_vocab_size()}")print(f"'Hello world' : {tiny.encode('Hello world').tokens}")
Two diagnostics, both damning. We asked for 1,000 merges and got a couple of hundred — the trainer exhausted the corpus before it ran out of budget, which is the signal that the corpus, not the setting, is the binding constraint. And Hello produces [UNK] followed by loose characters, because the training text is entirely lowercase: the byte H was never observed, so no merge involving it could ever be learned.
Neither of these is a bug in the library, and neither would have been visible from an error message. They are visible from the tokens.
The same code on a real corpus
Change nothing but the corpus. Here it is the 25,000 IMDB training reviews — a few tens of megabytes of ordinary English prose, described in more detail in the companion post on datasets:
Code
from datasets import load_datasetimdb = load_dataset("stanfordnlp/imdb", split="train")real = train_bpe((row["text"] for row in imdb), vocab_size=8000)print(f"actual vocab : {real.get_vocab_size()}")print(f"'Hello world' : {real.encode('Hello world').tokens}")print(f"unseen input : {real.encode('Some unseen text').tokens}")
The full 8,000-token budget is now used, Hello world resolves into three plausible subwords, and a sentence made of words the tokenizer never saw as units still decomposes sensibly instead of collapsing to characters. That last property — graceful degradation on unseen input — is the entire reason transformers use subword vocabularies rather than word ones, and it only emerges when the vocabulary was learned from enough text to contain the right fragments.
Training took a few seconds on 25,000 documents. The Rust implementation is genuinely the reason this is a cell in a blog post rather than an overnight job.
Special tokens are added after the fact
The model stage knows nothing about [CLS] or [SEP]; those are the post-processor’s job, applied to whatever the model produced. Attaching one to the well-trained tokenizer:
The single-sequence template wraps the subwords from before; the pair template additionally marks the second sequence with type id 1, which is what a model’s segment embeddings consume. The ids 1 and 2 in special_tokens must match the positions the trainer assigned — they were passed as the second and third entries of special_tokens above, and getting this wrong silently mislabels every sequence rather than raising.
Handing it to transformers
A trained Tokenizer becomes a transformers tokenizer by wrapping it, which is the bridge from this library to the rest of the ecosystem:
From here it works anywhere a tokenizer is expected — pipeline, Trainer, or a bare model’s forward pass. Saving with real.save("my-tokenizer.json") and reloading via PreTrainedTokenizerFast(tokenizer_file=...) is the same thing across a process boundary.
What the four stages buy
The claim at the start was that a tokenizer is trained, and that its failures localise. Both halves paid off in the same example: ['[UNK]', 'e', 'l', 'l', 'o', ...] told us the model stage lacked a character, and that the fix was either a Lowercase normalizer or — as it turned out — a corpus large enough to contain a capital letter. Nothing about that diagnosis required reading the library’s source.
Where it stops being simple is that the stages interact. Adding a normalizer changes what the model sees, which changes the merges it learns, which changes the offsets the post-processor reports — so a tokenizer is not a stack of independently tunable parts, and any change to an early stage invalidates a vocabulary trained under the old one. Retrain after touching the normalizer; do not patch it in afterwards and expect the merges to still apply.