Vectorembeddingcosine similaritydot productk-nnann

Vector Search: Embeddings, Similarity and ANN

An embedding turns text into a point in a high-dimensional space where nearby means similar; vector search finds the k nearest points to a query, and the whole engineering problem is doing that sublinearly without losing too much recall.

From text to vector

An embedding model maps a piece of text (or an image, or code) to a fixed-length vector — 384 to 3,072 floats — such that texts with similar meaning land near each other. The model is the whole semantics; two vectors from different models are not comparable. A document is split into chunks because one vector for forty pages means nothing; each chunk gets its own vector, and the chunk is the unit of retrieval. Chunk size is the most consequential parameter in the pipeline — see the agentic side in Ingestion: Parsing & Chunking.

The retrieval pipeline
top-kDocumentQuery vectorChunksEmbedding modelVector indexk-NNContext → LLM
UserLLMAgentToolDataDecisionHumanGuardrail

Similarity

Cosine similarity is the cosine of the angle between two vectors: direction only, magnitude ignored. The default for text. Dot product is direction and magnitude; on normalised vectors it equals cosine and is cheaper. Euclidean (L2) distance is the straight-line distance; natural for image features, unusual for text. Pick the one the embedding model was trained for — the model card says — and use it consistently.

Exact search scores every vector and sorts: O(n × d). Fine to a few hundred thousand vectors, hopeless at fifty million. That is where approximate nearest neighbour comes in.

Approximate nearest neighbour

HNSW (Hierarchical Navigable Small World) builds a layered graph: a sparse top layer for coarse navigation, denser layers below. Search enters at the top, greedily walks to the nearest node, drops a layer, repeats. O(log n) hops, each visiting a few neighbours. Two knobs: M (edges per node — memory and recall) and ef_search (candidates kept during search — latency and recall). Build is slow and memory-hungry; the index lives in RAM. IVF (inverted file) clusters the vectors, and a query searches only the nearest few clusters; cheaper to build, lower recall, sensitive to data drift.

Both are approximate: they may miss the true nearest neighbour. Recall of 95–99% is typical and tunable. Whether that is acceptable depends on the use — for RAG it almost always is, because the top-5 need only contain *a* good chunk, not *the* best one.

Metadata filtering and hybrid search

Real queries carry constraints: this tenant, this language, documents updated after a date. Pre-filtering applies the constraint before the ANN search, which can starve the graph walk of reachable candidates and collapse recall. Post-filtering takes the top-k and then filters, which can leave you with zero results. Engines handle this differently — pgvector’s HNSW with a WHERE clause, dedicated stores with filtered graph traversal — and it is the first thing to test with your actual filter selectivity.

Embeddings are bad at exact tokens: an error code, a product SKU, a name. Hybrid search runs a keyword (BM25) query alongside the vector query and fuses the two ranked lists, usually with reciprocal rank fusion. Most production retrieval is hybrid — see Dense, Sparse & Hybrid Retrieval.

pgvector with a filter
1CREATE INDEX chunks_embedding ON chunks USING hnsw (embedding vector_cosine_ops)
2 WITH (m = 16, ef_construction = 128);
3
4SET hnsw.ef_search = 64;
5SELECT c.id, c.text, 1 - (c.embedding <=> $1) AS similarity
6FROM chunks c JOIN documents d ON d.id = c.document_id
7WHERE d.region = 'eu'
8ORDER BY c.embedding <=> $1
9LIMIT 5;

Where to keep the vectors

PostgreSQL + pgvector: the vectors sit next to the rows they describe; filters are SQL; transactions cover both; one system to run. Good to tens of millions of vectors. Dedicated vector database: built for hundreds of millions to billions, sharded ANN, tuned filtered search — and a second system with its own consistency lag from your source of truth. Search engine: the strongest hybrid story if you already run one. The rule is the same as everywhere: start in Postgres, move when a measured number says so. See Vector Storage for the agentic-side view.

Key points

  • Embeddings from one model; chunks are the retrieval unit; chunk size matters most.
  • Cosine for text by default; exact k-NN is linear; HNSW is O(log n) and approximate.
  • Filter selectivity can wreck ANN recall; test pre- vs post-filtering with real filters.
  • Hybrid (BM25 + vector) fixes exact-token queries.
  • Postgres + pgvector first; a dedicated store when the numbers demand it.

Similarity search by eye

