ragretrievalreranking

Production RAG

Your RAG demo worked and production does not: answers are confidently wrong, and the passage that would have answered the question was never retrieved. This is how to fix that — measuring retrieval on its own, chunking, hybrid search, reranking, and the index settings that quietly cost you recall.

Diagnosing a Bad Answer

Telling a retrieval bug from a generation bug, and the logging you need to do it at all.

The model only sees what you retrieved

Most disappointing RAG systems fail the same way: the passage that would have answered the question never made it into the prompt. Prompt engineering does not recover from that, and neither does a bigger model. The model is answering honestly from a context that does not contain the answer.

Retrieval sets a ceiling. Generation runs underneath it. That is why upgrading the model is usually the wrong first move.

Retrieval failure vs generation failure

From outside, both look like a wrong answer. Inside they are different bugs with different fixes:

Retrieval failureGeneration failure
What happenedthe right chunk was never fetchedthe right chunk was fetched and ignored
Symptomvague answer, or confidently wronganswer contradicts a passage in the context
Fixchunking, hybrid search, rerankingprompt, citations, model

Log the retrieved IDs

You cannot do this triage after the fact unless you stored what came back. Log, per request: the query, the retrieved chunk IDs, their scores, and which ones you actually put in the prompt after truncation.

log.info("retrieval", extra={
    "query": query,
    "candidates": [(c["id"], round(score, 4)) for c, score in scored],
    "used": [c["id"] for c in used],
})

That last field matters more than it looks. Systems commonly retrieve 10 chunks and then drop half of them to fit a token budget, so the chunk that would have answered the question was retrieved and discarded — which reads as a retrieval failure in every dashboard and is actually a budgeting bug.

The triage

For twenty real failures, ask whether the correct passage was in the retrieved set. If it was not, sections two through six apply and nothing about your prompt matters yet. If it was, go to citations and grounding.

What this pack assumes

You have embeddings going into a store and coming back out. If that is new, start with Embeddings & Vector Search. Examples here use Python and Postgres with pgvector to match it. The techniques are not Postgres-specific, though a few of the sharper edges are.

Related Concepts

retrieval-augmented-generation-rag

External Resources

Introducing Contextual Retrieval
Anthropic

Measured failure-rate reductions for contextual embeddings, contextual BM25, and reranking

Measuring Retrieval

Scoring the retriever by itself, and why recall@k is the number that caps everything else.

Score retrieval separately

End-to-end answer quality mixes two systems, so it cannot tell you which to fix. Score retrieval alone and you get a number you can act on.

You need questions paired with the chunk IDs that actually answer them. Thirty is enough to start. Build it from real questions users asked, not invented ones.

Recall@k

Of the k chunks you put in the prompt, did a correct one make it in?

This is a hard ceiling. Retrieve 5 chunks with recall@5 of 60%, and 40% of questions are unanswerable regardless of which model reads them. No prompt change moves that.

MetricAnswers
Recall@kdid a correct chunk get into the context at all?
MRR@khow near the top did it land?
NDCG@khow good is the ordering, with graded relevance?
Precision@khow much of what came back was noise?

Gate on recall@k. Watch MRR second — a correct chunk at rank 9 of 10 competes with eight distractors for attention, and long-context attention is not uniform.

InformationRetrievalEvaluator

sentence-transformers computes all four against a labeled set:

from sentence_transformers.evaluation import InformationRetrievalEvaluator

evaluator = InformationRetrievalEvaluator(
    queries=queries,              # qid -> question text
    corpus=corpus,                # cid -> chunk text
    relevant_docs=relevant_docs,  # qid -> set of correct cids
    name="support-docs",
)
results = evaluator(model)

Note what this measures: the retriever in isolation, against the full corpus. It will not catch a bug in your WHERE clause or your token budget, because it never runs your query path. Keep a second, smaller set that hits the real endpoint.

Running it in CI

These are eval cases like any other — see Evals & Benchmarking for the harness. The difference is what you assert on: chunk IDs, not answer text. Assert on IDs and the test survives a reworded document; assert on answer text and it does not.

Related Concepts

evalsretrieval-augmented-generation-rag

External Resources

InformationRetrievalEvaluator
Sentence Transformers

Computes Recall@k, MRR@k, NDCG@k, and MAP against a labeled set

Chunking

Where fixed-size splitting loses answers, and how size trades against embedding quality.

Where fixed-size splitting breaks

Split every 500 characters and you cut tables in half, separate headings from the paragraph they introduce, and strand pronouns from their referents. Each produces a chunk that can no longer answer a question it physically contains.

The splitter decides what is retrievable at all, which is why it is worth more attention than it usually gets.

