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.
- an Anthropic API key (
ANTHROPIC_API_KEY) +pip install anthropic
Learning objectives
- Distinguish
str(code points) frombytes(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.
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?'
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.
s = "café ☕"is a normal string of 6 characters.len(s)counts characters, so it is6.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)is9.b.decode("utf-8")turns the bytes back into the original string — a clean round-trip.- 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 aUnicodeDecodeError.
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.
len("☕") 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).
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'
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.
aandbprint the same but are built differently, soa == bisFalse— a classic, invisible bug.unicodedata.normalize("NFC", …)converts both to the same standard form; after that they are equal, so the secondprintshowsTrue.- The
clean()function is a reusable tidy-up:NFKCnormalizes (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.
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.
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')...
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.
- Each word starts split into single characters plus a
</w>end-of-word marker, e.g.low→['l','o','w','</w>']. Counter()tallies how often each adjacent pair of symbols appears across all words, weighted by how common each word is.pairs.most_common(1)picks the single most frequent pair — that becomes the next merge. The innerwhileloop then walks through every word and fuses that pair wherever it occurs (e.g.e+s→es).- Repeat
num_mergestimes. The list of merges itreturns 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.
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.
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
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.
- 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. - For other model families, the commented
tiktokenlines count tokens locally on your machine. 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.
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.
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']
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.
- Named groups
(?P<name>...)label each captured piece. After matching the log line you read fields by name —m.group("level")givesERROR— instead of counting anonymous positions. - Lookbehind
(?<=\$)means "only match here if a$comes right before" — but the$itself is not included in the result. So[\d,]+grabs1,299from$1,299.00. 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).
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.
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])
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.
re.split(r"(?<=[.!?])\s+", …)breaks the text into sentences by splitting on the space that follows a.,!, or?.- The loop keeps adding sentences to the current chunk
curwhile it stays undermax_chars. - 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. - At the end it appends whatever is left in
curand 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.
7 · Parsing data formats robustly advanced
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()]
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.
extract_json(text)rescues JSON from an LLM reply. Models often wrap it in a```jsoncode fence or add prose, so the function strips the fence, finds the first{and last}, slices out that outermost object, and finally callsjson.loadsto turn the text into a real Python dict.- For CSV, note the raw line has a comma inside a quoted field (
"Doe, John").csv.DictReaderunderstands quoting and gives you each row as a dictionary — splitting on commas yourself would break here. read_jsonlhandles 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).
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
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.
pd.DataFrame([...])builds the table from a list of dictionaries — each dict is one row, each key is a column (case,model,passed,latency).df["passed"].mean()averages theTrue/Falsecolumn (Truecounts as 1), giving the overall pass rate,0.667.df.groupby("model")["passed"].mean()splits rows by model first, then averages within each group — the pass rate per model.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.
Exercises expert
- Write a
safe_read(path)that tries UTF-8 then falls back witherrors="replace", returning cleaned text. - Extend the BPE trainer to also produce an
encode(word)that applies the learned merges. - Compare
rough_tokensvs a real tokenizer (tiktoken) on English, code, and a long number; explain the gaps. - Improve
chunk_textto measure size in tokens and to never split inside a fenced code block. - 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.
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()))
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.
np.argsort(-probs)sorts the token indices from most to least likely (the minus flips ascending into descending).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 massp;+ 1includes the one that crosses the line.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.
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])
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.
text.strip()removes surrounding whitespace. If the reply starts with a```fence, it splits on the fence, takes the code portion, and drops a leadingjsonlabel withremoveprefix.text.find("{")andtext.rfind("}")locate the first opening and last closing brace — the outermost JSON object, ignoring any prose around it.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.
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
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
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'
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']
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.
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
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
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?