Search Tech Journey

Find topics, journeys and posts

6-month learning plan106 / 130
back to blog
llmadvanced 50m read

S106 · Tokenization — BPE, WordPiece, SentencePiece

The unglamorous layer that decides what your model can even see. BPE, WordPiece, SentencePiece — how the choice of tokenizer costs OpenAI millions and breaks non-English languages.

LLMsM13 · NLP & Transformers· Session 106 of 130 90 min

🎯 Understand how BPE, WordPiece, and SentencePiece turn raw text into integer IDs — and why the choice of tokenizer determines cost, latency, and multilingual quality.

Why this session exists

Every LLM you use has a tokenizer sitting between you and the model. GPT-4 charges by tokens; whether your Hindi prompt is 20 tokens or 200 tokens depends entirely on how the tokenizer was built. A misconfigured tokenizer can double your inference bill overnight, silently break emoji handling, or make your model unable to spell. And when you fine-tune, the tokenizer is one of the two things you must NOT change (weights being the other). Understanding tokenization is the difference between "why does it hallucinate on this simple thing?" and knowing exactly why.

You will be able to
  • Explain why char-level, word-level, and subword tokenizers each fail in specific ways.
  • Walk through the BPE merge algorithm step-by-step from a small corpus.
  • Recognise BPE, WordPiece, and SentencePiece from their output patterns.
  • Train a tokenizer on your own corpus and evaluate its efficiency (tokens per word).
  • Diagnose the four canonical tokenizer bugs: leading spaces, BOS/EOS tokens, byte fallback, and vocab mismatch.

Prerequisites

  • S104 · Embeddings — you need to know that token IDs get embedded into vectors.
  • S103 · RNNs & LSTMs — for context on why sequence models need discrete inputs.
  • S045 · Text Processing — regex, unicode, encoding basics.


(a) Intuition · 5 min

Cutting a sentence into Lego blocks
🌍 Real world

Imagine you can only speak in Lego blocks. Someone hands you a bag of pre-made blocks — some are single letters, some are common syllables ("ing", "tion"), some are whole common words ("the", "and"), some are entire brand names ("Microsoft"). To describe anything, you must build it from those blocks.

Your bag has ~50,000 unique blocks. If you have a great match for a common word, one block does it. Rare or made-up words? You have to fall back to letter-blocks and glue several together.

💻 Code world

That's tokenization. GPT-4's tokenizer has ~100K unique "blocks" (tokens). "hello" is one token. "supercalifragilisticexpialidocious" is 6 tokens (split into common English substrings). A Devanagari word might be 15 tokens because the tokenizer wasn't trained on much Hindi.

The blocks are chosen ONCE during tokenizer training — by scanning a giant corpus and finding the most-common merges. From then on, every input to the model gets sliced against that fixed vocabulary.

Why we don't use characters or words

The three tokenization regimes and their failure modes
  • Character-level — every char is a token. Vocab tiny (~256), no OOV problem, BUT sequences are 4-5× longer than word-level. Model wastes capacity learning to spell 'the' every time. Death by sequence length.
  • Word-level — one token per word. Sequences short, BUT vocab explodes (millions), and 'unhappily' and 'unhappiness' are totally unrelated tokens. Any word not in training vocab becomes <UNK> and information is lost.
  • Subword (BPE / WordPiece / SentencePiece) — common words stay whole, rare words split into pieces. Fixed vocab (~30-100K), no OOVs (byte fallback), sequences reasonable. This is what every modern LLM uses.

A short history of "how do we chop text?"

  1. 1994
    Byte Pair Encoding invented
    Philip Gage publishes BPE as a compression algorithm in C Users Journal. Nothing to do with NLP.
  2. 2015
    BPE for NMT · Sennrich et al.
    Repurpose BPE as a subword tokenizer for neural translation. Solves OOV problem for rare words.
  3. 2016
    WordPiece · Google (Wu et al.)
    BERT's tokenizer. Same idea as BPE but merges by likelihood, not raw frequency.
  4. 2018
    SentencePiece · Google (Kudo)
    Language-agnostic — works directly on raw Unicode bytes, no language-specific preprocessing.
  5. 2020
    GPT-2 · Byte-level BPE
    OpenAI's twist — BPE on UTF-8 bytes, not chars. Guarantees no OOV, ever. LLaMA-2 and Mistral follow.
  6. 2024
    tiktoken · GPT-4 tokenizer
    ~100K vocab, byte-level BPE. Handles 100+ languages but with wildly uneven efficiency.

(b) Visual walkthrough · 15 min

The BPE merge algorithm

Start with characters as your vocabulary. Repeatedly find the most-frequent adjacent pair and merge it into a new token. Stop when vocab reaches desired size.

BPE by hand on a tiny corpus

BPE vs WordPiece vs SentencePiece

BPE (GPT, LLaMA)

Merge by frequency

  • Simple greedy: most-common pair wins
  • Deterministic segmentation at inference
  • Byte-level variant handles any Unicode
  • GPT-4, LLaMA-2, Mistral
WordPiece (BERT)

Merge by likelihood

  • Chooses merges maximising corpus likelihood
  • Slightly better than BPE on some tasks
  • Uses ## prefix to mark subword-continuation
  • BERT, DistilBERT, ELECTRA
Unigram / SentencePiece (T5)

Prune from big vocab

  • Start with huge vocab, prune tokens that hurt likelihood least
  • Multiple segmentations per input possible (regularisation)
  • Works on raw Unicode, no pre-tokenization needed
  • T5, mT5, XLM-R, ALBERT
Byte-level BPE (GPT-2+)

BPE on UTF-8 bytes

  • Vocab starts with 256 bytes, not 100K+ chars
  • Zero OOVs — ever
  • Trade: emoji + non-English cost more tokens
  • GPT-2, GPT-3, GPT-4, LLaMA-1

The four moving parts every tokenizer has

Anatomy of any modern tokenizer

Normaliser
Unicode normalisation (NFC vs NFD), lowercasing, accent stripping. BERT-base-uncased applies BOTH lowercasing and accent stripping. Get this wrong → different token IDs for the 'same' string.
preprocess
Pre-tokenizer
Splits on whitespace and/or punctuation BEFORE running BPE. GPT-2 uses a regex splitting on 'word', 'punctuation', 'whitespace'. Determines what BPE can merge together.
split
Model (BPE/WordPiece/Unigram)
The actual algorithm that maps chunks → token IDs using the trained vocabulary.
model
Post-processor
Adds special tokens: [CLS] / [SEP] for BERT, <|begin_of_text|> / <|end_of_text|> for LLaMA. THESE ARE CRITICAL — using the wrong template silently degrades performance.
postproc

How tokens map to model input


Common misconception
✗ What most people think

"A token is roughly a word. So if I need to fit 4,000 words in a context window of 8,192 tokens, I have plenty of room — and my cost estimate scales with word count."

✓ What is actually true

A token is a byte-level subword unit chosen by a compression algorithm, and its ratio to words is a property of your data distribution, not of language. English prose lands near 1.3 tokens/word; JSON, code, GUIDs, base64, log lines, non-Latin scripts and rare proper nouns can run 3–10× worse. The same character count can differ by an order of magnitude in tokens.

Why the myth is so sticky

Because the myth is true for the corpus the tokenizer was fit on. BPE merges are learned to minimise sequence length on training text, which is overwhelmingly English web prose — so on English prose the near-word intuition holds and gets reinforced daily. The first time it breaks is when you paste a stack trace, a Kusto query result, or Telugu text, and suddenly a "small" payload blows the window.

Prove it to yourself

Tokenize four payloads of similar character length and compare:

import tiktoken
enc = tiktoken.get_encoding('cl100k_base')
samples = {
  'prose': 'the quick brown fox jumps over the lazy dog ' * 8,
  'json' : '{\"user_id\": \"a3f9c2\", \"ts\": 1712345678}' * 8,
  'guid' : '9f1c2d3e-4b5a-6789-0abc-def012345678 ' * 8,
  'b64'  : 'SGVsbG8gd29ybGQgdGhpcyBpcyBiYXNlNjQ=' * 8,
}
for k, v in samples.items():
    n = len(enc.encode(v))
    print(f'{k:6s} chars={len(v):5d} tokens={n:5d} chars/token={len(v)/n:.2f}')
