AI Tutorial
Home All Courses Blog Interview Prep ↗ 0%
1
Curious builder 0 XP earned · 300 to level 2
0 days Finish a lesson to begin
Badge collection
0 of 5 unlocked
small wins to finish your path
Next lesson →

Embeddings and similarity

Embeddings are what make semantic search possible. An embedding model turns a piece of text into a vector of numbers so that texts with similar meanings end up close together — even when they share no words. This lesson explains embeddings properly, shows how to create them with hosted APIs and local models, how to measure similarity, and how to choose and operate an embedding model in a real system.

Last updated: 01 Nov, 2026

Definitions

Definition · Embedding

A fixed-length vector of floating-point numbers (for example 384, 768, 1,536, or 3,072 dimensions) produced by an embedding model to represent the meaning of a text. Similar meanings produce vectors that point in similar directions.

Definition · Embedding model

A neural network — usually a transformer encoder — trained, often with contrastive learning, so that related texts (a question and its answer, a sentence and its paraphrase) map to nearby vectors and unrelated texts map far apart.

Definition · Semantic search

Retrieving documents by meaning rather than exact words: “How do I get my money back?” matches a passage about “refunds” even though no words overlap.

Why keyword search is not enough

QuestionRelevant passageShared words
How do I get my money back?Refunds are issued within 30 days.None
My laptop won't turn onTroubleshooting a device that does not power upNone
Cancel subscriptionHow to end your planNone

A good embedding model places each question near its passage. The toy embedding in this course cannot do that — it only matches words — which is precisely the gap real models fill.

Creating embeddings with an API

Python
from openai import OpenAI

client = OpenAI()

texts = [
    "Refunds are issued within 30 days for annual plans.",
    "How do I get my money back?",
    "Backups run every night at 2 AM.",
]

response = client.embeddings.create(model="text-embedding-3-small", input=texts)
vectors = [item.embedding for item in response.data]

print(len(vectors), "vectors of", len(vectors[0]), "dimensions")
print("tokens used:", response.usage.total_tokens)
3 vectors of 1536 dimensions tokens used: 29

One request can embed many texts at once — always batch, rather than calling the API once per chunk. Embeddings are cheap compared with chat generation: indexing a few thousand pages typically costs cents.

Creating embeddings locally

Open-source models from the sentence-transformers library run on your own CPU or GPU — free, private, and offline.

Python
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")      # ~90 MB, 384 dimensions

texts = ["Refunds are issued within 30 days.", "How do I get my money back?", "Backups run nightly."]
vectors = model.encode(texts, normalize_embeddings=True)

print(vectors.shape)
print((vectors @ vectors.T).round(2))     # cosine similarity matrix
(3, 384) [[1. 0.48 0.11] [0.48 1. 0.11] [0.11 0.11 1. ]]

The refund passage and the “money back” question score 0.48, while both score only 0.11 against the backups sentence — semantic similarity without shared words. (Exact values vary slightly by library version and hardware.)

Similarity metrics

MetricFormula (idea)RangeNotes
Cosine similaritydot(a, b) / (|a| × |b|)−1 to 1 (higher = more similar)The standard for text embeddings; ignores vector length.
Dot productsum(a × b)UnboundedEquals cosine when vectors are normalised to length 1 — and is faster.
Euclidean (L2) distance√sum((a − b)²)0 to ∞ (lower = more similar)Gives the same ranking as cosine for normalised vectors.

Normalise your vectors once (many APIs already return unit-length vectors), and then a plain dot product is cosine similarity. Here it is with the toy embedding used throughout the course:

Python
import hashlib
import re

import numpy as np

STOPWORDS = {"a", "an", "the", "is", "are", "be", "can", "do", "does", "i", "my", "to",
             "of", "in", "on", "for", "and", "or", "what", "how", "it", "you", "your", "at"}


def words(text: str) -> list[str]:
    """Lowercase, drop stopwords, and strip a few suffixes (refunded -> refund)."""
    out = []
    for w in re.findall(r"[a-z0-9-]+", text.lower()):
        if w in STOPWORDS:
            continue
        for suffix in ("ing", "ed", "s"):
            if w.endswith(suffix) and len(w) > len(suffix) + 3:
                w = w[: -len(suffix)]
                break
        out.append(w)
    return out


