Modules / Gen AI / Vector Search Lab

Embeddings for Retrieval and Vector Search

An embedding turns meaning into a position. Once it is a position, "find me something similar" is a geometry problem — and at scale, a geometry problem nobody can afford to solve exactly.

Overview

Quick Context

An embedding model maps a piece of text to a point, arranged so that texts about the same thing land near each other. Nothing about the words themselves survives the trip — "peel a mango" and "how to prepare tropical fruit" share no vocabulary and end up as neighbours anyway.

That is the whole trick behind semantic search, recommendation, deduplication and the retrieval half of RAG. Once meaning is a position, similarity is distance, and search becomes a nearest-neighbour problem.

The Query

3

The Index

1

only bites in partitioned mode

The Embedding Space

Two dimensions so it fits on a screen. A real embedding has hundreds, and behaves the same way.

Cost And Correctness

Comparisons 18
Work vs exact scan 100%
Recall @ k
100%
against the exact answer

What Came Back

 

Vector Search: A Practical Guide

Nearest neighbours, and the approximation everyone actually runs.

Cosine, and why it is the default

Retrieval almost always ranks by cosine similarity: the angle between the query vector and the document vector, ignoring how long either one is. Length in an embedding tends to carry things like document length or token count rather than meaning, so ignoring it is the point.

On vectors normalised to unit length, ranking by cosine and ranking by Euclidean distance give identical orders, which is why many systems normalise once at index time and then use whichever their hardware does faster. Switch the metric here and watch how little changes — and, on the points that are not near the unit circle, exactly where it does.

Exact search does not scale

An exact search compares the query against every vector in the index. That is 18 comparisons here and 18 million in a real corpus, per query, and it is why nobody runs exact search at scale.

The standard fix is to partition the space. Cluster the vectors once, keep a centroid per cell, and at query time compare against the centroids first and then search only the nearest few cells. This is an inverted file index (IVF); HNSW does the same job with a navigable graph instead of cells. Both are approximate: they trade a small chance of missing a true neighbour for an enormous reduction in work.

That chance is what recall@k measures — the share of the true top k that the approximate search actually returned. It is a dial, not a defect: probing more cells raises recall and costs comparisons.

Meaning as coordinates

An embedding turns text into a vector positioned so that similar meanings land near each other. Vector search is then a geometry problem: find the stored vectors closest to the query's vector.

That is what lets a search for "how do I reset my password" retrieve a document titled "Credential recovery procedure" — no shared words, similar meaning, nearby vectors.

The pipeline:

  1. Embed every chunk once, at indexing time.
  2. Store the vectors with their metadata.
  3. Embed the query at search time, with the same model.
  4. Compare against the stored vectors and return the nearest k.

Step 3's "same model" is not negotiable. Vectors from two different models occupy unrelated spaces, and comparing them produces noise that looks like results.

Cosine similarity, and why length is discarded

The standard comparison is the angle between vectors:

similarity = (a · b) / (‖a‖ ‖b‖)

Length in an embedding tends to reflect frequency or confidence rather than meaning, so ignoring it is deliberate: a long document and a short one about the same subject should be close.

On vectors normalised to length 1, cosine similarity and the dot product are identical, and Euclidean distance ranks the same way. That is why libraries normalise on the way in and then use fast dot products — a matrix multiply is far cheaper than computing norms per comparison.

Calibrate the threshold rather than assuming one. Many sentence-embedding models score unrelated text at 0.2–0.4, so a threshold of 0.5 admits considerable noise. Score a few hundred known-related and known-unrelated pairs and put the cut where the distributions separate.

Approximate search, and why exact does not scale

Comparing a query against every stored vector is exact and linear. With a million 768-dimensional vectors that is 768 million multiply-adds per query — tolerable in a batch job, too slow for an interactive request at any real volume.

Approximate nearest neighbour indexes trade a small amount of recall for orders of magnitude of speed.