From first principles
Start with the question

Why does BPE greedily merge the most frequent adjacent pair, rather than, say, merging the longest repeated substring? And why does it never produce a token that crosses a rare boundary?

  1. 1
    The tokenizer must map arbitrary text to a fixed vocabulary of size V, with no out-of-vocabulary failures ever.
    forced by · the embedding matrix has exactly V rows; an unknown symbol has no vector and the model cannot run
  2. 2
    Guaranteeing that requires the vocabulary to contain a base alphabet that covers every possible input — in practice the 256 raw bytes.
    forced by · any text is a byte string, so a byte-complete base makes coverage total by construction
  3. 3
    Given byte-completeness, every extra vocabulary slot is pure compression: it exists only to shorten sequences, and each slot must be paid for with an embedding row plus a softmax output row.
    forced by · vocabulary size costs parameters linearly at both the input and output ends of the model
  4. 4
    Sequence length is what actually costs money: attention is quadratic in sequence length and every layer is linear in it. So the objective is "minimise expected tokens per document, subject to V slots".
    forced by · compute per request is dominated by sequence length, not by vocabulary size
  5. 5
    Under that objective, the marginal value of adding a merge equals the number of token-positions it removes from the corpus — which is exactly the frequency of that adjacent pair. Greedily taking the highest-frequency pair is the greedy solution to the compression objective.
    forced by · each merge of a pair occurring f times shortens the corpus by f tokens, so frequency is the gain function
⇒ Therefore

Therefore BPE is not a linguistic algorithm at all — it is greedy corpus compression under a fixed budget, and word boundaries appear only as a side effect of frequency. Nothing in the derivation mentions morphemes.

And note what this predicts: text whose byte-pair statistics differ from the training corpus must tokenize worse, because none of its frequent pairs bought a slot. That is precisely why non-Latin scripts, minified code and hashes cost multiples more tokens per character — and why a domain-specific tokenizer is a real lever, not a micro-optimisation.

Mental modelA compression codebook, not a dictionary

Picture the tokenizer as a zip codebook fitted once to a specific corpus and then frozen forever. Frequent byte sequences in that corpus got their own short code. Everything else gets spelled out, byte by byte, at full price.

The model never sees your text. It sees a sequence of codebook indices. Anything the codebook fragments badly, the model must reassemble internally — spending attention and layers on work the tokenizer should have done.

  • Byte-level BPE never fails on unknown input; it just becomes expensive. Degradation is in cost and quality, never in a crash.
  • Tokens-per-character is a property of your data, not the language. Measure it on your payloads before estimating cost or context budget.
  • Leading whitespace is part of the token. " the" and "the" are different IDs — this is why prompts ending in a trailing space behave oddly.
  • Tasks that need character-level reasoning (spelling, reversing strings, counting letters, arithmetic on long digits) are hard because characters are hidden inside tokens.
🔔 Fires when you see

Fire this model the moment you see: a context-window overflow on text that "looked short" · a cost estimate built from word counts · a model that cannot count letters in a word · non-English or code-heavy inputs costing far more than expected · an embedding model behaving worse on IDs and SKUs than on prose.

The tradeoff

Your workload is domain-heavy (logs, telemetry schemas, code). Do you reuse a general pretrained tokenizer, extend it with domain tokens, or train a domain tokenizer from scratch?

Reuse the pretrained tokenizer as-is
+ you gain zero work, and — decisively — full compatibility with every pretrained checkpoint, every cached embedding and every eval you already ran
− you pay your domain strings fragment into many tokens, inflating cost and latency on every single request, and consuming context that should hold actual content
pick when you are consuming a hosted model you did not train, or your text is mostly natural language
Extend the vocabulary with domain tokens
+ you gain large sequence-length wins on the specific patterns you add, while every existing token ID keeps its meaning and its trained embedding
− you pay the new rows are randomly initialised, so the model is initially worse at them than at the fragmented spelling; requires continued pretraining to be worth anything, and grows the output softmax
pick when you control the weights, you can afford continued pretraining, and a measurable fraction of your tokens come from a small set of repeated domain patterns
Train a tokenizer from scratch on domain data
+ you gain optimal compression for your corpus, often a large drop in tokens per document, and full control over vocabulary size
− you pay every pretrained checkpoint becomes unusable — you are now pretraining a model too; and you inherit whatever bias your corpus has, including catastrophic behaviour on anything outside it
pick when you are pretraining a model anyway, and your domain is genuinely far from natural language (DNA, telemetry protocols, formal proofs)
What a senior engineer actually does

