-
Pick the embedding model, dimensionality, and distance metric together — they're coupled. Don't default to text-embedding-ada-002 (legacy). 2025-2026 strong choices:
| Model | Dim | Notes |
|---|
OpenAI text-embedding-3-large | 3072 (truncatable to 256/1024) | Matryoshka — truncate then re-normalize; strong general |
OpenAI text-embedding-3-small | 1536 (truncatable) | cheap, good baseline |
Cohere embed-v3 / embed-v4 | 1024 | has input_type (query vs document) — use it |
BAAI/bge-large-en-v1.5, intfloat/e5-large-v2 | 1024 | open, self-host; require a prefix (query: / passage:) — omitting it craters recall |
BAAI/bge-m3 | 1024 | multilingual + multi-vector |
Voyage voyage-3 | 1024 | strong retrieval, code/domain variants |
Rules: embed the query and the document with the SAME model (and the right input_type/prefix). Higher dim ≈ better recall but more RAM/latency — Matryoshka models let you truncate (e.g. 3072→1024) and trade recall for cost; re-normalize after truncating. Metric choice:
| Metric | Use when | pgvector op | Note |
|---|
| Cosine | text embeddings (default) | <=> (vector_cosine_ops) | direction only |
| Dot / inner product | already unit-normalized vectors | <#> (negative IP) | == cosine when normalized; faster |
| L2 / Euclidean | rarely for text; some image models | <-> (vector_l2_ops) | magnitude matters |
Normalize embeddings to unit length once at write time, then cosine == dot and you can use the faster IP path. Pick the index opclass to match the metric — a cosine index on un-normalized vectors silently mis-ranks.
-
Chunk with structure, sized to the model, with overlap and metadata — bad chunks cap recall before any tuning. Defaults: ~256–512 tokens per chunk, 10–15% overlap (~50–80 tokens) so a fact split across a boundary survives. Split on semantic boundaries (headings, paragraphs, code blocks, RecursiveCharacterTextSplitter by separator hierarchy) — never a blind fixed char window mid-sentence. Stamp every chunk with metadata for filtering and citation: {doc_id, chunk_id, source, title, section, page, created_at, tenant_id, lang}. Consider late chunking (embed the long context, then pool per-chunk) or a parent-document retriever (embed small, return the larger parent) when chunks lose context. One vector per chunk; keep the raw text + metadata in a payload column/store.
-
Choose the ANN index by corpus size and the recall/latency/memory tradeoff — there is no free lunch.
| Index | Recall | Latency | Memory | Build | Use when |
|---|
| Flat / exact (brute force) | 100% | O(N) | full | none | < ~50–100k vectors, or as the recall ground-truth |
| HNSW | high | very low | high (graph in RAM) | slow | low-latency, RAM-resident, ≤ tens of millions |
| IVF / IVF-Flat | tunable | low | medium | fast | large, want simple recall/latency knob (nprobe) |
| IVF-PQ / PQ | lower (lossy) | low | very low (compressed) | medium | 100M–1B+, must fit RAM/budget; accept recall hit |
| DiskANN / Vamana | high | low | on-disk | slow | billion-scale, can't fit graph in RAM |
HNSW knobs: M (neighbors/node, 16–64; higher = better recall + more RAM), efConstruction (build quality, 100–400), efSearch/ef (query-time recall↔latency dial — raise until recall target met). IVF knobs: nlist (clusters ≈ √N to 4√N), nprobe (clusters scanned at query — the recall↔latency dial). PQ knobs: m sub-quantizers (dim must be divisible), nbits (usually 8). Default to HNSW unless memory or scale forces IVF-PQ.
-
Per-store specifics — same concepts, different syntax.
- pgvector (Postgres):
CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops) WITH (m=16, ef_construction=64); then per-session SET hnsw.ef_search = 100;. IVFFlat: WITH (lists = N) + SET ivfflat.probes = 10; — build IVFFlat AFTER loading data (it clusters existing rows); HNSW can be built on empty. pgvector ≥0.7 supports halfvec (16-bit) to halve size. Pre-filter with a plain WHERE + a btree index — Postgres can combine.
- Qdrant: HNSW by default; set
hnsw_config (m, ef_construct) per collection, ef per search via params.hnsw_ef. Payload indexes on filtered fields enable filterable HNSW (filter applied during graph traversal, not after). Use scalar/product quantization via quantization_config.
- Milvus: explicit
index_type (HNSW, IVF_FLAT, IVF_PQ, DISKANN, SCANN) + metric_type (COSINE/IP/L2); search params = {ef} or {nprobe}. Must load() collection into memory before search.
- FAISS (library, no server):
IndexHNSWFlat, IndexIVFFlat, IndexIVFPQ; train IVF/PQ on a representative sample before add; index.nprobe = N. Wrap with IndexIDMap to keep external ids. You manage persistence + metadata yourself.
- Pinecone: managed; pick
metric at index creation (immutable), use namespaces for tenant isolation, filter in query for metadata. Serverless handles the index internals — you tune top_k and filters, not M/nprobe.
-
Pre-filter correctly — naïve post-filtering starves your k and silently drops good hits. Three interaction modes:
| Mode | What happens | Risk |
|---|
| Post-filter (ANN then drop non-matches) | fetch top-k, remove rows failing the filter | a selective filter can leave 0–few results; raise k won't reliably fix it |
| Pre-filter (filter then exact search) | filter to a subset, brute-force within it | exact but slow on large subsets |
| Filterable ANN (filter during graph/list traversal) | engine prunes by metadata inside HNSW/IVF | best — Qdrant payload index, Milvus filtered search, pgvector WHERE + index |
For a highly selective filter (e.g. tenant_id = X with few rows), pre-filter or partition (separate collection/namespace/partition per tenant) instead of filtering a global index. Always index the metadata fields you filter on; an unindexed filter forces a slow scan or weak post-filter. Test recall with the filter applied — unfiltered recall lies.
-
Add hybrid (BM25 + dense) and fuse with RRF — not score addition. Dense embeddings miss exact terms (IDs, codes, rare names, acronyms); BM25/keyword catches them. Run both retrievers, take each result's rank, and fuse with Reciprocal Rank Fusion:
RRF_score(d) = Σ_retrievers 1 / (k + rank_r(d)) # k ≈ 60
RRF is rank-based, so you don't have to normalize the wildly different BM25 vs cosine score scales (raw weighted score-sum is the classic bug — one scale dominates). Native support: Qdrant Query API with Fusion.RRF, Elasticsearch/OpenSearch rrf retriever, Milvus RRFRanker, Weaviate hybrid fusionType. In pgvector, run a BM25/tsvector (or ParadeDB pg_search) query and a vector query, then fuse in SQL. Hybrid typically beats either alone on heterogeneous corpora.
-
Rerank the shortlist with a cross-encoder — it fixes the ordering bi-encoders get wrong. Retrieve a wide net (top 50–100 by RRF), then rerank to the final k (5–10) with a cross-encoder that scores (query, doc) jointly: Cohere rerank-v3.5, BAAI/bge-reranker-v2-m3, or a cross-encoder/ms-marco-MiniLM model. Cross-encoders are far more accurate but O(candidates) per query — only ever run them over the shortlist, never the whole index. Reranking usually buys more nDCG than squeezing the ANN, and it's where "semantically close but mis-ranked" gets fixed. Budget the extra ~50–300ms.
-
Eval with a labeled set — tune to a recall TARGET, never by eyeballing. Build qrels: a set of queries each with known-relevant doc_ids (mine from clicks/logs, or hand-label 50–200). Metrics:
| Metric | Measures | When |
|---|
| recall@k | did the relevant doc make the top-k at all | the ANN/retrieval gate — most important for RAG (can't rerank what you didn't retrieve) |
| MRR | rank of the first relevant hit | single-answer / "find the doc" |
| nDCG@10 | graded relevance + position | multi-relevant, ranking quality (post-rerank) |
| precision@k | fraction of top-k relevant | when noise in context hurts |
Compute exact (flat) search as the recall=100% ground truth, then measure your ANN's recall@k against it — that's how you set efSearch/nprobe: raise it until recall@k hits target (e.g. 0.95), then stop (latency grows past it). Re-run the suite on every model/chunk/param change. Tools: ranx, BEIR, pytrec_eval, or a small custom harness.
-
Quantize only after measuring the recall loss — it's a memory/latency win that costs accuracy. Options: scalar quantization (float32→int8, ~4× smaller, small recall loss — good default), binary quantization (1-bit, ~32× smaller, big loss — only with a rescoring/oversampling pass), PQ (product quantization, tunable, needs training). Pattern: quantize for the fast first pass, then rescore the top candidates with full-precision vectors (Qdrant rescore, Milvus refine) to recover recall. Measure recall@k before/after on the eval set — never ship a silent quality drop. Also: store the original embedding model + dim in metadata so a model upgrade triggers a full re-embed (you can't mix embedding spaces).
Done = query and documents share one normalized embedding model with a matching distance metric/opclass, the ANN index is chosen for the corpus's scale/latency/memory budget and tuned to a measured recall@k target against exact search, hybrid retrieval fuses BM25 + dense by RRF, a cross-encoder reranks the shortlist, metadata filtering uses filterable/pre-filter (not post-filter starvation), and every change is validated by the recall@k / nDCG / MRR eval in checks 3–8.