What tokenization is

A model cannot read text. It works only with numbers, so the text has to be turned into numbers first. That step is tokenization, and it matters more than it looks: it decides what the model can see, how long each input is, and how much each request costs. The pieces it produces are tokens. What a token is, why you are billed in them, and how many fit in a context window is What is a token?; what the model does with them once they arrive is Inside an LLM. This article is about the process in between: the algorithm that decides where the cuts go.

Tokenization produces one thing: a list of ids - line numbers in the tokenizer's vocabulary. The model never sees your words, only those numbers.- the whole job, in one line

Why numbers? Because a model is arithmetic and nothing else. Every layer inside it multiplies matrices of floating-point numbers, and there is no operation anywhere in that machinery that accepts the letter c. So each piece of text is swapped for an integer, and that integer is used as a row number into a table of learned vectors. What actually reaches the model is a list of row numbers.

Text text to numbers · ids are illustrative, every tokenizer numbers its own vocabulary
you type "What is the capital of the UK?" split ["What", " is", " the", " capital", " of", " the", " UK", "?"] eight pieces - nothing had to break up here, and " the" takes the same id both times look up [2061, 318, 262, 3139, 286, 262, 3482, 30] eight ids - line numbers in the vocabulary, and that list IS your sentence now the model the same ids are now addresses in a second table: reads embedding line 2061 holds 0.41, -0.12, 0.77, ... embedding line 318 holds -0.03, 0.55, 0.19, ... 2061 was not turned into those decimals. it is an address: open that line of the model's own table and take whatever is already sitting there. nothing is computed from the id itself. those rows were filled in during training, and from here on the model works only with the decimals. your words are gone.

So the numbers are not quantities, and nothing is calculated from them. They are ids - line numbers in a table, working the way a page number works: page 2061 does not turn into the words printed on it, you simply open the book there.

One note on words before going on. This article says symbol, piece and token for the same thing - an entry in that list. Symbol while the algorithm is still counting, token once the model is reading; nothing changes but the point of view.

Two runs, not one

Two tables sit between your text and the model: the tokenizer's vocabulary, which turns a piece of text into an id, and the model's embedding table, which turns that id into a row of decimals. Where do they come from? From two separate training runs, one after the other. Each run takes something in and leaves something behind.

Run 1: the tokenizer run. The run finds the two symbols that sit next to each other most often, glues them into one, and repeats until the vocabulary is as big as you asked for - the loop in section 08. It takes a few hours on one machine. No network, no weights, and the model it will serve does not exist yet.

  • In: a corpus - a large sample of text, the same kind of writing the model will later read, boiled down to a list of distinct words with a count next to each.
  • Out: two plain text files. vocab.json lists the pieces with their numbers; merges.txt lists the gluing rules. Together they are the tokenizer, and they are small - about 1.5 MB for GPT-2, a few megabytes for a 128,000-token vocabulary.

Run 2: the model run. An LLM is a very large set of numbers, and at the start of this run every one of them is a small value straight out of a random number generator - a placeholder that means nothing, there only because the numbers have to start somewhere. The model is not empty and does not grow: how many numbers there are is settled before the run begins, so GPT-2's 124 million all exist from the first second, and the file is the same 548 MB on day one as at the end. Training changes their values, never their count. The embedding table is part of that set, not a thing beside it: one row for each line of run 1's vocabulary, filled with those same random values. Training fixes them: guess the next id, check how far off the guess was, adjust, and do it again over the whole training set. The text is never kept - it is read, used to correct the numbers, and dropped. The details are their own article: How a model learns.

  • In: those two files, plus the training set - the full collection of text the model learns from, far larger than the sample, and often the very text the sample came from. The model never reads it as text; run 1's files turn it into ids first.
  • Out: one file, model.safetensors, 548 MB for GPT-2. That file is the LLM.

A word on weights, since the file is named after them. A weight is one single number the model multiplies something by - one of the numbers that started random and that training spent the whole run adjusting. Their count is what a model's size means: GPT-2 has about 124 million of them, its largest version 1.5 billion, and parameters is the same word for the same thing. Stored as 4-byte decimals, 124 million numbers account for most of the 548 MB on disk. And there is nothing else in the file - no code, no vocabulary, no text - so the weights and the LLM are two names for one thing, not two things shipped together.

Text what is inside model.safetensors · GPT-2, and where the embedding table sits
model.safetensors 548 MB, ONE file, and it IS the LLM ~124 million numbers, 4 bytes each inside it: embedding table 50,257 rows x 768 columns ~38M ~31% one row per line of vocab.json 768 = the row width, picked per model twelve more layers attention and feed-forward matrices ~86M ~69% nothing else no code, no vocabulary, no text so the embedding table is one PART of the LLM - not a copy of it, not a file of its own, and not something beside it
The file is the weights, and the weights are the LLM - one thing, three names. The embedding table is not a fourth: it is one layer inside those numbers, about a third of them in GPT-2, and the layer whose height the vocabulary sets.- what contains what