Splitting on structure

Documents carry boundaries their authors intended. Use those first and fall back to length only inside a section that is genuinely too long.

  • Markdown and HTML — split on heading level, keep the heading text with its body
  • Code — split on function or class, never mid-body
  • Tables — repeat the header row in every chunk of a long table
  • Transcripts — split on speaker turn or topic shift, not character count

Choosing a chunk size

SizeBetter atWorse at
100-300 tokensprecise matching, less noise in contextlosing surrounding context
800-1500 tokensself-contained answersdiluted embeddings

The dilution is the part people underestimate. One chunk gets one vector, so a 1,500-token chunk spanning four topics produces a vector that sits near none of them and gets retrieved for none of them cleanly. That is the mechanism behind "we made chunks bigger and recall got worse."

Overlap of 10-20% hedges against splitting mid-answer. It does not fix bad boundaries.

Metadata to store

chunk = {
    "text": body,
    "doc_id": doc.id,
    "heading_path": "Billing > Refunds > Timing",
    "source_url": doc.url,
    "updated_at": doc.updated_at,
    "model": "all-mpnet-base-v2",
}

doc_id is what makes deletes possible later. model is what stops you silently mixing vectors from two embedding models in one table — see index maintenance. heading_path gives you both a retrieval filter and a citation breadcrumb.

Then measure

Every claim above is a hypothesis about your corpus, not a rule. Change one thing, re-run recall@k, keep it only if the number moved.

Related Concepts

retrieval-augmented-generation-rag

External Resources

Introducing Contextual Retrieval
Anthropic

Measured failure-rate reductions for contextual embeddings, contextual BM25, and reranking

Contextual Chunks

Prepending a situating sentence before embedding, with the measured effect and what it costs.

The orphaned chunk

"Revenue grew 3% over the previous quarter" is close to useless alone. Which company, which quarter? Embedded on its own it lands in a generic neighborhood of revenue sentences and gets retrieved for the wrong questions.

Prepending context

Anthropic's Contextual Retrieval generates a short situating sentence per chunk and prepends it before indexing:

This chunk is from an SEC filing on ACME corp's performance in Q2 2023;
the previous quarter's revenue was $314 million.

Revenue grew 3% over the previous quarter.

What you show the user is unchanged. What changed is what you embedded and indexed.

Measured results

Against their retrieval benchmark:

SetupFailure rateReduction
baseline5.7%
contextual embeddings3.7%35%
plus contextual BM252.9%49%
plus reranking1.9%67%

The last two rows are the next two sections, and they stack.

Cost

Generating 50-100 tokens per chunk sounds expensive until you notice the document is identical across every one of those calls. Prompt caching is what makes it affordable: you cache the document once and pay full rate only for the context sentence each chunk generates.

It runs at index time, so it adds nothing to request latency. Budget it as a batch job that reruns when a document changes, which means it rides along with incremental re-indexing rather than being a separate pipeline. Price it against your provider's current cached-input rate before committing to it on a large corpus — that rate is the whole reason this is viable.

When it does not help

Chunks that are already self-contained — FAQ entries, product records, tickets with their own titles — gain little from a situating sentence. This pays off on long narrative documents where any given paragraph assumes everything before it.

Related Concepts

retrieval-augmented-generation-rag

External Resources

Introducing Contextual Retrieval
Anthropic

Measured failure-rate reductions for contextual embeddings, contextual BM25, and reranking

Reranking

The second-stage scorer, which model to pick, and the failure it cannot fix.

Bi-encoders and cross-encoders

Your retriever is a bi-encoder: query and chunks are embedded separately, so chunk vectors are computed once and reused. That independence is what makes search over millions of chunks fast, and it is also the limitation — the chunk was embedded without ever seeing the query.

A cross-encoder takes query and chunk together and returns one relevance score. More accurate, far too slow to run over a corpus.

The two-stage pipeline

Retrieve 50-100 candidates cheaply, score just those, keep the top 5:

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L6-v2")
scores = reranker.predict([(query, c["text"]) for c in candidates])
top = [c for _, c in sorted(zip(scores, candidates), reverse=True)][:5]

Choosing a model

ModelNDCG@10Docs/sec
ms-marco-TinyBERT-L2-v269.849000
ms-marco-MiniLM-L4-v273.042500
ms-marco-MiniLM-L6-v274.301800
ms-marco-MiniLM-L12-v274.31960

L6-v2 is the usual pick. L12 costs roughly double the time for one hundredth of a point. At 100 candidates per query, L6 adds well under a tenth of a second on GPU — on CPU, measure it before you promise anyone a latency budget, because those throughput figures are not CPU figures.

