Part 01 · Mathematics for AI

Project: Find Similar Sentences with NumPy

NumPyBag of wordsCosine similarityTop-k

Turn sentences into count vectors and rank them by cosine similarity, with nothing but NumPy. The same idea, scaled up, is how semantic search works later in the course.

⏱ 3-4 hours · Station 5 of 92

What you'll build

A support team gets the same questions again and again. When a customer types "how do I get a refund", the help widget should show the closest existing answers. You'll build the search behind it: give it a sentence, get back the most similar sentences from a list, ranked by a score.

You'll use only NumPy and the ideas from this part: sentences become vectors, similarity is a cosine, and scoring every sentence at once is one matrix multiply. By the end you'll also see exactly where this approach breaks, which is the reason embedding models (Part 5) exist.

Prerequisites

Architecture

sentences13 FAQ lines13 support questions go in once, when the index is builttexttokenizelowercase words"How do I reset..." -> how, do, i, reset, ...wordscount + IDF(13, 48) matrixcount each word, then down-weight words every doc hasvectorsnormalizerows length 1divide each row by its length so a dot product is a cosineunit rowsmatrix @ query13 cosinesthe query becomes a vector too; one multiply scores every docscorestop-kbest 2sort the scores and return the best kbuild once, then every query is one matrix multiply and a sort
Building the index: sentences are split into words, counted into a matrix, re-weighted so common words count less, and every row is scaled to length 1. A query goes through the same steps, one matrix multiply scores it against every sentence, and a sort returns the top k.

Two phases, like every search engine:

PhaseWhen it runsCost
Indexonce, when the sentences changetokenize, count, weight and normalize every sentence
Queryon every searchturn one query into a vector, one matrix multiply, one sort

The trick that keeps queries cheap: normalize every row to length 1 when you build the index. Then cosine similarity is just a dot product, and all of them at once is matrix @ query.

Build it step by step

Step 1: tokenize. Lowercase the text and keep runs of letters. Punctuation and case stop mattering: "Password?" and "password" are the same word.

Step 2: vocabulary. Collect every distinct word across all sentences and give each one a column number. Sorting them makes the columns stable between runs.

Step 3: count matrix. One row per sentence, one column per word, each cell is how many times that word appears. This is a bag of words: order is thrown away, only counts stay.

Step 4: IDF weights. Words like "how", "do", "i", "my" appear everywhere, so matching them says little. Inverse document frequency gives each word a weight that's small when many sentences contain it and large when few do: log((1 + n) / (1 + df)) + 1, where df is the number of sentences containing the word. Multiply each column by its weight. Counts × IDF is called TF-IDF.

Step 5: normalize. Divide each row by its length. Guard the empty row: a sentence with no known words would divide by zero and produce NaN.

Step 6: query. Push the query through the same steps 1, 3, 4, 5 (with the same vocabulary and weights), multiply, sort, return the top k.

The whole module:

"""Find similar sentences with nothing but NumPy: bag of words, TF-IDF, cosine, top-k."""
import re
import numpy as np

DOCS = [
    "How do I reset my password",
    "I forgot my password and cannot log in",
    "Change the email on my account",
    "The app crashes when I open the camera",
    "Camera does not work after the update",
    "How do I delete my account",
    "Refund for a cancelled order",
    "My order arrived late and the box was damaged",
    "Where is my order",
    "Cancel my order and give me a refund",
    "How do I change my phone number",
    "How do I track my parcel",
    "How do I contact support",
]

def tokenize(text):
    return re.findall(r"[a-z]+", text.lower())

def build_vocab(docs):
    words = sorted({w for d in docs for w in tokenize(d)})
    return {w: i for i, w in enumerate(words)}

def count_matrix(docs, vocab):
    m = np.zeros((len(docs), len(vocab)))
    for row, d in enumerate(docs):
        for w in tokenize(d):
            if w in vocab:  # words never seen while building the vocabulary are dropped
                m[row, vocab[w]] += 1
    return m

def idf_weights(counts):
    df = (counts > 0).sum(axis=0)              # how many docs contain each word
    return np.log((1 + len(counts)) / (1 + df)) + 1

def normalize(m):
    norms = np.linalg.norm(m, axis=1, keepdims=True)
    norms[norms == 0] = 1                      # an empty row stays all zeros, no NaN
    return m / norms

class Index:
    def __init__(self, docs, use_idf=True):
        self.docs = docs
        self.vocab = build_vocab(docs)
        counts = count_matrix(docs, self.vocab)
        self.idf = idf_weights(counts) if use_idf else np.ones(len(self.vocab))
        self.matrix = normalize(counts * self.idf)  # (n_docs, n_words), each row length 1

    def search(self, query, k=3):
        q = normalize(count_matrix([query], self.vocab) * self.idf)[0]
        scores = self.matrix @ q                    # one matrix multiply = every cosine at once
        top = np.argsort(-scores, kind="stable")[:k]
        return [(float(scores[i]), self.docs[i]) for i in top]

A script that runs each step and compares plain counts against TF-IDF:

