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.
Definitions
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.
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.
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
| Question | Relevant passage | Shared words |
|---|---|---|
| How do I get my money back? | Refunds are issued within 30 days. | None |
| My laptop won't turn on | Troubleshooting a device that does not power up | None |
| Cancel subscription | How to end your plan | None |
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
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)
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.
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
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
| Metric | Formula (idea) | Range | Notes |
|---|---|---|---|
| Cosine similarity | dot(a, b) / (|a| × |b|) | −1 to 1 (higher = more similar) | The standard for text embeddings; ignores vector length. |
| Dot product | sum(a × b) | Unbounded | Equals 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:
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]}")
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
| Criterion | What to consider |
|---|---|
| Retrieval quality | Check the retrieval tasks of the MTEB leaderboard — then test on your data, which matters more. |
| Languages | Multilingual models are essential if documents and questions differ in language. |
| Dimensions | More 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 length | Must exceed your chunk size. |
| Hosting | API (no infrastructure, data leaves your network) vs local (private, you run it). |
| Cost and speed | Matters when indexing millions of chunks or embedding every query at high traffic. |
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.
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.
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))
- Install
sentence-transformersand embed ten FAQ questions and their answers. Does each question's nearest answer match? - Write three paraphrases of one question and check that they all retrieve the same answer.
- Compare the toy embedding and the real model on a query with no shared words.
- 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.