What this module covers: The vocabulary-mismatch problem that keyword search cannot solve, and embeddings as coordinates where distance stands in for meaning. Why the embedding model is a schema commitment you will pay to change, and why vectors from two models are not comparable. Chunking, which determines retrieval quality more than the index does. Why exact k-NN is a full scan and where the ceiling is. The two ANN index families — IVF and HNSW — with their real knobs, memory profiles, and update behaviour, plus quantisation as the memory lever. Recall as a number you must measure rather than assume, because a badly tuned index returns confident wrong answers with no error. The filtered-search trap, which is where multi-tenant vector search goes wrong. Hybrid retrieval with reciprocal rank fusion and why fusing ranks beats fusing scores. And the embedding pipeline, which is a background-job and cost problem before it is a search problem.
Keyword Search Fails When the User's Words Are Not the Document's Words
Module P-20 built an index over the words people wrote. It cannot help when the words differ:
A user searches for
The document says
BM25 finds
"my app keeps crashing"
"handling unhandled promise rejections"
nothing
"how do I stop being charged"
"cancelling your subscription"
nothing
"car"
"automobile"
nothing (unless you listed the synonym)
"the film with the spinning top"
"Inception (2010)"
nothing
This is vocabulary mismatch, and it is not a tuning problem. Stemming does not connect crashing to exception. Synonym lists help exactly as far as somebody has enumerated them, which means they help with the mismatches you already knew about — and every list is a maintenance burden that grows without ever being complete.
The lexical index has a structural limit: it matches tokens, and meaning is not made of tokens. What is needed is a representation where "my app keeps crashing" and "handling unhandled promise rejections" land near each other because they are about the same thing, without anyone having written down that they are.
Analogy: a library where every book is placed on a floor plan by subject rather than filed under its title's spelling. The cookbooks cluster in one corner, the physics texts in another, and a book about the thermodynamics of baking sits between them. To find something you walk to roughly where the topic lives and look around, instead of needing the exact word on the spine. Two things follow, and both matter for the rest of this module: the arrangement is only as good as the librarian who placed the books, and "look around nearby" is a fundamentally approximate act — you might miss the one shelf you did not turn towards.
An Embedding Is a Coordinate, and Distance Stands In for Meaning
An embedding model maps text (or an image, or audio) to a fixed-length vector of floats — a point in a space of 384, 768, 1,536 or 3,072 dimensions. The model is trained so that semantically similar inputs land close together. That is the whole idea; everything else is engineering.
ts
Closeness is measured by one of three functions, and the distinction is mostly bookkeeping:
Measure
Formula
Notes
Cosine similarity
a·b / (‖a‖‖b‖)
angle only, ignores magnitude — the usual choice for text
Dot product
a·b
identical to cosine if vectors are normalised; cheaper
Euclidean (L2)
‖a − b‖
monotonically related to cosine for normalised vectors
The practical rule: normalise your vectors once at write time, then use dot product, and you get cosine semantics at lower cost. The failure to check is a mismatch between the distance function your index was built with and the one your query uses — vector_cosine_ops index queried with <-> (L2) in pgvector means the index is silently not used, and you get a sequential scan that is correct but slow, or with some configurations results ordered by the wrong metric. No error either way.
Two consequences of dimensionality that show up on invoices:
Storage is n × d × 4 bytes for float32. Ten million chunks at 1,536 dimensions is 61 GB of raw vectors before any index — and an HNSW index is stored on top of that, in memory. Choosing a 3,072-dimension model over a 768-dimension one is a 4× decision about RAM, not a detail.
Higher dimensions are not straightforwardly better. Larger models often retrieve better, but the gain is frequently small relative to a 4× cost, and some models support dimension truncation (Matryoshka embeddings) that lets you keep most of the quality at a fraction of the size. Measure the retrieval quality difference on your corpus before paying for width.
The model is a schema commitment
This is the point that catches teams, and it is worth stating bluntly: vectors produced by two different models are not comparable. Not "less accurate" — meaningless. The spaces are unrelated, so a query embedded with model B searched against a corpus embedded with model A returns essentially arbitrary results, ranked confidently, with no error anywhere.
Which means:
Changing embedding model requires re-embedding the entire corpus, at full API cost and full ingestion time.
Query-time and index-time models must match exactly, including the version. Providers do update models behind an unversioned alias; pin the version.
Store the model identifier alongside every vector, and filter on it. A model_version column is three bytes of foresight that prevents a whole class of silent corruption during a migration.
The migration pattern is Module P-20's alias swap: embed into a new column or index, verify quality, switch reads, keep the old one for rollback.
Why this matters in production: the failure mode of a mismatched model is not an exception, it is plausible nonsense. Search returns ten results, they look like results, and they are unrelated to the query. If those results feed an LLM (Module A-12), the model will cheerfully synthesise an answer from irrelevant context, and the first person to notice will be a customer reading something confidently wrong.
Chunking Decides Retrieval Quality More Than the Index Does
You cannot embed a 40-page document usefully. One vector must summarise everything the text is about, and averaging forty topics produces a point that is near nothing in particular — the vector equivalent of a blurry photograph. So documents are split into chunks, each embedded separately, and chunking is where most retrieval quality is won or lost.
The tension is fixed:
Chunks too large: the vector is diluted. A 4,000-token chunk covering installation, configuration and troubleshooting is not strongly near any of those queries, and once retrieved it delivers mostly irrelevant text to whatever consumes it.
Chunks too small: context is destroyed. "This value defaults to 30 seconds" is a useless chunk — which value? A sentence-level chunk retrieves precisely and answers nothing.
The strategies, in the order most teams should try them:
Strategy
How
Trade-off
Fixed size + overlap
500 tokens, 50–100 overlapping
trivial to implement; splits mid-thought; overlap inflates storage ~15%
Structural
split on headings, list items, code blocks, paragraphs
respects the author's own boundaries; requires parsing per format
Recursive
try paragraph, then sentence, then hard split at the size limit
good general default; the practical baseline
Semantic
split where embedding similarity between adjacent sentences drops
best boundaries; costs an embedding pass over every sentence to compute
Parent-document
embed small chunks, but return the larger parent section
precise matching with full context — usually the biggest single quality win
Parent-document retrieval deserves the emphasis. Match on a tight 200-token chunk so the vector is sharp, then hand back the 2,000-token section it belongs to so the consumer has enough context to be useful. It decouples "what makes a good match" from "what makes a good result," which are genuinely different requirements, and it costs only a parent pointer per chunk.
Two things every chunk needs regardless of strategy:
Metadata, denormalised onto the chunk. Tenant ID, document ID, source URL, section heading, timestamp, ACL, and model_version. You will filter on these, and a filter you cannot express is a feature you cannot ship. The tenant ID in particular is not optional — see the filtered-search section.
Contextual prefixing. Prepending the document title and section heading to the chunk text before embedding measurably improves retrieval, because it restores the context that splitting removed. "Billing → Cancelling: This value defaults to 30 seconds" embeds far more usefully than the sentence alone.
Exact k-NN Is a Full Scan; Approximation Is the Whole Field
Finding the true nearest neighbours means computing the distance to every vector: O(n · d). That is a sequential scan with arithmetic, and its performance is more forgiving than people assume — modern SIMD makes a few hundred thousand comparisons at 768 dimensions a matter of tens of milliseconds.
Corpus size
Exact k-NN (768-d, rough)
Verdict
10,000
~1 ms
exact is obviously right
100,000
~10–30 ms
exact is still fine, and it is perfectly accurate
1,000,000
~200–400 ms
too slow interactively
10,000,000+
seconds
needs an index
The lesson from that table is one worth acting on: below roughly 100,000 vectors, do not build an ANN index. A brute-force scan is simpler, has no build step, no tuning, no recall loss, no staleness, and handles filters and updates for free. Adding an approximate index to a small corpus imports every operational problem below in exchange for milliseconds nobody notices.
Above that, you trade accuracy for speed. That is what the "A" in ANN means, and it is not a caveat — it is the deal.
IVF: partition the space, search a few partitions
Cluster the vectors into lists centroids (k-means). At query time, find the nearest few centroids and scan only those lists.
Recall knob:nprobe — how many lists to scan. Higher is more accurate and slower, near-linearly.
Requires training on a representative sample, because the centroids come from your data distribution.
Weak under heavy inserts: new vectors are assigned to existing centroids, so as the distribution drifts, the partitioning degrades and recall quietly falls until you retrain. This is the reason IVF is a poor fit for a continuously-growing corpus.
Cheap to build and compact in memory, which is its real advantage.
The failure to know about: a vector near a cluster boundary has its true nearest neighbours in a neighbouring list. If nprobe is small, they are never examined. Recall loss in IVF is therefore not uniform — it concentrates on exactly the boundary cases.
HNSW: a navigable graph, greedily descended
Build a multi-layer proximity graph: sparse long-range links on upper layers, dense local links at the bottom. Search enters at the top, greedily walks towards the query, and drops a layer — a skip list in metric space.
Build knobs:m (edges per node, memory and quality) and ef_construction (search width while building — higher means a better graph and a much slower build).
Query knob:ef_search — the candidate list size. This is the recall/latency dial you actually turn in production, and it is adjustable per query without rebuilding, which is HNSW's most useful property.
Excellent recall at low latency, generally the best quality-per-millisecond available.
Memory-hungry: the graph must be resident, and it is a substantial multiple of the vector data. Budget RAM for vectors plus graph.
Slow to build. Indexing ten million vectors is hours, and it is CPU-bound.
Deletes are tombstones. Removing a node from a graph without breaking connectivity is hard, so implementations mark and skip. A high-churn corpus accumulates tombstones that degrade both recall and speed until the index is rebuilt — the same "compaction is background work you must plan for" lesson as Module P-2 and Module P-20's segment merges.
Quantisation: the memory lever
Vectors compress well, and memory is usually the binding constraint:
Method
Size vs float32
Recall impact
Scalar / int8
4× smaller
small, often negligible
Product quantisation (PQ)
8–32× smaller
moderate, and tunable
Binary (1 bit/dim)
32× smaller
large alone — viable only with rescoring
The standard pattern is quantised index for candidate generation, full-precision vectors for rescoring: retrieve 200 candidates cheaply from the compressed index, then re-rank those 200 with exact distances on the full vectors. You keep most of the recall at a fraction of the memory, and the extra cost is bounded because you only rescore a small candidate set.
IVF
HNSW
Brute force
Build time
fast
slow
none
Memory
low
high
vectors only
Recall at low latency
good
best
perfect
Incremental inserts
degrades, needs retraining
fine
trivial
Deletes
rebuild-ish
tombstones, needs rebuild
trivial
Tuning per query
nprobe
ef_search
none needed
Recall Is a Number You Must Measure, Because Bad Recall Is Silent
Recall@k is the fraction of the true top-k (computed by brute force) that your approximate index actually returned. It is not accuracy, not relevance, and not something the index reports.
This is the most important operational sentence in the module: a badly tuned ANN index throws no errors, returns the requested number of results, and is fast. Latency looks great — often because recall is poor, since scanning less is exactly what makes it quick. Every dashboard is green while a fifth of the correct answers are missing.
So build a ground truth set, once, and keep it:
sql
Then measure recall@10 against it at several ef_search/nprobe values and pick a point on the curve deliberately:
ef_search
Recall@10
p99 latency
40
0.86
4 ms
100
0.95
9 ms
200
0.98
18 ms
400
0.995
35 ms
The shape is always this: recall approaches 1 with steeply diminishing returns while latency grows roughly linearly. Choosing 0.95 versus 0.98 is a product decision — for a support-article search, 0.95 is invisible; for retrieval feeding a medical or legal answer (Module A-12), one missing document in twenty is a different conversation.
Re-measure after every change to corpus size, model, chunking, or index parameters. Recall is a property of the combination, not of the index alone, and it drifts as the corpus grows.
The Filtered-Search Trap, Which Is Where Multi-Tenancy Breaks
Nearly every real query is filtered: this tenant's documents, this language, published, not archived. Combining a filter with ANN is genuinely hard, and the naive version fails in a way that looks like an empty database.
Post-filtering — run ANN for the top k, then apply the filter:
ask for 10 nearest → get 10 → 9 belong to other tenants → return 1 result
The tighter the filter, the worse it gets. A tenant holding 0.1% of the corpus will get zero results from a top-100 ANN search almost every time, and the page shows "no results" for a tenant whose documents are demonstrably there. Over-fetching (LIMIT 1000 then filter) papers over it at 100× the work and still fails for a small enough tenant.
Pre-filtering — apply the filter first, then search exactly within the survivors — is correct and its cost depends entirely on selectivity. If the filter leaves 3,000 rows, a brute-force scan of 3,000 vectors is a couple of milliseconds and better than any index. If it leaves 8 million, you are back to a full scan.
The real answers, in order:
Let the planner choose. In Postgres with pgvector, a selective WHERE tenant_id = $1 may correctly produce a filtered scan with exact distances rather than an index scan, and that is the right plan. This is one of the strongest arguments for keeping vectors in your database: the optimiser already reasons about selectivity, and no bolt-on vector store does.
Use an index that filters during traversal. Modern implementations (pgvector 0.8's iterative index scans, filtered HNSW in dedicated engines) continue walking the graph until enough matching results are found, rather than filtering afterwards. This is the correct primitive and it is worth checking your version supports it.
Partition by the dominant filter. A partial index per tenant, or an index per large tenant, makes the filter free because it is structural. This is Module P-8's bridge model again: silo the whales, pool the rest.
And the security point, which is the same as Module P-20's: the tenant filter must be applied by a layer that no individual query can omit. A forgotten filter in a vector search does not error — it returns another customer's documents, ranked by semantic similarity, which is a particularly unfortunate way to leak data because the results are relevant.
Hybrid Retrieval: Fuse Ranks, Not Scores
Vector search and BM25 fail in opposite directions, which is why production retrieval uses both.
Query
Lexical (BM25)
Vector
"ERR_MODULE_NOT_FOUND"
exact hit
weak — rare tokens embed poorly
"SKU-4417-B"
exact hit
near-useless
"why is my app slow"
weak
strong
"cancel my plan" vs "end subscription"
miss
strong
Names, IDs, error codes, code symbols
strong
unreliable
Embeddings are bad at exactly what keyword search is best at: rare, precise, out-of-vocabulary strings. Anyone who replaces BM25 with vector search discovers this the day a user pastes an error code.
Fusing them has a wrong way and a right way. The wrong way is adding the scores, because — as Module P-20 established — BM25 scores are unbounded and not comparable across queries, while cosine similarity sits in a fixed range. Any fixed weighting between the two is arbitrary and drifts with corpus statistics.
Reciprocal rank fusion (RRF) avoids the problem by discarding the scores and using only the ranks:
ts
RRF needs no normalisation, no tuning per corpus, and no assumption that two scoring systems share a scale. It rewards documents that both retrievers liked, which is a good proxy for relevance. Its cost is that it throws away magnitude — a document BM25 was overwhelmingly confident about is treated the same as one it merely ranked first — so where you have labelled data, a trained fusion or a learned ranker will beat it. RRF is the right default precisely because it works without labelled data.
Above fusion sits reranking: retrieve 50–100 candidates cheaply, then score each one with a cross-encoder that reads the query and document together rather than comparing two independent vectors. This is materially more accurate and much more expensive — it is a model inference per candidate, so it only works on a small set — which is the whole reason for the retrieve-then-rerank shape. Budget 50–200 ms for a reranking pass and decide whether the quality is worth it per surface: usually yes for a RAG pipeline, usually no for autocomplete.
The Embedding Pipeline Is a Job System and a Cost Centre
Everything above assumes the vectors exist. Producing and maintaining them is Module P-19's problem wearing new clothes, with a bill attached.
Backfill is a batch job with real duration. Ten million chunks through an embedding API at 1,000 chunks/second is about three hours, and that is if you batch requests properly (batching 100 texts per call rather than one is often a 10× throughput difference), respect the provider's rate limits (Module P-18 from the client side — honour Retry-After), and checkpoint so a failure at 80% does not restart from zero (Module P-19's cursor pattern). Track cost before starting: at roughly $0.02–0.13 per million tokens, ten million chunks of 500 tokens is $100–650 per full pass. That number is the reason "let's just try a bigger model" is a budget conversation.
Incremental updates are re-embeddings. When a document changes, its chunks must be re-chunked and re-embedded — you cannot patch a vector. Two optimisations matter: hash the chunk text and skip re-embedding when the hash is unchanged (editing one paragraph of a fifty-chunk document should cost one embedding, not fifty), and cache embeddings by content hash, because the same boilerplate text recurs across documents far more than you would guess.
Sync has the same three options as Module P-20, and for the same reasons: outbox plus worker, CDC, or periodic reconciliation as a backstop. Dual writes are broken here identically.
Model migrations need the alias-swap discipline. Add a new column or index, backfill it while the old one continues serving, compare recall and relevance on the ground-truth set, switch reads, keep the old one until you are sure. Do not migrate in place — you will have a period where the corpus contains vectors from two models, which is the meaningless-comparison failure at its worst because it is partial and therefore intermittent.
Deletes need a rebuild schedule. Tombstones accumulate in HNSW and IVF alike, so a corpus with high churn needs a periodic full index rebuild with an alias swap. Put it on the calendar rather than discovering it as gradual latency creep.
Where This Shows Up in Your Stack
PostgreSQL / pgvector: the default choice, and the reason is not performance — it is that vectors sit in the same transaction as your data, so there is no sync problem, no separate consistency story, and you can JOIN and WHERE against real columns while the planner reasons about selectivity. Use vector for ≤2,000 dimensions, halfvec to halve storage at negligible recall cost, HNSW for quality and IVFFlat when build time or memory dominates. Match the operator class to the operator (vector_cosine_ops with <=>) or the index is silently unused. Raise maintenance_work_mem substantially before an HNSW build or it will spill and take far longer. Check that your version supports iterative index scans before relying on filtered search. Vector columns are large, so they will be TOASTed — which matters for row-width and update cost (Module F-13).
Node.js: batch embedding calls rather than looping one-at-a-time; the throughput difference is an order of magnitude. Pin the model version explicitly and store it per row. Serialising a 1,536-dimension float array as JSON costs roughly 3× the bytes of a binary format and is real CPU on the event loop at volume — use the driver's native vector type where available.
Redis: an excellent embedding cache keyed by content hash, which is often the single largest cost saving in the pipeline. Redis vector search is a credible option at moderate scale, with the usual constraint that memory is the capacity ceiling and eviction must not be permitted to touch the index (Module P-12).
Dedicated vector stores (Pinecone, Qdrant, Weaviate, Milvus): earned above roughly 50–100 million vectors, or where you need filtered ANN, quantisation and horizontal sharding as managed features rather than as things you build. What you take on is the sync problem, a second consistency story, and the loss of JOIN — which is why the honest sequence is pgvector until it hurts, with a measured reason for moving.
Kafka / CDC: the same sync mechanisms as Module P-20, with the same benefit that a re-embedding becomes "reset the consumer offset and replay" — the cheapest possible full rebuild.
Embedding providers: rate limits and per-token cost make this the one search dependency with a marginal price per write. Treat it as a metered dependency: cache, batch, deduplicate by hash, and put the monthly spend on a dashboard (Module O-8) before the finance team finds it first.
Summary
Vector search exists to solve vocabulary mismatch — "my app keeps crashing" and "unhandled promise rejections" are about the same thing and share no tokens. Synonym lists only cover mismatches somebody already thought of.
An embedding is a coordinate; distance stands in for meaning. Normalise once at write time and use dot product for cosine semantics at lower cost, and make sure the index's distance function matches the query operator or the index is silently unused. Storage is n × d × 4 bytes, so model width is a RAM decision, not a detail.
The embedding model is a schema commitment. Vectors from two models are not comparable — not worse, meaningless — so changing model means re-embedding everything, query and index models must match including version, and every row should carry a model_version. The failure mode is plausible nonsense with no error, which is worse than a crash.
Chunking determines retrieval quality more than the index does. Too large dilutes the vector; too small destroys context. Recursive splitting is the practical baseline, and parent-document retrieval — embed small, return the enclosing section — is usually the biggest single quality win because matching and answering have different requirements. Denormalise tenant, document, ACL and model version onto every chunk, and prepend title/heading context before embedding.
Exact k-NN is a full scan and is genuinely fine below ~100,000 vectors — perfectly accurate, no build, no tuning, no staleness, filters and updates for free. Adding an ANN index to a small corpus buys milliseconds nobody notices and imports every operational problem in this module.
IVF partitions and probes: cheap and compact, nprobe as the recall knob, but it needs training and degrades under continuous inserts as the distribution drifts. HNSW is a navigable graph with the best quality-per-millisecond and a per-query ef_search dial, at the cost of high memory, slow builds, and deletes as tombstones that require periodic rebuilds — Module P-2's compaction lesson once again.
Quantise for memory and rescore for accuracy: int8 is often free in quality terms, PQ and binary buy 8–32× at real recall cost, and generating candidates from a compressed index then rescoring 200 of them with full-precision vectors keeps most of the quality at a fraction of the RAM.
Recall must be measured, because bad recall is silent — no errors, the right number of results, and better latency, since scanning less is what makes it fast. Build a brute-force ground-truth set for a few hundred queries, plot recall@k against latency across ef_search/nprobe, choose a point deliberately, and re-measure after any change to corpus, model, chunking or parameters.
Post-filtering breaks under selective filters: ask for 10, get 10, discard 9 that belong to other tenants, return 1 — and a small tenant gets zero results from a corpus that plainly contains their documents. Prefer letting the planner choose a filtered exact scan when the filter is selective, an index that filters during traversal, or partitioning by the dominant filter (Module P-8's bridge model). A missing tenant filter does not error; it returns another customer's documents, ranked by relevance.
Hybrid retrieval is not optional, because embeddings are unreliable on exactly what BM25 is best at — error codes, SKUs, names, code symbols. Fuse with reciprocal rank fusion, which uses ranks rather than scores and therefore needs no normalisation between two incomparable scales; its cost is discarding magnitude, so a learned fusion beats it once you have labelled data. Add a cross-encoder reranker over 50–100 candidates where the quality justifies 50–200 ms.
The pipeline is a job system with a bill: batch the API calls, honour rate limits from the client side, checkpoint the backfill, skip re-embedding on unchanged content hashes, and cache embeddings by hash. Sync via outbox or CDC, never dual write. Migrate models by alias swap, never in place, because a corpus half-populated with two models fails intermittently. And schedule the index rebuild that tombstones make necessary.
The next module, OLTP vs OLAP vs HTAP, steps back from retrieval to the shape of the storage itself. Everything in Practitioner so far has served requests that touch a few rows; the moment someone asks "revenue by region by month for three years," the row-oriented engine that has served you perfectly becomes the wrong tool — and the read replica that looks like the obvious fix is the wrong answer for a reason worth understanding precisely.
Knowledge Check
A team upgrades their embedding model from a 768-dimension model to a newer 1,536-dimension one. To manage cost, they embed all *new* documents with the new model and leave existing vectors in place, embedding queries with the new model. Search still returns ten results per query and latency is unchanged, but users report results are frequently unrelated to what they searched for. What is happening?
A multi-tenant knowledge base runs HNSW vector search over 20 million chunks. Large tenants get good results. Several small tenants — each holding a few thousand chunks — report that search returns nothing at all, even for text they can see in their own documents. The query does an ANN search for the top 50 and then applies WHERE tenant_id = $1. What is wrong?
A support-search team replaces their BM25 index entirely with vector search, and overall satisfaction improves. Then complaints arrive from power users: pasting an exact error code like ERR_MODULE_NOT_FOUND, or a SKU such as SKU-4417-B, returns loosely related articles rather than the exact page. What does the module prescribe, and how should the two systems be combined?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
-- Ground truth for ~500 sampled queries: exact k-NN, index disabled.SET enable_indexscan =off;-- force the sequential scan so results are exactSELECT id FROM chunks ORDERBY embedding <=> $1LIMIT10;
// score(d) = Σ over retrievers 1 / (k + rank(d)); k ≈ 60 damps the top positionsconst fused =newMap<string,number>();for(const list of[bm25Results, vectorResults]){ list.forEach((doc, i)=> fused.set(doc.id,(fused.get(doc.id)??0)+1/(60+ i +1)));}const ranked =[...fused].sort((a, b)=> b[1]- a[1]);