AI EngineeringZero to ProductionHome·About·What’s new·Contact
NLP & Transformers · Chapter K2

Feature Engineering & Text Representation

A model computes on numbers, not words — so the central NLP question is: how do you turn text into vectors? This chapter walks the ladder from counting words (bag-of-words, TF-IDF) to learned embeddings that capture meaning — the leap that makes semantic search and, ultimately, transformers possible.

⏱️ ~55 min🔢 Foundations + code🎯 Intermediate

Learning objectives

  • Explain why text must become vectors, and what a good representation preserves.
  • Build bag-of-words and TF-IDF representations and know their limits.
  • Explain the leap to embeddings — dense vectors that encode meaning.
  • Use cosine similarity to compare texts by meaning.
  • Connect classic embeddings to the ones powering RAG and LLMs today.
This is the "why" behind embeddings you already useYou've used embeddings for RAG (Ch 3) and seen the vector-DB machinery (A7) and cosine top-k (A4). This chapter is the conceptual ladder that leads to them: from counting words to representing meaning. Understanding the climb tells you why embeddings beat keyword search and what "meaning as geometry" really means.

Why text becomes vectors essential

Start with a frustration you've probably felt. You search a help centre for "my laptop won't turn on," and it returns nothing useful — even though there's a perfect article titled "troubleshooting a machine that won't boot." Same problem, different words, zero matches. That failure isn't the search engine being dumb; it's the search engine comparing spellings instead of meanings. Everything in this chapter is about closing that gap, and it all begins with one unavoidable fact: a computer has no idea what a word is.

Here's the mental model to hold onto. A model is a giant calculator. You can feed a calculator 3.7 and -0.2; you cannot feed it the word "diabetes." So before any learning, classifying, or retrieving can happen, something has to translate text into numbers — and not just any numbers, but numbers arranged so that math on them means something. That translation step is called representation, and it is the single most consequential design choice in an NLP system. Get it right and a simple model shines; get it wrong and no amount of model cleverness can recover the meaning you threw away.

The common beginner mistake is to treat representation as a boring preprocessing detail — "just tokenize and move on." In reality it sets the ceiling on everything downstream. Think of it like translating a poem into another language before anyone reads it: if the translation is flat, the most brilliant reader in the world still can't feel the poem. The representation is that translation, and this whole chapter is a ladder of ever-better translators — from crude word-counting (bag-of-words) to importance-weighting (TF-IDF) to the learned embeddings that finally capture meaning itself. Read the sections in order; each one exists to fix a specific flaw in the one before it, so the "why does this next thing exist?" answer is always "to repair what just broke."

Machine-learning models — from a logistic-regression classifier to a transformer — do math on numbers. Text is symbols. So the first job of any NLP system is representation: mapping text to a vector of numbers. The quality of that mapping caps everything downstream — a representation that loses meaning can't be rescued by a fancy model.

a model computes on numbers, not symbols text "diabetes" representation text → numbers vector [0.7, −0.2, …] feed raw text straight to the model → it can't compute the representation step sets the ceiling on everything downstream Representation is the gateway. A model does arithmetic, so text must become a vector first — and how faithfully that step preserves meaning caps how well anything after it can work.
🗺️ How to read this diagram

Read the top row left-to-right: a word enters, a translation step converts it, and a vector of numbers comes out the other side.

  • The left blue box is raw text — the word "diabetes", a symbol a calculator cannot add or multiply.
  • The middle amber box is the representation step: it maps text to numbers. Every recipe in this chapter (bag-of-words, TF-IDF, embeddings) is a different version of this box.
  • The right green box is the resulting vector — numbers the model can finally compute on.
  • The red arrow underneath is the shortcut that doesn't work: hand the model raw text and it stalls. You cannot skip the middle box.

In short: Text must pass through a representation step to become a vector — and the quality of that step is the ceiling on everything downstream.

The goal: similar meaning → similar vectorA good text representation puts texts with similar meaning close together in vector space, and different meanings far apart. That single property is what makes search, classification, and clustering work. The whole history of text representation is a march toward capturing more meaning (not just words) in that geometry.

Lab K2.1 · Bag-of-words: counting essential

Imagine tipping a sentence into a paper bag and shaking it. The words are all still in there, but the order is gone — you can only pull them out and tally how many of each you got. That deliberately crude picture is bag-of-words, the oldest and simplest text-to-vector recipe, and it's worth taking seriously precisely because it's so blunt: everything cleverer in this chapter is a reaction to what this loses.

The recipe has two steps, and the honest way to understand it is to see why each step is there. First you fix a vocabulary — one master list of every word you'll pay attention to, in a fixed order. That order is the whole point: position 3 in the list will always mean the same word, so every document can be described against the same yardstick. Second, each document becomes a row of counts: "how many times does vocabulary-word-1 appear? word-2? word-3?" and so on. A sentence stops being letters and becomes a fixed-length list of numbers a model can finally do arithmetic on.

Why does this simple thing work at all? Because for many real tasks, which words show up is genuinely informative. A document stuffed with "refund," "charge," and "invoice" is probably about billing, no matter the word order; one full of "fever," "dose," and "symptom" is about health. Topic-level jobs — spam filtering, routing a ticket to the right team, coarse document sorting — lean on exactly this "what words are present" signal, and bag-of-words captures it cheaply with no training whatsoever. Don't let its age fool you into skipping it; it is still a perfectly good baseline you should reach for before anything fancier.

But feel the two things it throws away, because the rest of the chapter is built on that loss. It's order-blind: "dog bites man" and "man bites dog" collapse to the identical vector, so any meaning that lives in word order is simply gone. And it's meaning-blind: every word is its own separate slot, so "great" and "excellent" are treated as no more related than "great" and "toaster." On top of that the vectors are sparse (a row is almost all zeros, because any one document uses only a tiny slice of a big vocabulary) and huge (as wide as the entire vocabulary — tens of thousands of columns is normal). Keep those four words — order-blind, meaning-blind, sparse, huge — in mind as you read the diagram: TF-IDF will attack "huge and dominated by common words," and embeddings will finally attack "meaning-blind."

