Skip to content

Tokens, Embeddings, and Attention

Tokenization and BPE

Splitting text into the units a model reads

A transformer never sees letters. It sees integers - a sequence like [15496, 11, 995]. Tokenization is the pre-processing step that decides how a string is chopped into those integers, and it quietly shapes everything downstream: how long your prompt is, what the model can spell, even how it does arithmetic. The winning recipe, used by GPT, Llama, and almost every modern model, is byte-pair encoding - and its whole idea is embarrassingly simple: repeatedly glue together the most common adjacent pair.

From characters to integers

The job is to map text onto a fixed - a numbered list of tokens. Every token has an id, and the id is all the model consumes. There are three obvious ways to slice, and each is a trap in a different direction:

GranularityVocabularySequence lengthOut-of-vocab?
Characterstiny (~100)very longnever
Whole wordshuge (105+)shortconstant
Subwords (BPE)medium (~30k-100k)mediumnever

Characters give a tiny alphabet but force the model to read t-o-k-e-n one letter at a time - long sequences waste compute and bury meaning. Whole words are the opposite: tokenization is one crisp unit, but the vocabulary explodes, and the first unseen word - a typo, a name, antidisestablishmentarianism - has no id at all. That is the dreaded out-of-vocabulary (OOV) problem. Subwords thread the needle: common words stay whole, rare words break into familiar fragments, and because the smallest fragments are single characters, nothing is ever unrepresentable.

The vocabulary is just an array

Once text is tokens, each id is nothing more than a row number. The model keeps an embedding matrix - rows, one per token, each a -dimensional vector (Lesson 12). Turning a token id into its vector is a single array lookup, , in - exactly the constant-time indexing you met with arrays. Tokenization exists to produce good row numbers.

Byte-pair encoding: merge the frequent pair

BPE learns the vocabulary from data instead of guessing it. Start with the raw alphabet - just the characters. Then repeat one move: scan the whole corpus, find the adjacent pair of symbols that occurs most often, and fuse it into a single new token. Do it again on the updated corpus. Each merge adds exactly one token to the vocabulary, and frequent letter-sequences - e+s+test, l+o+wlow - bubble up into their own units.

Below is a real BPE run on a nine-word toy corpus (the frequency after each word is how often it appears). Train it merge-by-merge and watch the amber pair - the current winner - fuse into a green token, growing the vocabulary. Then type into the tokenizer: it applies exactly the merges you have learned so far, so words collapse from characters into whole tokens as you train.

BPE trainer & tokenizer train, then type
1Train - fuse the most frequent adjacent pair
x5low
x2lower
x2lowest
x4slow
x3slower
x6newer
x3newest
x3wider
x4widest
vocab = 10

Most frequent adjacent pair: ‘l’ + ‘o’ occurs 16× - the winner. Press “Merge next” to fuse it.

#merge rule (pair → new token)freq
no merges yet - the vocabulary is just the 10 characters

2Tokenize - apply the merges you learned
try:
lowestslowernewestwidest
24tokens24characters1.00chars / token
learned subwordsingle characterdigitoutside alphabet

Untrained: the vocabulary is only characters, so every word is spelled out one token at a time. Train above and watch these fuse.

The payoff: a word it never trained on

Click Auto-train, then the unseen words preset. The word slowest was not in the corpus - yet it tokenizes to just two tokens, slow + est, both learned merges. That is the entire reason subwords win: BPE captures reusable morphology, so it generalizes to words it has never seen while keeping them short. wildest, which shares less, falls back further to wi+l+d+est - still fully representable, never OOV.

Tokenization is full of sharp edges

Load the quirks preset. Three real-world gotchas show up at once: casing - widest is one token but capitalized Widest shatters into four, because the uppercase W never appeared during training (tokenizers are case-sensitive; the and The are different ids). Numbers - 20 splits into 2 and 0; digits rarely merge, which is a big reason early LLMs fumbled arithmetic. Spaces - our toy splits on them, but real GPT tokenizers fold the leading space into the next token, so " dog" and "dog" get different ids. Small choices, large consequences.

Vocabulary size is a dial you tune

The number of merges you run is the vocabulary size. Start with the base alphabet , and every merge appends exactly one token, so after merges:

Our toy has characters; after all merges the vocabulary is tokens and every corpus word is a single id. Real tokenizers turn the same dial much further: GPT-2 stopped at , GPT-4's cl100k_base at about . Bigger means shorter sequences (cheaper to run) but a fatter embedding matrix and rarer tokens seen less often during training. There is no free token.

Why there is no true OOV

Production tokenizers do BPE over bytes, not characters - the base alphabet is all possible byte values. Any text - any language, any emoji, any garbage - is some sequence of bytes, so it always has a tokenization. The worst case is one token per byte; the common case is a handful of learned subwords. OOV is not "handled" so much as made impossible by construction. (Our character-level toy flags an unseen character in red to show the gap that byte-level BPE closes.)

The tokenizer, in ten lines

Applying a learned merge table to new text is a tight loop: start from characters, and repeatedly glue the surviving pair whose merge was learned earliest (lowest rank), until no adjacent pair is a known rule. This is exactly the logic running in the demo above.

def tokenize(word, ranks):            # ranks: {(a, b): merge_order}
    syms = list(word)                 # start from single characters
    while True:
        # every adjacent pair that is a known merge rule
        pairs = [(ranks[(a, b)], i)
                 for i, (a, b) in enumerate(zip(syms, syms[1:]))
                 if (a, b) in ranks]
        if not pairs:                    # nothing left to merge - done
            return syms                   # each survivor is one token id
        _, i = min(pairs)               # earliest-learned pair, leftmost
        syms[i:i+2] = [syms[i] + syms[i+1]]  # fuse the pair in place
BPE encode - greedy merge by learned rank

Check yourself

What does a single step of BPE training do?

Why does subword tokenization avoid out-of-vocabulary failures?

Recall: a token id is used to look up what, and how fast?

Nail down the connection between tokenization and embeddings. Try to state it, then check.

Lock it in

  • Tokenization turns text into integers; the integer is just a row number in the embedding matrix, fetched in O(1).
  • BPE learns the vocabulary by repeatedly merging the most frequent adjacent pair - one merge, one new token.
  • Common words stay whole, rare words decompose into familiar subwords, and single characters are the ultimate fallback - so nothing is ever out-of-vocabulary.
  • Vocabulary size V = base alphabet + number of merges. Bigger V means shorter sequences but a heavier embedding matrix and sparser training signal per token.

Primary source

The definitive build-it-yourself walkthrough is Andrej Karpathy's "Let's build the GPT Tokenizer", part of his Neural Networks: Zero to Hero series - he codes BPE from scratch and dissects every quirk you met here. To see production tokenizers split your own text token-by-token (GPT-4, Llama, and more), play with the interactive Tiktokenizer - the tool our "type and see chips" panel is modelled on.

Ask your teacher

Curious how the vocabulary is stored so lookup stays near-instant? The tokenizer is DSA in disguise: the merge rules live in a trie / hash map (Lesson 23, and tries in Lesson 41), which turns a string into ids in near-constant time; each id is then an array index into the embedding matrix (connection C5). Want the full byte-level BPE with the GPT-2 regex pre-tokenizer, or why tiktoken is so fast? Ask, and I'll build the follow-up.