AI EngineeringZero to ProductionHome·About·What’s new·Contact
Appendix · Advanced AI Engineering · Part 5

Advanced Strings, Tokenization & Data Parsing

Text is the input and output of every LLM — and it's messier than it looks. This part goes deep on the string mechanics that actually bite in production: Unicode & encodings, normalization, how tokenization (BPE) really works and why it drives cost and context limits, advanced regex, robust chunking, and parsing the formats your data arrives in (JSON/CSV/messy text) — plus pandas for tabular work.

⏱️ ~2 hours🎯 Intermediate → Expert🔤 text done rightrunnable
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it you'll need:
  • an Anthropic API key (ANTHROPIC_API_KEY) + pip install anthropic
Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.

Learning objectives

  • Distinguish str (code points) from bytes (encoded) and handle encodings safely.
  • Normalize and clean text so equal-looking strings compare equal.
  • Explain subword/BPE tokenization and why tokens ≠ words ≠ characters.
  • Count tokens to budget context and cost.
  • Use advanced regex (groups, lookarounds, named captures) judiciously.
  • Chunk documents well for RAG, and parse JSON/CSV/tabular data robustly.

Why string mechanics bite in production motivation

Here's the uncomfortable truth about LLM systems: the model is almost never the part that breaks. Your prompt engineering is fine, your retrieval is tuned, your evals pass — and then a user in Munich types their own name, "Müller", and your dedup logic files them as a brand-new customer because the "ü" arrived in a byte form your database had never seen. Nothing crashed. No error surfaced. You just quietly created a duplicate, and you won't find out until someone's support history is split across two accounts. That's the shape of a text bug: silent, plausible-looking, and discovered by a user before it's discovered by you.

The reason this whole layer is treacherous is that text pretends to be simple. A string looks like "just letters," the way a map looks like "just the ground." But underneath, a single line of text is really three different things at once — the characters a human reads, the bytes a disk stores, and the tokens a model actually consumes — and they almost never line up. Most bugs in this chapter come from an engineer reaching for the wrong one of those three: measuring token cost with len(str), comparing names without normalizing, splitting a CSV on commas. Each mistake feels reasonable in the moment and is invisible until scale or a foreign character exposes it.

The common mistake worth naming up front is trusting your eyes. If two strings look identical on screen, you assume they're equal; if a document looks like plain paragraphs, you assume you can split it anywhere; if English averages four characters a token, you assume the estimate holds for a customer ID or a Japanese sentence. Every one of those assumptions is false in a way that won't announce itself. This part exists to replace "looks fine" with "I measured it" — because getting text right is unglamorous, rarely praised, and the difference between a system that works on your laptop and one that survives real users.

An accented name that won't match, a CSV that breaks on a quoted comma, a prompt that silently blew the context window, a chunk that split a sentence mid-word and wrecked retrieval — every one of these is a text-handling bug, and they're among the most common in real AI systems. Getting text right is unglamorous and high-leverage.

one string, "café ☕" — three different things at once, with no fixed exchange rate characters what a human reads len = 6 bytes what a disk stores 9 (UTF-8) tokens what a model consumes ~3 (BPE) reaching for the WRONG one = the bug len(str) ≠ bytes ≠ tokens — never estimate one from another Three currencies, no fixed rate. The same text is characters, bytes, and tokens at once, and the counts don't line up — most bugs in this chapter come from measuring with the wrong one (token cost via len(str), comparing names by raw bytes, splitting a CSV on commas).
🗺️ How to read this diagram

Read the three boxes left to right — they are three simultaneous views of the very same line of text, not three steps in a process.

  • The blue box is characters — what a human reads; "café ☕" is 6 of them.
  • The green box is bytes — what a disk or wire stores; the same string is 9 bytes in UTF-8, because the accent and emoji take more than one byte each.
  • The amber box is tokens — what the model actually consumes; a BPE tokenizer sees roughly 3 pieces. Three different numbers for one string.
  • The red line underneath is the whole point: because there's no fixed conversion, grabbing the wrong count — estimating tokens from len(str), comparing names by raw bytes — is exactly where the silent bugs come from.

In short: characters, bytes, and tokens are three separate currencies for the same text — measure the one your job actually needs, never convert by guessing.

1 · Unicode & encodings — str vs bytes intermediate

Picture the last time you opened a file and saw caf� or a wall of é where an accent should be. That garbled text isn't corruption — it's a translation error. Somewhere, text that was written down in one alphabet-of-numbers got read back using a different one, and the two didn't agree. Understanding str versus bytes is understanding exactly where that translation happens, so you can stop it going wrong.

The cleanest analogy is ideas versus ink. A str is the idea of the text: the sequence of abstract characters "c, a, f, é" — what a human means, independent of how it's written down. bytes is the ink: the actual physical marks (numbers 0–255) you commit to disk or send down a wire. To get from idea to ink you must pick a writing system — that's what an encoding is. Encoding (str→bytes) is writing the idea down; decoding (bytes→str) is reading it back. And here's the catch that causes every garble: the reader has to use the same writing system the writer used. If you write with UTF-8 and someone reads with Latin-1, the ink is fine but the interpretation is wrong — hence é.

Why does this bite so hard in AI work specifically? Because your text comes from everywhere — scraped web pages, user uploads, PDFs, other people's APIs — and each source may have used a different writing system. UTF-8 is the modern standard (it can write every character Unicode defines, and ASCII is a subset of it), so "always assume and specify UTF-8" is the single habit that prevents most of this pain. The common mistake is letting the encoding default: calling open(path) with no encoding= argument means Python guesses based on the operating system, so the same code reads correctly on your Mac and mangles text on a Windows server — a bug that only appears in production. Name the encoding explicitly, every time, and the guessing stops. Now, the precise definitions.

A Python str is a sequence of Unicode code points (abstract characters). bytes is raw encoded data. You encode str→bytes to send/store, and decode bytes→str to read. UTF-8 is the standard encoding. Confusing the two is the source of endless UnicodeDecodeErrors.

the same text is an idea (str) and ink (bytes) — you must read it back with the writing system it was written with str "café" — the idea 6 code points bytes the ink on disk/wire 9 bytes (UTF-8) str "café" — read back clean round-trip encode decode decode with the WRONG encoding garbled: "é" Encode writes the idea down; decode reads it back. str is the abstract characters, bytes is the encoded ink — and reading the ink with a different encoding than it was written with is exactly what produces é garble.
🗺️ How to read this diagram

