Module 3 of 6 · 35 minutes

Embeddings and semantic search

Embeddings let systems match meaning rather than exact words. They're the foundation of semantic search, recommendations and RAG.

Learning objectives

  • Explain what an embedding is
  • Compute cosine similarity
  • Describe the steps of semantic search
  • Recognise the limits of embeddings

3.1 Meaning, not just words

TF-IDF only matches exact words: "parcel" and "package" look unrelated. Embeddings fix this by representing words, sentences or documents as lists of numbers (vectors) learned so that texts with similar meanings sit close together. They power semantic search, recommendations, clustering and retrieval-augmented generation.

parcel still missingpackage not arrivedcourier lost my boxrefund not receivedcharged twicemoney back pleasekettle brokelamp stopped workingquery: “my package hasn't come”meaning space (2D sketch)
Embeddings place texts with similar meanings close together, so a query finds its nearest neighbours even without shared words.

3.2 Measuring similarity

The usual measure is cosine similarity: how closely two vectors point in the same direction, from −1 (opposite) to 1 (identical direction). You can try the idea right now with TF-IDF vectors:

from sklearn.metrics.pairwise import cosine_similarity

query = ["my package has not arrived"]
q_vec = tfidf.transform(query)
scores = cosine_similarity(q_vec, X_tfidf).ravel()
best = scores.argsort()[-3:][::-1]
for i in best:
    print(round(scores[i], 2), messages["text"].iloc[i])

Notice that TF-IDF can only match on shared words like "arrived" — it doesn't know "package" means "parcel". That's the gap embeddings close.

3.3 Getting embeddings

Embeddings come from trained models. You can call an embeddings API (for example OpenAI's embeddings, Amazon Bedrock or Google Vertex AI), or run an open-source model locally. The shape of the code is the same either way:

Python · embeddings via an API (optional — needs an API key)import os
from openai import OpenAI

client = OpenAI()   # reads OPENAI_API_KEY from the environment — never paste keys into code
texts = ["my package has not arrived", "parcel still missing", "refund not received"]
resp = client.embeddings.create(model="text-embedding-3-small", input=texts)
vectors = np.array([d.embedding for d in resp.data])
print(vectors.shape)                         # (3, 1536)
print(cosine_similarity(vectors).round(2))   # the first two should be most similar

3.4 Semantic search in four steps

  1. Embed every document (or chunk) once and store the vectors.
  2. Embed the user's query with the same model.
  3. Rank documents by cosine similarity to the query.
  4. Return the top results — or pass them to an LLM to answer from (RAG).

For more than a few thousand documents, store vectors in a vector database or index that finds nearest neighbours quickly. Combining embeddings with keyword search (hybrid search) usually beats either alone — see the RAG course.

Embed alldocuments onceStore vectors(index / vector DB)Embed thequeryRank by cosinesimilarityReturn top results(or feed an LLM)
Semantic search in practice. Steps 1–2 happen in advance; steps 3–5 run for every query.

3.5 Limits of embeddings

  • Similar isn't the same as correct: "refund approved" and "refund refused" can sit close together
  • Embeddings reflect their training data, including its biases and blind spots
  • Domain jargon and rare product names may be poorly represented — test on your own data
  • Changing embedding model means re-embedding everything; vectors from different models aren't comparable
Exercise: a tiny FAQ search

Create a list of five FAQ answers (for example on returns, delivery times, refunds, discount codes and broken items). Using a TfidfVectorizer fitted on the FAQs, write search(question) that returns the best-matching answer and its score. Try a question that uses different words from the FAQ — what happens?

Show a solution
faqs = ["Returns are free within 30 days of delivery.",
        "Standard delivery takes 3 to 5 working days.",
        "Refunds are paid to your original payment method within 7 days.",
        "Discount codes must be entered before checkout and cannot be combined.",
        "If an item arrives broken, send a photo and we will replace it."]
faq_vec = TfidfVectorizer(stop_words="english").fit(faqs)
faq_X = faq_vec.transform(faqs)

def search(question):
    s = cosine_similarity(faq_vec.transform([question]), faq_X).ravel()
    return faqs[s.argmax()], round(s.max(), 2)

print(search("how long does delivery take"))
print(search("my mug came smashed"))   # no shared words: score 0 — embeddings would do better

Key takeaways

  • Embeddings place similar meanings close together.
  • Cosine similarity measures how closely vectors point the same way.
  • Embed documents once, embed each query, rank, return.
  • Similar isn't the same as correct; test on your own data.
  • Don't mix vectors from different models.