The simplest representation: build a vocabulary, then represent each document by how many times each word appears. Order is thrown away — hence "bag."

vocab: [cat, dog, sat, ran] "cat sat" 1 0 1 0 "dog ran" 0 1 0 1 each doc → a count vector the length of the vocabulary A document becomes a count vector. Fix a vocabulary; each document is a vector of word counts over it. Simple, fast, and it works surprisingly well for topic-level tasks. But it's sparse (mostly zeros), huge (vocab-sized), and meaning-blind — "cat"/"dog"/"lawyer" are equally distant.
🗺️ How to read this diagram

This picture shows the core trick of bag-of-words: turn a sentence into a row of numbers by counting words. A computer can't do math on the word "cat", but it can on the number 1.

  • The top line is the vocabulary — a fixed, ordered list of every word we care about: [cat, dog, sat, ran]. Position 1 always means "cat", position 2 always means "dog", and so on.
  • Each sentence becomes one row of counts, one number per vocabulary word. "cat sat" → [1, 0, 1, 0]: one "cat", zero "dog", one "sat", zero "ran".
  • Read a row left-to-right against the vocabulary above it: a 1 means the word is present (once), a 0 means absent. Green cells are the hits.
  • Notice the word order is gone — that's why it's called a "bag". "cat sat" and "sat cat" would give the exact same row of numbers.

In short: A document becomes a vector as long as the vocabulary, mostly full of zeros. That "mostly zeros" (sparse) and "one dimension per word" (meaning-blind) is exactly what the next steps — TF-IDF and embeddings — set out to improve.

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.
Lab K2.1
bow.pyfrom sklearn.feature_extraction.text import CountVectorizer

docs = ["the cat sat", "the dog ran", "the cat ran fast"]
vec = CountVectorizer()
X = vec.fit_transform(docs)     # sparse matrix: docs × vocabulary
print(vec.get_feature_names_out())   # ['cat' 'dog' 'fast' 'ran' 'sat' 'the']
print(X.toarray())              # each row = one doc's word counts
▶ How this works

This is bag-of-words in real code. scikit-learn's CountVectorizer does the whole job for you: it reads your documents, builds the vocabulary, and fills in the word counts — the diagram above, automated.

  1. docs is a plain list of three short strings. These are the "documents" we want to turn into number rows.
  2. vec = CountVectorizer() creates the counter. vec.fit_transform(docs) does two things at once: fit (look at all the docs and decide the vocabulary) and transform (count each word in each doc). The result X is a matrix with one row per document and one column per vocabulary word.
  3. vec.get_feature_names_out() shows the vocabulary in column order — here ['cat' 'dog' 'fast' 'ran' 'sat' 'the'], sorted alphabetically, not in the order you wrote them.
  4. X.toarray() turns the compact "sparse" matrix into a plain grid of numbers so you can read it. Each row lines up with the vocabulary above.

What the output means: You see the vocabulary list, then a grid of counts. For "the cat sat" the row reads 1 0 0 0 1 1 — one "cat", one "sat", one "the", zero of the rest.

Try this: Add a doc like "the dog sat" to docs and re-run. Watch a new row appear; the vocabulary only grows if you introduce a brand-new word.

Bag-of-words is meaning-blind and order-blindTwo flaws limit it. Order-blind: "dog bites man" and "man bites dog" get identical vectors. Meaning-blind: "great" and "excellent" are as unrelated as "great" and "toaster" — there's a dimension per word and no notion that words can be similar. TF-IDF fixes the "common words dominate" problem; embeddings fix the meaning problem.

Lab K2.2 · TF-IDF: weighting by importance essential

Bag-of-words has a loud-neighbour problem. Count the words in almost any English document and the winners are "the," "of," "and," "a" — filler that appears everywhere and tells you nothing about what the document is about. Meanwhile the one word that actually pins down the topic — "diabetes," "refund," "kubernetes" — might appear just once or twice and gets drowned out. If you rank documents by raw counts, you're mostly ranking by how much grammatical glue they contain. TF-IDF is the fix, and it's one of those ideas that feels obvious the moment you see it.

The insight is that a word is informative when it's frequent here but rare everywhere else. Think of how you'd describe a friend in a crowd: you wouldn't say "has two eyes" (everyone does — useless) — you'd say "bright red jacket" (rare, therefore identifying). TF-IDF turns that instinct into a number by multiplying two factors. Term frequency (TF) asks "how much does this word show up in this document?" — the more, the more this document is about it. Inverse document frequency (IDF) asks "how rare is this word across the whole collection?" — and crucially it shrinks the weight of words that appear in many documents. Multiply them and a word only scores high when it's both prominent here and distinctive overall.

The most elegant consequence is worth pausing on: TF-IDF discovers stopwords by itself, from the data. A word like "the" appears in essentially every document, so its IDF is computed as roughly log(N/N) = log(1) = 0 — and anything times zero is zero. You never handed it a list of boring words to ignore; the math notices that ubiquitous words carry no distinguishing signal and mutes them automatically. That's why the same TF-IDF recipe works on medical notes, legal filings, or DevOps runbooks without you curating a stopword list for each — the "importance" is always defined relative to the collection in front of it.

A caution that trips people up: TF-IDF makes the counts smarter, but it does not make them understand. It's still, at heart, bag-of-words — one dimension per word, order discarded, and no clue that two different words can mean the same thing. It solves "common words dominate"; it does not solve "car and automobile are unrelated." That remaining wall is the whole subject of the next section, and it's the reason embeddings had to be invented. For now, notice the code below is nearly identical to the bag-of-words lab — same call, same matrix shape — because the only thing that changed is what each number means.

In bag-of-words, "the" (in every doc) counts as much as "diabetes" (in one). TF-IDF fixes that: weight each word by how often it appears in this doc (term frequency) times how rare it is across all docs (inverse document frequency). Common words get downweighted; distinctive words shine.