from search import DOCS, Index, build_vocab, count_matrix, tokenize

print("step 1  tokens:", tokenize("How do I reset my Password?"))
vocab = build_vocab(DOCS)
print("step 2  vocabulary size:", len(vocab))
counts = count_matrix(DOCS, vocab)
print("step 3  count matrix shape:", counts.shape)

for use_idf in (False, True):
    index = Index(DOCS, use_idf=use_idf)
    print(f"\nstep 4  {'TF-IDF' if use_idf else 'plain counts'}")
    for query in ["how do i get a refund", "i can't log in", "automobile insurance"]:
        print(f"  query: {query!r}")
        for score, doc in index.search(query, k=2):
            print(f"    {score:.3f}  {doc}")

Output:

step 1  tokens: ['how', 'do', 'i', 'reset', 'my', 'password']
step 2  vocabulary size: 48
step 3  count matrix shape: (13, 48)

step 4  plain counts
  query: 'how do i get a refund'
    0.600  How do I contact support
    0.548  How do I reset my password
  query: "i can't log in"
    0.612  I forgot my password and cannot log in
    0.258  How do I contact support
  query: 'automobile insurance'
    0.000  How do I reset my password
    0.000  I forgot my password and cannot log in

step 4  TF-IDF
  query: 'how do i get a refund'
    0.468  Refund for a cancelled order
    0.388  Cancel my order and give me a refund
  query: "i can't log in"
    0.626  I forgot my password and cannot log in
    0.107  How do I reset my password
  query: 'automobile insurance'
    0.000  How do I reset my password
    0.000  I forgot my password and cannot log in

Reading it:

  • 13 sentences produced 48 distinct words, so the index is a (13, 48) matrix. Every sentence is a vector in 48 dimensions, mostly zeros.
  • Plain counts get "how do i get a refund" wrong. The top hits are "How do I contact support" and "How do I reset my password", because "how", "do", "i" match. The refund sentences lose.
  • TF-IDF fixes it. Four FAQ lines start with "How do I", so those words get low weights, and "refund" (in only 2 sentences) gets a high one. Now both refund sentences are on top.
  • "automobile insurance" scores 0.000 everywhere. None of its words are in the vocabulary, so its vector is all zeros. The results it "returns" are just the first rows. A bag of words knows nothing about meaning: "car" and "automobile" would also score 0 against each other.

Test it

Three properties every version of this search must keep:

from search import DOCS, Index

index = Index(DOCS)

def test_exact_sentence_ranks_first_with_score_one():
    for doc in DOCS:
        score, top = index.search(doc, k=1)[0]
        assert top == doc and abs(score - 1) < 1e-9

def test_unknown_words_give_zero_not_nan():
    for score, _ in index.search("zzz qqq", k=3):
        assert score == 0

def test_scores_are_sorted():
    scores = [s for s, _ in index.search("refund my order", k=5)]
    assert scores == sorted(scores, reverse=True)

if __name__ == "__main__":
    for name, fn in list(globals().items()):
        if name.startswith("test_"):
            fn()
            print("pass", name)
pass test_exact_sentence_ranks_first_with_score_one
pass test_unknown_words_give_zero_not_nan
pass test_scores_are_sorted
  • A sentence finds itself with score 1. If this fails, normalization or the vocabulary is broken.
  • Unknown words give 0, never NaN. This is the empty-row guard from step 5. Without it, one empty query poisons the sort.
  • Scores come back sorted. Cheap to check, and it catches an inverted sort, a common slip with argsort.

Beyond unit tests, keep a short list of real queries with the answer you expect (a tiny golden set, Part 8) and check the top result after every change.

Trade-offs

ChoiceGoodCosts
Bag of wordssimple, fast, explainable: you can see which words matchedno meaning: synonyms score 0, word order is lost ("dog bites man" = "man bites dog")
TF-IDF over countscommon words stop drowning out the important onesweights depend on the corpus; adding sentences changes every score
Dense matrixone NumPy line does the scoring13 × 48 is nothing, but 1 million sentences × 100,000 words is 400 GB of mostly zeros; real systems use sparse matrices or inverted indexes
Brute-force scoringexact, no index to tunecost grows with every sentence; at millions you need the approximate search of Part 5

This method is still alive in production as BM25, a refined TF-IDF used by Elasticsearch and others. Part 5 combines it with embeddings in hybrid search, because each one catches what the other misses.

Take it further

  1. Add bigrams (pairs of neighbouring words like "log in") as extra columns, and see whether "i can't log in" ranks better.
  2. Swap the dense matrix for scipy.sparse.csr_matrix and time both with 50,000 generated sentences.
  3. Add a minimum score: return "no good match" below 0.2 instead of showing unrelated rows like the "automobile insurance" results.
  4. Write down three queries where meaning matters ("my parcel is late", "money back") and keep them. When you reach embeddings in Part 5, run them again and compare.

Station stamp

Finish the chapter

Done reading? Press Complete to save your progress. XP is Pro-only — on Pro this chapter is worth +20 XP.

Go Pro to earn XP →