def embed(text: str, dim: int = 256) -> np.ndarray:
    """Toy embedding: hashed bag of words, L2-normalised. Real models capture meaning."""
    vec = np.zeros(dim, dtype=np.float32)
    for word in words(text):
        bucket = int(hashlib.md5(word.encode()).hexdigest(), 16) % dim
        vec[bucket] += 1.0
    norm = np.linalg.norm(vec)
    return vec / norm if norm else vec


docs = [
    "Annual plans can be refunded within 30 days of purchase.",
    "Backups run every night and files are kept for 90 days.",
    "Admins must enable two-factor authentication.",
]
doc_vecs = np.stack([embed(d) for d in docs])

for query in ["refund for annual plan", "how long are files kept", "pizza recipe"]:
    scores = doc_vecs @ embed(query)
    best = int(np.argmax(scores))
    print(f"{query!r:<26} best={best}  scores={[round(float(s), 2) for s in scores]}")
'refund for annual plan' best=0 scores=[0.65, 0.0, 0.0] 'how long are files kept' best=1 scores=[0.0, 0.41, 0.0] 'pizza recipe' best=0 scores=[0.0, 0.0, 0.0]

The unrelated query “pizza recipe” scores 0 against everything. Real embeddings rarely give exactly 0, but irrelevant queries do score noticeably lower — which is how a similarity threshold can trigger an “I don't know” answer.

Choosing an embedding model

CriterionWhat to consider
Retrieval qualityCheck the retrieval tasks of the MTEB leaderboard — then test on your data, which matters more.
LanguagesMultilingual models are essential if documents and questions differ in language.
DimensionsMore dimensions can mean better quality but more storage and slower search. Some models (e.g. OpenAI text-embedding-3) let you shorten vectors.
Max input lengthMust exceed your chunk size.
HostingAPI (no infrastructure, data leaves your network) vs local (private, you run it).
Cost and speedMatters when indexing millions of chunks or embedding every query at high traffic.
Watch out

Queries and documents must be embedded with the same model (and the same version). Vectors from different models live in different spaces and their similarities are meaningless. If you change embedding models, you must re-embed the entire corpus.

Note

Some models expect a prefix that tells them whether text is a query or a passage — for example "query: " and "passage: " for the E5 family, or a task_type / input_type parameter in Google's and Cohere's APIs. Read the model card; skipping the prefix can noticeably reduce quality.

Operating embeddings in production

  • Batch requests (hundreds of texts per call) and respect rate limits.
  • Cache by content hash so unchanged chunks are never re-embedded.
  • Store the model name and version with every vector.
  • Use float32 (or quantised formats in your vector database) to control memory.
  • Embed the right text: include the heading path or title with the chunk; exclude boilerplate.
Python
import hashlib

CACHE: dict[str, list[float]] = {}
api_calls = {"count": 0}


def fake_embed_batch(texts: list[str]) -> list[list[float]]:
    api_calls["count"] += 1
    return [[float(len(t))] for t in texts]          # stand-in vectors


def embed_with_cache(texts: list[str], model: str = "text-embedding-3-small") -> list[list[float]]:
    keys = [hashlib.sha256(f"{model}:{t}".encode()).hexdigest() for t in texts]
    missing = [t for t, k in zip(texts, keys) if k not in CACHE]
    if missing:
        for t, vec in zip(missing, fake_embed_batch(missing)):
            CACHE[hashlib.sha256(f"{model}:{t}".encode()).hexdigest()] = vec
    return [CACHE[k] for k in keys]


embed_with_cache(["chunk A", "chunk B"])
embed_with_cache(["chunk A", "chunk B", "chunk C"])     # only C is new
print("API calls:", api_calls["count"], "| cached vectors:", len(CACHE))
API calls: 2 | cached vectors: 3
Try it
  1. Install sentence-transformers and embed ten FAQ questions and their answers. Does each question's nearest answer match?
  2. Write three paraphrases of one question and check that they all retrieve the same answer.
  3. Compare the toy embedding and the real model on a query with no shared words.
Key takeaways
  • An embedding is a vector representing meaning; similar meanings point in similar directions.
  • Create embeddings with APIs or local sentence-transformers models — always in batches.
  • Use cosine similarity, or a dot product on normalised vectors.
  • Choose models by quality on your own data, language support, dimensions, input length, hosting, and cost.
  • Embed queries and documents with the same model, store the model version, and cache by content hash.