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.
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.
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 similarity6FROM chunks c JOIN documents d ON d.id = c.document_id7WHERE d.region = 'eu'8ORDER BY c.embedding <=> $19LIMIT 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
| # | chunk | region | vector | score |
|---|---|---|---|---|
| 1 | Duplicate charges below 100 EUR are refunded automatically without contacting support. | eu | [0.88, 0.22, 0.05] | 0.999 |
| 2 | US customers must contact support with the transaction ID to reverse a duplicate charge. | us | [0.86, 0.18, 0.12] | 0.999 |
| 3 | Refunds are issued within 5 business days for items returned in the original packaging. | eu | [0.92, 0.15, 0.1] | 0.998 |
| 4 | Refunds for digital goods are only possible before the licence key is revealed. | eu | [0.8, 0.3, 0.2] | 0.980 |
| 5 | Returns must be handed to the carrier within 14 days of the label being issued. | eu | [0.66, 0.3, 0.5] | 0.852 |
| 6 | To return an item open the order page, choose "Start a return" and print the label. | eu | [0.7, 0.35, 0.55] | 0.840 |
| 7 | Sales tax is calculated at checkout and shown separately on the invoice. | us | [0.35, 0.25, 0.3] | 0.802 |
| 8 | Shipping costs are only refunded when the item arrived damaged. | eu | [0.55, 0.72, 0.1] | 0.767 |
| 9 | Error E-4821 means the payment was captured twice because the gateway retried. | global | [0.62, 0.1, 0.85] | 0.661 |
| 10 | Hardware carries a 24 month warranty starting at the delivery date. | eu | [0.3, 0.62, 0.35] | 0.590 |
| 11 | Error E-1180 means the address failed validation and the order was not created. | global | [0.2, 0.55, 0.8] | 0.386 |
| 12 | Standard shipping takes 3 to 5 working days; express shipping arrives the next working day. | eu | [0.1, 0.9, 0.15] | 0.332 |
region = eu filter — metadata filtering is not an optimisation, it is a correctness requirement.HNSW walkthrough
M (links per node: more memory, better recall) and ef_search (beam width: slower, better recall). Build is expensive, so bulk-load before indexing.Where should the vectors live?
- Your data is already in Postgres
- Under ~10–50M vectors
- You need transactions across vectors and rows
- Metadata filters are complex joins
- HNSW build and memory at hundreds of millions
- Filtered ANN can lose recall (pre/post-filter problem)
- One more workload on your primary
-- 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
- Semantic retrieval for RAG, recommendations, deduplication, "find similar".
- 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.