Almost always reuse. The tokenizer is the one component that is coupled to everything — weights, caches, evals, downstream services — so changing it is never a local change. Measure first: run your real payload distribution through the encoder and get an actual tokens-per-record number. If the fragmentation cost is a few percent, you have no problem; if a repeated schema key is eating a third of every request, fix that in the prompt format before you touch the vocabulary.

The senior move is usually neither of the three: restructure the payload. Dropping verbose JSON keys, stripping GUIDs to short IDs, or moving a repeated schema into the system prompt buys most of the win at none of the compatibility cost.


(c) Hands-on · 25 min

Train a BPE tokenizer from scratch on a small corpus, encode/decode text, and benchmark efficiency (tokens per word) against pretrained tokenizers.

# mini_bpe.py — a minimal BPE trainer + encoder.
# Trains on a text file, then encodes/decodes sample text.
# Also compares tokens-per-word against tiktoken (GPT-4 tokenizer).
import sys
import re
from collections import Counter
from pathlib import Path
 
VOCAB_TARGET = 500  # small for demo; production uses 30K-100K
END_OF_WORD = "</w>"
 
 
def tokenise_words(text: str) -> list[str]:
    """Simple pre-tokenizer: split on whitespace + punctuation boundaries."""
    return re.findall(r"[a-zA-Z]+|[^a-zA-Z\s]", text.lower())
 
 
def get_stats(splits: dict[tuple[str, ...], int]) -> Counter:
    """Count all adjacent symbol pairs across all words, weighted by word frequency."""
    pairs: Counter = Counter()
    for word, freq in splits.items():
        for i in range(len(word) - 1):
            pairs[(word[i], word[i + 1])] += freq
    return pairs
 
 
def merge_pair(splits: dict[tuple[str, ...], int], pair: tuple[str, str]) -> dict[tuple[str, ...], int]:
    """Merge all occurrences of `pair` in every word split."""
    a, b = pair
    merged = a + b
    new_splits: dict[tuple[str, ...], int] = {}
    for word, freq in splits.items():
        new_word: list[str] = []
        i = 0
        while i < len(word):
            if i < len(word) - 1 and word[i] == a and word[i + 1] == b:
                new_word.append(merged)
                i += 2
            else:
                new_word.append(word[i])
                i += 1
        new_splits[tuple(new_word)] = freq
    return new_splits
 
 
def train_bpe(corpus_path: str, vocab_target: int) -> tuple[list[str], list[tuple[str, str]]]:
    text = Path(corpus_path).read_text(encoding="utf-8")
    words = tokenise_words(text)
    counts = Counter(words)
    print(f"unique words: {len(counts):,}")
 
    # Represent each word as tuple(chars) + end-of-word marker.
    splits: dict[tuple[str, ...], int] = {
        tuple(list(w) + [END_OF_WORD]): freq for w, freq in counts.items()
    }
 
    # Base vocab: all chars seen + EOW.
    vocab: set[str] = {END_OF_WORD}
    for word in splits:
        vocab.update(word)
    print(f"base vocab (chars): {len(vocab)}")
 
    merges: list[tuple[str, str]] = []
    while len(vocab) < vocab_target:
        stats = get_stats(splits)
        if not stats:
            break
        best_pair, best_count = stats.most_common(1)[0]
        if best_count < 2:
            break
        splits = merge_pair(splits, best_pair)
        merges.append(best_pair)
        vocab.add(best_pair[0] + best_pair[1])
        if len(merges) % 50 == 0:
            print(f"  merge {len(merges):4d}: {best_pair} ×{best_count}  vocab={len(vocab)}")
    return sorted(vocab), merges
 
 