The filename is convention, not meaning. Most open models, GPT-2 included, are published on Hugging Face, the public hub for sharing them (GPT-2's files sit at huggingface.co/openai-community/gpt2); model is what the hub calls the main weights file, and .safetensors is the format it introduced: a plain container of number arrays that cannot run code when it loads. What is not in it is the tokenizer: the vocabulary and the merge rules ship as their own files beside it, which is the whole point of section 03. GPT-2 is this article's example because OpenAI released its weights in 2019 - an open-weights model is a file anyone can download, run and inspect, where a closed model such as GPT-4 or Claude is reachable only through an API and its numbers never leave the vendor.

A diagram headed two runs, not one, with two panels separated by a dashed vertical line labelled frozen here. The left panel, run 1, the tokenizer run, starts from a small stack of sheets labelled corpus, a sample of text, words with counts, flows down through count pairs, merge, repeat, and ends in two file chips, vocab.json and merges.txt, with the note no network, no weights. Both file chips send lines across the dashed line into the right panel, one labelled 50,257 lines to 50,257 rows. The right panel, run 2, the model run, starts from a much taller stack of sheets labelled training set, the full text, read only as ids. That stack and the two incoming files meet at a box reading tokenize to ids, then flow down through one box reading guess the next id, see how far off, adjust every number, into one large red-outlined box labelled model.safetensors, 548 MB, the model. Inside that box sits the embedding table, one row per line, with rows 0, 1, 2061 and 50256 holding small decimals and a final row of dots; row 2061 is highlighted and reads 0.41, minus 0.12, 0.77, with the note starts random, filled by training. Outside the box: learned by gradient. A line under both panels reads: first the list, then the model built around it.
Two runs, in order · a sample in, two files out; those files and the full text in, one weights file out

The two outputs are the two things that ship. Notice the direction of travel. The vocabulary only ever goes in to run 2: fixed before the first step, never changed by it. The embedding table only ever comes out: it is not something the model is given, it is something the model ends up with.

Two rules follow. The tokenizer has to be frozen before model training starts, or the rows would end up pointing at the wrong pieces. And the model can only write a token that has a row, which is why its vocabulary is a closed set. The next two sections take the outputs in order: run 1's two files, then run 2's embedding table, which the model builds to match one of them.

Run one: the two files

What the tokenizer run leaves behind is not a program. It is two ordinary text files, shipped alongside the model - plain text, a megabyte or two all in, and between them the entire tokenizer. One of them, the vocabulary, will be paired with the model's embedding table in section 04, across the run boundary; here it is paired with the other file the same run wrote. They do two very different jobs, and only one of them is about numbers.

  • merges.txt - where the text gets cut. The ordered merge rules - which pair of pieces gets glued into one, in the order the rules were learned - replayed from the top on every piece of new text. This is the only learned half of the tokenizer run and the hard half of the problem: deciding that Tokenizers breaks as Token + izers, and not as To + ken + izers.
  • vocab.json - what number each piece gets. This is the vocabulary from section 01, as a real file on disk. A dictionary lookup, and the easy half: if ids were all a tokenizer had to produce, this file on its own would be the whole tokenizer.

The example below uses a toy corpus - cat, the, bat, sat - small enough to read whole, with ids numbered from 1.

A diagram headed two ordinary text files on disk, with two white panels side by side. The left panel, vocab.json, lists every token with its id: quote c to 1, quote a to 2, quote t to 3, quote at to 4, quote cat to 5, quote the to 6, then a note reading 100,000 more lines. The right panel, merges.txt, lists the merge rules in the order they were learned: a plus t gives at, c plus at gives cat, t plus he gives the, b plus at gives bat, s plus at gives sat, then a note reading 100,000 more rules.
Two files, one job · the merges decide where the splits go, the vocabulary turns the pieces into ids

Read the two together and the whole thing demystifies. The single characters were in the vocabulary from the start; at is there because a + t was worth merging, and cat because c + at was. Every id the model receives is a line in the first file, and every split the tokenizer performs is the second file replayed from the top - fast, deterministic, and with no neural network involved at all. This step is table lookup.

The same two files on a sentence the toy corpus never saw. Each word is split into characters, the merge rules are replayed from the top inside each word, and whatever pieces remain are looked up. One of the words is on the six-line list above; the rest use lines further down the real files.

Text two files, one sentence · ids beyond the first six are illustrative
you type "My beloved orange cat lives in Atlanta." start M y b e l o v e d o r a n g e c a t l i v e s i n A t l a n t a . one symbol per character, word by word capital M and A are their own symbols, and so is the full stop merges.txt replayed from the top, inside each word only My M+y -> My beloved b+e -> be, l+o -> lo, v+e -> ve, lo+ve -> love, love+d -> loved orange o+r -> or, a+n -> an, g+e -> ge, an+ge -> ange cat a+t -> at, c+at -> cat rules 1 and 2 lives l+i -> li, li+v -> liv, e+s -> es in i+n -> in Atlanta A+t -> At, a+n -> an, l+an -> lan, t+a -> ta never seen whole - three pieces it has . nothing to merge - one symbol, one piece pieces My | be loved | or ange | cat | liv es | in | At lan ta | . vocab.json each piece -> its line number ["My", "be", "loved", "or", "ange", "cat", "liv", "es", "in", "At", "lan", "ta", "."] [1831, 320, 4913, 412, 2819, 5, 2606, 274, 22, 1742, 588, 97, 8] seven words and a full stop in, thirteen ids out. nobody wrote a rule for any of these words - the cuts fall wherever the counted pairs landed.

Those are toy numbers. Here are the real ones - GPT-2's published files, listed next to the weights they ship with:

Text openai-community/gpt2 · a 50,257-token vocabulary, as it ships
vocab.json 1.04 MB 50,257 entries token -> id merges.txt 456 KB 50,000 rules where to cut ------- the tokenizer ~1.5 MB openable in any text editor model.safetensors 548 MB the weights - run 2's artifact, the LLM the tokenizer is ~0.3% of what ships

Two files you could open in a text editor, deciding what a 548 MB model - the whole of run 2's artifact, the LLM itself - is able to see at all. The 50,000 rules and the 50,257 entries are the same number twice, near enough: the vocabulary is 256 starting symbols - the byte values, for reasons section 06 gets to - plus one merged token per rule, plus a single end-of-text marker. Both files scale with the vocabulary and nothing else, so a 128,000-token tokenizer is a few megabytes rather than one.

One packaging note, because a modern repository often contains neither filename: the two tables are now usually shipped as a single tokenizer.json - 1.36 MB for GPT-2 - holding the vocabulary, the merge list and the pre-tokenization pattern together. Same two tables, one file.

Only one of the two files is about numbers. The other decides where the text breaks - and that is the half the tokenizer run exists to learn.- why a tokenizer is two files and not one

Run two: the embedding table

Of the two files, vocab.json is the one that crosses the run boundary: it is the tokenizer's table, and the model keeps a table of its own to match it, row for row. The two are owned by different halves of the system. The vocabulary belongs to the tokenizer: every piece it knows, one per line. The embedding table belongs to the model: it is the LLM's first layer, shipped inside the weights file itself, and it holds one row of numbers for each line of that vocabulary. That row - an ordered list of decimals, 768 of them in GPT-2 and a few thousand in larger models - is the piece's embedding vector. They stay separate because different runs build them - the vocabulary by counting text, the embedding table by training a model, the process How a model learns follows step by step - and the id is the only thing that ever passes between the two. It is also why a different tokenizer hands the same sentence different numbers.

A diagram headed two ordinary tables, one number. At the top, a single chip holds the id 2061, labelled the id the model receives, with two arrows fanning out from it to two panels below. The left panel, vocab.json, the tokenizer's table, shows a long list elided to one visible line: 2061 paired with the token quote What. The right panel, embedding table, the model's first layer, shows the same list position: row 2061 holding the decimals 0.41, -0.12, 0.77 and more. A line underneath reads: line 2061 in one table, row 2061 in the other.
One id, two tables · the tokenizer assigns the number, the model is addressed by it

One thing the embedding table is not: a vector database. Both hold vectors, and that is where the resemblance ends. A vector database stores embeddings of your documents and is searched by similarity - you hand it a vector, it returns the nearest ones, which is the retrieval step in Inside a RAG pipeline. The embedding table is never searched and nothing is ever compared: the id is a row number, the row comes back, done. Nor is it a file you can swap or a service you can query - it is part of the model's weights, learned during training and frozen with them. That is the sense in which it is the model's table: not a store the LLM consults, but the LLM's first layer.

The tokenizer is the dictionary that gives a piece a number. The embedding table is the table that gives that number a learned vector.- the two tables in one line

The tokenizer never touches the second table, and the model never sees your text. The id is the whole conversation between them. What is in those rows is Inside an LLM.

Back to the tokenizer's own two files. Both are fixed before a single word is processed - which pieces exist at all, and what number each one carries - and the only real decision behind them is the first one: where do you cut?

The tokenization challenge

There are only three answers to that question. The first two are the obvious ones, and both of them break. Take one sentence through each.

Option one: one token per word

Text word-level tokenization
"The quick brown fox jumps over the lazy dog." ["The", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog", "."] 10 tokens

Pros

  1. Every unit carries meaning on its own.
  2. The sequence is as short as text can be made.

Cons

  1. The vocabulary has to be enormous, and frozen before training starts. English alone has more than 170,000 words in current use, before inflections, proper nouns, product names and typos.
  2. Anything outside that list becomes <UNK>, and everything the word carried is lost.
  3. Morphologically rich languages - German, Finnish, Turkish - generate word forms faster than any fixed list can hold them.
<UNK> is short for unknown: the one placeholder a word-level tokenizer writes for every word it has never seen. The word is not split or approximated - it is replaced, and whatever it carried is gone before the model sees it.- the unknown token

Option two: one token per character

Text character-level tokenization
["T", "h", "e", " ", "q", "u", "i", "c", "k", " ", "b", "r", ...] 44 tokens

Pros

  1. A tiny vocabulary: about a hundred symbols covers everything.
  2. Nothing is ever unknown, because anything can be spelled out.

Cons

  1. The same text becomes several times more tokens - 44 here against 10, and around five times as many for English prose in general.
  2. Attention cost grows with the square of the sequence length - the mechanism Inside an LLM walks through - so every one of those extra tokens is expensive.
  3. A single character carries almost no meaning on its own, so the model rebuilds every word from scratch, every time.

The out-of-vocabulary problem

The word-level failure has a name. Suppose a translation system trained on news articles meets this:

Text one German compound, two ways
"The Bundesausbildungsfoerderungsgesetz provides student aid." word level ["The", "<UNK>", "provides", "student", "aid", "."] everything the word carried is gone subword ["Bundes", "ausbildungs", "foerderungs", "gesetz"] federal training support law four pieces the model has seen thousands of times

That is the observation the whole field is built on, and Sennrich, Haddow and Birch stated it plainly in 2016: rare words are usually compositional. Split them into the right smaller units and a network can translate - and produce - words it has never once seen in training. The unknown token stops being necessary.

Option three: one token per piece

Which is where BPE comes in. Option three keeps frequent words whole and assembles rare ones out of fragments that are themselves common, and byte-pair encoding is the most widely used way of deciding which fragments those are. Every model traced through Inside an LLM reads its input this way. Everything after this section is how it picks them.

Pros

  1. A manageable vocabulary, 50,000 to 256,000 entries.
  2. Sequence lengths close to the word count.
  3. Coverage of any text at all - anything unfamiliar is composed from pieces already on the list, so <UNK> disappears.

Cons

  1. The pieces are chosen by counting, not by meaning, so they respect neither morphemes nor digits nor word boundaries unless you force them to.
  2. The token count of a text is no longer predictable from its word count - a piece is not a word, so counting one tells you little about the other.
Three ways to cut text Three columns compared on three rows. Cut by words: a list of 170,000 or more, the shortest text, and a word never seen breaks. Cut by characters: a list of about 100, text about five times longer, and nothing ever breaks. Cut by pieces, which is what BPE does: a list of 50,000 to 256,000, text about as long as the word count, and nothing ever breaks. Pieces are the compromise every model ships. THREE WAYS TO CUT TEXT size of the list length of the text a word never seen by word 170,000+ shortest breaks by character about 100 5x longer fine by piece (BPE) 50k - 256k about one per word fine pieces are the compromise every model ships
Three ways to cut text · pieces win on all three rows
Cut text into pieces and any word ever written can be covered, at about one token per word. BPE is the most common way to choose the pieces.- the trade, in one line

The history of BPE

BPE was not designed for language, and it was not designed for models. It arrives in AI from data compression, by way of machine translation, and each hand-off changed what the loop counts while leaving the loop itself alone.

Three hand-offs, one unchanged loop A horizontal timeline with four points. 1994, Gage compresses bytes by replacing the most frequent pair. 2016, Sennrich, Haddow and Birch compress words into subwords using characters, an end-of-word marker and frequency weighting. 2019, GPT-2 returns to bytes with a 256-value base vocabulary, regex pre-tokenization and no unknown token. 2020s, the same loop ships in every major tokenizer at 50,000 to 256,000 pieces. The loop never changed; what changed each time is what it counts. FROM A COMPRESSION TRICK TO EVERY MODEL YOU USE 1994 Gage compresses bytes replace the top pair 2016 Sennrich: subwords characters, </w>, weights 2019 GPT-2: back to bytes 256 bytes, regex, no UNK 2020s every major tokenizer 50k to 256k pieces the loop never changed · what changed each time is what it counts, and what it is allowed to merge
Three hand-offs · bytes to characters and back to bytes, with the same greedy loop throughout

1994: a data-compression algorithm

In February 1994 Philip Gage published A New Algorithm for Data Compression in The C Users Journal. The target was byte streams, and the algorithm was four steps long.

  1. Find the most frequent pair of consecutive bytes in the data.
  2. Replace every occurrence of that pair with a new, unused byte.
  3. Record the replacement in a lookup table.
  4. Repeat until no pair occurs more than once, or until the compression is good enough.

Run it on a word you already know and the whole idea fits in three passes.

Text Gage 1994 · replace the most frequent pair with an unused symbol
input ABRACADABRA 11 symbols ties go to the pair that occurs first pass 1 AB, BR and RA all occur twice; AB comes first, and Z is unused ZRACADZRA Z = AB pass 2 ZR occurs twice YACADYA Y = ZR pass 3 YA occurs twice XCADX X = YA output XCADX + { X=YA, Y=ZR, Z=AB } 5 symbols and 3 rules instead of 11 symbols rebuild X -> YA -> ZRA -> ABRA so XCADX unpacks to ABRACADABRA, exactly

Two of those properties carried into tokenization. Two did not.

  • The loop carried. Steps 1 and 2 are the whole of BPE: find the top pair, replace every occurrence of it. A frequent pair is worth a symbol of its own - as true of ing in English prose as of a repeated byte pattern in a file.
  • The table carried. Step 3. Compression is worthless if you cannot invert it, so the substitution table ships with the data. In a tokenizer that table is merges.txt, and inverting it is how decoding works.
  • The unused byte did not. Step 2 needed a genuinely unused byte for every rule, so Gage's compression runs out of room once the byte space is exhausted. A tokenizer just allocates the next integer, and never runs out.
  • The stopping rule did not. Step 4 stops when nothing repeats, because the goal is a smaller file. A tokenizer stops when the vocabulary reaches a size you picked in advance - which is how that number ends up being the only real knob in the whole algorithm.

2016: subwords for translation

Twenty-two years later, Sennrich, Haddow and Birch pointed the same loop at a different problem: rare words in neural machine translation. Rather than compressing bytes for storage, they compressed characters into subwords, learned from a corpus - the same word-and-count list section 02 introduced, drawn here from the text the translation system would have to handle. Three modifications, all of which survive today.

  • Characters, not bytes. The base vocabulary is the set of characters that appear in the corpus.
  • An end-of-word marker. Writing </w> at the end of each word keeps est at the end of a word distinct from est in the middle of one, and makes the split reversible.
  • Frequency weighting. Pairs are counted across the corpus weighted by how often each word occurs, not once per distinct word.

2019: byte-level BPE

Character-level BPE still has a hole in it: a character the training corpus never contained has no representation, so an emoji or an unfamiliar script falls back to <UNK>. GPT-2 closed the hole with three changes that are now standard.

  • The base vocabulary is the 256 byte values. Not characters - bytes. Any text encodes to UTF-8, UTF-8 is bytes, so nothing can fail. An unfamiliar emoji can cost up to four tokens rather than one, but it is never unknown, and the unknown token disappears from the design entirely.
  • The leading space belongs to the token. " the" and "the" are separate entries with separate ids. GPT-2 prints that space as Ġ in vocabulary dumps so it is visible; in the data it is an ordinary space byte.
  • The text is split before BPE runs at all. A fixed pattern - pre-tokenization - cuts the input into runs of letters, runs of digits, runs of punctuation, contractions and whitespace, each run keeping the single space in front of it. Merges are then only ever allowed to act inside those chunks.

Pre-tokenization does more than tidy up. Without it the merge loop happily learns tokens that span word boundaries, because a space followed by a common word is a frequent pair like any other. Train a small BPE on a megabyte of Shakespeare with no pre-tokenization and the vocabulary fills with entries like " in the ", "lord, " and "OF YORK:\n" - each one a perfectly good compression of that corpus and a perfectly useless unit of language. Pre-tokenization spends the vocabulary on word pieces instead.

The byte-level pipeline, in order Four stacked rows for the text the café. Row one is the raw text. Row two is the regex chunks: the, then a space plus café. Row three is the UTF-8 bytes of each chunk, where the accented e is two bytes, c3 and a9. Row four is the result after merges are applied inside each chunk: the, then space caf, then the accented e on its own. The note records that merges stay inside a chunk and that bytes come first, so nothing can fail to encode. THE BYTE-LEVEL PIPELINE · ONE DIRECTION, FOUR STEPS raw text the café regex chunks "the" " café" utf-8 bytes 74 68 65 | 20 63 61 66 c3 a9 after merges "the" " caf" "é" é is two bytes, c3 a9, and still comes out as one piece · merges stay inside a chunk bytes first, so nothing can ever fail to encode
Two words, four steps · chunks first, then bytes, then merges inside each chunk

One inherited quirk is worth knowing. That pattern puts a run of digits in its own chunk but never caps how long the run can be, so long numbers get chopped into whatever groups the merge list happened to learn, owing nothing to place value. Later tokenizers patched it by splitting digits into fixed groups of one to three. It is the clearest case of a general rule: arithmetic weakness in a model is often a tokenizer decision, not a reasoning failure.

The cousins, and what ships today

BPE won, but it did not win alone, and it does not ship the way the 2016 paper described it. Two other algorithms build the same kind of list by a different route, and the tokenizers in production today pack the two files into one - or into none you are allowed to see.

Since then: two cousins

Different loops, same artifact - a fixed vocabulary of pieces the model is then built around - so everything in sections 02 to 04 holds for all three.

WordPiece is the tokenizer behind BERT (Google, 2018) and its descendants - DistilBERT, ELECTRA, and most of the encoder models still used for classification and search. It runs the same merge loop as BPE but scores a pair differently: not by how often it occurs, but by how much more often it occurs than its two halves would predict - roughly the count of the pair divided by the counts of its two parts. A pair of rare parts that almost always appear together beats a pair of very common parts that merely happen to sit next to each other. Two traces of it in the wild: a continuation piece is written with a leading ## (play, ##ing) where GPT-2 marks a word start with a leading space, and the list is small - about 30,000 entries for English BERT. Encoding new text is different too: WordPiece keeps no merge list to replay. It takes the longest piece in the vocabulary that matches the front of the word, then repeats on what is left.

Unigram works from the other end. Instead of growing a list from characters, it starts with a very large candidate vocabulary - every frequent substring in the corpus - and prunes: on each pass it estimates how much worse the corpus would be described if a piece were removed, drops the least useful few percent, and stops at the target size. The pieces that survive are the ones the corpus most needs. It ships in Google's SentencePiece library and is the tokenizer of T5, ALBERT and XLNet. Unigram also keeps something BPE and WordPiece throw away: a probability for every piece. One word can therefore be cut several valid ways, and the encoder picks the most likely split - or, during training, deliberately samples a less likely one, which makes the model more robust to typos and unfamiliar spellings.

Text three algorithms, one artifact
BPE WordPiece Unigram direction grow from chars grow from chars prune a big list picks by pair frequency pair frequency loss if removed over its parts encodes by replaying merges longest match first most likely split marks " word" (space) ##piece ▁word used by GPT, Llama BERT family T5, ALBERT, XLNet all three leave the same thing behind: a fixed list of pieces with an id each, frozen before the model is trained

Today: tiktoken, and tokenizers you cannot open

The tokenizer OpenAI ships is tiktoken: byte-level BPE exactly as section 06 described it, a Rust core with Python bindings, published as open source so anyone can count tokens before sending a request. Each model generation has its own encoding, and the names say how long the list is: r50k_base for GPT-2 and GPT-3 (the 50,257-entry vocabulary this article has been using), p50k_base for the Codex models, cl100k_base for GPT-3.5 and GPT-4 at about 100,000 entries, and o200k_base for GPT-4o, the o-series and GPT-5 at about 200,000. A longer list is why the same sentence costs fewer tokens on a newer model: Tokenization is two pieces under cl100k_base and one under o200k_base.

One packaging detail closes the loop with section 03. A .tiktoken file is a single list, one line per piece: the piece's bytes and its rank. There is no separate merges.txt, because the rank does both jobs at once - it is the piece's id, and it is the order its merge is replayed in. The two files were always one table read two ways. The pre-tokenization pattern travels alongside, and it is where cl100k_base caps a run of digits at three.

Anthropic has taken the other route. Claude's tokenizer is not published: there is no file to download, and the API's count_tokens endpoint returns a number without showing the split. The vocabulary and the merge list stay on the vendor's side - the same closed arrangement section 02 described for the weights - which is also why third-party Claude token counters built on cl100k_base are estimates, not measurements.

The algorithm, in depth

The whole idea fits in one sentence: two neighbours that keep turning up together deserve to become one piece. Everything else in this section is the bookkeeping that makes that sentence run.

BPE is a greedy algorithm, and the word is doing real work. A greedy algorithm builds its answer one step at a time, and at each step it takes the choice that looks best right now - the locally optimal one - without weighing how that choice constrains the steps still to come. It is deliberately short-sighted. It never backtracks, never revises, and what it produces is not guaranteed to be the best possible answer, only a good one reached quickly.

Here that means: merge whichever pair is most frequent at this moment, then look again. BPE never asks whether some other merge now would have produced a better vocabulary twenty thousand merges later, and it never undoes a merge it has already made. Given a corpus and a target vocabulary size, it does this and nothing else.

  • Initialise the vocabulary with the base tokens - characters, or the 256 bytes.
  • While the vocabulary is smaller than the target: count every adjacent pair, find the most frequent one, add the merged pair to the vocabulary as a new token, and replace every occurrence of it in the corpus.
  • Record each merge, in order. That order is the tokenizer.
BPE training: one greedy loop, run once, offline A corpus of words and frequencies feeds a loop of three steps: count every adjacent pair, merge the most frequent pair, write the rule down. An arrow returns from the last step to the first, labelled repeat while the vocabulary is under its target size. When the vocabulary is full the loop exits into two files: merges.txt, holding every merge in the order it was learned, and vocab.json, holding every token with its id. No neural network appears anywhere in the loop. TRAINING · ONE GREEDY LOOP, RUN ONCE, BEFORE THE MODEL EXISTS word : frequencycorpus adjacent, frequency-weightedcount every pair the most frequent pairmerge the winner write the rule repeat while the vocabulary is still under its target size when the vocab is full merges.txtevery merge, in the order it was learned vocab.jsonevery token, with its id counting, merging, and two files · the model that will use them has not been built yet
The whole algorithm · a loop over pair counts, and the two files it leaves behind

Written out, the loop is about a dozen lines. The only subtlety is that the pair counts have to be recomputed after every merge, because merging e+s destroys the pair (s,t) and creates the pair (es,t).

Python train · the whole algorithm
def train(corpus, vocab_size): vocab = base_tokens(corpus) # characters, or the 256 bytes splits = {word: list(word) for word in corpus} merges = [] # the order IS the algorithm while len(vocab) < vocab_size: counts = count_pairs(splits, corpus) # weighted by frequency if not counts: break # nothing repeats any more best = max(counts, key=counts.get) # ties break the same way splits = apply_merge(splits, best) # rewrite every split merges.append(best) # rank = index in this list vocab.append(best[0] + best[1]) return vocab, merges

The same loop, line by line, for anyone who does not read Python:

  • vocab = base_tokens(corpus) - the starting list: every single character in the corpus, or the 256 byte values. Nothing is merged yet.
  • splits = {word: list(word) ...} - every distinct word written out as separate symbols, low as l o w. This is the working copy the loop keeps rewriting.
  • merges = [] - an empty list that will become merges.txt. Its order is the whole tokenizer.
  • while len(vocab) < vocab_size - keep going until the list is as long as you asked for. The target size is the only knob.
  • counts = count_pairs(...) - walk every word, count every pair of neighbours, weighted by how often the word occurs. (e,s) scores 9 in the toy corpus because newest occurs six times and widest three.
  • if not counts: break - no pair occurs any more, so there is nothing left to merge. Stop early.
  • best = max(counts, ...) - take the pair with the highest count. Ties break the same way every run, so the same corpus always yields the same list.
  • splits = apply_merge(splits, best) - rewrite every word: wherever the two symbols sit next to each other, glue them into one. n e w e s t becomes n e w es t.
  • merges.append(best) - write the rule down. Its position in the list is its rank, and rank 1 is replayed before rank 2 for the rest of the tokenizer's life.
  • vocab.append(best[0] + best[1]) - the glued pair is a new token; it gets the next free id.
  • return vocab, merges - the two files from section 03. The loop never runs again; from here on everything is replay.
Nothing is fit, nothing is optimised, nothing converges. You count, you merge the winner, you write the rule down - and the vocabulary size is the only hyperparameter in the room.- why BPE training is not model training

Hold that against the other run. Model training, as How a model learns lays out, is a loss measured and a gradient stepped, over trillions of tokens, on hardware that costs a fortune. This is a frequency count that finishes in an afternoon. The two share the word training and nothing else.

At production scale nothing about that shape changes, only the numbers: a corpus of hundreds of gigabytes, a target somewhere between 50,000 and 256,000 tokens, and a few hours on a single machine. Choosing that target is the one judgement call in the process - what a bigger vocabulary costs is a question for the token article.

Walkthrough one: the paper's toy corpus

This is the corpus from the 2016 paper, and it is worth following line by line, because everything that later surprises people about tokenizers is visible in it. Four distinct words, each with a frequency, each split into characters, with </w> closing the word.

A four-panel dark diagram titled Byte-pair encoding: merge the most common pair, write the rule down, repeat. Panel one, start with characters: low times five, lower times two, newest times six, widest times three, each spelled out as separate characters ending in the end-of-word marker. Panel two, count the pairs: a table where e s scores nine and is highlighted, s t nine, t plus end-of-word nine, w e eight, l o seven, o w seven, with a note that counts include word frequencies. Panel three, merge then repeat: the rule merge e s into es, and the four words again with low and lower unchanged and the new piece es boxed inside newest and widest, followed by the next merges in order: es plus t, then est plus end-of-word, then l plus o, then lo plus w. Panel four, the result: two file cards, vocab.json listing 11 es, 12 est, 13 est with end-of-word, 14 lo, 15 low, and merges.txt listing e s, es t, est end-of-word, l o, lo w. Under them a flow: the word lowest goes into the BPE tokenizer and comes out as two pieces, low and est with end-of-word, then as two ids, 15 and 13, then into the model, captioned a word the corpus never saw, made from two pieces it has.
Four steps and the two files · the counts include word frequencies, and lowest comes out as two pieces it already has
Text five iterations over a four-word corpus
corpus base vocabulary (11 tokens) l o w </w> : 5 0 d 3 l 6 r 9 w l o w e r </w> : 2 1 e 4 n 7 s 10 </w> n e w e s t </w> : 6 2 i 5 o 8 t w i d e s t </w> : 3 iter pair counts, weighted by word frequency merge id ------------------------------------------------------------------------- 1 (e,s) 9 (s,t) 9 (t,</w>) 9 (w,e) 8 (l,o) 7 e+s -> es 11 2 (es,t) 9 (t,</w>) 9 (l,o) 7 (o,w) 7 es+t -> est 12 3 (est,</w>) 9 (l,o) 7 (o,w) 7 est+</w> 13 4 (l,o) 7 (o,w) 7 (n,e) 6 (e,w) 6 l+o -> lo 14 5 (lo,w) 7 (n,e) 6 (e,w) 6 lo+w -> low 15

Three details in that table do real work later.

  • Counts are weighted by word frequency, not by distinct words. (e,s) scores 9 because it appears in newest six times and widest three. A pattern common in the corpus wins even if it occurs in only two distinct words - which is exactly why tokenizers are so sensitive to what they were trained on.
  • Ties happen, and the tie-break is arbitrary but fixed. Iteration 1 has three pairs at 9: (e,s), (s,t) and (t,</w>). Implementations settle it by first-seen or by sort order; what matters is that the same corpus always yields the same merge list, because the merge list is the tokenizer.
  • The order is the output. Each merge is appended to a list, and its position in that list is its rank. Rank 1 is applied before rank 2, always, for the rest of the tokenizer's life.

Walkthrough two: "the cat in the hat"

Same loop, no word markers, straight over a running string - which is closer to how a byte-level tokenizer sees its corpus.

Text three iterations over one sentence
start t h e _ c a t _ i n _ t h e _ h a t iter 1 (t,h) 2 (h,e) 2 (e,_) 2 (a,t) 2 merge t+h -> th th e _ c a t _ i n _ th e _ h a t iter 2 (th,e) 2 (e,_) 2 (a,t) 2 merge th+e -> the the _ c a t _ i n _ the _ h a t iter 3 (the,_) 2 (a,t) 2 merge a+t -> at a tie, and this run breaks it the other way the _ c at _ i n _ the _ h at 18 symbols down to 12, and "the", "at" and "th" are now tokens

Notice what the loop just built without being told anything about English: a determiner, a rhyme fragment, and a digraph. Nobody supplied a rule about spelling. Three passes of counting produced units a linguist would recognise, which is the whole reason the technique works.

After training: the same rules, replayed

Once the two files exist, the loop never runs again. Tokenizing new text is a replay: split the text into its base symbols, then walk down merges.txt from the top and apply each rule wherever its pair appears, in the order the rules were learned - never by what is most frequent in the new text, and never by position. Run the five merges above on lowest, a word the toy corpus never contained, and it comes out as low + est</w>: two pieces, both already on the list, ids 15 and 13. That is the compositional payoff from section 05, and it is deterministic - the same text through the same files gives the same ids, every time, with no network involved. Decoding is the same table read backwards: look up each id, concatenate the pieces, and the original text comes back exactly.

What to keep: BPE is a compression loop that stops early. It counts pairs over a sample of text, merges the winner, writes the rule down, and leaves two small files behind - the tokenizer - before the model exists. The model is then trained around that list and can never see past it. Every oddity downstream - the cost of an emoji, the arithmetic slips, the bill for a language the corpus barely contained - traces back to a frequency table built before the first gradient step. For what happens to the ids once they reach the model, read Inside an LLM; for the run that fills the table those ids address, read How a model learns.