weight of a word in a doc = TF × IDF TF frequent in this doc IDF rare across all docs × weight TF × IDF "the" IDF≈0 → mute "diabetes" high → shines a word scores high only when it is frequent HERE and rare EVERYWHERE else Multiply two signals, get importance. TF (prominent here) times IDF (distinctive overall) means "the" — in every doc, IDF≈0 — collapses to zero, while a rare topical word survives.
🗺️ How to read this diagram

Read left-to-right: two input signals on the left combine at the × into a single weight, and the right side shows what that weight does to two example words.

  • The top-left box is TF — how often the word appears in this document. The bottom-left box is IDF — how rare the word is across the whole collection.
  • They meet at the ×: the amber weight box is their product. Multiplying (not adding) is the key — if either factor is near zero, the whole weight is near zero.
  • The red box is "the": it's in every document, so its IDF is about zero, so its weight is muted no matter how often it appears. The green box is "diabetes": frequent in one doc and rare overall, so it scores high.

In short: TF × IDF only rewards words that are both prominent here and distinctive overall — which is how filler words silence themselves.

Lab K2.2

Requires: pip install scikit-learn

tfidf.pyfrom sklearn.feature_extraction.text import TfidfVectorizer

vec = TfidfVectorizer()
X = vec.fit_transform(docs)     # same shape as BoW, but weighted
# "the" (in every doc) → near-zero weight; a rare distinctive word → high weight
▶ How this works

Same idea as bag-of-words, but smarter about which words matter. TfidfVectorizer still produces one number per word per doc — but instead of a raw count, each number is a weight that rewards distinctive words and punishes filler like "the".

  1. vec = TfidfVectorizer() and vec.fit_transform(docs) mirror the bag-of-words code exactly — same call, same shape of result. The only change is what the numbers mean.
  2. Under the hood each number is TF × IDF: how often the word appears in this doc (term frequency) times how rare it is across all docs (inverse document frequency).
  3. Because "the" appears in every document, its IDF is tiny, so its weight collapses toward zero — it can't dominate anymore. A word that shows up in just one doc gets a high weight, marking it as characteristic of that doc.

What the output means: X has the same rows-and-columns shape as the bag-of-words matrix, but the cells are decimals (weights) instead of whole-number counts. Common words → near 0; distinctive words → larger values.

Try this: Print X.toarray() here too and compare it to the bag-of-words grid. Find the column for "the" and confirm its numbers are much smaller than a rare word's.

ComponentMeaning
TF (term frequency)How often the word appears in this document — more = more relevant here
IDF (inverse doc frequency)How rare the word is across all documents — rarer = more distinctive
TF × IDFHigh for words frequent here but rare overall — the words that characterize this doc
TF-IDF is still a real toolDon't dismiss it as ancient. TF-IDF (and its cousin BM25) powers a lot of production keyword search, and it's the sparse half of the hybrid retrieval you met in advanced RAG (A7): combine TF-IDF/BM25 keyword scores with dense embedding scores for the best of both. It's cheap, interpretable, and needs no training — sometimes exactly right.

The gap TF-IDF can't cross essential

Here is the concrete failure that ended the counting era. A customer types "how do I recover my login" into your support search. The perfect answer article says "steps to reset your password." Between those two sentences there is not a single shared content word — recover ≠ reset, login ≠ password — so TF-IDF scores them at essentially zero similarity. The system confidently returns nothing, or worse, something irrelevant that happened to share the word "how." The intent is identical; the vocabulary is not; and TF-IDF can only see vocabulary.

This has a name — vocabulary mismatch — and it isn't a bug you can tune away, it's baked into the whole approach. Remember that in bag-of-words and TF-IDF, every word gets its own private dimension. "Car" is axis 1, "automobile" is axis 2, and to the geometry those two axes are perpendicular — as unrelated as "car" and "banana." The representation literally has no place to record that two words are near-synonyms, because that fact lives between words, and a one-slot-per-word scheme only ever stores facts about single words. No amount of clever weighting rescues you: you can weight "automobile" perfectly and it still shares no axis with "car."

The instinctive patches all disappoint, which is why this deserves its own section. You could hand-build a thesaurus and expand every query with synonyms — but language has millions of words, synonymy is fuzzy and context-dependent ("bank" the riverside vs "bank" the vault), and you'd be maintaining that list forever. You could stem or lemmatize — but that only collapses spelling variants ("running"→"run"), never true synonyms. The honest conclusion the field reached: matching on the surface form of words has a hard ceiling, and to break through you have to represent meaning directly, so that "car" and "automobile" end up close by construction rather than by a lookup table. That is exactly the leap the diagram below sets up and the embeddings section delivers — from matching spellings to matching meanings.

TF-IDF weights words better, but it's still word-matching: every word is its own dimension, and it has no idea that "car" and "automobile" mean the same thing. Search for "automobile" and a document full of "car" scores zero. This vocabulary mismatch is the wall classic representations hit — and the reason embeddings were a revolution.

TF-IDF: orthogonal car automobile unrelated dimensions Embeddings: close car automobile near each other = similar meaning Words vs meaning. TF-IDF gives "car" and "automobile" separate, unrelated dimensions — synonyms are as far apart as opposites. Embeddings place them near each other because they mean the same thing. Crossing from word-matching to meaning-matching is the whole point of the next step.
🗺️ How to read this diagram

This picture shows the one big weakness bag-of-words and TF-IDF cannot fix, and what embeddings do instead. Both panels are little maps where closeness means similar meaning.

  • Left (red, TF-IDF): "car" and "automobile" sit on two separate axes at right angles. To this method they are unrelated dimensions — as far apart as two words that mean totally different things. It only matches identical words.
  • Right (green, embeddings): the same two words are plotted as dots that are close together, because the model learned they mean nearly the same thing.
  • The takeaway: TF-IDF matches spellings; embeddings match meanings. Search "car" over a document that only says "automobile" and TF-IDF scores it zero — the "vocabulary mismatch" wall.