def encode(text: str, merges: list[tuple[str, str]]) -> list[str]:
    """Apply merges in order to a new piece of text."""
    words = tokenise_words(text)
    encoded: list[str] = []
    for word in words:
        pieces: list[str] = list(word) + [END_OF_WORD]
        # Apply merges greedily in order.
        for a, b in merges:
            merged = a + b
            i = 0
            while i < len(pieces) - 1:
                if pieces[i] == a and pieces[i + 1] == b:
                    pieces = pieces[:i] + [merged] + pieces[i + 2 :]
                else:
                    i += 1
        encoded.extend(pieces)
    return encoded
 
 
def compare_to_gpt4(text: str, our_tokens: list[str]) -> None:
    try:
        import tiktoken
    except ImportError:
        print("(pip install tiktoken to compare against GPT-4 tokenizer)")
        return
    enc = tiktoken.get_encoding("cl100k_base")  # GPT-4
    gpt4_tokens = enc.encode(text)
    words = len(text.split())
    print(f"\nText: {text[:60]!r}...")
    print(f"  words:           {words}")
    print(f"  our tokens:      {len(our_tokens)}  ({len(our_tokens)/words:.2f} per word)")
    print(f"  GPT-4 tokens:    {len(gpt4_tokens)}  ({len(gpt4_tokens)/words:.2f} per word)")
 
 
def main(corpus_path: str) -> None:
    vocab, merges = train_bpe(corpus_path, VOCAB_TARGET)
    print(f"\nfinal vocab={len(vocab)}  merges={len(merges)}")
 
    demo = "The quick brown fox jumps over the lazy dog. Tokenization matters."
    tokens = encode(demo, merges)
    print(f"\nDemo encode: {demo}")
    print(f"  → {tokens[:30]}{'...' if len(tokens) > 30 else ''}")
    print(f"  → {len(tokens)} tokens for {len(demo.split())} words")
    compare_to_gpt4(demo, tokens)
 
    # Try something non-English if the corpus has it.
    hindi = "नमस्ते दुनिया"
    print(f"\nNon-English test: {hindi!r}")
    compare_to_gpt4(hindi, encode(hindi, merges))
 
 
if __name__ == "__main__":
    main(sys.argv[1])

Anatomy of the script

What the interesting lines do

re.findall(r'[a-zA-Z]+|[^a-zA-Z\s]', text)
Pre-tokenizer. Splits into words + individual punctuation. GPT-2 uses a much fancier regex that also handles contractions and numbers.
presplit
splits = {tuple(list(w) + [EOW]): freq ...}
Each word represented as a tuple of symbols + end-of-word marker. EOW is CRITICAL — it prevents the tokenizer from merging across word boundaries.
represent
get_stats(splits)
Counts every adjacent pair across all word representations, weighted by word frequency. The heart of BPE.
count
best_pair, best_count = stats.most_common(1)[0]
The greedy choice — merge the pair that occurs most often. This is what makes BPE 'BPE.'
greedy
merges.append(best_pair)
We save merges IN ORDER. At encode time we replay merges in the same order — this is what makes segmentation deterministic.
record
encode(): apply merges greedily
For each new word, start with chars + apply every stored merge in order. Result: same word always segments the same way.
encode
tiktoken.get_encoding('cl100k_base')
GPT-4's actual tokenizer, ~100K vocab. Use tiktokenizer.vercel.app to inspect visually.
compare
Try itBreak your tokenizer with a made-up word

Modify main() to encode some torture-test strings:

