Skip to content

    AI Architect

    Data and Retrieval Architecture for AI Systems

    How retrieval works inside an AI system: embeddings, HNSW, IVF and product quantization, BM25, rank fusion, rerankers, chunking, access control and freshness.

    By Published 44 min read

    Share

    This lesson is part of AI Architect: From AI Engineer to Expert. Enroll for free, then sign in, to read it in full.

    What you'll learn: how the retrieval half of an AI system is built and where it breaks. You will see how embeddings, HNSW graphs, IVF with product quantization, BM25, reciprocal rank fusion and cross-encoder rerankers work, with the formulas and worked numbers, and then use that to decide on chunking, index type, hybrid search, access control, multi-tenancy and incremental indexing. The page is about design decisions: what each mechanism costs in recall, latency, memory and risk.

    A language model answers from whatever text reaches its prompt. In a retrieval-augmented system that text is chosen by the retrieval layer, so a wrong, stale or forbidden passage is a retrieval defect long before it is a model defect. The original RAG paper by Lewis et al. (arXiv 2005.11401) framed the model as a parametric memory plus a non-parametric memory (a dense vector index of Wikipedia) reached through a retriever. This page is about that second memory: how to build it, how to keep it correct, and what the mechanisms underneath actually do.

    The real-world problem

    Composite scenario, not a real company. A software vendor launches an assistant that answers customers' questions from product manuals, contract templates and a ticket archive. In the demo it is excellent. In production three complaints arrive in one week. A customer asks about "clause 14.2" and receives text about a different clause that merely sounds similar. A small customer with a tiny share of the corpus gets "I could not find anything" for questions that the manuals answer. And the assistant quotes a warranty period that was replaced two months ago, because the old document is still in the index next to the new one.

    None of these is fixed by a better prompt. The first is a missing lexical search, the second is a filter applied after the nearest-neighbour search, and the third is an ingestion pipeline with no notion of versions. Each has a mechanism behind it, and this page teaches those mechanisms.

    After this page you will be able to

    • Compute memory for a vector index from dimension, vector count, HNSW connection count or product-quantization settings, and say what accuracy you traded for it.
    • Calculate a BM25 score and a reciprocal rank fusion score by hand from the published formulas, and explain what each ignores.
    • Choose between pre-filtering and post-filtering for a given access rule, state what the documentation of your database says about it, and write the test that proves tenants cannot cross.
    • Design an ingestion pipeline with versions, deletes, retries and a dead-letter path, and define a freshness objective for it.
    • Decide when a reranker is worth its latency and which failure it cannot fix.

    Read Diagramming AI systems first: the figures here reuse its component names (Retriever, Vector index, Document store, Orchestrator, Policy engine, Event bus, Worker, Eval service) and its trust-boundary view. Background that this page does not repeat:

    Terms used on this page: Retriever, Vector index, Document store, trust boundary, embedding, HNSW, product quantization, BM25, reciprocal rank fusion, cross-encoder, pre-filtering and post-filtering.

    Core concepts

    The teaching system is the policy assistant from the diagramming page, now with a lexical index beside the Vector index. "Lexical index", "Reranker", "Embedding model" and "Chunker" are added to the course component names on this page only. The Lexical index is a search index over words (for example a full-text index) that serves BM25 scoring.

    Figure 1. Retrieval architecture: ingestion path and query path

    flowchart LR accTitle: Retrieval architecture with ingestion and query paths accDescr: On the ingestion path a change in the Document store is published to the Event bus, a Worker chunks and embeds the document and writes to the Vector index and the Lexical index. On the query path the Orchestrator asks the Retriever, which applies the caller's access filter to both indexes, fuses the results and sends the candidates to a reranker. The Eval service replays saved queries against the Retriever. subgraph ING["Ingestion path (asynchronous)"] DS[("Document store")] EB["Event bus"] Wk["Worker"] end subgraph IDX["Indexes"] VI[("Vector index")] LI[("Lexical index")] end subgraph QRY["Query path (synchronous)"] Orch["Orchestrator"] PE["Policy engine"] Ret["Retriever"] RR["Reranker"] end Ev["Eval service"] DS -.->|"change event"| EB EB -.->|"consume"| Wk Wk -->|"chunks plus embeddings plus metadata"| VI Wk -->|"chunk text plus metadata"| LI Orch -->|"question plus verified identity"| Ret Ret -->|"may this caller see which scope?"| PE Ret -->|"search with access filter"| VI Ret -->|"search with access filter"| LI Ret -->|"fused candidates"| RR Ev -->|"replay saved queries"| Ret

    How to read this: dotted arrows are asynchronous, solid arrows are calls the user waits for, cylinders are stores, and the access filter is applied inside the Retriever on both indexes, not in the Orchestrator after the fact.

    Ingestion and freshness pipelines

    The index is a derived copy of the Document store, so it is only as fresh as the pipeline that fills it. A pipeline needs four things: a change signal (an event per create, update, delete or permission change), idempotent processing (the same event twice must not duplicate chunks), a place for failures (retry, then a dead-letter queue), and a measurement (the lag between a source change and the moment it is searchable). A typical example: a policy owner publishes version 4 of a warranty document. The Worker re-chunks it, writes version 4 chunks, and only then marks the version 3 chunks superseded, so a query never sees a gap. The mechanism is in Incremental indexing and freshness.

    Figure 2. Ingestion of one updated document

    sequenceDiagram accTitle: Ingestion of an updated document accDescr: The Document store publishes a change event to the Event bus. A Worker reads the new version, skips the work if the content hash is unchanged, chunks and embeds it, writes the new chunks to the Vector index and the Lexical index, then marks the old version superseded and commits the event offset. If embedding fails after retries the event goes to a dead-letter queue and an alert is raised. autonumber participant D as Document store participant E as Event bus participant W as Worker participant M as Embedding model participant V as Vector index participant L as Lexical index D-)E: change event doc 42 version 4 E-)W: deliver event W->>D: read doc 42 version 4 Note over W: Decision - if the content hash equals the stored hash, stop here and skip the rest W->>M: embed chunks with model id m1 alt Embedding succeeds M-->>W: vectors W->>V: upsert chunks keyed by doc, version, chunk number W->>L: upsert chunk text and metadata W->>V: mark version 3 chunks superseded W-)E: commit offset after all writes else Embedding fails after retries W-)E: send event to the dead-letter queue and alert end

    How to read this: time runs downward, the open-headed arrows are asynchronous messages, and the alt block shows the success path and the failure path of the embedding step.

    Chunking

    A chunk is the unit that gets one vector, one citation and one chance to be retrieved. Splitting decides what each vector can mean: a chunk cut through the middle of a table row or a contract clause has a vector that represents half of each. The mechanism and the failure modes are in Chunking and its failure modes; the splitting methods themselves are in the linked chunking post.

    Index choice

    Start with the simplest index that meets the latency budget. pgvector's README states that by default it performs exact nearest-neighbour search with perfect recall, and that adding an approximate index changes results. Approximate indexes (HNSW, IVF, product quantization) buy speed and memory with recall. Anthropic's Contextual Retrieval post also notes that a knowledge base under 200,000 tokens (about 500 pages) can go into the prompt without retrieval at all. Examples: a per-tenant partition of 40,000 chunks can reasonably be searched exactly; a 20-million-chunk shared corpus is usually too large to scan exactly within an interactive latency budget.

    Hybrid retrieval

    Run a vector search and a lexical search and fuse the rankings. The lexical side catches exact identifiers (clause numbers, error codes, product names) that embeddings blur; the vector side catches paraphrases that share no words. See BM25 and hybrid search and Reciprocal rank fusion.

    Reranking

    A reranker rescoring the top candidates with a model that reads query and passage together is the usual second stage. It improves the order of candidates; it cannot recover a passage the first stage never returned. See Rerankers.

    Access control in retrieval

    Whether a caller may see a chunk is a retrieval-time decision, taken from a verified identity and applied inside the search. Where in the search it is applied changes recall, not only security. See Access control in retrieval.

    Multi-tenancy

    When one index serves many tenants, two things must hold: no cross-tenant results, and one tenant's data must not degrade another's results. The pgvector README warns that sharing an approximate index between tenants means vectors from one tenant can affect recall (and speed) for another, and suggests partitioning or separate tables. Large tenants usually get their own partition; small tenants share one with a mandatory tenant filter.

    Evaluation hooks

    Retrieval must be measurable on its own, separately from the final answer. Log, for every request, the chunk ids, document versions, scores and the index and embedding model versions. Keep a golden set of questions with the chunks that should be returned, and replay it from the Eval service on every change to the chunker, the model or the index settings. Recall at k and the other metrics are defined in the evaluation page of this course and in Evaluating RAG with RAGAS.

    Conflicting and stale sources

    Two documents can disagree, and one can be out of date. The retriever cannot decide which is true, but metadata can: version, effective date, status and a "supersedes" link let it prefer the current document, and let it surface both when the evidence is genuinely contested.

    Figure 3. What the Retriever does when passages disagree

    flowchart TD accTitle: Handling conflicting or stale passages accDescr: A decision tree. If the retrieved passages from different documents disagree, the Retriever first checks whether one document supersedes the other and keeps only the current one. If neither supersedes the other it returns both with their dates and sources for the answer to show. If a passage is past its review date it is flagged as possibly stale. If there is no passage with enough relevance the system abstains. Start(["Passages retrieved"]) Q0{"Any passage above the relevance threshold?"} Q1{"Do passages from different documents disagree?"} Q2{"Does one document supersede the other?"} Q3{"Is the kept passage past its review date?"} A0["Abstain: say nothing was found and offer a human route"] A1["Answer from the passages as retrieved"] A2["Keep the current version, drop the superseded one"] A3["Return both with dates and sources, let the answer state the conflict"] A4["Answer and flag the passage as possibly stale"] Start --> Q0 Q0 -->|"no"| A0 Q0 -->|"yes"| Q1 Q1 -->|"no"| Q3 Q1 -->|"yes"| Q2 Q2 -->|"yes"| Q3 Q2 -->|"no"| A3 Q3 -->|"no"| A1 Q3 -->|"yes"| A4

    How to read this: start at the top, follow yes or no at each diamond, and stop at the first rectangle.

    Figure 3 refers to a relevance threshold. Which score to threshold is a design question taken up under Reciprocal rank fusion and Rerankers.

    Figure 4. The query path with hybrid search, an access filter, reranking and abstention

    sequenceDiagram accTitle: Query path of a hybrid retrieval accDescr: The Orchestrator sends a question with a verified identity to the Retriever. The Retriever asks the Policy engine for the caller's scope, searches the Vector index and the Lexical index in parallel with that scope as a filter, fuses the two rankings, and reranks the candidates. If the reranker times out the fused order is used. If no passage reaches the threshold the Retriever returns an abstain result and the Orchestrator does not call the model. autonumber participant O as Orchestrator participant R as Retriever participant P as Policy engine participant V as Vector index participant L as Lexical index participant X as Reranker O->>R: question plus verified identity R->>P: which scopes may this caller read? P-->>R: tenant t1, groups g1 and g2 par Vector search R->>V: nearest chunks with scope filter, top 50 and Lexical search R->>L: BM25 chunks with scope filter, top 50 end V-->>R: ranked chunk ids L-->>R: ranked chunk ids Note over R: Fuse the two rankings with reciprocal rank fusion alt Reranker replies in time R->>X: query plus fused candidates X-->>R: relevance score per candidate else Reranker times out Note over R: Use the fused order and mark the trace end alt Top score at or above threshold R-->>O: top passages with ids, versions and scores else Below threshold R-->>O: abstain, no passages end

    How to read this: the par block runs the two searches at the same time, the first alt block is the reranker failure path, and the second alt block is the abstain decision that stops the model from being called.

    Case files

    Four case files apply this page. Three are composite scenarios and one is a public case sourced from a published paper.

    Under the hood: Embeddings and similarity metrics

    Intuition

    An embedding model turns a piece of text into a fixed-length list of numbers (a vector) so that texts the model treats as similar end up near each other. The Sentence-BERT paper (Reimers and Gurevych, arXiv 1908.10084) defines "semantically meaningful" the same way: semantically similar sentences are close in vector space. What a vector captures is whatever the training objective rewarded. The same paper reports that simply averaging a raw BERT model's outputs, or taking its first-token output, gave "rather bad" sentence embeddings, often worse than averaging GloVe word vectors, and that fine-tuning in a siamese network on sentence-pair data fixed it. So an embedding is not a compressed copy of the text: it is a learned summary of topical and semantic closeness, and it is blunt about exact strings and numbers. In the RAG paper's own comparison, a word-overlap BM25 retriever beat the dense retriever on the FEVER fact-checking task, which the authors tentatively put down to FEVER claims being heavily entity-centric.

    Mechanism

    Three measures are in common use (the FAISS paper lists L2 distance, cosine similarity and inner product as the most common):

    • Dot product: a·b = Σ aᵢbᵢ (higher is closer).
    • Cosine similarity: a·b / (|a| |b|) (higher is closer; it ignores length).
    • L2 distance: √Σ(aᵢ−bᵢ)² (lower is closer).

    A worked example with illustrative vectors a = (3, 4, 0) and b = (4, 3, 0): the dot product is 3·4 + 4·3 = 24, both lengths are 5, so cosine = 24 / 25 = 0.96, and the L2 distance is √2 ≈ 1.414. Now scale a to c = (6, 8, 0), the same direction. Cosine with b stays 0.96, the dot product doubles to 48 and the L2 distance grows to about 5.385. The measures disagree unless vectors have unit length. If a and b are first divided by their lengths, the dot product equals the cosine (0.96) and the squared L2 distance equals 2 − 2·cosine = 0.08, so all three rank identically. This is why the FAISS paper notes that measures can be made equivalent by preprocessing, why the HNSW paper treats cosine as equivalent to L2 after normalisation, and why the pgvector README advises inner product for best performance when vectors are normalised to length 1. pgvector exposes <-> (L2), <#> (negative inner product, because Postgres only supports ascending index scans on operators) and <=> (cosine distance), and an index is created for one of them, for example with vector_cosine_ops.

    Figure 5. Index time and query time must use the same embedding model

    sequenceDiagram accTitle: Embedding at index time and query time accDescr: A Worker embeds each chunk with model m1 and stores the vector with the model id. At query time the Retriever embeds the question with the model id the index was built with. If the Retriever is configured with a different model id than the index records, it refuses to search and raises an alert instead of comparing vectors from different spaces. autonumber participant W as Worker participant M as Embedding model participant V as Vector index participant R as Retriever W->>M: embed chunk text with model m1 M-->>W: vector of d numbers W->>V: store vector plus model id m1 R->>V: which model id built this index? V-->>R: m1 alt Retriever configured with m1 R->>M: embed the question with m1 M-->>R: query vector R->>V: nearest vectors by the index metric else Retriever configured with m2 Note over R: Refuse - vectors from m1 and m2 are not comparable R-)W: alert and start a re-index into a new index end

    How to read this: the first half is index time, the alt block at the end is query time with the model check, and the failure branch refuses to search.

    Why a model change forces re-indexing: stored vectors are outputs of model m1, and a query vector from m2 comes from a different learned space, possibly with a different number of dimensions, so distances between them mean nothing. The RAG paper shows the same coupling: its retriever is a bi-encoder whose document encoder and query encoder score by inner product, and the authors kept the document encoder (and so the index) fixed during training because updating it "requires the document index to be periodically updated".

    What this means for architects

    Record the embedding model id and version beside every vector, and make the Retriever refuse a mismatch (Figure 5). Treat a model change as a migration: build a new index in parallel, replay the golden queries against both, compare recall at k, then switch traffic, the same hot-swap idea the RAG paper demonstrates for whole indexes. Dimension drives cost: each float32 value is 4 bytes, and pgvector stores a vector as 4 × dimensions + 8 bytes. Choose normalised vectors plus inner product when the model documents unit-length output, and make sure the index operator class matches the metric you query with. Re-embedding the whole corpus is the real price of a model upgrade, so size it before promising one; use the LLM cost estimator and the token counter for current numbers.

    Under the hood: HNSW graphs

    Intuition

    Hierarchical Navigable Small World (HNSW) graphs, introduced by Malkov and Yashunin (arXiv 1603.09320), find near neighbours by walking a graph in which every vector is linked to some near ones. To avoid long walks, the vectors are arranged in layers like a skip list: a few vectors appear in the top layers with long links that jump far, and every vector appears in layer 0 with short links. A search enters at the top, jumps quickly toward the right region, drops a layer, and refines.

    Mechanism

    Each inserted vector gets an integer top layer drawn from an exponentially decaying distribution, controlled by a normalisation factor mL; the paper suggests mL = 1/ln(M), which gives a fraction M⁻ˡ of vectors reaching layer l or above. With M = 16, one vector in 16 reaches layer 1, one in 256 reaches layer 2, and in a collection of 1,000,000 vectors the expected number reaching layer 5 is 1,000,000 × 16⁻⁵ ≈ 1, so the top layer is around 4 or 5.

    Insertion searches from the top layer down to the vector's own layer with a candidate list of size 1 (a plain greedy walk). From there down to layer 0 it searches with a candidate list of size efConstruction, picks M neighbours with a heuristic that favours diverse directions over simply the closest ones, links them in both directions, and shrinks any neighbour that now has more than Mmax links (layer 0 uses Mmax0, which the paper suggests setting to 2M).

    Search (the paper's K-NN-SEARCH) does the same descent: greedy with a list of size 1 on the upper layers, then at layer 0 a list of size ef (efSearch), stopping when the nearest unexamined candidate is farther than the farthest in the list, and returning the K nearest from the list. pgvector names the same parameters m (default 16), ef_construction (default 64) and hnsw.ef_search (default 40), and states that results are limited by the size of that dynamic candidate list.

    Figure 6. HNSW search: greedy descent through the layers

    sequenceDiagram accTitle: HNSW search walkthrough accDescr: The query enters at the entry point in the top layer and walks greedily to the closest node it can reach there. It descends through the middle layers, repeating the greedy walk from the node it found. At layer 0 it keeps a candidate list of size ef, examines the nearest unexamined candidate each step, and stops when no candidate can improve the list. The K nearest entries of the list are returned. autonumber participant Q as Query vector participant T as Top layer participant M as Middle layers participant B as Layer 0 Q->>T: enter at the entry point Note over T: Greedy walk with list size 1 - move only to a closer neighbour T-->>M: closest node found, descend from it Note over M: Same greedy walk on each layer M-->>B: closest node found, descend from it Note over B: Keep ef best candidates - expand the nearest unexamined one Note over B: Stop when the nearest unexamined candidate is farther than the worst kept B-->>Q: the K nearest of the ef kept

    How to read this: the query moves down the participants from left to right in time, each note is what happens on that layer, and only layer 0 uses the larger candidate list ef.

    Memory. The paper gives the average per-vector cost of the graph as (Mmax0 + mL × Mmax) × bytes per link, with 4-byte links up to about four billion vectors, and reports typical M values between 6 and 48, or about 60 to 450 bytes per object excluding the vector data. Worked example (illustrative inputs): M = 16, Mmax0 = 32, and assuming Mmax = M = 16, mL = 1/ln 16 ≈ 0.361, so (32 + 0.361 × 16) × 4 ≈ 151 bytes per vector. For 1,000,000 vectors of 1,536 float32 values the vectors take 1,000,000 × 6,144 B = 6.144 GB and the graph about 0.151 GB. The vectors, not the graph, dominate. The same formula gives about 61 bytes at M = 6 and 434 bytes at M = 48, consistent with the paper's range. Real systems store extra per-row overhead, so treat this as a floor, not a quote for any product.

    The tradeoff. The paper states that memory is proportional to M, that smaller M is generally better for lower recall and lower-dimensional data and larger M for high recall and high-dimensional data, that efConstruction trades build time for quality (efConstruction = 100 gave a reasonable index for 10 million SIFT vectors in about three minutes on their server, with little gain from going higher), and that the search-time ef controls recall against latency. It reports logarithmic search scaling, analysed under an exact-Delaunay-graph assumption and confirmed on low-dimensional data, and says that for high-dimensional data further analytical evidence is required. The pgvector README adds that HNSW has a better speed-recall tradeoff than IVFFlat but slower builds and more memory.

    What this means for architects

    Recall is a dial, not a property. Measure it on your own vectors by comparing approximate results with exact results, as the pgvector README advises, then set efSearch to the lowest value that meets your recall target. Raise it for queries that feed a reranker (more candidates) and lower it for autocomplete-style paths. Budget memory from the formula, and keep the index resident: the README says indexes need not fit in memory but usually perform better if they do. Expect slower inserts at high efConstruction and slow vacuum after many deletes (the README suggests reindexing first). Filters change all of this, see Access control in retrieval. Do not copy the paper's benchmark numbers into your plan.

    Under the hood: IVF and product quantization

    Intuition

    Two separate tricks, often combined. An inverted file (IVF) divides the space into cells with k-means and, at query time, looks only in the few cells nearest the query. Product quantization (PQ) compresses each vector into a short code by splitting it into pieces and replacing each piece with the id of its nearest representative. IVF reduces how many vectors you look at; PQ reduces how many bytes each one takes.

    Mechanism

    IVF. The FAISS paper (Douze et al., arXiv 2401.08281) describes a coarse quantizer with K centroids (nlist); each vector is stored in the inverted list of its nearest centroid, and a query visits only the P nearest lists (nprobe). With balanced lists and an exhaustive coarse quantizer the number of distance computations is K + P × N/K, minimised at K = √(P × N), which the paper calls a rough approximation, since P must grow with K to hold accuracy and lists are never equal in size. In its experiments the best K was roughly 15 to 20 times √N (the paper notes the ratio depends on the data distribution). Worked example (illustrative): N = 10,000,000 and P = 16 give K = √160,000,000 ≈ 12,649 and 12,649 + 16 × 10,000,000 / 12,649 ≈ 25,298 distance computations, about 395 times fewer than the 10,000,000 of an exhaustive scan. IVF must be trained (k-means needs data), so the pgvector README says to create an IVFFlat index after the table has data, with lists of rows/1000 up to one million rows and √rows beyond, and probes starting at √lists.

    Product quantization. In the original paper (Jégou, Douze and Schmid, TPAMI 2011) a D-dimensional vector is split into m sub-vectors of dimension D/m, and each sub-vector is quantized by its own k-means codebook of k* centroids. The code is m × log₂ k* bits, and the implied codebook has (k*)ᵐ centroids even though only m × k* × D/m = k* × D floating-point values are stored. Distances are estimated without decoding: for a query, a table of squared distances to every sub-codebook centroid is computed once, and each database vector then costs m table lookups and additions (the asymmetric version, which leaves the query unquantized and is more accurate than quantizing both). IVFADC combines the two: it encodes the residual between a vector and its coarse centroid, and, with k coarse centroids and balanced lists, a search scans about n × w / k entries for w visited lists.

    Worked example (illustrative inputs): N = 10,000,000 vectors of d = 1,536 float32 values take 6,144 bytes each, 61.44 GB in all. With PQ at m = 192 sub-vectors of 8 dimensions and k* = 256 (8 bits each), a code is 192 bytes, so 1.92 GB, a 32-times reduction; at m = 96 it is 96 bytes, 0.96 GB, 64 times. The codebooks add k* × d × 4 = 1,572,864 bytes (about 1.57 MB), and 8-byte ids add 80 MB. The accuracy cost is real and data-dependent: the distance is estimated from the code, not computed from the vector. The paper reports that for a fixed number of bits, fewer sub-quantizers with more centroids each give lower quantization error, and that its recall measurements depend on m, k* and w.

    Figure 7. IVF with product quantization: coarse search, code scan, exact re-rank

    sequenceDiagram accTitle: IVF with product quantization search accDescr: The Retriever finds the nearest coarse centroids to the query and reads those inverted lists. For each list entry it estimates the distance from the stored short code using lookup tables built from the query. It keeps a shortlist of the best estimates and re-scores the shortlist with the original full vectors to produce the final top results. autonumber participant R as Retriever participant C as Coarse centroids participant L as Inverted lists with PQ codes participant F as Original vectors R->>C: find the nprobe nearest centroids to the query C-->>R: list ids Note over R: Build lookup tables of query-to-codebook distances R->>L: read the chosen lists L-->>R: entries as id plus short code Note over R: Estimate each distance with m table lookups, keep a shortlist R->>F: fetch full vectors for the shortlist only F-->>R: exact vectors Note over R: Re-score exactly and return the top results

    How to read this: the first two steps narrow the search to a few lists, the middle steps use compressed codes, and the last two steps correct the estimate with full vectors for a small shortlist.

    What this means for architects

    IVF and PQ are what you reach for when vectors no longer fit in memory, and the cost is a measured loss of recall. Decide the memory budget first, then pick m and nprobe to hit the recall target, and always evaluate recall against exact search on your data. Keep the original vectors (on cheaper storage) and re-score a shortlist, as the PQ paper does with its shortlist R (in its setup the full vectors sit in main memory, which the authors note limits the scale at which re-ordering can be used) and as the pgvector README's binary-quantization example does. Measure how much of the compression loss this recovers on your data. The FAISS paper also warns that when the vector distribution shifts a lot through additions and removals, IVF and PQ degrade because they were fitted to the old data, so schedule retraining. Compared with HNSW, IVF builds faster and uses less memory but, per the pgvector README, has a lower speed-recall tradeoff.

    Intuition

    BM25 scores a document by how many of the query's words it contains, weighting each word by how rare it is across the collection, letting repeated occurrences count less and less, and discounting long documents. It needs the literal token in the text. That is its weakness (no synonyms) and its strength (an identifier such as "14.2" or "ERR-4012" either matches or does not).

    Mechanism

    Stanford's Introduction to Information Retrieval (Manning, Raghavan and Schütze, section 11.4.3) builds up to the form used for document scoring, equation 11.32 (equation 11.33 adds a weight for repeated query terms with a third parameter k₃, and 11.34 adds relevance-feedback estimates; neither is used here):

    RSV_d = Σ over query terms t of [ log(N / df_t) × (k₁ + 1) × tf_td / ( k₁ × ((1 − b) + b × (L_d / L_ave)) + tf_td ) ]

    where N is the number of documents, df_t the number containing term t, tf_td the count of t in document d, L_d the document length and L_ave the average length. The book says k₁ is a positive tuning parameter (0 gives a binary model, a large value approaches raw counts), b between 0 and 1 sets length scaling (1 is full scaling, 0 none), and that without tuning, reasonable values are k₁ between 1.2 and 2 and b = 0.75. Lucene's BM25Similarity defaults are k₁ = 1.2 and b = 0.75, and it computes idf as log(1 + (docCount − docFreq + 0.5) / (docFreq + 0.5)), so implementations differ from the book in the idf form and scores are not comparable across systems.

    Worked example (illustrative, natural log): N = 1,000 documents, L_ave = 200 words, k₁ = 1.2, b = 0.75, query "indemnification cap". Suppose df = 40 for "indemnification" (idf = ln 25 = 3.219) and df = 300 for "cap" (idf = ln 3.333 = 1.204).

    Document Length Counts Score
    A 150 indemnification 3, cap 1 5.345 + 1.341 = 6.686
    B 400 indemnification 5, cap 0 4.987
    C 200 indemnification 1, cap 2 3.219 + 1.656 = 4.874

    A wins: it is shorter and contains both terms. B repeats the rare word five times but is twice the average length, so its length factor (1 − b) + b × L_d/L_ave is 1.75. Saturation shows in the term-frequency factor at average length, (k₁ + 1) × tf / (k₁ + tf): 1, 1.375, 1.774, 1.964 and 2.148 for tf = 1, 2, 5, 10 and 50, approaching the ceiling k₁ + 1 = 2.2.

    Figure 8. Hybrid retrieval: two searches in parallel, then fuse

    sequenceDiagram accTitle: Lexical and vector search in parallel accDescr: The Retriever sends the question to the Lexical index and, in parallel, embeds it and sends the vector to the Vector index. The Lexical index analyses the question into terms, reads the postings for each term and scores documents with BM25. Both return ranked lists, which the Retriever fuses into one list. autonumber participant R as Retriever participant L as Lexical index participant V as Vector index par Lexical branch R->>L: question text with access filter Note over L: Analyse into terms, read postings, score each document with BM25 L-->>R: ranked list A and Vector branch R->>V: query vector with access filter Note over V: Approximate nearest-neighbour search by the index metric V-->>R: ranked list B end Note over R: Fuse list A and list B into one ranking

    How to read this: the par block shows the two branches running at the same time, and the final note is the fusion step described in the next section.

    Why hybrid. Anthropic's Contextual Retrieval post describes running BM25 and embeddings together and merging with rank fusion, and reports in its own evaluation that embeddings plus BM25 beat embeddings alone, with a top-20 retrieval failure rate of 5.7% falling to 2.9% when contextual embeddings and contextual BM25 were combined. Those are the post's numbers on its datasets, not a prediction for yours.

    What this means for architects

    Add a lexical index when queries contain identifiers, defined terms or exact phrases (legal, compliance, code, parts catalogues). pgvector's README suggests combining it with Postgres full-text search. Check what the text analyser does to your identifiers, for example whether "14.2" stays one token, because BM25 can only match the tokens the analyser produces. Tune k₁ and b on your golden set if you can, since the book says they should be tuned on a development collection. Keep one set of statistics per index and do not mix scores from different systems. The fusion step is the next section, and the lexical score should not be added directly to a cosine score, since the two are on unrelated scales.

    Under the hood: Reciprocal rank fusion

    Intuition

    Different retrievers produce scores that cannot be compared: a BM25 score of 6.7 and a cosine of 0.82 mean different things. Reciprocal rank fusion (RRF) ignores scores and uses only ranks: a document earns more the nearer the top it sits in each list, and documents found by several lists add up.

    Mechanism

    Cormack, Clarke and Büttcher (SIGIR 2009, "Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods") publish the formula. Given a set of documents D and a set of rankings R, each a permutation on 1..|D|:

    RRFscore(d ∈ D) = Σ over rankings r ∈ R of 1 / (k + r(d)), with k = 60

    The paper says k = 60 "was fixed during a pilot investigation and not altered during subsequent validation", that the pilot indicated k = 60 was near-optimal but "the choice was not critical", and explains the shape: highly ranked documents matter more, but the importance of lower-ranked ones does not vanish as it would with an exponential. Its pilot table of mean average precision against k ran from 0.2072 at k = 0 to 0.2145 at k = 60, peaked at 0.2147 at k = 80, and was 0.2098 at k = 500. Across their pilot and TREC experiments RRF beat Condorcet Fuse, CombMNZ and the best single system by 4% to 5% on average, though not in every comparison (CombMNZ scored higher in one TREC comparison and slightly higher on LETOR 3); those were not retrieval-augmented generation corpora. Elasticsearch documents the same formula, 1.0 / (k + rank) with ranks starting at 1, a rank_constant default of 60, and equal weight for every retriever.

    Worked example (illustrative lists): dense ranking [d3, d1, d7, d2] and BM25 ranking [d1, d9, d3], k = 60.

    Document Dense rank BM25 rank RRF score
    d1 2 1 1/62 + 1/61 = 0.032522
    d3 1 3 1/61 + 1/63 = 0.032266
    d9 none 2 1/62 = 0.016129
    d7 3 none 1/63 = 0.015873
    d2 4 none 1/64 = 0.015625

    The fused order is d1, d3, d9, d7, d2. d1 wins although the dense retriever ranked d3 first, because both retrievers found d1 and agreement adds up. The constant matters at the margin. Compare a document a ranked first in only one list with a document b ranked fourth in both lists (ignoring all others): at k = 60, a scores 1/61 = 0.016393 and b scores 2/64 = 0.03125, so b wins; at k = 1, a scores 1/2 = 0.5 and b scores 2/5 = 0.4, so a wins. A small k rewards a top rank in one list, a large k rewards agreement.

    Figure 9. Fusing two rankings with reciprocal rank fusion

    sequenceDiagram accTitle: Reciprocal rank fusion steps accDescr: The Retriever receives a dense ranking and a BM25 ranking from the two indexes. For each document in each list it adds one over k plus its rank to a running score, with k equal to 60. It sorts by total score, breaks ties by document id, and keeps the top depth documents for reranking. autonumber participant V as Vector index participant L as Lexical index participant R as Retriever participant X as Reranker V-->>R: dense ranking d3, d1, d7, d2 L-->>R: BM25 ranking d1, d9, d3 Note over R: For each list and each document add 1 over 60 plus rank Note over R: d1 gets 0.032522, d3 gets 0.032266, d9 gets 0.016129 Note over R: Sort by total, break ties by id, cut to depth R->>X: fused candidates d1, d3, d9, d7, d2

    How to read this: the two indexes send ranked lists, the notes are the fusion arithmetic, and the last message is the fused list passed on.

    What this means for architects

    RRF needs no score calibration and no training, which is why it is the default hybrid fuser, and the cost is what it throws away. It treats all retrievers equally (you cannot weight BM25 above the vector side without changing the method), and it discards score magnitude: a near-perfect match and a poor match at the same rank earn the same. So the fused score is a poor signal for abstention; threshold a reranker score or a per-retriever score instead, calibrated on labelled queries. Depth matters: each list contributes only as many candidates as you fetch (Elasticsearch calls this the rank window size), and a document outside both windows cannot appear. Keep k at 60 unless the golden set shows a reason to move it.

    Under the hood: Rerankers

    Intuition

    A bi-encoder embeds the query and each passage separately and compares the vectors; the passage vectors are computed ahead of time, so search is cheap. A cross-encoder feeds the query and one passage into the model together, so every query word can attend to every passage word, and outputs a relevance score. It can be more accurate because it sees both texts at once (an expectation, which you should test on your data), and it is far more expensive because nothing can be precomputed: one model run per query-passage pair.

    Mechanism

    The Sentence-BERT paper puts numbers on the cost of the cross-encoder form: finding the most similar pair among 10,000 sentences needs n(n − 1)/2 = 49,995,000 BERT inferences, about 65 hours on a V100 GPU, against about 5 seconds to embed the 10,000 sentences once and a further 0.01 seconds for the cosine comparisons. Nogueira and Cho (arXiv 1901.04085) show the intended use of a cross-encoder, as the second stage of a pipeline: a standard retriever such as BM25 returns a large candidate set (for example a thousand), the re-ranker scores each candidate, and the top ten or fifty go to the answer stage. Their re-ranker feeds the query as sentence A and the passage as sentence B, truncating the query to 64 tokens and the whole input to 512, takes the [CLS] vector into a single-layer network to produce a probability of relevance, scores each passage independently, and sorts by that probability. On the MS MARCO passage task, MRR@10 on the dev set was 16.7 for untuned Lucene BM25 and 36.5 for the BERT-large re-ranker over the BM25 candidates.

    Cost is linear in the number of candidates: reranking 1,000 passages needs 20 times as many model runs as reranking 50. Anthropic's Contextual Retrieval post sits at the practical end of this: retrieve the top 150 chunks, rerank them, pass the top 20 to the model, and it notes that reranking more chunks improves performance but adds latency and cost. In its evaluation, adding reranking to contextual embeddings and contextual BM25 lowered the top-20 failure rate from 2.9% to 1.9% (a 67% reduction against the 5.7% baseline).

    Figure 10. Two-stage retrieval: a cheap wide stage, then a costly narrow stage

    sequenceDiagram accTitle: Two-stage retrieval with a reranker accDescr: The Retriever fetches a wide candidate list from the first stage. It sends each query and candidate pair to the Reranker, which returns a relevance score per pair. The Retriever sorts by that score, drops candidates below the threshold and keeps the top few. If the Reranker is unavailable it keeps the first-stage order and shortens the list. autonumber participant R as Retriever participant I as First-stage indexes participant X as Reranker R->>I: hybrid search, wide candidate list I-->>R: fused candidates alt Reranker available R->>X: score each query and candidate pair X-->>R: one relevance score per pair Note over R: Sort by score, drop below threshold, keep top few else Reranker unavailable Note over R: Keep fused order, shorten the list, flag the trace end R-->>R: pass passages to the Orchestrator

    How to read this: the first stage is cheap and wide, the alt block is the costly narrow stage with its failure branch, and the self-message at the end is the hand-off.

    What this means for architects

    A reranker is worth adding when the right passage is usually among the first-stage candidates but not near the top, which you can see by comparing recall at 50 with recall at 5 on the golden set. It cannot fix a first stage that never returned the passage; for that, repair chunking and hybrid retrieval first. Budget latency as candidates times per-pair cost, since model runs batch on a GPU but still grow with the list. A hosted reranker is another trust boundary: query and passage text leave your network, so access-filtered text only, and a timeout path (Figure 10). Mind the input limit of your reranker, 512 tokens in the cited setup, because chunks longer than that are cut. A reranker score is the usual abstention signal, but whether a threshold generalises is something to test on your labelled queries and not assume.

    Under the hood: Chunking and its failure modes

    Intuition

    One chunk gets one vector and one citation. If a chunk mixes two topics, its vector sits between them and matches neither well. If it cuts a sentence from its subject, the vector cannot say what the sentence is about. Chunking is therefore a modelling decision about what the smallest retrievable unit should be, not a preprocessing detail.

    Mechanism

    The RAG paper split each Wikipedia article into disjoint 100-word chunks, 21 million in all, embedded each one and built a single index. Anthropic's Contextual Retrieval post states that chunks are usually no more than a few hundred tokens, that chunk size, boundary and overlap affect retrieval performance, and illustrates context loss: a chunk reading "The company's revenue grew by 3% over the previous quarter" does not say which company or period. Its remedy is to prepend a short generated context (usually 50 to 100 tokens) to each chunk before embedding and before building the BM25 index, and it describes using prompt caching to make that affordable. Sentence-BERT's default pooling averages the model's output vectors over the tokens, so a long chunk with several topics produces a blurred average (many models pool this way; check yours).

    Figure 11. A chunker that keeps structure and attaches context

    sequenceDiagram accTitle: Structure-aware chunking accDescr: A Worker passes a parsed document to the chunker. The chunker splits at headings first. A section longer than the size limit is split at paragraph boundaries, and a block that cannot be split, such as a table, is kept whole or split by rows with its header repeated. Each chunk gets a context header with document title, section path, version and effective date, and then goes to the embedding model and the stores. autonumber participant W as Worker participant C as Chunker participant M as Embedding model participant S as Indexes W->>C: parsed document with headings, tables and metadata Note over C: Split at headings first alt Section within the size limit Note over C: Keep the section as one chunk else Section over the limit Note over C: Split at paragraphs, repeat table headers on row splits end Note over C: Prepend title, section path, version and effective date C->>M: chunk text with its header M-->>C: vector C->>S: chunk, vector, doc id, version, offsets

    How to read this: splitting goes from coarse to fine, the alt block is the size decision, and every chunk leaves with its context header and metadata.

    Failure modes to look for: a cut through a table row, clause, list or code block; a lost referent such as "the company" or "this clause"; chunks over the embedding model's input limit, which is truncated or rejected depending on the model; chunks too small to hold an answer; overlapping chunks that fill the top k with near duplicates; and several versions of one document chunked identically so that stale and current text compete. The last is the root of the policy-conflict case.

    What this means for architects

    Store, with every chunk, the document id, version, section path, offsets and the chunker version, so that a citation can be shown, a document deleted, and a chunker change rolled back. Treat chunk size and overlap as parameters tuned on the golden set by recall at k, not defaults. A change to the chunker or the embedding model is a re-index, so version both and roll out like a migration. Put dates and versions in the chunk's own text only if the answer needs them; otherwise keep them as filterable metadata. For long structured sources (contracts, manuals) chunk by structure; for short ones (FAQs) one chunk per question and answer is usually right. See RAG chunking strategies.

    Under the hood: Access control in retrieval

    Intuition

    Access control in retrieval answers one question: which chunks may this caller see? Where the answer is applied relative to the nearest-neighbour search decides both what is returned and what is exposed along the way. Pre-filtering restricts the search to allowed chunks. Post-filtering searches everything, takes the top k, and then removes forbidden chunks.

    Mechanism

    What is documented depends on the engine. The pgvector README states that with approximate indexes "filtering is applied after the index is scanned", so a filtered query can return fewer rows than asked for: if a condition matches 10% of rows, HNSW with the default hnsw.ef_search of 40 returns about 4 matching rows on average. Its fixes are iterative index scans (hnsw.iterative_scan set to strict_order or relaxed_order, which keep scanning until enough rows pass the filter, bounded by hnsw.max_scan_tuples, default 20,000), a partial index when the filter has few distinct values, and partitioning when it has many; and for multi-tenant use it warns that a shared approximate index lets one tenant's vectors affect another's recall and speed. Elasticsearch's documentation says a filter inside the knn clause "is applied during approximate kNN search to ensure that k matching documents are returned", while a filter beside it in a bool query is applied after the vector search, "so the response can contain far fewer than five hits, or none at all"; when few documents match in a segment it can skip the graph and score them directly. An Elastic Search Labs post dated 2026-10-02 says Elasticsearch now decides per query whether to filter before or after, keeps pre-filter semantics with a retry and fallback, and is scheduled to enable this by default in 9.6. The FAISS paper describes filtering by a predicate on ids, and "vector-first" versus "metadata-first" search, where metadata-first scans the matching subset by brute force, acceptable when the subset is small. So the behaviour is a property of your index and database, which you must read and test; none of the above transfers to another product.

    Figure 12. Pre-filter versus post-filter for a tenant with a small share of the data

    sequenceDiagram accTitle: Pre-filter and post-filter retrieval compared accDescr: The Retriever receives the caller's scope from the Policy engine. In the pre-filter branch it sends the scope with the search and the index returns only allowed chunks. In the post-filter branch the index returns the top 40 chunks from all tenants, the Retriever removes forbidden ones and may be left with almost none, and forbidden chunks were present in the Retriever before removal. autonumber participant P as Policy engine participant R as Retriever participant V as Vector index P-->>R: scope tenant t1 alt Pre-filter R->>V: search with scope t1 inside the index scan V-->>R: up to k allowed chunks else Post-filter R->>V: search without the scope, top 40 V-->>R: 40 chunks from many tenants Note over R: Drop forbidden chunks - 0.2 percent allowed means 0.08 expected Note over R: Forbidden chunks were in memory before this step end

    How to read this: the alt block compares the two designs, the arithmetic in the post-filter branch is the expected survivors, and the last note is the exposure that post-filtering creates.

    Why post-filtering loses recall: with 500 equal tenants a tenant matches 1/500 = 0.2% of rows, so a top-40 list holds 40 × 0.002 = 0.08 allowed rows on average, which is usually zero (the same arithmetic as the README's 10% example). Why it can leak is reasoning, not a documented behaviour of any one product: the forbidden chunks have already been retrieved and exist in the retrieval layer before they are removed, so any step placed before the filter, such as logging, a cache, a call to an external reranker, or an error message that reports counts or scores, handles data the caller may not see. Postgres row-level security is one way to put the rule in the database: the documentation says policies restrict, per user, which rows normal queries can return, that with security enabled and no policy the default is deny, that superusers and roles with BYPASSRLS always bypass it, and that table owners normally do too unless FORCE ROW LEVEL SECURITY is set. The documentation read for this page does not say how policies combine with approximate vector index scans, so test that combination.

    What this means for architects

    Derive the filter from the verified identity inside the Retriever or below it, never from model output or a client field (the missing-boundary lesson of the helpdesk case in the diagramming page). Prefer pre-filtering semantics and prove them with a recall test per tenant size, not only a security test. Make the service's database role something that is not the table owner or a bypass role. Write tests that try to cross tenants (a query as tenant A that should match a tenant B chunk must return nothing, including through the lexical index, the cache and the reranker), and run them on every index change. Treat a permission change as a fast-path update: access revocation must reach the index much sooner than a content edit.

    SET hnsw.iterative_scan = relaxed_order;
    
    SELECT id, content
    FROM chunks
    WHERE tenant_id = $1
    ORDER BY embedding <=> $2
    LIMIT 8;

    The statement is the README's filtered-query pattern with iterative scans so that a selective tenant filter still returns rows. relaxed_order can return results slightly out of distance order, and the README describes a materialised-CTE way to restore strict order. The README says iterative scans start with pgvector 0.8.0, so check the setting exists in your installed version.

    Under the hood: Incremental indexing and freshness

    Intuition

    The index is a copy, and copies go stale. Freshness is the lag between a change in the Document store and the moment queries reflect it, and it has three parts: how fast events are noticed, how fast documents are processed, and how reliably failures are retried. The same machinery handles deletes, permission changes and a model change.

    Mechanism

    The RAG paper measured what staleness costs. It built an index from a December 2016 Wikipedia dump and compared it with the December 2018 index on 82 world leaders who had changed in between, asking "Who is {position}?": RAG answered 70% correctly with the 2016 index for 2016 leaders and 68% with the 2018 index for 2018 leaders, but only 12% (2018 index, 2016 leaders) and 4% (2016 index, 2018 leaders) when the index and the question's era did not match. The authors conclude that replacing the non-parametric memory updates the world knowledge without retraining. The index technology sets the rules for how you update: the FAISS paper says HNSW can add vectors on the fly, that the library's API can remove and update vectors by id (while its HNSW graph index does not support removal or mutation), and that when the vector distribution changes a lot techniques fitted to it, including IVF and PQ, degrade; pgvector's HNSW index can be created on an empty table while IVFFlat needs data first, the README recommends CREATE INDEX CONCURRENTLY in production so writes are not blocked, and notes that vacuuming an HNSW index can take a while, so reindex first.

    Figure 13. Life of an index entry for one chunk version

    stateDiagram-v2 accTitle: Lifecycle of a chunk version in the index accDescr: A change event puts a chunk version into pending. Successful embedding and writes make it indexed. Errors after retries make it failed, from where a dead-letter retry returns it to pending. When a newer version is indexed the old one becomes superseded. A deleted source or expired retention makes it deleted. Every path ends in indexed, superseded or deleted, or in the dead-letter queue with an alert. [*] --> Pending: change event received Pending --> Indexed: embedded and written to both indexes Pending --> Failed: error after retries Failed --> Pending: retried from the dead-letter queue Indexed --> Superseded: newer version indexed Indexed --> Deleted: source deleted Superseded --> Deleted: retention period ends Deleted --> [*]

    How to read this: each box is a state of one chunk version, each arrow is a transition with its trigger, and the black dot and ringed dot are start and end.

    The ordering rule that makes it safe: write the new version, then mark the old one superseded, then commit the event offset. A crash between any two steps repeats work and does not lose it, which is why every step must be idempotent (keyed by document, version and chunk number, as in Figure 2).

    What this means for architects

    Define a freshness objective in terms a user can feel, for example "99% of changes searchable within N minutes", measure it as the age of the oldest unprocessed change, and alert on it. N is yours to set; the paper gives no figure to copy. Separate lanes by urgency: deletes and permission revocations get a fast path, bulk re-embeds a slow one. Reconcile on a schedule by comparing document versions in the Document store with those in the indexes, since events can be lost. For a model or chunker change, build a new index beside the old, fill it from the Document store, evaluate on the golden set, then switch an alias, and keep the old one until rollback is no longer wanted. Put a dead-letter queue and an owner on it: a silent failure is a stale answer.

    Decision framework

    1. If the exact search meets the latency budget on your corpus (for example, a single tenant's partition), then use exact search with a filter, because recall is perfect and there is nothing to tune; revisit when p95 latency or memory breaks the budget.
    2. If queries contain identifiers, clause numbers or defined terms, then run a lexical index beside the vector index and fuse with RRF, because embeddings blur exact strings and BM25 needs the literal token; revisit when the golden set shows the lexical side adds nothing.
    3. If recall at 50 is good but recall at 5 is poor, then add a reranker; if recall at 50 is itself poor, then fix chunking and hybrid retrieval first, because a reranker only reorders what it is given; revisit when the first-stage candidate set changes.
    4. If any caller sees only part of the corpus, then derive a filter from the verified identity and apply it inside the search (pre-filtering semantics), with partitions for large tenants, because post-filtering returns too few results and exposes forbidden text to earlier steps; revisit when the engine or its version changes.
    5. If you change the embedding model, the chunker or the index parameters, then build a new index beside the old and switch after a golden-set comparison, because vectors from different models are not comparable and a bad change should be reversible; revisit when re-embedding time exceeds your change window.
    6. If sources can disagree, then keep version, effective date and status as metadata, prefer the superseding document, and show both when neither supersedes the other, because the retriever cannot decide truth; revisit when a conflict is reported by a user.

    Comparison of the main options

    Option Strength Weakness Fits when
    Exact (brute force) scan Perfect recall, no tuning, works with any filter Cost grows with the number of vectors searched Small corpora or a small partition
    HNSW Strong speed-recall tradeoff, adds vectors on the fly More memory, slower builds, filter behaviour depends on the engine Medium corpora that fit in memory
    IVF (flat) Faster build and lower memory than HNSW Needs training, lower speed-recall tradeoff, drifts with data Larger corpora with stable distributions
    IVF with product quantization Smallest memory (for example 32 times smaller in the worked example) Lossy distances, needs a re-score step and retraining Corpora that cannot fit in memory
    Lexical (BM25) Exact identifiers and defined terms, no embedding cost No synonyms or paraphrase Legal, code, catalogue and compliance text
    Hybrid with RRF Combines both without score calibration Equal weights, discards score magnitude, two indexes to keep in sync Mixed queries
    Add a reranker Better order of the top results Latency per candidate, another boundary, cannot add candidates Right passage found but ranked low
    No retrieval, whole text in the prompt No index to maintain Limited by the context window and prompt cost Knowledge base under about 200,000 tokens, per Anthropic's post

    Failure scenarios to design for

    1. The embedding model changes, or a stale index is queried with a new model. Defined behaviour: the index records its model id and the Retriever refuses to search on a mismatch and raises an alert; a new index is built in parallel and switched in after the golden-set comparison.
    2. A filtered query returns too few results, or none. Defined behaviour: the Retriever uses pre-filtering semantics or iterative scans up to a cap, and if fewer than the minimum number of passages remain it returns an abstain result instead of padding with unfiltered text; a per-tenant-size recall test runs on every index change.
    3. The lexical index and the vector index diverge because one write failed. Defined behaviour: a chunk version counts as indexed only when both writes succeed, the offset is committed after both, and a scheduled reconciliation compares the two and the Document store and alerts on a mismatch.
    4. The reranker times out or is unavailable. Defined behaviour: the Retriever falls back to the fused order with a shorter candidate list and marks the trace; for high-risk use cases it returns an abstain result instead, because the abstention threshold cannot be applied without the reranker score.
    5. Two sources conflict or a source is stale. Defined behaviour: the Retriever keeps the superseding version, or returns both with dates and sources for the answer to state, and flags passages past their review date.
    6. Evidence is weak. Defined behaviour: when no passage reaches the calibrated threshold the Orchestrator does not call the model and tells the user nothing was found, as in the retrieval-failure sequence of the diagramming page.
    7. A permission is revoked but the index still serves the chunk. Defined behaviour: permission changes travel on a fast lane, the filter metadata is updated first, and the staleness of permissions is measured separately from content freshness.

    Worked example

    Size a retrieval tier for a multi-tenant assistant and check three decisions with arithmetic.

    Assumptions (all illustrative): 2,000,000 documents averaging 10 chunks each, so N = 20,000,000 chunks; vectors of d = 1,024 float32 values (4,096 bytes); 500 tenants of equal size; HNSW with M = 16, Mmax0 = 32 and Mmax = 16, so 151 bytes of graph per vector by the paper's formula; product quantization at m = 128 sub-vectors of 8 bits; first stages fetch 50 candidates each; embedding throughput of 2,000 chunks per second; an average of 300 tokens per chunk.

    Memory. Vectors: 20,000,000 × 4,096 B = 81.92 GB. HNSW graph: 20,000,000 × 151 B ≈ 3.02 GB. PQ codes: 20,000,000 × 128 B = 2.56 GB (32 times smaller than the raw vectors, with the originals kept elsewhere for re-scoring).

    Filtering. One tenant holds 20,000,000 / 500 = 40,000 chunks, 40,000 × 4,096 B = 163.84 MB. A shared HNSW index post-filtered by tenant leaves 40 × (1/500) = 0.08 allowed results per top-40 on average, so most queries return nothing. A per-tenant partition holds only 40,000 vectors, and an exact scan of it is 40,000 × 1,024 = 40.96 million multiply-adds per query, with perfect recall and no index parameters. The decision is therefore a partition per large tenant and a mandatory pre-filter for small ones.

    Reranking. Fusing two lists of 50 gives at most 100 distinct candidates; reranking all of them is 100 model runs per query, against 1,000 runs if the first stage returned a thousand, a 10-times difference in model work.

    Re-embedding. A model change means 20,000,000 chunks at 2,000 per second, 10,000 seconds or about 2.8 hours, and 20,000,000 × 300 = 6.0 billion tokens to embed (price it with the cost estimator). That is why the plan is a parallel index and an alias switch rather than an in-place update.

    A hybrid retrieval function with RRF

    The function below fetches two ranked lists with an identity-derived filter, fuses them with the published RRF formula and reranks with a fallback. The identity, vector_index, lexical_index and reranker objects stand for your own components; the assertions reproduce the worked example above, and the code was run with stub components to check it.

    from collections import defaultdict
    
    RRF_K = 60  # the constant published by Cormack et al. (SIGIR 2009)
    
    def rrf(rankings, k=RRF_K):
        """rankings: ranked lists of chunk ids, best first. Returns [(id, score)], best first."""
        scores = defaultdict(float)
        for ranking in rankings:
            for rank, chunk_id in enumerate(ranking, start=1):  # ranks start at 1
                scores[chunk_id] += 1.0 / (k + rank)
        return sorted(scores.items(), key=lambda kv: (-kv[1], kv[0]))  # ties: by id
    
    def hybrid_search(query, identity, vector_index, lexical_index,
                      reranker=None, depth=50, final_n=8):
        allowed = identity.allowed_filter()  # built from the verified identity, never from model output
        dense = vector_index.search(query, filter=allowed, k=depth)    # chunk ids, best first
        lexical = lexical_index.search(query, filter=allowed, k=depth)
        fused = [cid for cid, _ in rrf([dense, lexical])][:depth]
        if reranker is not None:
            try:
                fused = reranker.rank(query, fused)  # reorders only, cannot add candidates
            except TimeoutError:
                pass  # fall back to the fused order and record it in the trace
        return fused[:final_n]
    
    # the worked example from the lesson
    fused = rrf([["d3", "d1", "d7", "d2"], ["d1", "d9", "d3"]])
    assert [d for d, _ in fused] == ["d1", "d3", "d9", "d7", "d2"]
    assert abs(fused[0][1] - (1 / 62 + 1 / 61)) < 1e-12

    The reranker can only reorder the fused list, so a timeout degrades order and not correctness, but the function does not abstain: add a threshold check on the reranker score before using it in a high-risk path.

    Common mistakes

    • Adding a vector store and calling it retrieval. Without a lexical side, versions, filters and a golden set, the first exact-match or stale-document complaint has no fix.
    • Mixing embedding models in one index. Vectors from different models are not comparable, and the search still returns results, so the error is silent.
    • Post-filtering for access control. It returns too few results for small tenants and processes forbidden text before removing it.
    • Adding scores from different retrievers. A BM25 score and a cosine similarity are on unrelated scales; fuse ranks, or calibrate on labelled data.
    • Treating a reranker as a fix for bad recall. It reorders candidates and cannot add the passage that was never retrieved.
    • Never measuring recall of the approximate index. The pgvector README tells you to compare approximate against exact search; without it the efSearch or nprobe setting is a guess.

    Try it

    Time: 30 to 40 minutes. Use any small corpus you have (a folder of manuals, a wiki export) and at least 20 questions for which you know the right passage.

    1. Split the corpus into chunks with ids. For each question record the id of the right chunk.
    2. Build two rankings per question: one from a vector search (any embedding model and exact cosine over the vectors) and one from BM25 (any library that implements it with k₁ = 1.2 and b = 0.75).
    3. Fuse them with the rrf function above and compute recall at 5 for vector only, BM25 only and the fused list.
    4. Pick one question where the fused result differs from the vector-only result and write down which retriever supplied the right chunk and why the other missed it.

    Done when you have a three-row table of recall at 5 for vector, BM25 and fused retrieval over at least 20 questions, and a one-paragraph explanation for one question where fusion changed the top result.

    Key takeaways

    • An embedding is a learned summary of semantic closeness, blunt on exact strings; cosine, dot product and L2 agree only on unit-length vectors, and a model change forces a re-index.
    • HNSW is a layered graph with three dials: M (memory and recall), efConstruction (build quality) and efSearch (recall against latency). Memory is roughly (Mmax0 + mL × Mmax) × 4 bytes per vector, small next to the vectors themselves.
    • IVF limits how many vectors you scan and product quantization limits the bytes per vector, at a measurable accuracy cost; keep originals and re-score a shortlist.
    • BM25 needs the literal token and scores by rarity, saturation and length; RRF fuses ranks with 1 / (60 + rank) and needs no calibration, but discards score magnitude.
    • A cross-encoder reads query and passage together: accurate, linear in candidates, and unable to add a missing passage.
    • Pre-filtering versus post-filtering is engine-specific; read and test your engine, and design for pre-filtering semantics and partitions for large tenants.
    • Treat the index as a derived copy with versions, an ordered write sequence, a dead-letter path, reconciliation and a freshness objective.

    Self-check

    1. Two embedding vectors point in the same direction but one is twice as long. Which of cosine, dot product and L2 distance treat them as equal to a third vector, and what makes the three agree?

    Cosine similarity ignores length, so it scores both the same; dot product and L2 distance change with length (in the worked example the dot product doubles from 24 to 48). The three give the same ranking when all vectors are normalised to unit length, because then the dot product equals the cosine and the squared L2 distance equals 2 minus twice the cosine.

    2. An HNSW index has M = 16. About how much memory does the graph take per vector with Mmax0 = 32 and Mmax = 16, and why is the total dominated by something else?

    (32 + (1/ln 16) × 16) × 4 is about 151 bytes per vector under the paper's formula. A 1,536-dimension float32 vector takes 6,144 bytes, so the vectors dominate unless they are compressed.

    3. Two ranked lists give document X rank 1 in one and nothing in the other, and document Y rank 4 in both. With the published constant, which wins under RRF, and what happens if k were 1?

    With k = 60, X scores 1/61 = 0.016393 and Y scores 2/64 = 0.03125, so Y wins because both retrievers found it. With k = 1, X scores 0.5 and Y scores 0.4, so X wins: a small constant favours a single top rank, a large one favours agreement.

    4. A tenant owns 0.2% of the chunks in a shared HNSW index and the query is post-filtered from the top 40. What do you expect, and what are two documented ways to fix it?

    On average 40 × 0.002 = 0.08 allowed results, so usually none. The pgvector README documents iterative index scans, and partial indexes or partitioning (and, for tenants, separate tables or partitions); Elasticsearch documents putting the filter inside the knn clause so it applies during the search. Which applies depends on your engine.

    5. A reranker lifts the right passage to rank 1 in most tests, but one class of questions still fails. What is the likely cause and where do you look?

    The right passage is probably not among the first-stage candidates for that class, and a reranker only reorders what it receives. Check recall at the candidate depth for those questions, then look at chunking (a cut or missing context), the lexical index (an identifier not matched) and the filter.

    Further reading

    Last verified: 2026-10-22

    Next

    The next page covers agents, tools and the Model Context Protocol: Agents, tools and MCP.

    Enroll to keep reading

    Lesson 5 of 13 · AI Architect: From AI Engineer to Expert

    How retrieval works inside an AI system: embeddings, HNSW, IVF and product quantization, BM25, rank fusion, rerankers, chunking, access control and freshness.

    In the rest of this lesson

    • Under the hood: IVF and product quantization
    • Under the hood: BM25 and hybrid search
    • Under the hood: Reciprocal rank fusion
    • Under the hood: Rerankers
    • Under the hood: Chunking and its failure modes
    • Under the hood: Access control in retrieval

    Enrolling is free. It unlocks every lesson in AI Architect: From AI Engineer to Expert, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments