Semantic Search
Search sample data by meaning — switch between distance metrics live.
You already know that embeddings turn meaning into geometry: similar texts become nearby points in space. Semantic search is the payoff — instead of matching keywords, you match meaning. Search for "robots" and get results about androids and rogue AIs, even if the word "robots" never appears in them.
Why does this exist?
Keyword search fails the moment your users phrase things differently than your documents. A support ticket saying "my card got declined" will never keyword-match a help article titled "Troubleshooting failed payments". Semantic search fixes this by comparing embeddings — vectors that capture meaning — so paraphrases, synonyms, and even related concepts still find each other.
The problem with keywords
Classic search engines index words. If the query word isn't in the document, the document doesn't come back. Engineers spent decades patching this with synonym lists, stemming, and spell correction — all brittle, all hand-maintained.
Embeddings sidestep the whole mess. An embedding model reads a text and outputs a vector (a list of numbers, typically 384 to 3072 of them). Texts with similar meaning get vectors that are close together. Search then becomes a geometry question: which document vectors are closest to my query vector?
The pipeline is always the same:
- Index time: embed every document once and store the vectors.
- Query time: embed the query with the same model, then find the nearest stored vectors.
- Return the documents attached to those vectors, ranked by closeness.
But what does "closest" mean?
Here's the part people gloss over: there are several ways to measure how "close" two vectors are, and they can produce different rankings.
- Cosine similarity measures the angle between two vectors. Range: -1 to 1, higher is better. It ignores vector length entirely — only direction (i.e., topic mix) matters. This is the default in most systems.
- Euclidean distance is the straight-line distance between the two points. Lower is better. Both direction and magnitude matter.
- Dot product multiplies matching dimensions and sums them. Higher is better. Like cosine, it rewards aligned direction — but it also rewards longer vectors, so "intense" documents that are strongly about several things get boosted.
If all your vectors are normalized to length 1 (many embedding models do this), the three metrics agree on ranking. When vectors have different lengths, they diverge — and you'll see exactly that below.
Try it: search by meaning
The demo below has 20 tiny "movie description" documents, each with a small hand-crafted embedding. Pick a query, then flip between metrics and watch the ranking reshuffle. The scatter plot shows a 2D projection of the space — the ring is your query, filled dots are the top matches.
Semantic search playground
Top matches — Cosine similarity
- 1.A rogue AI takes control of a city's power grid.0.946
- 2.Colonists terraform Mars while politics unravel back home.0.920
- 3.Time travelers try to prevent a global blackout.0.913
- 4.Astronauts race to repair a station before orbit decays.0.896
- 5.A detective hunts a hacker hiding inside a virtual world.0.871
2D projection
Ring = your query. Filled dots = top-5 matches under the current metric. Hover any dot to see its description.
Things to try:
- Search "robots" with cosine, then switch to dot product. Documents whose embeddings are "louder" (strongly about action and sci-fi) climb the ranking.
- Search "rom-com" — notice the top hits blend romance and comedy dimensions, even though no document contains the word.
- Switch to euclidean and note the scores flip meaning: now the smallest number is the winner.
The code
Real semantic search is only a few lines. Here's the whole idea with plain Python — no database required yet:
import numpy as np
def cosine(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
def search(query_vec, doc_vecs, docs, k=5):
scores = [cosine(query_vec, v) for v in doc_vecs]
top = np.argsort(scores)[::-1][:k] # highest first
return [(docs[i], scores[i]) for i in top]
This brute-force loop compares the query against every document. Perfectly fine up to tens of thousands of vectors — and exactly what breaks at millions, which is the subject of the next lesson.
One model to rule the index
Always embed queries and documents with the same model (and version). Vectors from different models live in different, incompatible spaces — comparing them produces garbage rankings that fail silently.
Build it yourself
- Take 20 sentences of your own (FAQ entries, movie plots, tweets).
- Embed them with any embedding model (e.g. a sentence-transformers model runs locally).
- Implement the
searchfunction above and try five paraphrased queries. - Swap cosine for euclidean and dot product. Do rankings change? Check whether your model outputs normalized vectors (
np.linalg.norm(v)≈ 1.0).
Summary
- Semantic search embeds documents and queries into the same vector space, then ranks by geometric closeness.
- Cosine similarity compares direction only; dot product also rewards magnitude; euclidean measures straight-line distance (lower = better).
- Metrics agree on normalized vectors and diverge otherwise — know which one your system uses.
- Brute-force comparison works fine for small datasets; scale is the vector database's job (next lesson).