In short: Distance on these maps = difference in meaning. Moving from the left picture to the right one — from separate word-axes to nearby meaning-points — is the whole reason embeddings exist.

Lab K2.3 · Embeddings: meaning as geometry intermediate

This is the idea that quietly powers everything you've built in this course. Every RAG system, every semantic search box, every "find similar" feature rests on one move: representing meaning as a location in space. It sounds abstract, so ground it with a map. Imagine a vast map where every word and sentence is a pin, and the rule is simply things that mean similar things get pinned close together. "Car" and "automobile" sit almost on top of each other; "car" and "banana" are on opposite sides of the map. Once you have that map, "are these two texts about the same thing?" becomes the delightfully concrete question "how far apart are their pins?" — pure geometry, which computers are superb at.

An embedding is exactly that: the coordinates of a text's pin, written as a dense vector of a few hundred to a few thousand real numbers. Contrast it with the sparse vectors you just met. TF-IDF gives you a 30,000-long vector that's almost all zeros, with one dimension nailed to each specific word. An embedding gives you maybe 384 or 1,536 numbers, all of them nonzero and meaningful, where no single number stands for a single word — instead meaning is smeared across the whole vector. That's the crucial architectural shift: giving up "one dimension = one word" is precisely what buys room to place synonyms nearby, because the same handful of dimensions can encode "vehicle-ness" for both "car" and "automobile."

The part that surprises everyone is where the coordinates come from: nobody hand-places the pins. The vectors are learned from data, and the training trick is beautifully simple in spirit — a model reads mountains of text and learns to predict a word from the company it keeps ("you shall know a word by the friends it keeps"). Words that show up in the same kinds of contexts — "doctor," "nurse," "hospital" — drift together on the map; words that never co-occur drift apart. Meaning was never programmed in; it fell out of the statistics of real usage. That is why the classic party trick works: the geometry ends up so structured that king − man + woman lands right next to queen. Directions on the map came to encode concepts like gender and royalty, purely from reading.

One honest caveat before the table, because it matters later: the earliest embeddings (word2vec, GloVe) gave each word one fixed pin regardless of context, so "bank" got a single blurry location caught between rivers and money. Modern embeddings — the ones your APIs return — are contextual: they read the surrounding sentence and place "bank" differently in "river bank" versus "savings bank." Same core idea (meaning as a location, compared by distance), just far sharper. Hold that thread; the "modern embeddings" section picks it up, and K5 reveals the transformer machinery that produces those context-aware coordinates.

An embedding maps each token/word/document to a dense vector (a few hundred to a few thousand real numbers) learned from data so that similar meanings land near each other. Instead of one dimension per word, meaning is spread across the whole vector — and relationships emerge as geometry.

sparse (TF-IDF): vocab-sized, mostly zeros 0 0 1 0 0 0 0 1 0 ≈ 30,000 wide · one dim per word · meaning-blind dense (embedding): short, all meaningful .71 −.2 .05 .63 −.4 ≈ 384–3072 wide · learned · all meaningful same job — represent a text as numbers — two very different shapes made by counting learned from data Sparse vs dense. A TF-IDF vector is enormous and almost all zeros, one slot per word; an embedding is short and every number carries meaning — which is exactly the room needed to place synonyms nearby.
🗺️ How to read this diagram

Compare the two rows of cells across the dashed divider: left is the sparse vector you already met, right is the dense embedding this section introduces.

  • The left row is a TF-IDF-style vector: mostly grey zeros with a couple of green hits. It is vocabulary-sized (tens of thousands wide) and each slot is nailed to one specific word.
  • The right row is an embedding: only a handful of amber cells, but every one holds a real, nonzero number. No single cell means one word — meaning is spread across all of them.
  • The two green pills at the bottom name the crucial difference in how they're built: the sparse vector is made by counting; the dense vector is learned from data.

In short: Giving up "one dimension = one word" for a short, all-meaningful vector is exactly what buys the space to put synonyms close together.

Sparse (BoW / TF-IDF)Dense (embeddings)
SizeVocabulary-sized (huge, mostly zeros)Fixed, small (e.g. 384–3072), all meaningful
Made byCountingLearned from data (a model)
CapturesWhich words appearWhat the text means
SynonymsUnrelatedClose together
The famous "king − man + woman ≈ queen"Early word embeddings (word2vec, GloVe) showed meaning became arithmetic: the vector from "man" to "woman" is roughly the same as "king" to "queen", so king − man + woman lands near queen. That was the proof that a learned geometry captures real semantic structure — analogy, gender, tense, category — not just word identity. Modern contextual embeddings (from transformers, K5) go further: the vector for "bank" differs in "river bank" vs "savings bank", because it's computed in context.

Comparing by meaning: cosine similarity intermediate

You have a map of meaning — now you need a ruler. Embeddings turned every text into a point in space, but "these two points are near each other" is only useful if you can put a number on "near." The obvious ruler is straight-line distance, and it's exactly the wrong one for text. Here's why, and it's a mistake worth understanding rather than memorizing: a long document naturally has a bigger vector (more words push its coordinates further from the origin) than a short one on the identical topic. Straight-line distance would then rank the short focused answer as "less similar" purely for being short — penalizing brevity, which is absurd for search.

The fix is to stop measuring how far the pins are and start measuring which way they point. Picture each vector as an arrow from the origin. Cosine similarity looks only at the angle between two arrows and ignores their length entirely. Two texts about the same thing point the same direction — small angle, high similarity — whether one is a tweet and the other a 10-page report. That length-independence is the single property that makes cosine the default text-similarity metric across the entire industry; it's what lets a one-line FAQ answer fairly beat a rambling article for the same query.