Hosted reranking APIs exist and save you from serving a model, at the cost of another network round trip inside your request path.

What reranking cannot fix

Reranking moves MRR a lot and recall barely at all. It reorders what it was given; it cannot retrieve something the first stage missed.

So if recall@50 is already poor, a reranker will not save you and you should go back to chunking and hybrid search. The corollary is to retrieve wider than feels necessary — the reranker is what makes a wide, noisy candidate set safe to ask for.

Related Concepts

retrieval-augmented-generation-rag

External Resources

Retrieve & Re-Rank
Sentence Transformers

Why a bi-encoder retrieves and a cross-encoder reranks

Pretrained Cross-Encoders
Sentence Transformers

MS MARCO reranker models with NDCG@10 and throughput for each

Query Rewriting

Turning what users actually type into something worth embedding.

Users do not type search queries

Real questions arrive as "does that work on the old plan?" — pronouns, missing subject, none of the vocabulary the documents use. Embedding that verbatim searches for the wrong thing.

Three techniques, in the order worth trying.

Resolving follow-ups

Cheapest, and the one most often skipped. Resolve the question against conversation history before retrieving:

rewrite = f'''Rewrite the follow-up question as a standalone search query.

Conversation:
{history}

Follow-up: {question}
Standalone query:'''

"does that work on the old plan?" becomes "does CSV export work on the Legacy Starter plan", which has something to match against.

Watch the failure mode: on a first turn with no history, a rewriter will sometimes invent specificity that was not there. Skip the rewrite when history is empty rather than trusting it to no-op.

Multi-query

Generate two or three phrasings, retrieve for each, merge with the same RRF from the previous section, then rerank the merged set. One small model call, and it covers vocabulary you did not anticipate.

HyDE

Questions and answers are written differently, which weakens direct question-to-chunk similarity. HyDE has a model write a hypothetical answer and embeds that instead of the question.

The generated answer may contain invented specifics, and that turns out not to matter — the encoder's compression discards false details while keeping topic and vocabulary, landing you near real documents. It was introduced for zero-shot retrieval with no labeled data, which is where most teams start.

The cost is a generation call before every search, on the latency path. Worth it when questions are short and documents are dense prose; usually not when your corpus is already question-shaped.

Measure each separately

All three add latency and two add cost. Add them one at a time against the labeled set, and keep only what moves recall@k.

Related Concepts

retrieval-augmented-generation-rag

External Resources

Precise Zero-Shot Dense Retrieval without Relevance Labels
arXiv

HyDE: embed a generated hypothetical answer instead of the raw query

Reciprocal Rank Fusion
Elastic

The RRF formula and what the rank constant k controls

Citations and Grounding

Making a wrong answer detectable in code rather than by reading it.

Asking for citations

Citations are usually treated as a UI nicety. They are more useful as the mechanism that makes a wrong answer detectable — by a reviewer, by an automated check, and by the user.

Ask structurally, so they can be verified in code:

prompt = f'''Answer using only the sources below. Every sentence must cite
the source ID it came from, as [S1]. If the sources do not contain the
answer, say so and cite nothing.

{format_sources(chunks)}

Question: {question}'''

Assign short stable IDs at prompt-assembly time and keep the map back to doc_id and source_url.

Verifying them

An unchecked citation is decoration. Two assertions catch most of it:

  • Every cited ID exists in the set you supplied. A [S7] when you passed five sources is fabricated, and that is a one-line check.
  • Every sentence carries a citation, or the answer is an explicit refusal.

Both are code, not judgment, and both belong in the eval suite.

Detecting ignored context

The generation failure from the first section needs a grader that reads the answer and the sources together. Use a binary rubric, one claim at a time:

Does every factual claim in the answer appear in the cited source?
Answer yes or no.

Binary per-claim questions score far more consistently than "rate faithfulness 1 to 5." Grade with a different model than the one that wrote the answer.

Allowing "not in the sources"

A system that must always answer will always invent something. Give the model an explicit refusal option and put it in the eval set: a case whose correct answer is "the sources do not cover this" is one of the most valuable rows you have, because that is precisely where a confident fabrication does the most damage.

Related Concepts

retrieval-augmented-generation-ragevals

External Resources

Introducing Contextual Retrieval
Anthropic

Measured failure-rate reductions for contextual embeddings, contextual BM25, and reranking

Index Maintenance

Deletes, model changes, and the pgvector settings that silently cost you recall.

Incremental re-indexing

Full rebuilds are simple and get skipped because they are slow. Hash the chunk text and only re-embed what changed:

digest = hashlib.sha256(chunk_text.encode()).hexdigest()
if digest != stored_digest:
    embed_and_upsert(chunk_id, chunk_text, digest)