IndexHow it worksCharacter
FlatCompare against everythingExact, slow, fine below ~100k
IVFCluster, search only nearby clustersFast, needs training, tunable
HNSWA navigable graph of neighboursVery fast, high recall, memory-hungry
PQ / compressionStore compressed vectorsLarge memory saving, some accuracy loss

HNSW is the common default: build a multi-layer graph where each vector links to its neighbours, then walk it greedily from a coarse layer down to a fine one. Query time grows logarithmically rather than linearly.

The parameter to understand is the search-time breadth (ef_search in HNSW, nprobe in IVF). Higher means better recall and slower queries — a dial you set from measured recall against exact search, not from intuition.

Filtering, and the interaction that surprises people

Real systems need constraints: only documents this user may see, only from the last year, only of a certain type.

Two ways to apply them, and the difference matters:

Pre-filter — restrict the candidate set, then search within it. Correct results, and it can defeat the index structure, since the graph or clusters were built over everything.

Post-filter — search first, then discard non-matching results. Fast, and it can return fewer than k results, or none, if the top matches are all filtered out.

Modern vector databases implement filtered search that navigates the index while respecting the filter, which is the right answer. Where that is unavailable, the workaround is to over-retrieve — ask for 10× k and filter — and accept that a highly selective filter may still come up short.

For permissions specifically, filtering must be enforced server-side at query time. Retrieving first and filtering in the application means the vectors of documents the user cannot see have already influenced what was returned.

What the coordinates can and cannot do

"Meaning as coordinates" is the right picture, and it is worth being precise about which meanings survive the trip. This does the famous vector arithmetic, then measures the two properties of real embedding spaces that decide whether a retriever works -- and one of them is the reason opposites are neighbours.

example_01.pyNumPy
Output

Guided tour

  1. Look at the space. Four topics, four visible clumps. Nothing arranged them by keyword; they are grouped because their meanings are.
  2. Search exactly. The default compares all 18 documents and returns the true nearest three. Recall is 100% by definition — this is the answer everything else is measured against.
  3. Partition it. Switch Search Mode to partitioned with one cell probed. Comparisons drop to the cell's contents plus the four centroids, and recall usually stays at 100% — the neighbours were in the obvious cell.
  4. Now find the failure. Raise k to 5 or 6 with one cell probed. The cell runs out of documents, so the search returns whatever it has and recall drops below 100%: real neighbours were sitting in a cell nobody looked at.
  5. Buy the recall back. Raise Cells Probed. Recall climbs back to 100% and the comparison count climbs with it. That trade is the entire tuning surface of a vector database.
  6. Probe everything. At 4 cells the search does 22 comparisons for an index of 18 documents — worse than the exact scan, because you pay for the centroids too. Partitioning only pays when you skip most of the index.
  7. Change the metric. On the mango query, cosine calls "a summer fruit salad" and "peeling a ripe mango" a tie at 1.000, because they sit at almost the same angle from the origin. Euclidean puts "peeling a ripe mango" clearly first, because it is genuinely nearer. Same points, same query, different question being asked.

Where this goes wrong

  • Mixing embedding models. Vectors from two different models are not comparable, even at the same dimension. Re-embed everything when you change model, or the index quietly returns nonsense.
  • Expecting exact matches. Embeddings are poor at part numbers, error codes and rare proper nouns, which is why serious systems run hybrid retrieval — keyword search alongside vectors.
  • Ignoring recall. An approximate index that has drifted to 70% recall looks perfectly healthy from the outside; every query returns results. Measure against exact search on a sample.
  • Chunk size chosen by accident. A vector represents its whole chunk, so a chunk covering three topics has an embedding that means none of them.
  • No filtering plan. "Nearest neighbours, but only from this tenant, in the last 30 days" is a much harder query than pure similarity, and the index has to be built for it.

The short of it