The formula packs this into one line: divide the dot product of the two vectors by the product of their lengths (their norms). Don't let the notation intimidate — the dot product measures how much the arrows overlap, and dividing by both lengths is exactly the step that cancels magnitude so only direction survives. That division is not a technicality you can skip; forget it and you're back to being fooled by document length. The result lands on a clean, fixed scale you can reason about instantly: 1.0 means the arrows point the same way (same meaning), 0 means they're at right angles (unrelated), and −1 means they point opposite ways (opposite meaning). In practice with real embeddings you'll mostly see scores between roughly 0 and 1, and "rank by cosine, keep the top few" is semantic search — the precise operation the code below performs and the beating heart of every RAG retriever in this course.

Once texts are vectors, "how similar in meaning?" becomes "how close are the vectors?" The standard measure is cosine similarity — the angle between vectors, ignoring length. This is the exact operation under semantic search and RAG retrieval.

θ small long doc short doc same direction → same meaning (length ignored) cosine scale: only the angle matters 1.0 — same direction (same meaning) 0 — right angle (unrelated) −1 — opposite direction cos = (a·b) / (‖a‖ ‖b‖) dividing by both norms cancels length Angle, not distance. Two texts about the same thing point the same way regardless of length; dividing the dot product by both norms cancels magnitude so only direction — meaning — survives.
🗺️ How to read this diagram

Look at the left panel first — two arrows from the same origin — then read the scale on the right that turns their angle into a number.

  • The blue arrow is a long document and the dashed green arrow is a short one on the same topic. The green arrow is shorter, but both point in nearly the same direction.
  • The small amber angle θ between them is all cosine cares about. Because it ignores length, the short doc is not penalized for being short — the mistake plain straight-line distance would make.
  • The right panel is the fixed output scale: 1.0 when arrows align (same meaning), 0 at a right angle (unrelated), −1 when opposite. The formula underneath divides by both norms (‖a‖‖b‖) — that division is the step that erases length.

In short: Cosine scores meaning by angle and throws away length, so "rank by cosine, keep the top few" fairly compares texts of any size — which is semantic search.

Lab K2.4

Requires: pip install numpy

similarity.pyimport numpy as np

