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

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.

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

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

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.

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

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

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

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.

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

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

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

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