Similarity search you can read by eye
Twelve chunks with 3-dimensional embeddings. Change the metric, the filter and k, and watch which context the model would receive.
top-kDocumentChunkEmbedding modelVectorVector indexSimilarity searchRetrieved contextLLM / agent
Query
query vector
[0.9, 0.2, 0.08]
Cosine similarity compares direction and ignores magnitude: a long chunk and a short chunk about the same thing score the same. The default for text embeddings, most of which are normalised anyway.
#chunkregionvectorscore
1Duplicate charges below 100 EUR are refunded automatically without contacting support.eu[0.88, 0.22, 0.05]0.999
2US customers must contact support with the transaction ID to reverse a duplicate charge.us[0.86, 0.18, 0.12]0.999
3Refunds are issued within 5 business days for items returned in the original packaging.eu[0.92, 0.15, 0.1]0.998
4Refunds for digital goods are only possible before the licence key is revealed.eu[0.8, 0.3, 0.2]0.980
5Returns must be handed to the carrier within 14 days of the label being issued.eu[0.66, 0.3, 0.5]0.852
6To return an item open the order page, choose "Start a return" and print the label.eu[0.7, 0.35, 0.55]0.840
7Sales tax is calculated at checkout and shown separately on the invoice.us[0.35, 0.25, 0.3]0.802
8Shipping costs are only refunded when the item arrived damaged.eu[0.55, 0.72, 0.1]0.767
9Error E-4821 means the payment was captured twice because the gateway retried.global[0.62, 0.1, 0.85]0.661
10Hardware carries a 24 month warranty starting at the delivery date.eu[0.3, 0.62, 0.35]0.590
11Error E-1180 means the address failed validation and the order was not created.global[0.2, 0.55, 0.8]0.386
12Standard shipping takes 3 to 5 working days; express shipping arrives the next working day.eu[0.1, 0.9, 0.15]0.332
The top result is the US billing FAQ, and this customer is in the EU. Pure similarity does not know about jurisdictions. Turn on the region = eu filter — metadata filtering is not an optimisation, it is a correctness requirement.
This is exact k-NN: every chunk is scored, then sorted. At 12 chunks that is instant; at 50 million it is not, and you need an approximate index — see the HNSW walkthrough below — which trades a little recall for a lot of speed.
Metric
Metadata filter

HNSW walkthrough

HNSW: a skip list over a graph
60 vectors projected to 2-D. Layers get sparser going up; search starts sparse and coarse, then drops down and refines. Each step visits only a handful of nodes.
querylayer 2 · 5 nodes present
Layer
2
Nodes visited
1 / 60
Start at the entry point on the top layer. Only ~4% of nodes exist up here, so each hop covers a lot of ground.
The trade: brute force visits every node and is always right. HNSW visits O(log n) and is usually right — recall of 95–99% is typical. Two knobs: M (links per node: more memory, better recall) and ef_search (beam width: slower, better recall). Build is expensive, so bulk-load before indexing.
1/7

Where should the vectors live?

Vector database vs Postgres + pgvector vs search engine
Same question as everywhere in this domain: what is the simplest thing that meets the measured requirement?
Fits when
  • Your data is already in Postgres
  • Under ~10–50M vectors
  • You need transactions across vectors and rows
  • Metadata filters are complex joins
Weak when
  • HNSW build and memory at hundreds of millions
  • Filtered ANN can lose recall (pre/post-filter problem)
  • One more workload on your primary
Verdict: The default. One system, one backup, one transaction boundary, SQL filters. Move off it when you measure a reason to.
-- pgvector: the whole feature in four lines
CREATE EXTENSION vector;
ALTER TABLE chunks ADD COLUMN embedding vector(1536);
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);
SELECT text FROM chunks WHERE region = 'eu'
ORDER BY embedding <=> $1 LIMIT 5;   -- <=> is cosine distance

Try it in the playground

When to use — and when not

Use it when
  • Semantic retrieval for RAG, recommendations, deduplication, "find similar".
Avoid it when
  • Exact lookups, structured filters, anything a WHERE clause answers.
  • As a replacement for keyword search when queries are mostly identifiers.

Failure modes

  • Mixing vectors from two models.
  • Pre-filter starving the HNSW walk.
  • No hybrid, so error codes and names never match.
  • A dedicated vector store drifting from the source of truth.

See how this works internally →

Descend one layer: the same topic explained from the machinery up.