def cosine(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
# 1.0 = same direction (same meaning), 0 = unrelated, -1 = opposite

# in practice: embed a query and your docs, rank docs by cosine to the query
q = embed("how do I reset my password")
scores = [(d, cosine(q, embed(d))) for d in docs]
top = sorted(scores, key=lambda s: s[1], reverse=True)[:3]
▶ How this works

Once every text is a vector, "how similar are these two texts?" becomes "how close are their two vectors?" Cosine similarity answers that by measuring the angle between them — this is the exact math behind semantic search and RAG retrieval.

  1. cosine(a, b) takes two vectors. np.dot(a, b) is the dot product; dividing by np.linalg.norm(a) * np.linalg.norm(b) (their lengths) cancels out size so only direction matters. Two texts pointing the same way score high no matter how long they are.
  2. The scale is fixed: 1.0 = same direction (same meaning), 0 = unrelated (at right angles), -1 = opposite.
  3. embed(...) turns a piece of text into its vector. We embed the query "how do I reset my password" once, then embed each document.
  4. scores = [(d, cosine(q, embed(d))) for d in docs] scores every doc against the query, then sorted(..., reverse=True)[:3] keeps the three closest — the top search results.

What the output means: top is a list of the 3 documents most similar in meaning to the query, each paired with its cosine score (closest to 1.0 first). That ranked list is semantic search.

Try this: This is the retrieval core of Chapter 3's RAG. Swap the query string and watch which docs rise to the top — you're ranking by meaning, not by shared keywords.

You've built this alreadyCosine similarity and top-k retrieval are implemented from scratch in A4, and they're the retrieval core of Chapter 3's RAG. Here you see why it works: cosine measures angle, and in a good embedding space, small angle = similar meaning. Semantic search is "embed everything, then find the nearest vectors to the query."

From classic to modern embeddings intermediate

When you call an embedding API today, you're standing at the top of a twenty-year staircase — and it helps to know which step you're on. The reason this matters practically: developers who don't know the lineage assume all "embeddings" are the same thing, then get baffled when a word2vec vector and a modern API vector behave completely differently on their task. They're the same idea at wildly different power levels, and confusing the two leads to bad tool choices.

The staircase has three steps, and each one fixed a real limitation of the last. Static word embeddings (word2vec, GloVe, ~2013) were the breakthrough: one fixed vector per word, learned from co-occurrence, good enough to make "king − man + woman ≈ queen" work. Their fatal flaw was exactly the "bank" problem — a single word with two meanings got one averaged, muddy pin, because the model had no way to look at context. Contextual embeddings (BERT-style, from the transformers you'll meet in K5) fixed that by computing a word's vector fresh for each sentence: "bank" near "river" lands somewhere different than "bank" near "deposit." Meaning finally became situational, the way it actually is in language. Sentence and document embeddings (today's retrieval-tuned models and APIs) took the final step: instead of one vector per word, they hand you one vector for a whole passage, deliberately trained so that questions land near their answers — purpose-built for search and RAG rather than as a by-product.

So the honest summary is: the concept never changed — meaning as a location, compared by cosine — but each generation reads more context and packs more of it into the vector. Don't fall for "newer is always better," though. A tiny static embedding can be the right call when you need speed and have no GPU, just as TF-IDF still beats embeddings for exact-term matching. What the ladder buys you is options: knowing where each rung sits lets you pick the cheapest representation that clears your task's bar, which is the same match-the-tool-to-the-job discipline running through this whole course.

The idea is the same across eras; the quality climbed dramatically. Knowing the lineage tells you what today's embedding APIs actually give you.

same idea — meaning as a location — reading more context each step static word word2vec, GloVe contextual BERT-style (K5) sentence / doc modern APIs one pin per word "bank" shifts by sentence a passage → one tuned vector A twenty-year staircase. Each rung fixes the last one's blind spot — static gave "bank" one blurry pin, contextual reads the sentence, sentence/doc models pack a whole passage into one retrieval-tuned vector.
🗺️ How to read this diagram

Read the three boxes bottom-left to top-right, climbing the staircase — each step sits higher because it captures more context than the one before.

  • The lowest box is static word embeddings (word2vec, GloVe): one fixed pin per word, so a word with two meanings — "bank" — gets a single muddy location.
  • The middle box is contextual embeddings (BERT-style, from the transformers in K5): the vector for a word is recomputed for each sentence, so "river bank" and "savings bank" land in different places.
  • The top box is sentence / document embeddings — today's APIs — which turn a whole passage into one vector deliberately tuned so questions land near their answers.
  • The rising arrows show the through-line: the core idea (meaning as a location) never changes; each rung just reads more context and packs more of it into the vector.

In short: Newer rungs read more context, but the ladder buys you options — pick the cheapest rung that clears your task's bar.

GenerationExampleKey property
Static wordword2vec, GloVeOne fixed vector per word (no context — "bank" is one point)
ContextualBERT-style (from transformers, K5)Vector depends on the sentence — "bank" shifts by context
Sentence / documentModern embedding models & APIsA whole passage → one vector, tuned for retrieval
This is what an embedding API returnsWhen you call an embedding model for RAG (Ch 3, A7), you get a modern contextual, document-level embedding — the top of this ladder. It's the same core idea as word2vec (meaning as geometry, compared by cosine), just far more capable because it's produced by a transformer that reads the whole text in context. K5 is where those context-aware vectors come from.

Choosing a representation advanced

what does the task need? match text how? by term or by meaning TF-IDF / BM25 exact terms embeddings semantic search both (hybrid) production retrieval exact keyword meaning match the tool to the task, not to the hype A three-way fork. Exact-term matching wants sparse TF-IDF; meaning wants dense embeddings; production retrieval fuses both to get keyword precision and semantic recall.
🗺️ How to read this diagram

Start at the top box — the question you ask about the task — then follow the arrow down to the representation that answers it.

  • The top box is the deciding question: are you matching text by exact term or by meaning?
  • The left branch (labelled "exact keyword") lands on TF-IDF / BM25 — best for exact terms like product codes, small data, and interpretable features.
  • The right branch (labelled "meaning") lands on embeddings — best for synonyms, paraphrase, and semantic search.
  • The middle green box is the production answer: use both and fuse the scores, getting keyword precision plus semantic recall.

In short: Let the task's matching need — term vs meaning — pick the branch; in production you usually take the hybrid middle path.

Use…When…
Bag-of-words / TF-IDFKeyword matching, small/interpretable models, no training budget, the sparse half of hybrid search
EmbeddingsSemantic search, "find similar meaning", RAG, clustering by topic
Both (hybrid)Production retrieval — keyword precision + semantic recall (A7)
Newer isn't automatically better hereEmbeddings win on meaning, but TF-IDF/BM25 still beat them for exact-term matching (product codes, names, rare jargon) and cost nothing to run. The strongest retrieval systems (A7) use both and fuse the scores. Same lesson as the rest of the course: match the tool to the task, not to the hype.

Common pitfalls advanced

PitfallFix
Expecting BoW/TF-IDF to grasp meaningThey match words; use embeddings for meaning
Dismissing TF-IDF as obsoleteStill great for keyword search & hybrid retrieval
Using dot product without normalizingCosine ignores length; normalize or use cosine directly
Assuming synonyms match in TF-IDFThey don't — vocabulary mismatch; that's what embeddings fix
Ignoring hybrid search in productionCombine sparse + dense for best retrieval (A7)

Exercises advanced

Exercise K2.1 — BoW vs TF-IDF

Context: Seeing TF-IDF re-weight a common word versus a distinctive one, side by side with raw counts, is the fastest way to internalize what idf buys you.

Your task: Vectorize five short documents with both CountVectorizer and TfidfVectorizer, then compare the weights for a filler word like the against a distinctive one.

Requirements:

  • Vectorize the same docs with counts and with TF-IDF
  • Inspect the weight a common word gets under each scheme
  • Inspect a distinctive word's weight under each scheme
  • Confirm TF-IDF downweights the filler and highlights the signal

💡 Hint: Watch the ubiquitous term's weight collapse under TF-IDF while the rare term's weight holds up.

Exercise K2.2 — The synonym wall

Context: The synonym wall is the single clearest motivation for embeddings: intent-identical sentences with no shared words score low under keyword matching.

Your task: Compute TF-IDF cosine between "I need to reset my password" and "how do I recover my login", then embed both and compare cosine — explain the jump.

Requirements:

  • Measure TF-IDF cosine between the two intent-identical sentences
  • Observe the similarity is low despite matching intent
  • Embed both sentences and recompute cosine
  • Attribute the jump to the vocabulary-mismatch wall embeddings cross

💡 Hint: TF-IDF sees almost no shared tokens (reset/recover, password/login); embeddings place both near "regain account access".

Show what to look for

TF-IDF sees almost no shared words (reset/recover, password/login) → low similarity. Embeddings place both near "regain account access" → high similarity. That gap is the vocabulary-mismatch wall, and crossing it is why semantic search works.

Exercise K2.3 — Semantic search in ~20 lines

Context: Ranking embedded FAQ answers by cosine against an embedded question is, essentially, the retrieval core of a RAG system — built up from the representation layer.

Your task: Embed a small set of FAQ answers, embed a user question, rank the answers by cosine, and return the top match.

Requirements:

  • Embed each FAQ answer once into a dense vector
  • Embed the incoming question with the same model
  • Rank the answers by cosine similarity to the question
  • Return the single best-matching answer

💡 Hint: This is the same retrieve-by-cosine loop RAG uses; you have just built its core from scratch.

🪜 Practice ladder beginner → industry

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

Exercise 1 · Bag-of-words vectorsBeginner

Context: Bag-of-words is the simplest text→vector map: fix a vocabulary, then represent each document as counts over that vocabulary.

Your task: Build a vocabulary from a corpus, then vectorize each document as a count vector in vocabulary order.

Requirements:

  • Derive the vocabulary as the sorted unique tokens across the corpus
  • Represent each document as counts aligned to the vocabulary indices
  • Words outside the vocabulary contribute nothing
  • Print the vocabulary and each document's bag-of-words vector

💡 Hint: Every dimension is one vocabulary word and its value is that word's count — nothing more; the weighting problem is what TF-IDF fixes next.

Show solution

Vocabulary = sorted unique tokens; vector = counts in vocab order. Runnable:

from collections import Counter

def build_vocab(corpus):
    vocab = sorted({w for doc in corpus for w in doc.split()})
    return {w: i for i, w in enumerate(vocab)}

def bow(doc, vocab):
    v = [0] * len(vocab)
    for w, c in Counter(doc.split()).items():
        if w in vocab:
            v[vocab[w]] = c
    return v

corpus = ["cat sat mat", "dog sat log", "cat and dog"]
vocab = build_vocab(corpus)
print("vocab:", list(vocab))
for d in corpus:
    print(bow(d, vocab), d)

BoW is the simplest text→vector map, but it treats every word as equally important — which TF-IDF fixes next.

Exercise 2 · TF-IDF weighting by handIntermediate

Context: TF-IDF down-weights words that appear everywhere by multiplying term frequency by inverse document frequency, so filler words fade and distinctive terms stand out.

Your task: Implement tfidf(corpus) as term frequency × inverse document frequency and show that ubiquitous words receive near-zero weight.

Requirements:

  • Compute document frequency per term across the corpus
  • Use idf = log(N / df) and multiply by term frequency
  • A word appearing in every document gets idf = 0 and vanishes
  • Distinctive words score higher than common ones

💡 Hint: TF-IDF discovers stopwords from the data itself: a term in all N docs has log(N/N)=0 idf and contributes nothing.

Show solution

idf = log(N / df); tf-idf = tf × idf. Pure stdlib. Runnable:

import math
from collections import Counter

def tfidf(corpus):
    docs = [doc.split() for doc in corpus]
    N = len(docs)
    df = Counter()
    for d in docs:
        for w in set(d):
            df[w] += 1
    out = []
    for d in docs:
        tf = Counter(d)
        n = len(d)
        out.append({w: (tf[w] / n) * math.log(N / df[w]) for w in tf})
    return out

corpus = ["the cat sat", "the dog sat", "the cat ran"]
for row in tfidf(corpus):
    print({w: round(s, 3) for w, s in sorted(row.items())})
# 'the' -> 0.0 everywhere (df==N, idf==0); distinctive words score higher

The word "the" appears in every doc, so its idf is log(N/N)=0 and it contributes nothing — TF-IDF automatically discovers stopwords from the data.

Exercise 3 · Cosine similarity between vectorsAdvanced

Context: Cosine similarity compares vector direction, not magnitude, so a short focused document and a long one are compared fairly — the property that makes it the default text-similarity metric.

Your task: Implement cosine(a, b) in the standard library and use it to rank documents against a query vector.

Requirements:

  • Compute cosine as the dot product over the product of the norms
  • Handle a zero-length vector without dividing by zero
  • Rank the documents best-first by similarity to the query
  • Show that length independence lets a short doc outrank a longer one

💡 Hint: Because cosine normalizes out magnitude, document length stops distorting the match; only the shared-term direction matters.

Show solution

cos(a,b) = a·b / (|a||b|). Pure stdlib. Runnable:

import math
from collections import Counter

def cosine(a, b):
    keys = set(a) | set(b)
    dot = sum(a.get(k, 0) * b.get(k, 0) for k in keys)
    na = math.sqrt(sum(v*v for v in a.values()))
    nb = math.sqrt(sum(v*v for v in b.values()))
    return dot / (na * nb) if na and nb else 0.0

def vec(text):
    return Counter(text.split())

query = vec("cat food")
docs = {"d1": vec("cat food cat treats"),
        "d2": vec("dog food"),
        "d3": vec("premium cat food for cats")}
for name, sim in sorted(((n, cosine(query, v)) for n, v in docs.items()),
                        key=lambda x: -x[1]):
    print(f"{name}: {sim:.3f}")

Cosine ignores length, so a short focused doc can outrank a long one — the property that makes it the default text-similarity metric.

Exercise 4 · The gap TF-IDF can't crossExpert

Context: TF-IDF has a hard ceiling: two synonyms with no shared token score zero similarity. Crossing that vocabulary-mismatch wall is exactly why semantic search moves to embeddings.

Your task: Demonstrate that cosine('car', 'automobile') is 0 under BoW/TF-IDF, then show a toy embedding where a shared latent dimension gives them high similarity.

Requirements:

  • Show lexical cosine between the synonyms is exactly 0 (no shared token)
  • Hand-build a small dense embedding where synonyms share a latent dimension
  • Compute cosine on the dense vectors to show the synonyms now score high
  • Confirm an unrelated word stays far apart in that space

💡 Hint: The leap is lexical → semantic: BoW sees no overlap, but a dense space places car and automobile near each other.

Show solution

First show TF-IDF's blind spot, then a hand-built embedding where synonyms share a dimension. Runnable:

import math
from collections import Counter

def cosine(a, b):
    keys = set(a) | set(b)
    dot = sum(a.get(k,0)*b.get(k,0) for k in keys)
    na = math.sqrt(sum(v*v for v in a.values()))
    nb = math.sqrt(sum(v*v for v in b.values()))
    return dot/(na*nb) if na and nb else 0.0

print("TF-IDF/BoW cosine('car','automobile') =",
      cosine(Counter(["car"]), Counter(["automobile"])))   # 0.0 -- no shared token

# toy embedding: both map onto a 'vehicle' latent dimension
EMB = {"car":        [0.9, 0.1, 0.0],
       "automobile": [0.88, 0.12, 0.0],
       "banana":     [0.0, 0.1, 0.95]}
def cos_vec(a, b):
    dot = sum(x*y for x, y in zip(a, b))
    na = math.sqrt(sum(x*x for x in a)); nb = math.sqrt(sum(y*y for y in b))
    return dot/(na*nb)
print("embedding cosine('car','automobile') =", round(cos_vec(EMB["car"], EMB["automobile"]), 3))
print("embedding cosine('car','banana')     =", round(cos_vec(EMB["car"], EMB["banana"]), 3))

BoW/TF-IDF sees no overlap between synonyms; embeddings place them near each other in a dense space — the leap from lexical to semantic matching. Real embeddings come from a trained model (labeled needs-libs).

Exercise 5 · Choosing a representationProfessional

Context: Choosing a representation is a rule-of-thumb the lesson makes concrete: sparse TF-IDF for exact terms, small data, and interpretability; dense embeddings for meaning, synonyms, and transfer.

Your task: Encode choose_representation(need) that maps a stated requirement to the representation the lesson recommends.

Requirements:

  • Route exact-keyword / interpretable / tiny-dataset needs to TF-IDF (sparse)
  • Route synonym / paraphrase / semantic-search needs to dense embeddings
  • Cover a cross-lingual need with multilingual embeddings
  • Fall back to prototyping both when no rule matches

💡 Hint: Sparse for exact terms, small data, and interpretability; dense for meaning, synonyms, and cross-lingual transfer.

Show solution

Map the requirement to the representation the lesson recommends. Runnable:

def choose_representation(need):
    rules = [
        ("exact keyword match",   "TF-IDF / BM25 (sparse, exact terms)"),
        ("interpretable features","TF-IDF (each dim is a word)"),
        ("tiny labeled dataset",  "TF-IDF + linear model (few params)"),
        ("synonyms / paraphrase", "dense embeddings (semantic)"),
        ("semantic search",       "dense embeddings + vector index"),
        ("cross-lingual",         "multilingual embeddings"),
    ]
    for key, rec in rules:
        if key in need:
            return rec
    return "prototype both and measure"

for n in ["exact keyword match", "semantic search", "synonyms / paraphrase"]:
    print(f"{n:>24} -> {choose_representation(n)}")

The rule of thumb: sparse (TF-IDF) for exact terms, small data, and interpretability; dense embeddings for meaning, synonyms, and transfer.

Exercise 6 · A mini TF-IDF search indexIndustry scenario

Context: A TF-IDF search index is a legitimate retrieval baseline — many production search systems start exactly here before adding dense embeddings for the semantic gap.

Your task: Ship a small TfidfIndex class: fit TF-IDF on a corpus, then rank documents for a free-text query by cosine similarity.

Requirements:

  • Fit vocabulary and idf over the corpus at construction time
  • Build a TF-IDF vector per document
  • A search(query, k) vectorizes the query and ranks by cosine
  • Return the top-k documents best-first

💡 Hint: This is the classic sparse retrieval baseline (TF-IDF/BM25); embeddings get bolted on later only to close the synonym gap.

Show solution

Combine vocab, idf, tf-idf vectors, and cosine ranking into one class. Runnable:

import math
from collections import Counter

class TfidfIndex:
    def __init__(self, corpus):
        self.docs = [d.split() for d in corpus]
        self.raw = corpus
        N = len(self.docs)
        df = Counter(w for d in self.docs for w in set(d))
        self.idf = {w: math.log((1 + N) / (1 + df[w])) + 1 for w in df}
        self.vecs = [self._vec(d) for d in self.docs]
    def _vec(self, toks):
        tf = Counter(toks); n = len(toks) or 1
        return {w: (tf[w]/n) * self.idf.get(w, 0.0) for w in tf}
    def _cos(self, a, b):
        keys = set(a) | set(b)
        dot = sum(a.get(k,0)*b.get(k,0) for k in keys)
        na = math.sqrt(sum(v*v for v in a.values()))
        nb = math.sqrt(sum(v*v for v in b.values()))
        return dot/(na*nb) if na and nb else 0.0
    def search(self, query, k=3):
        q = self._vec(query.split())
        scored = [(self._cos(q, v), self.raw[i]) for i, v in enumerate(self.vecs)]
        return sorted(scored, reverse=True)[:k]

idx = TfidfIndex(["cheap flights to paris", "best pizza in paris",
                  "cheap car rental", "luxury hotels paris"])
for score, doc in idx.search("cheap paris trip", k=3):
    print(f"{score:.3f}  {doc}")

This is a legitimate retrieval baseline — many production search systems start exactly here (TF-IDF/BM25) before adding dense embeddings for the semantic gap.

✓ Checkpoint — you can move on when you can…

  • Explain why text must become vectors and what a good representation preserves.
  • Build bag-of-words and TF-IDF and state their limits.
  • Explain embeddings as dense, learned, meaning-capturing vectors.
  • Use cosine similarity to rank texts by meaning.
  • Choose sparse, dense, or hybrid for a retrieval task.
🏗️ Toward the capstoneThe capstone's runbook retrieval embeds each incident and finds the nearest procedure by cosine — this chapter is that mechanism from first principles. And the "meaning as geometry" idea is the seed of everything: transformers (K5) are, in a sense, machines that build ever-better contextual embeddings. Next, K3 puts these representations to work in a classifier. Next: text classification →

Knowledge check check yourself

✓ Knowledge check

What is the 'vocabulary mismatch' wall that TF-IDF cannot cross, and how do embeddings solve it?

Show answer
In TF-IDF every word is its own orthogonal dimension, so 'car' and 'automobile' are as unrelated as opposites — a query for one scores zero against a doc using the other. Embeddings place synonyms near each other in vector space, matching meaning instead of exact spelling.
✓ Knowledge check

Cosine similarity is the standard way to compare embedded texts. What does it measure, why divide by the vectors' norms, and what do 1, 0, and -1 mean?

Show answer
It measures the angle between two vectors; dividing by both norms cancels length so only direction (meaning) matters. 1.0 = same direction/meaning, 0 = unrelated (right angles), -1 = opposite.
© 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