AI EngineeringZero to ProductionHome·About·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

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.

1 · Unicode & encodings — str vs bytes intermediate

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.

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

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).

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

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.

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+ses).
  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

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.

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 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.

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

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.

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

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

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).

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