for demo in [ "supercalifragilistic", "AAAAAAAAAA", # runs of one char "1234567890", # digits "print(hello_world)", # code tokens "🎉🎊🎈", # emoji]: tokens = encode(demo, merges) print(f"{demo!r} {

You'll observe GPT-4 dominating on common patterns and matching yours on truly rare ones. Tokenizer efficiency = "how much your training corpus overlapped with real user text."

💡 Hint · Try encoding 'supercalifragilistic'. Compare how your tiny tokenizer segments it vs GPT-4. Bonus: try 'AAAAAAAAAAA' (10 A's) — GPT-4 has learned a token for that specific run of A's because it appears often in code.

(d) Production reality · 15 min

War story OpenAI · GPT-3 tokenizer· 2020all API traffic
🔥 What broke

GPT-3's tokenizer (r50k_base) was trained heavily on English web scrapes. When developers built apps for non-English users, they discovered inference costs were 3-5× higher than expected for the same message length. A Korean user's prompt might use 4× the tokens of an English equivalent — and get charged 4× per API call.

🧯 The fix
OpenAI released cl100k_base for GPT-4, doubling vocab to ~100K and specifically training more on non-English text. Ratio improved (Hindi went from ~8 tokens/word to ~4) but nowhere near English efficiency. The fundamental issue: byte-level BPE + English-heavy training data = permanent multilingual tax. GPT-4o's o200k_base further improved things — but the gap remains.
🎓 Lesson to steal
Tokenizer choice is a business decision. If your product serves non-English users, either budget for the token tax or use a language-specific model (Aya, Bloom, IndicBERT) with a better-balanced tokenizer.
Post-mortem
War story Meta · LLaMA tokenizer· 2023research + production
🔥 What broke
LLaMA-1 used a SentencePiece BPE tokenizer with 32K vocab, trained mostly on English. When Meta released it publicly, the community immediately found it was terrible at code — Python indentation would consume tokens ridiculously, and common patterns like `self.` weren't single tokens.
🧯 The fix
LLaMA-2 kept the 32K vocab but retrained the tokenizer on more code + multilingual data. LLaMA-3 went further: 128K vocab, dramatically better code + multilingual efficiency. Result: LLaMA-3 has ~15% shorter sequences than LLaMA-2 for the same input, which translates directly into 15% faster inference and lower memory.
🎓 Lesson to steal
Vocab size is a real hyperparameter. Bigger = shorter sequences (faster inference) but larger embedding + softmax matrices (more memory). LLaMA-3's jump from 32K to 128K reflects the industry consensus that vocab was too small in the GPT-2 era.
Post-mortem
War story Google · BERT [CLS] token bug· 2019community
🔥 What broke
Thousands of BERT tutorials had users doing `tokenizer.encode(text)` and feeding to the model — forgetting that BERT expects [CLS] at position 0 for classification. Users would report accuracy far below the paper's claim on their fine-tuned models. Root cause: the model was seeing a shifted sequence with no [CLS] token, so the pooled output was nonsense.
🧯 The fix
Hugging Face defaulted `tokenizer.__call__()` to add special tokens automatically. Users who explicitly wanted no special tokens had to pass `add_special_tokens=False`. Confusion decreased overnight.
🎓 Lesson to steal
Post-processor (special tokens template) is model-specific and NON-NEGOTIABLE. LLaMA needs <s>, BERT needs [CLS]+[SEP], GPT-4 needs the chat template with system/user/assistant markers. Skip these and your model silently underperforms.
Post-mortem

Where this shows up in the rest of the plan

Tokenization decisions propagate through every downstream Transformer decision
S107 · Attention Intuition
Attention operates on sequences of tokens. Longer sequences = quadratic memory. Tokenizer efficiency = latency.
S111 · Full Transformer
Every input starts with tokenizer → embedding. Change tokenizer, retrain.
S112 · BERT & Pretraining
MLM objective operates on token IDs. WordPiece vs BPE affects what 'masking' can reveal.
S119 · LoRA + PEFT
Fine-tuning MUST use the exact same tokenizer as pretraining. Adding tokens is possible but tricky.
S125 · RAG
Context length limits are counted in tokens, not chars. Tokenizer efficiency = how much you can retrieve.
S030 · Regex + Text Processing
The pre-tokenizer is often a regex. You need this to build custom tokenizers.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three without notes, redo the session:

  1. Why is subword tokenization the industry default? What did char-level and word-level get wrong?
  2. Walk through BPE training in your head on 'low low lower newest'.
  3. Why does a Hindi prompt cost 4× more than an English one on GPT-4?

What comes next

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.