An embedding puts meaning somewhere in space, so similarity becomes geometry and retrieval becomes nearest-neighbour search, ranked by cosine because vector length carries length rather than meaning. Exact search costs one comparison per document and does not survive scale, so production indexes partition the space and probe only the nearest few cells — buying a large drop in work for a small, measurable chance of missing a true neighbour. That chance is recall@k, and it moves with how many cells you probe, which makes vector search a tuning problem rather than a solved one.

Why hybrid search wins

Embeddings are strong at paraphrase and weak at exact tokens. They will happily return a document about "annual leave" for a query about "holiday allowance" — and will blur "invoice INV-2024-8871" into every other invoice number.

Keyword retrieval (BM25) is the opposite: precise on names, codes, numbers and rare terms, and blind to paraphrase.

Combining them beats either alone, reliably enough that it is the standard recommendation. Reciprocal rank fusion is the usual merge:

score(d) = Σlists 1 / (k + rank(d))

with k around 60. It needs no score normalisation between the two systems, which is what makes it practical — BM25 scores and cosine similarities are not on comparable scales.

Add a reranker on top: retrieve 50 candidates from the hybrid stage, then score each against the query with a cross-encoder that reads both together rather than comparing precomputed vectors. Substantially more accurate, and affordable because it runs on 50 items.

Practical notes

from sentence_transformers import SentenceTransformer
import numpy as np

model = SentenceTransformer("all-MiniLM-L6-v2")
vecs = model.encode(chunks, normalize_embeddings=True,
                    batch_size=64, show_progress_bar=True)

q = model.encode([question], normalize_embeddings=True)
scores = vecs @ q[0]                       # dot product = cosine, normalised
top = np.argsort(-scores)[:5]

Four things that matter at scale:

Batch the embedding calls. One call per chunk is dominated by overhead; batches of 32–128 are far faster.

Normalise once, at indexing time. Then every query is a dot product.

Store the dimension you need. 384 dimensions instead of 1,536 is a quarter of the memory and search time, and often a small accuracy cost. Some models support truncating dimensions deliberately (Matryoshka embeddings).

Version your index. Changing the embedding model means re-embedding everything, so record which model produced the index.

Questions people ask

Which embedding model should I use? Start with a well-regarded general model, then evaluate alternatives on your own question set. Public benchmarks are a weaker signal than your own data.

Do I need a vector database? Below ~100,000 vectors, a NumPy array and a dot product is genuinely fine. Above that, an index earns its place.

How do I choose k? 3–10 chunks for generation. Measure recall@k on known answers to find where returns flatten.

Why does search miss obvious matches? Usually vocabulary the embedding model has not seen, or an exact identifier that embeddings blur. Hybrid search is the fix.

Can I search across languages? With a multilingual embedding model, yes — a query in one language retrieves documents in another.

What about updating documents? Re-embed the changed chunks and upsert them. HNSW handles insertions well and deletions less well; periodic rebuilds are normal.

Recap in one screen

  • Embeddings place similar meanings near each other, so search becomes finding the nearest vectors.
  • Cosine similarity ignores length; on normalised vectors it is just a dot product.
  • Exact search is linear and does not scale — HNSW and IVF trade a little recall for large speed gains.
  • Filtering must happen inside the search for permissions to be enforced correctly.
  • Hybrid retrieval plus a cross-encoder reranker is the reliable recipe for quality.

Recall check

0 of 3

Say the answer out loud before you reveal it — recalling it is what makes it stick, and rereading it is not.

  1. What does this module say about “Quick Context”?

  2. What does this module say about “Cosine, and why it is the default”?

  3. What does this module say about “Exact search does not scale”?

Cheat sheet

Embeddings and Vector Search

An embedding turns meaning into a position. Once it is a position, "find me something similar" is a geometry problem — and at scale, a geometry problem nobody can afford to solve exactly.

GEN AI · vizlearn.in/gen_ai/embeddings_and_vector_search.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.