Embeddings and Vector Search
How text becomes a vector whose distance tracks meaning, why exact search does not scale, and how HNSW and IVF indexes trade recall for speed and memory.
An embedding model turns a piece of text into a fixed-length list of numbers, a vector, trained so that texts with similar meaning land close together. Search then becomes geometry: embed the query, find the stored vectors nearest to it. Comparing against every vector is exact but grows linearly with the collection, so real systems use an ANN (approximate nearest neighbor) index such as HNSW or IVF that checks a small fraction of vectors and accepts missing a few true neighbours in exchange for millisecond queries.
Context
The idea that a word can be represented by where it sits in a space of numbers is old, but it became practical with word2vec (Google, 2013), which learned vectors where "king" minus "man" plus "woman" lands near "queen". Those were vectors for single words. Sentence-BERT (2019) showed how to train a transformer to produce one vector for a whole sentence that is directly comparable with others, and since then embedding models have become an API call: send text, get back a vector of a few hundred to a few thousand numbers. The indexing side has its own lineage: product quantization (2011), the HNSW graph index (paper 2016), Facebook's FAISS library (2017), and from 2023 onward vector search built into ordinary databases, such as the pgvector extension for PostgreSQL gaining HNSW indexes in version 0.5.0.
You have used it every time a search box found "how do I cancel my plan" for the query "stop subscription", when a support bot quoted the right help article, or when an app recommended "similar items". The smallest version fits in a few lines:
// embed() calls whatever embedding model you use
const docs = ['Cancel your plan in Settings', 'Change your avatar']
const docVecs = await Promise.all(docs.map(d => embed(d)))
const q = await embed('stop subscription')
const scores = docVecs.map(v => cosine(q, v))
// [0.82, 0.11] -> the first doc wins, with no shared words- Embedding / vector
- The list of numbers a model outputs for one input, for example 768 or 1536 floats. All vectors from one model have the same length (dimension).
- Similarity metric
- How closeness is measured: cosine similarity (angle), dot product, or Euclidean distance.
- kNN / exact search
- k nearest neighbours: compare the query with every stored vector and keep the top k. Always correct, cost grows with N.
- ANN index
- A structure that finds most of the true nearest neighbours while only examining a small part of the data.
- Recall@k
- Of the true top k neighbours, the share the index actually returned. The quality metric for ANN.
Why it matters
Embeddings are the retrieval half of most applied AI features: retrieval-augmented generation, semantic search, deduplication, recommendations, clustering support tickets. The failure modes are quiet. A query embedded with a different model than the documents returns plausible nonsense; a filter applied after the ANN search returns three results instead of ten; an index tuned for speed silently drops the one document that answered the question. None of these throw errors. Knowing the mechanism is how you notice them, and how you size the memory bill before it arrives.
Meaning as distance
An embedding model is trained on pairs of texts that should be close (a question and its answer, a sentence and its paraphrase) and pairs that should be far apart. The result is a space where direction carries meaning. Individual dimensions mean nothing on their own; only the relative positions of vectors do, and only between vectors from the same model.
Cosine, dot product, Euclidean
Cosine similarity compares only the angle between two vectors, ignoring length. Dot product also rewards length. Euclidean distance measures the straight line between the points. If every vector is normalised to length 1, which many embedding APIs do by default, all three produce the same ranking, and the dot product is the cheapest to compute. If vectors are not normalised, use the metric the model was trained for; its documentation says which.
export function dot(a: number[], b: number[]): number {
let s = 0
for (let i = 0; i < a.length; i++) s += a[i] * b[i]
return s
}
export function normalize(v: number[]): number[] {
const len = Math.sqrt(dot(v, v))
return v.map(x => x / len)
}
// cosine = dot of the normalised vectors; normalise once at write time
export const cosine = (a: number[], b: number[]) =>
dot(normalize(a), normalize(b))Why exact search stops scaling, and how ANN indexes work
Exact search compares the query with all N vectors of d dimensions, so each query costs about N × d multiply-adds. Ten thousand vectors is instant; fifty million 1,536-dimensional vectors is around 77 billion operations per query. A B-tree, the usual database index, cannot help, because "nearest in 1,536 dimensions" has no single sort order to binary-search. ANN indexes give up the guarantee of finding the exact top k and instead find almost all of it quickly.
- 1Memory first. 10 million vectors × 1,536 dimensions × 4 bytes per float32 = about 61 GB before any index overhead. Halving the dimension or quantizing halves or quarters that, which is often the deciding design choice.
- 2HNSW (Hierarchical Navigable Small World). Each vector is a node in a graph linked to its close neighbours, with sparse upper layers for long jumps and a dense bottom layer. A search starts at the top, greedily walks towards the query, and drops a layer at a time.
M(links per node) andef_constructionset build quality and memory;ef_searchsets how many candidates a query explores, which is the recall-vs-latency dial. - 3IVF (inverted file index). Cluster the vectors into, say, 4,096 groups with k-means, and store each vector under its nearest centroid. A query compares itself with the centroids and scans only the closest
nprobegroups. Cheaper to build and smaller than HNSW, but needs training data and degrades if the data drifts from the clusters. - 4PQ (product quantization) and friends. Compress each vector by splitting it into chunks and replacing each chunk with the ID of its nearest code word. Distances are computed on the codes, cutting memory many times over at some cost in accuracy; int8 and binary quantization are simpler versions of the same trade.
- 5Measure recall@k. Run a sample of queries through exact search and through the index, and compare the top-k sets. Tune
ef_searchornprobeuntil recall is where the product needs it, typically 0.95 or higher, then check latency.
Where vectors live and how filters break them
You can store vectors in a dedicated vector database, in a search engine that has vector fields, or in the relational database you already run. The core mechanism is the same everywhere; the differences are operations, scale and how well filtering is integrated.
| Option | Strength | Watch out for |
|---|---|---|
| Postgres + pgvector | Vectors next to the rows they describe; joins, transactions, one system to run | Index must fit memory for speed; very large collections need partitioning |
| Search engine | Keyword and vector search in one query (hybrid), mature filtering | Another cluster to operate and keep in sync |
| Vector database | Built for large collections, quantization and filtered ANN | Data lives apart from your source of truth |
| In-process library | Fastest for a fixed, local dataset | No persistence or concurrency story of its own |
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
doc_id bigint NOT NULL,
tenant_id bigint NOT NULL,
content text NOT NULL,
embedding vector(1536) NOT NULL -- must match the model's dimension
);
-- HNSW index for cosine distance (pgvector 0.5.0+)
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);
-- <=> is cosine distance: smaller = more similar
SET hnsw.ef_search = 100; -- recall vs latency for this session
SELECT id, content
FROM chunks
WHERE tenant_id = $2
ORDER BY embedding <=> $1
LIMIT 10;Model changes mean re-embedding
Vectors from two different models live in unrelated spaces, even if the dimensions happen to match. Upgrading the model therefore means re-embedding every stored document and switching queries over at the same moment, usually by building a new column or index alongside the old one and flipping reads once it is complete. Store the model name next to each vector so mixed spaces can be detected.
Pitfalls
- Mixing models between indexing and querying
Embedding documents with one model and queries with another produces vectors in incompatible spaces, so the nearest neighbours are close to random while scores still look normal. Pin the model version, store it with the vectors and re-embed everything when it changes.
- Expecting embeddings to match exact tokens
Semantic similarity blurs exactly what keyword search is good at: order IDs, error codes, SKUs, rare names, and often negation ("not refundable" lands near "refundable"). Combine vector search with keyword search (hybrid retrieval) for queries that contain identifiers.
- Post-filtering a selective filter
When the index searches first and filters later, a narrow filter (one tenant, one date range) can eliminate almost every candidate, returning too few results with no error. Test with your most selective filters, not with the whole collection.
- Never measuring recall
Index parameters chosen for speed can quietly drop recall to 0.8, meaning one in five true neighbours never shows up. Keep a small benchmark of queries, compare index results against exact search, and re-run it after every parameter or data-size change.
- Embedding text longer than the model reads
Every embedding model has a maximum input length; beyond it the text is truncated, often silently, so the end of a long document never influences its vector. Split documents into chunks that fit, and embed each chunk separately.
Interview questions
Q1What is an embedding and why does vector distance capture meaning?
An embedding is a fixed-length vector a model produces for an input, trained so that inputs with similar meaning end up close together. Training pushes related pairs (a question and its answer, paraphrases) together and unrelated pairs apart, so the geometry encodes similarity. Individual dimensions have no meaning; only distances between vectors from the same model do.
Q2Cosine similarity, dot product or Euclidean distance: which do you use?
Whichever the model was trained for, and with normalised vectors it does not matter for ranking, because all three order results identically. Cosine looks only at the angle, dot product also rewards length, Euclidean measures the straight-line gap. In practice I normalise at write time and use the dot product, which is the cheapest.
Q3Walk me through implementing semantic search over a few million documents.
Split documents into chunks that fit the model's input limit, embed each chunk, and store the vector with the chunk text, document ID, tenant and model version. Build an HNSW index with the model's metric. At query time, embed the query with the same model, run a top-k ANN query with the tenant filter, and return the chunks. Before shipping, measure recall@k against exact search on a sample, tune the search effort, and add keyword search for queries with identifiers.
Q4Why can a B-tree not index vectors, and how does HNSW work instead?
A B-tree relies on a single sort order, and nearness in hundreds of dimensions has none. HNSW builds a graph where each vector links to near neighbours, with sparse upper layers for long hops. A query enters at the top, greedily moves to the neighbour closest to the query, descends layer by layer and finishes with a wider search at the bottom. It explores a tiny fraction of nodes, so it is fast, and occasionally misses a true neighbour, which is the approximation.
Q5What happens when you filter by tenant and get back only three results out of ten?
The index found its nearest candidates first and the filter removed most of them afterwards, because the tenant owns a small share of the vectors. I would confirm by checking how selective the filter is, then fix it by raising the search effort, using filter-aware or iterative ANN, or partitioning the index by tenant so each search only covers that tenant's vectors.
Q6How much memory does a vector index need, and how do you reduce it?
Raw storage is N × dimensions × 4 bytes for float32, so 10 million 1,536-dimensional vectors is about 61 GB, plus graph overhead for HNSW. To shrink it: use a model with fewer dimensions or truncate a model trained for truncation, quantize to int8 or binary with a re-ranking step on full vectors, or use IVF with product quantization. Each step trades some recall, so measure it.
Q7You need to upgrade the embedding model. How do you do it safely?
Treat it as a data migration, because old and new vectors are not comparable. Add a new column or index, re-embed all documents in the background, compare retrieval quality on a benchmark set, then switch queries and the read path to the new index together, and drop the old one later. Storing the model version per vector makes a half-migrated state detectable.
- An embedding maps an input to a vector where closeness tracks meaning, but only between vectors from the same model.
- With normalised vectors, cosine, dot product and Euclidean rank results the same; normalise once and use the dot product.
- Exact search costs N × d per query; ANN indexes like HNSW and IVF trade a little recall for speed, and recall@k is how you measure that trade.
- Memory is N × d × 4 bytes before index overhead; dimensions and quantization are the main levers.
- Selective filters applied after the ANN search can starve results; test with your narrowest filters.
- Changing the model means re-embedding everything; combine vector and keyword search for IDs and exact terms.