Deleting stale chunks

Upserts get built on day one; deletes get forgotten. A deleted source document leaves its chunks in the index indefinitely, which is how a system ends up citing a policy that no longer exists.

Two rules avoid it. Every chunk carries doc_id, so removing a document is one delete by that key. And re-indexing a changed document deletes its old chunks first — chunk boundaries move when text changes, so matching by chunk ID strands orphans.

Changing the embedding model

Vectors from two models are not comparable even at identical dimensionality, so swapping models means re-embedding everything. There is no partial migration.

The vector(n) column also pins the dimension, so a model with a different output size is a schema change, not a config change. Store the model name per row and treat a mismatch as a hard error rather than discovering it through mysteriously bad results.

The 2,000-dimension index limit

A vector column accepts up to 16,000 dimensions. HNSW and IVFFlat indexes only support up to 2,000.

That gap is a live trap: a 3,072-dimension embedding inserts fine, queries fine, and cannot be indexed. You get correct results by sequential scan and discover the problem as a latency cliff in production. halfvec raises the ceiling to 4,000, and binary quantization further, at a recall cost.

Filtering collapses top-k

The one most likely to bite. With an approximate index, filtering is applied after the index is scanned:

SELECT id FROM chunks
WHERE tenant_id = $2          -- applied after the scan
ORDER BY embedding <=> $1
LIMIT 10;

Ask for 10, and if tenant_id matches a small slice of the table you may get 2 — the scan found its candidates first and the filter deleted most of them. It is not an error and nothing logs a warning.

Fixes: enable iterative index scans so the scan continues until enough rows survive the filter, or use partial indexes per tenant.

Build and search settings

hnsw.ef_search defaults to 40 and is the recall dial at query time:

SET hnsw.ef_search = 100;

At build time, m (16) and ef_construction (64) trade index size and build time for accuracy, and the build is dramatically faster when the graph fits in maintenance_work_mem — Postgres emits a notice when it does not. Use CREATE INDEX CONCURRENTLY so the build does not block writes, and load your data before building rather than after.

Before rewriting your chunker, compare against an exact scan on a small sample. Chunks that appear missing are often indexed and simply not being reached.

Related Concepts

retrieval-augmented-generation-rag

External Resources

pgvector
pgvector

Dimension limits, index build settings, and how filtering interacts with index scans

What to Fix First

Symptom to section, and the order that wastes the least time.

Start from the symptom

SymptomLikely causeSection
Vague, hedging answerscorrect chunk never retrievedmeasuring retrieval
Error codes and SKUs never matchpure vector searchhybrid search
Right document, wrong passageboundaries cut the answerchunking
Correct chunk retrieved, ranked 40thfirst-stage orderingreranking
Retrieved for the wrong topic entirelychunks lack situating contextcontextual chunks
Follow-up questions retrieve nothingpronouns, missing subjectquery rewriting
Answer contradicts a passage in contextgeneration failurecitations and grounding
Fewer results than the LIMIT you asked forfilter applied after index scanindex maintenance
Latency cliff with correct resultsvectors too wide to indexindex maintenance
Was accurate, now is notdrift, or deletes never wired upindex maintenance

The order that wastes the least time

Most teams work this backwards — prompt first, model second, retrieval last.

  • Step 1 — label 30 questions with their correct chunk IDs.
  • Step 2 — measure recall@k. This is your ceiling and it tells you whether anything else matters.
  • Step 3 — if recall is low, fix chunking, then add hybrid search. These move it most.
  • Step 4 — if recall is fine and answers still miss, add reranking to raise MRR.
  • Step 5 — only now touch the prompt, and add citations so failures become visible.

The cheapest three

Each is roughly an afternoon and independently measurable:

  • Hybrid search. One extra query, and it fixes an entire class of failure embeddings cannot address.
  • Reranking. One model, 50 candidates down to 5, large jump in how often the best chunk lands first.
  • Contextual chunks. A batch job at index time, reported at 35% failure-rate reduction alone and 49% combined with lexical search.

None needs a framework, a migration, or a bigger model. All three are measurable against the labeled set from section two, which is the only reason to believe they worked.

Related Concepts

retrieval-augmented-generation-ragevals

External Resources

Introducing Contextual Retrieval
Anthropic

Measured failure-rate reductions for contextual embeddings, contextual BM25, and reranking

Retrieve & Re-Rank
Sentence Transformers

Why a bi-encoder retrieves and a cross-encoder reranks

pgvector
pgvector

Dimension limits, index build settings, and how filtering interacts with index scans

Related Study Packs

embeddings vector searchevals benchmarking