Follow the top row left to right — that's the happy path a piece of text takes from memory, to storage, and back into memory.

  • The blue box on the left is a str: the idea of the text, "café" as 6 abstract code points. The blue arrow "encode" turns it into the green box — bytes, the actual UTF-8 "ink" (9 bytes, because é and the space's neighbours take more than one byte each).
  • The second blue arrow "decode" reads those bytes back into a str on the right — an identical, lossless round-trip because the same encoding was used both ways.
  • The dashed grey arrow dropping down to the red box is the failure: decode the same bytes with the wrong encoding and the ink is intact but misread — you get é instead of é.

In short: characters ≠ bytes, and a clean round-trip needs the reader and writer to agree on the encoding — so always name UTF-8 explicitly.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it
pythons = "café ☕"              # a str: 6 code points
b = s.encode("utf-8")         # bytes: 'é' and '☕' take multiple bytes
print(len(s), len(b))          # 6 9  — chars != bytes!
print(b)                       # b'caf\xc3\xa9 \xe2\x98\x95'
print(b.decode("utf-8"))       # 'café ☕' back to str

# Reading files: ALWAYS specify encoding, or you get platform-dependent bugs
# with open("doc.txt", encoding="utf-8") as f: text = f.read()

# Handling bad bytes gracefully instead of crashing:
messy = b"caf\xff"
print(messy.decode("utf-8", errors="replace"))   # 'caf?'
▶ How this works

Computers store text as numbers. A Python str is the human-readable text (a sequence of characters); bytes is the same text turned into the raw numbers that get saved to a file or sent over a network. You encode to go str→bytes and decode to go bytes→str. This block shows why the two are not the same length.

  1. s = "café ☕" is a normal string of 6 characters. len(s) counts characters, so it is 6.
  2. s.encode("utf-8") converts it to bytes using UTF-8 (the standard encoding). Accented letters and emoji need more than one byte, so the byte count is bigger — len(b) is 9.
  3. b.decode("utf-8") turns the bytes back into the original string — a clean round-trip.
  4. The last line feeds broken bytes (b"caf\xff") to decode. errors="replace" means "don't crash on bad bytes — put a placeholder instead", so you get 'caf?' rather than a UnicodeDecodeError.

What the output means: print(len(s), len(b)) prints 6 9 — proof that characters and bytes are different counts. print(b) shows the raw bytes with \x.. escapes for the multi-byte characters.

Try this: Change s to plain ASCII like "cafe" and re-run — now len(s) and len(b) match, because every ASCII character is exactly one byte. The mismatch only appears with accents/emoji.

The len() traplen("☕") is 1 (code points), but some emoji are built from multiple code points (a family emoji can be 7+), so "visible characters" ≠ len. And len(str) has nothing to do with token count (§3–4) or byte count. Never use character length to estimate tokens or storage.

2 · Normalization & cleaning advanced

This is the bug from the intro, and it's worth meeting head-on because it will fool you. Type "café" one way and "café" another way, print them side by side, and they are pixel-for-pixel identical. Then ask Python if they're equal and it says False. You'll stare at the screen convinced the language is broken. It isn't — you've hit the single sneakiest fact about Unicode: the same visible character can be built more than one way, and the different constructions are genuinely different data even though they render the same.

The analogy is spelling versus handwriting. The letter "é" can be written as one pre-composed symbol (the code point U+00E9, "e-with-acute"), or as two pieces stacked — a plain "e" followed by a separate "combining acute accent" that draws itself on top of the previous letter. Your screen renders both as a single "é" because that's what they mean to a reader. But to a computer comparing raw code points, one is a single item and the other is a list of two, so they can't be equal — the way "color" and "colour" mean the same thing but are not the same string. Unicode normalization is the fix: it rewrites text into one canonical spelling so that things which mean the same actually are the same. NFC composes pieces back into single characters; NFKC goes further and also folds "compatibility" look-alikes (a full-width "A", a ligature "fi") down to their plain forms.

Why does an AI engineer care so much about a two-vs-one accent? Because so much of what we do is using text as a key: deduplicating documents, caching by prompt, matching a user's name, joining retrieved chunks. Every one of those silently assumes "equal-looking means equal." The mistake — and it's a data-quality landmine, not a crash — is keying on raw, un-normalized text: your cache misses on a query it has already answered, your dedup keeps two copies of the same paragraph, your user gets a second account. None of it throws an error; it just quietly corrupts your data at scale, and the entry point (macOS filenames, copy-paste from Word, certain keyboards) produces decomposed forms all the time. Normalize before you compare, hash, or store, and the whole class of bug disappears. The precise version follows.

The same text can have multiple byte representations — "é" as one code point or "e" + a combining accent. They look identical but compare unequal. Unicode normalization (NFC/NFKC) canonicalizes them, essential before dedup, search, or using text as a key (D3).

one visible "é", built two ways — they render the same but are different data until you normalize composed · "é" 1 code point (U+00E9) decomposed · "e" + ◌́ 2 code points (e, U+0301) a == b → False normalize (NFC) one canonical "é" a == b → True Same glyph, two encodings, one fix. A composed "é" (one code point) and a decomposed "é" (e + combining accent) render identically but compare unequal — normalization rewrites both to a single canonical form so equal-looking text is actually equal.
🗺️ How to read this diagram

Read the two stacked boxes on the left as two different ways to store the same visible "é", then follow the arrows right to see them merge into one.

  • The top-left box is the composed form: a single code point U+00E9. The bottom-left box is the decomposed form: a plain e followed by a separate combining accent — two code points. They look the same on screen, which is why the red a == b → False is so surprising.
  • The two blue arrows converge into the amber normalize (NFC) box: normalization takes either input and rewrites it to one canonical spelling.
  • The arrow then drops into the green box — one canonical "é" — and now a == b → True. That green terminus is the payoff: after normalizing, equal-looking strings compare, hash, and dedup as equal.

In short: normalize before you compare, hash, or key on text, and the "identical but unequal" bug disappears.

Try it
pythonimport unicodedata

a = "café"                          # 'é' = single code point U+00E9
b = "café"                    # 'e' + combining acute U+0301
print(a == b)                        # False! (look identical, differ in bytes)
print(unicodedata.normalize("NFC", a) == unicodedata.normalize("NFC", b))  # True

def clean(text):
    text = unicodedata.normalize("NFKC", text)   # canonical + compatibility
    text = " ".join(text.split())              # collapse whitespace/newlines/tabs
    return text.strip()

print(clean("  Hello\t\n  world  "))         # 'Hello world'
▶ How this works

Two strings can look identical on screen yet be stored as different bytes — for example "é" as one character, versus "e" followed by a separate accent mark. Python then says they are not equal. Normalization rewrites both into one canonical form so they compare equal. This matters any time you dedupe, search, or use text as a lookup key.

  1. a and b print the same but are built differently, so a == b is False — a classic, invisible bug.
  2. unicodedata.normalize("NFC", …) converts both to the same standard form; after that they are equal, so the second print shows True.
  3. The clean() function is a reusable tidy-up: NFKC normalizes (also folding compatibility look-alikes), " ".join(text.split()) collapses every run of spaces/tabs/newlines into single spaces, and .strip() trims the ends.

What the output means: clean(" Hello\t\n world ") returns 'Hello world' — messy whitespace becomes one clean line.

Try this: Run clean on text with double spaces and tabs and watch them collapse. Normalizing like this before storing text is how you stop "same content, different bytes" from creating duplicate records.

🔗 Used in the courseNormalizing + collapsing whitespace before chunking (Ch 3) makes embeddings cleaner and dedup reliable. Using normalized text as a cache/dedup key (D3) prevents "same content, different bytes" from creating duplicate entries.

3 · Tokenization & BPE — how LLMs actually see text expert intermediate

Every time you've seen an LLM confidently miscount the letters in "strawberry," you've watched this section's idea in action. The model doesn't fumble because it's dumb — it fumbles because it never sees the letters. It sees "strawberry" as maybe two or three opaque chunks, the way you might read the word "the" as a single shape without ever registering the individual t-h-e. If you don't know how text is chopped up before the model touches it, half of what LLMs do — and half of what they cost — stays mysterious. This is the section that lifts that veil.

Here's the mental model. Imagine you had to design a fixed dictionary of, say, 100,000 "pieces" and promise that any text on the internet could be spelled using only those pieces. You'd be silly to fill it with whole words (you'd run out of slots and still miss every typo and proper noun) and silly to use only single letters (then every word is a long, wasteful sequence). The clever middle ground is subword pieces: give common words their own single piece ("the", " and", "python"), and let rare words fall back to smaller fragments ("tokenization" → "token" + "ization"). These pieces are called tokens, and how you build that dictionary is the tokenizer's whole job. The dominant recipe is Byte-Pair Encoding (BPE), and its logic is almost embarrassingly simple: start with individual characters, find the most frequent adjacent pair across all your text, glue that pair into a new piece, and repeat thousands of times. Frequency does the work — the pairs that show up constantly (like "e"+"s", "t"+"h") get merged early and become the dense, cheap tokens; the rare stuff never gets merged and stays fragmented.

The consequence you must internalize is that tokens, words, and characters are three different currencies with no fixed exchange rate. Common English prose is dense — roughly a token every four characters — because the frequent pieces earned their own slots. But a UUID, a chunk of minified code, a long number, or a sentence of Thai was never frequent in the training text, so it shatters into many tokens. The classic mistake is reasoning about the model in words: "my prompt is 500 words, that's fine" tells you almost nothing about whether it fits the context window or what it'll cost, because a 500-word English paragraph and a 500-"word" config dump can differ several-fold in tokens. Tokens are the unit the model, the context limit, and your bill are all denominated in — so tokens are the unit you must learn to think in. The from-scratch trainer below makes the merging concrete.

LLMs don't read characters or words — they read tokens: subword units from a fixed vocabulary. Modern tokenizers use Byte-Pair Encoding (BPE): start from bytes/characters and repeatedly merge the most frequent adjacent pair into a new token, learning a vocabulary where common words are one token and rare words split into pieces. This is why token count ≠ word count, and why "strawberry" might be 2–3 tokens.

BPE builds tokens by gluing the most frequent adjacent pair, over and over start · chars n e w e s t w i d e s t every word is single chars — long, wasteful merge top pair e+s → es es+t → est repeat N times learned tokens new · est wid · est common chunk "est" is now ONE token loop back — merge next pair Frequency decides the vocabulary. BPE starts from characters and repeatedly glues the most frequent adjacent pair, so common sequences like "est" earn their own single token while rare text stays fragmented — which is exactly why token count ≠ word count.
🗺️ How to read this diagram

Read the three boxes left to right — that's one word's journey from raw characters to learned subword tokens — then notice the dashed loop underneath.

  • The blue box on the left is the starting point: every word is just a list of single characters (newest, widest), which is long and wasteful.
  • The amber middle box is one merge step: BPE counts every adjacent pair, finds the most frequent (e+s), and glues it into a new symbol es — then does it again (es+t → est).
  • The dashed grey arrow looping back means "repeat": each pass merges the next most frequent pair. After enough passes you reach the green box — a learned vocabulary where the common chunk est (shared by newest and widest) is now a single dense token.

In short: frequent pieces become one cheap token and rare text shatters into many — so you must count tokens, never words, for cost and context.

Try it — BPE training, from scratch
pythonfrom collections import Counter

def bpe_train(words, num_merges):
    # represent each word as a list of chars + end marker
    vocab = {w: list(w) + ["</w>"] for w in words}
    merges = []
    for _ in range(num_merges):
        pairs = Counter()
        for sym, freq in [(vocab[w], words[w]) for w in vocab]:
            for i in range(len(sym) - 1):
                pairs[(sym[i], sym[i+1])] += freq   # count adjacent pairs
        if not pairs: break
        best = pairs.most_common(1)[0][0]        # most frequent pair
        merges.append(best)
        for w in vocab:                          # merge it everywhere
            s, out = vocab[w], []
            i = 0
            while i < len(s):
                if i < len(s)-1 and (s[i], s[i+1]) == best:
                    out.append(s[i] + s[i+1]); i += 2
                else:
                    out.append(s[i]); i += 1
            vocab[w] = out
    return merges

words = {"low": 5, "lower": 2, "newest": 6, "widest": 3}
print(bpe_train(words, 5))     # learns merges like ('e','s'), ('es','t'), ('l','o')...
▶ How this works

LLMs don't read letters or whole words — they read tokens, which are common chunks of text from a fixed vocabulary. This is a tiny from-scratch version of Byte-Pair Encoding (BPE), the algorithm that builds that vocabulary: it starts with single characters and repeatedly glues the most frequent neighboring pair into a new token. Don't worry about every line — follow the big loop.

  1. Each word starts split into single characters plus a </w> end-of-word marker, e.g. low → ['l','o','w','</w>'].
  2. Counter() tallies how often each adjacent pair of symbols appears across all words, weighted by how common each word is.
  3. pairs.most_common(1) picks the single most frequent pair — that becomes the next merge. The inner while loop then walks through every word and fuses that pair wherever it occurs (e.g. e+s → es).
  4. Repeat num_merges times. The list of merges it returns is the learned vocabulary of subword pieces.

What the output means: bpe_train(words, 5) prints the 5 merges it learned, like ('e','s') then ('es','t') — the algorithm discovered that "est" is a useful chunk because it appears in newest and widest.

Try this: Lower the merge count to 1 to see just the first, most-common pair. This is exactly why token count ≠ word count: common pieces become one token, rare words get split into several.

Why this matters practicallyBecause merges are frequency-based, common English is dense (~1 token ≈ 4 chars / 0.75 words) while code, rare words, other languages, and long numbers cost more tokens. That directly affects cost (you pay per token), context limits, and truncation. Different models use different tokenizers, so counts differ — always measure with the right one.

4 · Counting tokens for budget & cost advanced

Two things in an LLM app are metered by the token, and getting the count wrong breaks both. The first is your bill — you pay per token in and per token out, so an off-by-40% estimate is an off-by-40% cost forecast. The second is the hard wall of the context window: every model can only read so many tokens at once, and the request that sails through in testing with three retrieved chunks will throw an error (or worse, silently truncate the end of your prompt) the day a user's query pulls in eight. Counting tokens isn't bookkeeping pedantry; it's how you keep an app from either surprising you on the invoice or falling over under a heavier-than-average request.

The trap here follows directly from the last section. Because English runs about four characters to a token, it is so tempting to write len(text) // 4 and call it a token count. As a back-of-envelope sanity check that's fine — but the moment you rely on it for anything real, it lies. Feed it code, a table of numbers, JSON, or any non-English language and the four-chars-per-token rule collapses; those inputs fragment into far more tokens than the heuristic predicts, so your "safe" prompt blows the window and your cost estimate is fiction. The rule of thumb is only calibrated on the one kind of text (ordinary English prose) that behaves.

The honest answer is to ask the real tokenizer, and the mistake to avoid is treating all tokenizers as interchangeable — they aren't. Each model family was trained with its own vocabulary and merge rules, so the same string counts differently across them; a number that's known exactly for Claude means using Claude's tokenizer, not OpenAI's tiktoken and not a heuristic. For Anthropic models the source of truth is the count_tokens endpoint, which returns the server's exact count for the model you name — no guessing. Reserve the char/4 estimate for cheap local guardrails ("is this obviously too big?") and reach for the provider's counter whenever money or the context limit is actually on the line. The code contrasts both.

You must know a request's token count to stay under context limits and estimate cost. Use the provider's tokenizer/counting rather than guessing from characters.

the same text, two rulers — only one is trustworthy for the context limit and the bill your prompt (text) English · code · a UUID len(text) // 4 rough guess — sanity check only count_tokens (real) exact for THIS model — the truth off 2–5× on code / numbers / langs what context limit & bill are counted in Estimate cheaply, measure when it matters. The char/4 heuristic is a quick guard that only holds for English prose; the provider's count_tokens gives the exact, model-specific count that context limits and billing actually use.
🗺️ How to read this diagram

Start at the prompt box on top and follow the two arrows down to the two ways of counting its tokens.

  • The top blue box is the text you're about to send — and its true token count depends on what kind of text it is (English, code, a UUID).
  • The left amber box is the cheap heuristic len(text) // 4: fast and offline, but the note beneath it warns it can be off by several-fold on code, long numbers, or non-English — so it's only a rough sanity check.
  • The right green box is the payoff: the provider's count_tokens endpoint returns the exact count for the specific model — and that green number is the one the context window and your invoice are both denominated in.

In short: use char/4 only for "is this obviously too big?"; ask the real tokenizer whenever money or the context limit is on the line.

Try it — token counting
python# The Anthropic SDK provides server-side token counting:
#   from anthropic import Anthropic
#   client = Anthropic()
#   n = client.messages.count_tokens(
#           model="claude-opus-4-8",
#           messages=[{"role": "user", "content": text}],
#       ).input_tokens
#
# For OpenAI-family / local models, tiktoken does it locally:
#   import tiktoken; enc = tiktoken.get_encoding("cl100k_base")
#   n = len(enc.encode(text))

# A rough offline estimate ONLY for sanity checks (never for billing):
def rough_tokens(text):
    return max(1, len(text) // 4)      # ~4 chars/token for English prose

print(rough_tokens("The quick brown fox jumps."))   # ~6
▶ How this works

You pay LLM APIs per token, and each model has a maximum number of tokens it can read at once (its context window). So before sending text you often need to know how many tokens it is. The commented lines show the real way; the small function is a rough backup.

  1. The commented block at the top is the accurate method: Anthropic's SDK has client.messages.count_tokens(...) which asks the server for the exact count for a given model — the source of truth for Claude.
  2. For other model families, the commented tiktoken lines count tokens locally on your machine.
  3. rough_tokens(text) is a quick offline guess: English prose averages about 4 characters per token, so it divides the character length by 4. max(1, …) makes sure even a tiny string counts as at least 1 token.

What the output means: rough_tokens("The quick brown fox jumps.") returns about 6 — a ballpark, not an exact bill.

Try this: Never trust the char/4 estimate for real billing — it's only a sanity check. For code, other languages, or long numbers it can be way off, which is why you measure with the actual tokenizer when money or limits are on the line.

🔗 Used in the courseBudgeting context (how many retrieved chunks fit), trimming the chat window (Ch 4), and cost control (Ch 6) all depend on token counts. The count_tokens endpoint is the source of truth for Claude; a char/4 heuristic is fine only for rough guards.

5 · Advanced regex — powerful, use with restraint advanced

Regex is the chainsaw of text processing: astonishing when the job fits, dangerous when it doesn't. Reach for it to rip a timestamp, an error level, and a service name out of a million log lines and it'll do in one expression what a hand-written loop couldn't touch. Point it at nested, quoted, recursive structure — JSON, HTML, a CSV with commas inside fields — and it will turn on you: fragile, unreadable, and occasionally able to hang your process outright. The skill this section teaches is half syntax and half judgment about which of those two situations you're in.

The way to hold it in your head: a regular expression is a tiny pattern language for describing the shape of text so the engine can find every place that shape occurs. The beginner tools (P2) let you say "some digits, then a dash, then more digits." The advanced tools let you do two extra things that matter a lot in practice. Named capture groups — (?P<level>\w+) — let you pull out labelled pieces and read them back by name instead of counting anonymous positions, which is the difference between self-documenting extraction and a mess of m.group(3) calls nobody can maintain. Lookarounds — lookahead (?=...) and lookbehind (?<=...) — let you match a spot because of what surrounds it without swallowing that context: "grab the digits that come right after a $, but leave the $ out of the result." They assert a condition at a position rather than consuming characters.

Two mistakes are worth burning into memory. The first is using regex on formats that have real parsers. HTML, JSON, and CSV are recursive and quote-aware; a regex sees flat text and will get quoted commas, escaped quotes, and nesting wrong in ways that pass every test you write and fail on the input you didn't imagine. Use json, csv, an HTML library — that's literally the next section. The second, subtler danger is catastrophic backtracking (ReDoS): a seemingly innocent pattern with nested quantifiers like (a+)+ can, on a maliciously crafted string, force the engine to try exponentially many ways to match and effectively freeze — a real denial-of-service vector when the input comes from users. Keep patterns simple, anchored, and free of nested "one-or-more of one-or-more," and regex stays the sharp, safe tool it should be. The lab shows the good features and flags the traps.

Regex is the right tool for structured pattern extraction (log lines, IDs, citations). Beyond the basics (P2), the advanced features are groups, named captures, and lookarounds — and knowing when a real parser beats a regex.

is the input a fixed flat shape, or nested/quoted structure? text to extract from flat, fixed shape → regex log lines, IDs, prices, citations groups · named captures · lookarounds nested / quoted → real parser JSON, CSV, HTML (§7) regex gets quoting & nesting wrong ⚠ avoid nested quantifiers like (a+)+ — catastrophic backtracking (ReDoS) Match the tool to the shape. Regex shines on flat, fixed patterns (logs, IDs, prices); nested or quoted formats belong to a real parser — and nested quantifiers risk catastrophic backtracking either way.
🗺️ How to read this diagram

Read it as a decision: the box at the top is the text you need to pull data out of, and the two arrows are the choice you make based on its shape.

  • The left green branch is the "yes, use regex" case: the input is flat with a fixed shape — log lines, IDs, prices, citations — and that's exactly where the advanced features (named groups, lookarounds) pay off.
  • The right red branch is the trap: the input is nested or quoted — JSON, CSV, HTML — where a regex sees flat text and gets quoting and nesting wrong. Those go to a real parser (the very next section).
  • The amber bar underneath is the danger that applies regardless of branch: a pattern with nested quantifiers like (a+)+ can blow up into exponential backtracking (ReDoS) on hostile input — keep patterns simple and anchored.

In short: flat fixed shape → regex; nested/quoted → real parser; and never write nested "one-or-more of one-or-more."

Try it — named groups & lookarounds
pythonimport re

# Named capture groups -> readable, self-documenting extraction
log = "2026-09-02 14:03:11 ERROR checkout-api: timeout after 30s"
m = re.match(r"(?P<date>\S+) (?P<time>\S+) (?P<level>\w+) (?P<svc>[\w-]+): (?P<msg>.*)", log)
print(m.group("level"), m.group("svc"))     # ERROR checkout-api

# Lookahead (?=...) / lookbehind (?<=...): match position, don't consume
price = "$1,299.00"
digits = re.findall(r"(?<=\$)[\d,]+", price)   # ['1,299'] — the part after $

# Compile once if reused in a loop (perf)
CITATION = re.compile(r"\[(\d+)\]")
print(CITATION.findall("See [1] and [12]."))   # ['1', '12']
▶ How this works

A regular expression (regex) is a mini-pattern language for finding and pulling structured pieces out of text — great for log lines, IDs, and prices. This block shows three power features. The r"..." prefix means a raw string so backslashes are taken literally.

  1. Named groups (?P<name>...) label each captured piece. After matching the log line you read fields by name — m.group("level") gives ERROR — instead of counting anonymous positions.
  2. Lookbehind (?<=\$) means "only match here if a $ comes right before" — but the $ itself is not included in the result. So [\d,]+ grabs 1,299 from $1,299.00.
  3. re.compile(...) builds the pattern once and reuses it, which is faster inside loops. CITATION.findall(...) returns every match as a list, here ['1', '12'].

What the output means: You get the named fields (ERROR checkout-api), the price digits (['1,299']), and all citation numbers (['1', '12']).

Try this: Regex is powerful but the wrong tool for JSON/CSV/HTML — those have real parsers (next section). Also avoid patterns like (a+)+, which can hang on some inputs (a real denial-of-service risk called ReDoS).

Don't parse structured formats with regexHTML, JSON, and CSV have real parsers — use them (json, csv, an HTML library). Regex on nested/quoted structures is fragile and a classic source of bugs. And beware catastrophic backtracking: patterns like (a+)+ on certain inputs can hang — a real denial-of-service risk (ReDoS). Keep patterns simple and anchored.

6 · Chunking text for RAG expert advanced

Ask anyone who has debugged a disappointing RAG system and they'll eventually tell you: the retrieval was fine, the chunking was the problem. It's the least glamorous step in the pipeline and the one that quietly caps how good everything downstream can be. If you cut your documents into the wrong pieces before you ever embed them, no amount of clever similarity search or re-ranking can recover the answer — because the answer, as a coherent unit, no longer exists in your index. Chunking is where retrieval quality is won or lost, long before a query arrives.

Think of chunking like cutting a long document into index cards, where each card has to make sense on its own because that's how it'll be pulled and shown to the model. That framing exposes the tension immediately. Make the cards too big — cram three topics onto one — and a search for topic B drags along the noise of A and C, diluting the relevance signal and eating your token budget when the chunk is retrieved. Make them too small — a single sentence per card — and you shred context: "It raised revenue 40%" is useless on its own if "the pricing change" that it refers to landed on a different card. Good chunking splits along the seams the text already has — paragraph and sentence boundaries — so each piece is a self-contained thought rather than an arbitrary slice through the middle of one.

The move that separates a toy chunker from a real one is overlap: letting the end of one chunk repeat at the start of the next. This is the same sliding-window instinct from the arrays track (D1), applied to prose, and it's insurance against the boundary problem — a fact that straddles two chunks survives intact in at least one of them instead of being cleanly severed from its context. Two mistakes to avoid: chunking with zero overlap (which guarantees some ideas get guillotined at a boundary) and measuring chunk size in characters when the real constraint is tokens (§4) — a chunk that's "800 characters" can be wildly different token counts depending on whether it's English or code, so production chunkers count tokens and split on real structure (headings, code fences, table rows), never blindly at a character offset. The lab builds a sentence-aware chunker with overlap so you can see both ideas working together.

Retrieval quality starts with chunking: too big and you dilute relevance and blow token budgets; too small and you lose context. Good chunking respects structure (paragraphs/sentences) and uses overlap so ideas spanning a boundary aren't lost.

split the document so neighbouring chunks share a tail — the overlap is the safety net one long document chunk 1 chunk 2 chunk 3 amber bands = overlap (each chunk repeats the previous chunk's tail) overlap overlap Overlap is insurance against the boundary. The document is cut into chunks that each repeat the tail of the one before (the amber bands), so a fact that straddles a cut survives intact in at least one chunk instead of being severed from its context.
🗺️ How to read this diagram

Read top to bottom: the single bar at the top is the whole document, and the green boxes below are the chunks it gets cut into.

  • The blue bar is one long document. The dashed grey lines dropping down mark roughly where the cuts fall.
  • The three green boxes are the resulting chunks. They are drawn overlapping on purpose — each one starts a little before the previous one ended.
  • The amber bands where consecutive chunks meet are the overlap: the tail of chunk 1 is repeated at the head of chunk 2, and so on. That repetition is the payoff — a sentence or fact sitting on a boundary is fully present in at least one chunk rather than guillotined by the cut.

In short: split on real structure and let chunks overlap, so boundary-spanning ideas are never lost — and size the chunks in tokens, not characters.

Try it — sentence-aware chunking with overlap
pythonimport re

def chunk_text(text, max_chars=800, overlap=150):
    # split into sentences (simple heuristic; use nltk/spacy for hard cases)
    sentences = re.split(r"(?<=[.!?])\s+", text.strip())
    chunks, cur = [], ""
    for s in sentences:
        if len(cur) + len(s) <= max_chars:
            cur = (cur + " " + s).strip()
        else:
            if cur: chunks.append(cur)
            # start next chunk with the tail of the previous (overlap)
            cur = (cur[-overlap:] + " " + s).strip() if cur else s
    if cur: chunks.append(cur)
    return chunks

doc = "RAG grounds answers. It retrieves chunks. Then it cites them. " * 10
cs = chunk_text(doc, max_chars=120, overlap=30)
print(len(cs), "chunks; first:", cs[0][:60])
▶ How this works

For RAG (retrieval), you split a long document into smaller chunks to store and search. Chunks that are too big waste tokens; too small and they lose context. This function splits on sentence boundaries and keeps a little overlap so an idea that straddles a boundary isn't cut in half.

  1. re.split(r"(?<=[.!?])\s+", …) breaks the text into sentences by splitting on the space that follows a ., !, or ?.
  2. The loop keeps adding sentences to the current chunk cur while it stays under max_chars.
  3. When the next sentence would overflow, it saves the current chunk and starts a new one — but seeds it with cur[-overlap:], the tail of the previous chunk. That repeated overlap is the safety net for ideas spanning a boundary.
  4. At the end it appends whatever is left in cur and returns the list of chunks.

What the output means: print(len(cs), …) shows how many chunks the repeated sentence produced and the first 60 characters of chunk one — you can see sentences kept whole and the overlap carried forward.

Try this: Shrink max_chars to see more, smaller chunks; raise overlap to carry more context between them. Real systems chunk on structure (headings, code blocks) and measure size in tokens, not characters.

🔗 Used in the courseThis is the chunking step of the RAG build (Ch 3), refined. Real systems chunk on structure (headings, code blocks, table rows) and often measure size in tokens (§4) not chars. The overlap is the sliding-window idea from D1 applied to documents.

7 · Parsing data formats robustly advanced

The demo always works. Production is where the data fights back. You test your CSV loader on a clean file and it's flawless; then a real export arrives with a company name like "Doe, John" — a comma inside a field — and your naive line.split(",") silently shifts every column one to the right for that row. You ask an LLM for JSON and get pristine JSON in the notebook; then in production the model wraps it in a friendly "Sure! Here's your data:" and a ```json fence, and json.loads throws on the very first character. Robust parsing is the discipline of writing for the messy input you'll actually receive, not the tidy one you tested with.

The single most important principle here is use the real parser, never hand-roll one. Formats like JSON and CSV look simple enough that splitting strings by hand feels reasonable — and that instinct is exactly the mistake. These formats have rules you'll forget: CSV allows quoted fields, commas and even newlines inside quotes, and doubled quotes ("") to mean a literal quote character; JSON has nested structure, escaping, and Unicode. Python's csv and json modules already implement every one of those rules correctly, so csv.DictReader reads "Doe, John" as one field without you thinking about it. Reaching for str.split on structured data is re-writing a parser badly — it's the same lesson as "don't parse HTML with regex," and it's why the previous section handed off to this one.

Two more habits make parsing production-grade. First, expect LLM output to be dirty and defend against it: models editorialize and fence their answers, so a helper that strips prose and slices out the outermost { … } before calling json.loads turns a fragile call into a reliable one — though the truly robust move is to ask for structured output or tool use (covered in A6) so clean JSON arrives by contract and you never scrape it out of chatter at all. Second, know your line-oriented cousin JSONL (one JSON object per line), the standard for datasets and logs: you parse it line by line, which has the happy property that one malformed record can be skipped instead of killing the entire batch. The lab shows all three — rescued JSON, quote-aware CSV, and JSONL — done the safe way.

real input is dirty — strip it down to the object, then let the real parser do the work messy LLM reply "Sure! Here's your data:" ```json { "ok": true } ``` prose + fence + object extract drop prose + fence, slice outer {…} json.loads real parser → {'ok': True} Isolate the object, then use the real parser. A model wraps JSON in prose and a code fence; the extractor strips both and slices the outermost {…}, and only then does json.loads — never a hand-rolled split — turn it into a clean dict.
🗺️ How to read this diagram

Follow the flow left to right: the messy thing you actually receive on the left is funneled into a clean Python value on the right.

  • The red box on the left is a realistic LLM reply: a friendly preamble, a ```json code fence, the JSON object, and a closing fence — json.loads would choke on the very first character of this.
  • The amber "extract" box is the defensive step: it drops the prose and the fence and slices out just the outermost {…}.
  • The green box is the real parser, json.loads, running on that clean slice to produce a genuine Python dict {'ok': True}. The lesson lives in the colour change — you only reach the trustworthy green stage by first isolating the object and then handing it to the real parser, not a hand-written string split.

In short: expect dirty input, strip it down to the object, and always parse with json/csv — better still, ask for structured output (A6) so there's nothing to scrape.

Try it — JSON (incl. from LLM output), CSV, JSONL
pythonimport json, csv, io

# 1. Robust JSON: LLMs sometimes wrap JSON in prose or ```json fences
def extract_json(text):
    text = text.strip()
    if text.startswith("```"):
        text = text.split("```")[1].removeprefix("json").strip()
    start = text.find("{")
    if start >= 0:
        text = text[start:text.rfind("}") + 1]     # slice the outermost object
    return json.loads(text)
# (Better: ask for structured output / tool use so you never parse prose — see A6.)

# 2. CSV — never split on commas! The module handles quoting/embedded commas.
raw = 'name,note\n"Doe, John","says ""hi"""\n'
for row in csv.DictReader(io.StringIO(raw)):
    print(row["name"], "|", row["note"])     # Doe, John | says "hi"

# 3. JSONL — one JSON object per line (the standard for datasets/logs)
def read_jsonl(text):
    return [json.loads(line) for line in text.splitlines() if line.strip()]
▶ How this works

Real data arrives in messy formats. This block shows the safe way to handle three: JSON that a model wrapped in chatter, CSV with awkward commas, and JSONL. The golden rule is use the real parser (json, csv) rather than splitting strings by hand.

  1. extract_json(text) rescues JSON from an LLM reply. Models often wrap it in a ```json code fence or add prose, so the function strips the fence, finds the first { and last }, slices out that outermost object, and finally calls json.loads to turn the text into a real Python dict.
  2. For CSV, note the raw line has a comma inside a quoted field ("Doe, John"). csv.DictReader understands quoting and gives you each row as a dictionary — splitting on commas yourself would break here.
  3. read_jsonl handles JSONL (one JSON object per line, the standard for datasets/logs): it loops over lines and parses each non-empty one.

What the output means: The CSV loop prints Doe, John | says "hi" — the embedded comma and escaped quotes were handled correctly by the parser.

Try this: Even better than parsing prose: ask the model for structured output / tool use (covered in A6) so the JSON arrives clean and you never scrape it out of text.

8 · Tabular data with pandas intermediate → advanced

The moment your text becomes a table, stop writing loops. Eval results, request logs, token-usage records, a text-to-SQL result set — all of it lands as rows and columns, and the reflex to iterate over it with a for loop and a running dictionary is both slower and far more bug-prone than the tool built for exactly this shape. That tool is pandas, and learning to think in it is what turns "let me write forty lines to compute the pass rate per model" into a single, readable line.

The core object is the DataFrame — picture a spreadsheet living in memory: named columns, indexed rows, and a whole vocabulary of operations that act on entire columns at once instead of one cell at a time. The reason this isn't just a nicer syntax is the same reason NumPy (A4) is fast: pandas is vectorized, so an operation like "average the latency column" runs in optimized C over the whole column rather than in a Python loop over each value. The three verbs you'll use constantly map to questions you already ask of data: filter ("show me only the slow requests") via a boolean mask, group ("break this down per model") via groupby, and aggregate ("give me the mean, the count, the quartiles") via functions like .mean() and .describe(). Chain those three and most day-to-day data questions answer themselves.

The mistake to unlearn is the loop-and-accumulate habit carried over from general Python: manually walking rows, maintaining counters, building result dicts by hand. It works on ten rows and quietly becomes both a performance problem and a correctness problem as the data grows — off-by-one filters, forgotten edge cases, mutable state you have to reason about. A boolean mask (df[df["latency"] > 1.0]) is the same idea you met with NumPy arrays, and groupby(...).mean() expresses "split, compute, recombine" declaratively so there's simply less code to get wrong. For the eval summaries and cost analyses in this course, that clarity is the whole point. The lab analyzes a small eval-results table with exactly these operations.

When data is tabular (eval results, logs, the data-analyst project), pandas is the tool: load, filter, group, aggregate — vectorized on top of numpy (A4).

groupby is split → apply → combine, done in one vectorized line DataFrame opus ✓ opus ✗ haiku ✓ rows × columns group · opus ✓ , ✗ group · haiku ✓ combined summary opus → 0.50 haiku → 1.00 mean per model split by "model" apply .mean() → combine Split → apply → combine. df.groupby("model")["passed"].mean() splits rows into per-model groups, averages the passed column within each, and recombines them into a tiny summary — one declarative line replacing a hand-written loop-and-accumulate.
🗺️ How to read this diagram

Trace left to right: one table becomes several groups, each group is summarized, and the summaries recombine into a small result table.

  • The white box on the left is the full DataFrame — every eval row, with a model column and a passed tick or cross.
  • The two blue boxes in the middle are the split: groupby("model") gathers all opus rows into one group and all haiku rows into another.
  • The arrows into the green box are apply then combine: .mean() runs on the passed column within each group, and pandas stitches the per-group answers back into one summary (opus 0.50, haiku 1.00). That green table is what a whole loop-and-counter would have produced — in a single line.

In short: filter/group/aggregate express "split, compute, recombine" declaratively, so there's far less code to get wrong than a manual row loop.

Try it — analyze eval results
pythonimport pandas as pd

df = pd.DataFrame([
    {"case": "q1", "model": "opus", "passed": True,  "latency": 1.2},
    {"case": "q2", "model": "opus", "passed": False, "latency": 2.1},
    {"case": "q3", "model": "haiku", "passed": True, "latency": 0.4},
])
print(df["passed"].mean())              # overall pass rate: 0.667
print(df.groupby("model")["passed"].mean())   # pass rate per model
slow = df[df["latency"] > 1.0]             # boolean mask (like numpy)
print(df["latency"].describe())          # count/mean/std/min/max/quartiles
▶ How this works

When data is a table (rows and columns — like eval results or logs), pandas is the standard tool. A DataFrame is that table in memory; you can filter, group, and summarize it with one-liners instead of writing loops.

  1. pd.DataFrame([...]) builds the table from a list of dictionaries — each dict is one row, each key is a column (case, model, passed, latency).
  2. df["passed"].mean() averages the True/False column (True counts as 1), giving the overall pass rate, 0.667.
  3. df.groupby("model")["passed"].mean() splits rows by model first, then averages within each group — the pass rate per model.
  4. df[df["latency"] > 1.0] keeps only rows where latency exceeds 1.0 (a boolean mask). .describe() prints summary statistics (count, mean, min, max, quartiles).

What the output means: You get the overall pass rate (0.667), a per-model breakdown, and a statistics summary of the latency column.

Try this: Change the > 1.0 threshold to filter differently, or group by a different column. This is exactly how you'd summarize eval runs or cost logs in the course projects.

🔗 Used in the courseSummarizing eval runs (Ch 5) and the text-to-SQL data-analyst project lean on pandas. It's also how you'd analyze token-usage/cost logs from the monitoring layer (A8).

Exercises expert

Practice
  1. Write a safe_read(path) that tries UTF-8 then falls back with errors="replace", returning cleaned text.
  2. Extend the BPE trainer to also produce an encode(word) that applies the learned merges.
  3. Compare rough_tokens vs a real tokenizer (tiktoken) on English, code, and a long number; explain the gaps.
  4. Improve chunk_text to measure size in tokens and to never split inside a fenced code block.
  5. Load a messy CSV with quoted commas and newlines-in-fields correctly, then group it with pandas.

🎯 Interview practice interview

The interview questions this topic gets asked — worked, with code. For the full pattern catalog see A9 · Big Tech AI-engineering patterns.

Implement top-p (nucleus) sampling

Sort probs desc, take the smallest set whose mass ≥ p, renormalize, sample.

pythonimport numpy as np
def top_p_sample(probs, p=0.9):
    idx = np.argsort(-probs)
    cum = np.cumsum(probs[idx])
    cutoff = np.searchsorted(cum, p) + 1
    keep = idx[:cutoff]
    return int(np.random.choice(keep, p=probs[keep]/probs[keep].sum()))
▶ How this works

This is a classic interview question: top-p (nucleus) sampling is how an LLM picks the next token. Instead of always taking the single most likely token, it keeps just enough of the top choices to cover a probability mass p (say 90%), then samples from that shortlist — giving controlled randomness.

  1. np.argsort(-probs) sorts the token indices from most to least likely (the minus flips ascending into descending).
  2. np.cumsum(...) makes a running total of those sorted probabilities. np.searchsorted(cum, p) finds how many of the top tokens are needed to reach the mass p; + 1 includes the one that crosses the line.
  3. keep = idx[:cutoff] is that shortlist. The final line renormalizes their probabilities so they sum to 1 (probs[keep]/probs[keep].sum()) and randomly picks one, returning its index.

What the output means: It returns the integer index of the chosen token — one of the high-probability candidates, chosen at random in proportion to its likelihood.

Try this: A smaller p (e.g. 0.5) keeps fewer candidates → safer, more repetitive output; a larger p keeps more → more varied, riskier. This is the same "top_p" knob you set on real API calls.

Robustly extract JSON from an LLM reply

Models wrap JSON in prose or code fences; strip to the outermost object before parsing.

pythonimport json
def extract_json(text):
    text = text.strip()
    if text.startswith("```"):
        text = text.split("```")[1].removeprefix("json").strip()
    start, end = text.find("{"), text.rfind("}")
    return json.loads(text[start:end+1])
▶ How this works

The other common interview task for this topic: pull clean JSON out of an LLM reply. Models like to add explanations or fence the JSON in ``` blocks, so you can't just call json.loads on the raw text — you first trim it down to the JSON object.

  1. text.strip() removes surrounding whitespace. If the reply starts with a ``` fence, it splits on the fence, takes the code portion, and drops a leading json label with removeprefix.
  2. text.find("{") and text.rfind("}") locate the first opening and last closing brace — the outermost JSON object, ignoring any prose around it.
  3. json.loads(text[start:end+1]) parses that slice into a real Python dict.

What the output means: You get a usable Python dictionary regardless of whether the model wrapped its answer in prose or code fences.

Try this: Feed it a string like 'Sure!\n```json\n{"ok": true}\n```' and confirm it returns {'ok': True}. In production, prefer structured output (A6) so this cleanup is rarely needed.

Checkpoint expert

  • Handle str/bytes/encodings and normalize text before comparing or keying it.
  • Explain BPE and why tokens ≠ words, and count tokens for budgeting/cost.
  • Use advanced regex safely and know when to use a real parser instead.
  • Chunk documents with structure + overlap, and parse JSON/CSV/JSONL/tabular data robustly.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · str vs bytes round-tripBeginner

Context: str is a sequence of Unicode code points while bytes is the UTF-8 encoding, and multi-byte characters make the byte length exceed the character length — the confusion behind most encoding bugs.

Your task: Encode a string containing a non-ASCII character to UTF-8 bytes and back, and show that the byte length differs from the character length.

Requirements:

  • Use a string with at least one multi-byte (non-ASCII) character
  • Encode to UTF-8 bytes and decode back to the original
  • Show len(bytes) > len(str)
  • Confirm the round-trip is lossless (decoded equals original)

💡 Hint: Characters and bytes are different counts — a non-ASCII glyph takes multiple UTF-8 bytes, so the two lengths won't match.

Show solution

str is a sequence of Unicode code points; bytes is the UTF-8 encoding. Multi-byte characters make len(bytes) > len(str).

s = "caf\u00e9"          # 'café', 4 characters
b = s.encode("utf-8")
print(len(s), "chars")     # 4
print(len(b), "bytes")     # 5  (é is 2 bytes in UTF-8)
print(b)                    # b'caf\xc3\xa9'
print(b.decode("utf-8") == s)  # True
Exercise 2 · Normalize before comparingIntermediate

Context: The same glyph can be one composed code point or a base plus a combining mark, so visually-identical strings compare unequal until NFC-normalized — essential before dedup, search, or cache keys.

Your task: Show that two visually identical strings compare unequal until NFC-normalized, then equal.

Requirements:

  • Construct the same visible character two ways (composed vs base + combining mark)
  • Show they compare unequal and have different lengths before normalization
  • Normalize both with unicodedata.normalize("NFC", ...)
  • Show they compare equal after normalization

💡 Hint: One code point vs base-plus-combining-mark look identical but hash and compare differently — NFC canonicalizes both to the same form.

Show solution

The same glyph can be one code point (composed) or a base + combining mark (decomposed). NFC normalization makes them comparable — essential before dedup or cache keys.

import unicodedata

composed = "\u00e9"          # é as one code point
decomposed = "e\u0301"       # e + combining acute
print(composed == decomposed)                 # False!
print(len(composed), len(decomposed))         # 1 2

a = unicodedata.normalize("NFC", composed)
b = unicodedata.normalize("NFC", decomposed)
print(a == b)                                 # True
Exercise 3 · Whitespace & control-char cleaningAdvanced

Context: Dirty text (zero-width chars, odd whitespace, control bytes) corrupts chunking and token counts, so you clean deterministically before indexing — the pre-chunking hygiene step for RAG.

Your task: Write a cleaner that normalizes to NFC, strips control characters, and collapses runs of whitespace to single spaces.

Requirements:

  • Normalize to NFC first
  • Strip control characters (Unicode category starting with ‘C’) while keeping newline/tab
  • Collapse runs of spaces/tabs to a single space and cap excessive blank lines
  • Return trimmed output; demonstrate on a string with a zero-width char and a null byte

💡 Hint: Normalize, then filter by Unicode category, then collapse whitespace with a regex — order matters so control chars are gone before you squeeze spaces.

Show solution

Dirty text (zero-width chars, odd whitespace, control bytes) corrupts chunking and token counts. Clean deterministically before indexing.

import unicodedata, re

def clean(text):
    text = unicodedata.normalize("NFC", text)
    # drop control chars (category starting with 'C') except newline/tab
    text = "".join(c for c in text
                   if c in "\n\t" or not unicodedata.category(c).startswith("C"))
    text = re.sub(r"[ \t]+", " ", text)      # collapse spaces/tabs
    text = re.sub(r"\n{3,}", "\n\n", text)    # cap blank lines
    return text.strip()

dirty = "Hello\u200b   world\x00 !\n\n\n\nDone"
print(repr(clean(dirty)))   # 'Hello world !\n\nDone'
Exercise 4 · A tiny BPE-style mergeExpert

Context: BPE repeatedly merges the most frequent adjacent pair; the per-merge operation — scan and replace a pair everywhere — is the primitive a real tokenizer applies from a learned ordered list.

Your task: Implement the core BPE step: given a word as a list of symbols and a merge rule (pair → merged symbol), apply the merge everywhere it occurs.

Requirements:

  • Take a list of symbols and a (pair, merged) rule
  • Replace every non-overlapping occurrence of the adjacent pair with the merged symbol
  • Advance past a merged pair so it isn't re-scanned mid-pair
  • Leave non-matching symbols untouched; demonstrate applying two merges in sequence

💡 Hint: Walk the list with an index, emitting the merged symbol and skipping two positions when the pair matches, otherwise copying one symbol.

Show solution

BPE repeatedly merges the most frequent adjacent pair. The per-merge operation — scan and replace a pair — is the primitive; a real tokenizer just applies a learned ordered list of these.

def apply_merge(symbols, pair, merged):
    out, i = [], 0
    while i < len(symbols):
        if i < len(symbols)-1 and (symbols[i], symbols[i+1]) == pair:
            out.append(merged)
            i += 2
        else:
            out.append(symbols[i])
            i += 1
    return out

word = list("lower")                    # ['l','o','w','e','r']
word = apply_merge(word, ("l","o"), "lo")
word = apply_merge(word, ("e","r"), "er")
print(word)                              # ['lo', 'w', 'er']
Exercise 5 · Token-budget-aware chunkingProfessional

Context: Chunks must fit the model's context and the embedding limit, so you chunk by a token estimate with overlap to keep boundary-spanning facts intact — the production RAG chunker.

Your task: Chunk text so no chunk exceeds a token budget, using a word-count proxy for tokens, with overlap for context continuity.

Requirements:

  • Convert a token budget to a word budget via a proxy factor (e.g. ~1.3 tokens/word)
  • Produce chunks that don't exceed the budget
  • Consecutive chunks overlap by the configured amount so context spanning a boundary isn't lost
  • Note that production would swap the word proxy for a real tokenizer (e.g. tiktoken)

💡 Hint: Slide a window of the word-budget size, advancing by (budget − overlap) each step so neighbouring chunks share trailing words.

Show solution

Chunks must fit the model's context and the embedding limit. Use a token estimate (words × a factor, or a real tokenizer) and add overlap so facts spanning a boundary aren't split away from their context.

def chunk(text, max_tokens=20, overlap_tokens=5):
    words = text.split()
    est = lambda n: int(n * 1.3)              # ~1.3 tokens/word proxy
    step = max(1, max_tokens - overlap_tokens)
    # convert token budget to a word budget
    max_words = int(max_tokens / 1.3)
    ov_words  = int(overlap_tokens / 1.3)
    chunks, i = [], 0
    while i < len(words):
        piece = words[i:i+max_words]
        chunks.append(" ".join(piece))
        i += max_words - ov_words
    return chunks

text = " ".join(f"w{i}" for i in range(40))
cs = chunk(text)
print(len(cs), "chunks; overlap visible:")
for c in cs:
    print("  ", c[:30], "...")

In production swap the word proxy for the real tokenizer (e.g. tiktoken — needs the package) so budgets are exact.

Exercise 6 · Robust NDJSON log parserIndustry scenario

Context: Real-world NDJSON is dirty, so ingestion code must be line-resilient: parse line-by-line, coerce types at the boundary, and measure parse quality so a silent 30% drop becomes visible instead of a data incident.

Your task: Parse a stream of newline-delimited JSON where some lines are malformed — skip bad lines without crashing, coerce a numeric field, and report a parse-success rate.

Requirements:

  • Parse line-by-line so one bad record can't kill the batch
  • Skip blank and malformed lines, counting the failures
  • Defensively coerce a numeric field (handle it arriving as a string or missing)
  • Return the parsed records plus stats including a success rate
  • State the lesson: be line-resilient, coerce at the boundary, and measure parse quality

💡 Hint: Wrap each json.loads in try/except and coerce fields with a fallback — the success rate is good over total, which surfaces silent data-quality problems.

Show solution

Design: parse line-by-line so one bad record can't kill the batch; validate/coerce fields defensively; and emit a success rate so upstream data-quality problems are visible instead of silent.

import json

def parse_logs(lines):
    good, bad = [], 0
    for ln in lines:
        ln = ln.strip()
        if not ln:
            continue
        try:
            rec = json.loads(ln)
        except json.JSONDecodeError:
            bad += 1
            continue
        # defensive coercion: latency may arrive as str or missing
        try:
            rec["latency_ms"] = float(rec.get("latency_ms", 0))
        except (TypeError, ValueError):
            rec["latency_ms"] = 0.0
        good.append(rec)
    total = len(good) + bad
    rate = good and round(len(good)/total, 3)
    return good, {"ok": len(good), "bad": bad, "success_rate": rate}

lines = [
    '{"path": "/a", "latency_ms": "12.5"}',
    'NOT JSON',
    '{"path": "/b", "latency_ms": 30}',
    '{"path": "/c"}',            # missing field -> coerced to 0.0
]
recs, stats = parse_logs(lines)
print(stats)                      # {'ok': 3, 'bad': 1, 'success_rate': 0.75}
print([r["latency_ms"] for r in recs])   # [12.5, 30.0, 0.0]

Lesson: real-world text/JSON is dirty. Ingestion code must be line-resilient, coerce types at the boundary, and measure parse quality — a silent drop of 30% of records is a data incident waiting to happen.

Knowledge check check yourself

✓ Knowledge check

The lesson warns never to use character length (len(str)) to estimate token count or storage. Why are tokens, characters, and bytes all different, and what should you use for a Claude token count?

Show answer
BPE merges are frequency-based so tokens are subword units (dense for common English, more for code/rare words/numbers), while bytes depend on UTF-8 encoding of each character — none map cleanly to the others. Use the provider's tokenizer, e.g. Anthropic's count_tokens endpoint, as the source of truth.
✓ Knowledge check

Two strings that look identical ("café" one code point vs "e" + combining accent) compare unequal. Why does the lesson insist on Unicode normalization before dedup, search, or using text as a key?

Show answer
The same visible text can have multiple byte representations, so equality and hashing break silently; normalizing (NFC/NFKC) canonicalizes both forms so equal-looking strings actually compare equal and don't create duplicate cache/dedup entries.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in