Hoofdstuk 13 · van tekst naar nummers en terug
Hoe een taalmodel tekst leest
Een neuraal netwerk rekent met getallen. Tekst bestaat uit letters. Daar moet iets tussen zitten — en dat tussenstuk, de tokenizer, bepaalt meer dan je zou denken: waarom Nederlands duurder is dan Engels, waarom modellen niet kunnen tellen hoeveel letters een woord heeft, en waarom een spatie op de verkeerde plek het antwoord verandert.
In één alinea
Een tokenizer knipt tekst in stukjes die vaak samen voorkomen, en geeft elk stukje een nummer. Niet per letter, want dan worden de reeksen te lang. Niet per woord, want dan is de lijst nooit af. Maar per veelvoorkomend brokstuk, geleerd uit de tekst zelf.
Voor dit model zijn dat 151 936 stukjes. "the" is er één van. "onwaarschijnlijk" zijn er zes. Het getal 2026 kost er vijf. Waarom dat zo is, en wat het voor gevolgen heeft, is waar dit hoofdstuk over gaat.
Deel 01Het probleem
Een taalmodel is een berg getallen die vermenigvuldigd worden. Er komt nergens een letter aan te pas. Toch typ je tekst in en krijg je tekst terug. Iets moet die vertaling doen.
De voor de hand liggende oplossing is een lijst: a is 1, b is 2, enzovoort. Dat werkt, maar het is een slechte keuze, en om te begrijpen waarom moet je weten wat een token het model kost.
Elke token die het model verwerkt, kost één volledige doorloop van het hele netwerk. Bij ons voorbeeldmodel is dat 494 miljoen vermenigvuldigingen. Per token. Bovendien kijkt elke token naar alle voorgaande, dus de kost loopt sneller op dan lineair. En het contextvenster — hoeveel het model tegelijk kan overzien — wordt niet in letters geteld maar in tokens.
Een token is dus de eenheid van kostprijs. Hoe minder tokens je nodig hebt voor dezelfde inhoud, hoe beter.
Waarom niet per letter
Met 26 letters, cijfers en leestekens kom je uit op een lijstje van misschien honderd. Klein en overzichtelijk. Maar de reeksen worden vier tot vijf keer zo lang, en dus vier tot vijf keer zo duur. Erger nog: het model moet dan zelf leren dat k-a-t samen iets betekent, en dat kost een deel van zijn capaciteit dat het aan nuttiger dingen kan besteden.
Waarom niet per woord
Dan wordt de lijst nooit af. Nieuwe woorden, namen, tikfouten, andere talen — en in het Nederlands bovendien samenstellingen die je onbeperkt kan blijven maken. Wat doet het model met een woord dat niet in de lijst staat? Er is geen goed antwoord op die vraag.
Deel 02De stukjes worden niet bedacht, maar geleerd
Het algoritme heet byte-pair encoding, afgekort BPE, en het is verrassend eenvoudig. Het komt uit de compressiewereld van de jaren negentig en werd later hergebruikt voor taalmodellen.
Het recept, om de lijst te leren:
- Neem een enorme berg tekst en knip alles in losse tekens.
- Tel welk paar naburige tekens het vaakst voorkomt.
- Voeg dat paar overal samen tot één nieuw stuk, en noteer die regel.
- Herhaal, tot je er genoeg hebt.
Meer is het niet. Er komt geen taalkunde aan te pas, geen woordenboek, geen grammatica. Puur tellen.
Wat eruit komt is een lijst van regels, in de volgorde waarin ze geleerd zijn. Regel 12 is e + n, want dat is een van de allervaakst voorkomende paren. Regel 47 036 is lij + k, veel zeldzamer. Die volgorde heet de rang.
Waarom de volgorde alles bepaalt
Een lage rang betekent: dit paar kwam heel vaak voor, dus voeg het als eerste samen. Bij het toepassen wordt die volgorde exact aangehouden. Dat is de hele truc: de tokenizer voegt niet samen van links naar rechts, maar van vaakst naar zeldzaamst.
Deel 03De lijst toepassen
Nu de andere kant: je hebt de lijst, en er komt tekst binnen. Begin met losse bytes en voeg telkens het paar samen met de laagste rang, tot er niets meer kan.
Hieronder gebeurt dat met het woord " gewichten", stap voor stap, met de echte rangen uit het modelbestand.
Kijk naar de rangen: 12, 36, 86, 109, 170, 4609, 22283, 28442. Ze lopen op. Kijk dan naar wáár in het woord de samenvoegingen gebeuren: eerst achteraan (en), dan in het midden (ic), dan vooraan (Ġg), dan weer in het midden. Het springt heen en weer.
Dat is het gedrag dat de meeste mensen verrast. Een tokenizer werkt niet als een lezer die van links naar rechts gaat. Hij werkt als iemand die eerst alle veelvoorkomende lettergrepen aan elkaar plakt, en pas daarna de zeldzamere combinaties.
Bij " tokenizer" zie je hetzelfde, maar dan tot het einde doorgezet:
rang 3 Ġ + t -> Ġt
rang 5 e + r -> er
rang 12 e + n -> en
rang 55 Ġt + o -> Ġto
rang 193 i + z -> iz
rang 2456 k + en -> ken
rang 2879 iz + er -> izer
rang 3694 Ġto + ken -> Ġtoken
rang 45702 Ġtoken + izer -> Ġtokenizer één enkel token
Negen samenvoegingen om van tien losse bytes naar één token te komen. Het woord "tokenizer" kwam blijkbaar vaak genoeg voor in de trainingstekst om een eigen plek in de lijst te verdienen.
Waarom dit efficiënt kan
Naïef uitgevoerd zou je bij elke stap de hele reeks moeten aflopen op zoek naar het beste paar. Bij een lange reeks — denk aan tweehonderd spaties achter elkaar — wordt dat kwadratisch en loopt het vast. De implementatie in dit project gebruikt daarom een dubbelgeschakelde lijst plus een prioriteitswachtrij, waarmee de kost logaritmisch blijft.
Deel 04De byte-truc, en dat rare teken Ġ
Tot hier deed ik alsof tekst uit letters bestaat. Dat is ze niet. In een computer is tekst een reeks bytes, en dat verschil is precies wat BPE zo robuust maakt.
Het probleem met werken op letters: er zijn er te veel. Unicode heeft meer dan 150 000 tekens. Zou je die allemaal als startpunt nemen, dan is je woordenschat op voorhand al vol.
De oplossing is om onder het niveau van letters te duiken. Er zijn maar 256 mogelijke bytes. Neem die als startpunt, en elke denkbare tekst — in elke taal, met elk emoji, zelfs een stuk van een fotobestand — kan gecodeerd worden. Er bestaat geen "onbekend woord" meer.
Waarom een spatie een eigen teken nodig heeft
Er zijn 94 bytes die overeenkomen met gewone zichtbare tekens: de letters, cijfers en leestekens. Die blijven zichzelf. Alle andere — spatie, tab, regeleinde, en alles boven de 127 — zouden onzichtbaar zijn of samenvallen met witruimte. Daar zou je bij het opsplitsen last van krijgen.
Daarom krijgt elke van die bytes een eigen zichtbaar plaatsvervanger, ergens in een ongebruikt hoekje van Unicode. De spatie wordt Ġ, het regeleinde wordt Ċ, de tab wordt ĉ.
| Je typt | Unicode | Bytes | Wordt |
|---|---|---|---|
| spatie | U+0020 | 20 | Ġ |
| regeleinde | U+000A | 0A | Ċ |
| tab | U+0009 | 09 | ĉ |
| a | U+0061 | 61 | a |
| é | U+00E9 | C3 A9 | é |
| — | U+2014 | E2 80 94 | âĢĶ |
| 🎉 | U+1F389 | F0 9F 8E 89 | ðŁİī |
De spatie hoort bij het volgende woord
Kijk goed naar de tokens: het is Ġthe, niet the. De spatie zit in het token, aan het begin. Dat heeft een merkwaardig gevolg: "strawberry" aan het begin van een zin en " strawberry" middenin een zin zijn voor het model twee verschillende dingen.
Met spatie is het één token. Zonder spatie zijn het er drie: strawberry. Dat is geen curiositeit — het verklaart waarom een prompt die met een spatie eindigt soms slechtere antwoorden geeft.
Deel 05Eerst knippen, dan pas samenvoegen
Er ontbreekt nog een stap. Als je BPE zomaar op een hele zin loslaat, gaat het mis.
Stel dat "hond." vaak voorkomt aan het einde van zinnen. Dan zou BPE daar één token van maken, en zou "hond" mét punt iets anders zijn dan "hond" zonder. Dat wil je niet. Een punt hoort geen deel te zijn van een woord.
Daarom wordt de tekst eerst in brokken geknipt, met een reguliere uitdrukking, en gebeurt het samenvoegen alleen binnen een brok. Over de grens heen wordt nooit samengevoegd.
De uitdrukking die dat doet ziet er afschrikwekkend uit, maar bestaat uit zes losse regels achter elkaar:
// 1. Engelse samentrekkingen apart houden
(?:'[sS]|'[tT]|'[rR][eE]|'[vV][eE]|'[mM]|'[lL][lL]|'[dD])
// 2. een reeks letters, eventueel met één teken ervoor (meestal een spatie)
|[^\r\n\p{L}\p{N}]?\p{L}+
// 3. één cijfer
|\p{N}
// 4. leestekens, eventueel met een spatie ervoor
| ?[^\s\p{L}\p{N}]+[\r\n]*
// 5, 6. regeleindes en overige witruimte
|\s*[\r\n]+|\s+(?!\S)|\s+
Regel 3 is de interessante. Er staat \p{N} — één cijfer. Niet \p{N}+. Daardoor valt elk getal uiteen in losse cijfers.
| Tekst | Tokens | Opdeling |
|---|---|---|
| 2026 | 5 | Ġ2026 |
| 1815 | 5 | Ġ1815 |
| 123456789 | 10 | Ġ123456789 |
Dat lijkt verspilling, maar het is een bewuste keuze. Als "2024" één token zou zijn en "2025" een ander, dan moet het model voor elk getal apart leren hoe groot het is. Met losse cijfers ziet het model de opbouw, en kan het leren rekenen op een manier die overdraagbaar is. Modellen die getallen in brokken van drie knippen — zoals Llama 3 — maken hier een andere afweging.
Waar dit stil misgaat
Het verschil tussen de tokenizer van Qwen en die van Llama 3 is precies één teken: \p{N} tegenover \p{N}{1,3}. Gebruik je per ongeluk de verkeerde, dan werkt alles nog. De tekst gaat er heen en weer doorheen zonder een letter te verliezen, er komt geen enkele foutmelding.
Maar het model krijgt andere nummers dan het geleerd heeft, en antwoordt navenant — vloeiend, grammaticaal correct, en volslagen naast de kwestie. Dit is een van de vervelendste fouten in dit hele vakgebied, precies omdat er niets kapotgaat.
Deel 06Tokens die geen tekst zijn
Achteraan de woordenschat, vanaf nummer 151 643, staan 22 tokens die nooit uit tekst kunnen ontstaan.
Dat zijn de speciale tokens: markeringen waarmee het model weet waar een gespreksbeurt begint, wie er aan het woord is, en waar het moet stoppen. Ze worden herkend vóór de voorsplitser eraan te pas komt, en gaan nooit door BPE.
| Nummer | Token | Waarvoor |
|---|---|---|
| 151643 | <|endoftext|> | einde van een document |
| 151644 | <|im_start|> | begin van een gespreksbeurt |
| 151645 | <|im_end|> | einde van een gespreksbeurt — hier stopt het model |
| 151646–51 | <|object_ref_…|> <|box_…|> <|quad_…|> | verwijzen naar dingen in een beeld |
| 151652–56 | <|vision_…|> <|image_pad|> <|video_pad|> | plaats waar beeldgegevens komen |
| 151657–58 | <tool_call> </tool_call> | het model roept een extern hulpmiddel aan |
| 151659–64 | <|fim_…|> <|repo_name|> <|file_sep|> | code aanvullen middenin een bestand |
Een gesprek ziet er voor het model zo uit — en dit is letterlijk wat er in de reeks tokens staat:
<|im_start|>system
Je bent een behulpzame assistent.<|im_end|>
<|im_start|>user
Hoeveel is 2 + 2?<|im_end|>
<|im_start|>assistant
Het model vult daarna aan, en stopt zodra het zelf <|im_end|> produceert. Er is niets magisch aan een "systeemopdracht" — het is gewoon tekst tussen twee markeringen, op een plek waar het model geleerd heeft dat daar instructies staan.
Een reden om voorzichtig te zijn
Wat gebeurt er als een gebruiker letterlijk <|im_end|> intypt? Dan hangt het ervan af of de tokenizer speciale tokens herkent in gebruikersinvoer. Doet ze dat, dan kan iemand het gesprek van binnenuit herschrijven en zich voordoen als het systeem.
Daarom heeft de tokenizer in dit project een schakelaar: encode(tekst, false) behandelt zulke reeksen als gewone tekst en knipt ze in zes stukjes. Voor invoer van buiten is dat de juiste stand.
Deel 07En weer terug
Decoderen is bijna te makkelijk om een eigen hoofdstuk te verdienen: zoek elk nummer op, plak de stukjes aan elkaar, zet de plaatsvervangende tekens terug om in bytes, en lees die als UTF-8.
Bijna. Er is één addertje, en dat komt pas boven bij het genereren.
Een token is een reeks bytes, en die reeks hoeft geen heel teken te bevatten. De é bestaat uit twee bytes; er is niets dat verbiedt dat de ene byte in het ene token zit en de andere in het volgende. Wie tijdens het genereren elk token meteen wil tonen, krijgt dan een half teken te zien — het bekende vraagteken-in-een-ruitje.
De oplossing is de bytes te bufferen en pas te tonen wanneer ze samen een geldig teken vormen. In dit project levert tokenBytes(id) daarvoor de ruwe bytes; het bufferen zelf komt in hoofdstuk 15 aan bod — waar precies deze valkuil nog een hoofdrol blijkt te spelen.
Deel 08Wat dit in de praktijk betekent
Vier gevolgen die je merkt zonder ooit een regel code te zien.
1. Nederlands is duurder dan Engels
Dezelfde zin, vertaald, en dan geteld:
Nederlands kost 43 % meer tokens dan Engels voor exact dezelfde inhoud. Dat betekent: 43 % meer rekentijd, 43 % meer van je contextvenster, en bij betaalde modellen 43 % meer geld. De reden is simpel — er stond nu eenmaal veel meer Engels dan Nederlands in de trainingstekst, dus zijn er meer Engelse brokken in de lijst terechtgekomen.
Je ziet het terug in losse woorden:
| Engels | Tokens | Nederlands | Tokens |
|---|---|---|---|
| the | 1 | de | 1 |
| cat | 1 | kat | 1 |
| hospital | 1 | ziekenhuis | 4 |
| strawberry | 1 | aardbei | 3 |
| — | — | onwaarschijnlijk | 6 |
| — | — | meervoudigepersoonlijkheidsstoornis | 13 |
2. Modellen kunnen de letters van een woord niet zien
Vraag een taalmodel hoeveel keer de letter r in "strawberry" voorkomt, en het antwoordt vaak fout. Dat is geen gebrek aan intelligentie maar een gevolg van de tokenizer.
Voor het model is " strawberry" één ondeelbaar nummer: 72600. Het ziet geen s, geen t, geen r. Het heeft ooit geleerd dat dat nummer met aardbeien te maken heeft, maar de spelling zit er niet in — net zomin als jij aan het cijfer 7 kan zien hoeveel streepjes er in het woord "zeven" zitten.
Aan het begin van een zin, zonder spatie ervoor, valt het woord toevallig wél uiteen in strawberry — en dan gaat het soms opeens wel goed. Dat verklaart waarom hetzelfde model dezelfde vraag de ene keer juist en de andere keer fout beantwoordt.
3. Getallen zijn duur
Een jaartal kost vijf tokens, een rekeningnummer dertig. Wie tabellen met cijfers aan een model voert, betaalt daar stevig voor. Een tabel van duizend getallen kan makkelijk vijfduizend tokens kosten — meer dan tien bladzijden gewone tekst.
4. Structuur kost meer dan proza
JSON is voor een tokenizer heel duur: alle accolades, aanhalingstekens en dubbele punten zijn losse tokens. Dezelfde gegevens in gewone zinnen zetten scheelt vaak de helft. Bij dit model kostte een klein JSON-fragment 31 tokens waar de Engelse zin er 14 nodig had, terwijl beide ongeveer even lang waren.
Deel 09De woordenschat in cijfers
Wie de nummers naast de samenvoeglijst legt, ziet iets moois: het zijn niet twee lijsten maar één.
De eerste samenvoegregel is Ġ + Ġ, en het resultaat ĠĠ is token nummer 256. De tweede regel geeft token 257. En zo verder, zonder één uitzondering: voor alle 151 387 regels geldt dat het tokennummer precies de samenvoegrang plus 256 is.
| Nummers | Hoeveel | Wat |
|---|---|---|
| 0 – 255 | 256 | de losse bytes, het vangnet waarmee elke tekst codeerbaar is |
| 256 – 151.642 | 151.387 | de resultaten van de samenvoegregels, in de volgorde waarin ze geleerd zijn |
| 151.643 – 151.664 | 22 | de speciale tokens |
| 151.665 – 151.935 | 271 | ongebruikt, opgevuld tot een rond getal |
De woordenschat is dus de samenvoeglijst, met de 256 bytes ervoor geplakt. Dat is geen ontwerpkeuze die iemand achteraf gemaakt heeft — het is gewoon wat er uit het leerproces rolt: eerst de bouwstenen, dan alles wat je daaruit hebt samengesteld, in de volgorde van samenstellen.
| Wat | Aantal | Wat het zegt |
|---|---|---|
| tokens in totaal | 151.936 | waarvan 22 speciale |
| begint met een spatie | 53.021 | ruim een derde — de spatie hoort bij het woord |
| zuiver ASCII | 94.629 | 62 %, de rest is Chinees, cyrillisch, emoji, accenten |
| één byte lang | 256 | precies alle mogelijke bytes — de vangnetlaag |
| bestaat alleen uit cijfers | 10 | alleen 0 tot en met 9, en geen enkel getal meer |
| op zich geen geldige tekst | 1.448 | halve tekencodes, alleen bruikbaar in combinatie |
De lengteverdeling laat zien waar het zwaartepunt ligt. De meeste tokens zijn drie of zes bytes lang — een lettergreep of een kort woord:
1 byte 256 ▏
2 5.073 ████████
3 25.206 ███████████████████████████████████████████
4 18.359 ███████████████████████████████
5 15.992 ███████████████████████████
6 28.818 ██████████████████████████████████████████████████
7 13.592 ███████████████████████
8 10.801 ██████████████████
9 12.688 ██████████████████████
10 5.848 ██████████
11 4.547 ███████
12 4.814 ████████
13 1.786 ███
De piek bij zes bytes is verraderlijk: dat zijn grotendeels Chinese woorden van twee tekens, want elk Chinees teken kost drie bytes in UTF-8.
Deel 10Zelf kijken
Alles in dit hoofdstuk is afgelezen uit een modelbestand dat waarschijnlijk al op je schijf staat.
De volledige broncode zit bij dit boek in de map code/ — hieronder ook als zip — en draait met één JDK (22 of nieuwer). Voer de opdrachten uit vanuit die map. $MODEL staat voor het pad naar het GGUF-bestand — bij ollama de blob die je in hoofdstuk 12 via het manifest vond.
# hoe valt een zin uiteen
java src/Tok.java $MODEL "In 1815 kostte dat 42,50 fr."
# alleen het aantal
java src/Tok.java $MODEL --count "hoeveel tokens is dit?"
# toon ook de voorsplitsing, vóór het samenvoegen
java src/Tok.java $MODEL --chunks "In 1815 kostte dat 42,50 fr."
# en terug
java src/Tok.java $MODEL --decode 641 220 16 23 16 20
De uitvoer toont per token het nummer, de byte-level notatie met haar Ġ's, en hoe het stukje er als gewone tekst uitziet. Onderaan staat of de tekst ongeschonden terugkomt.
Een aardig experiment
Tokeniseer eens dezelfde zin met en zonder spatie ervoor, of met een hoofdletter in plaats van een kleine letter. De opdeling verandert vaker dan je zou verwachten — en daarmee ook wat het model precies te zien krijgt.
SamenvattingHet hele verhaal in acht zinnen
- Een taalmodel rekent met getallen, dus tekst moet eerst in genummerde stukjes worden opgedeeld.
- Per letter is te duur, per woord is onmogelijk; BPE kiest ertussenin door de stukjes uit de tekst zelf te leren.
- Leren gebeurt door telkens het vaakst voorkomende naburige paar samen te voegen en die regel te noteren — 151 387 keer.
- Toepassen gebeurt in diezelfde volgorde: van vaakst naar zeldzaamst, niet van links naar rechts.
- Er wordt niet met letters gewerkt maar met bytes, en elke byte krijgt een zichtbaar plaatsvervangend teken; daarom is er nooit een onbekend woord.
- Vóór het samenvoegen wordt de tekst in brokken geknipt, zodat leestekens niet aan woorden vastgroeien en cijfers los blijven.
- Tweeëntwintig tokens zijn geen tekst maar markeringen, voor gespreksbeurten en hulpmiddelen.
- De gevolgen zijn merkbaar: Nederlands kost 43 % meer dan Engels, getallen zijn duur, en het model ziet de letters van een woord niet.