
RAG That Actually Works: Chunking, Hybrid Search, Reranking, and Evals
The full production playbook: why naïve RAG demos die on the second question, how to chunk, index, retrieve, rerank, cite, evaluate, and cost-engineer a retrieval-augmented system that survives real users.
A production-grade RAG system is 20% embeddings and 80% engineering. Chunk on document structure, run hybrid (BM25 + dense) retrieval, always rerank, ground the LLM with strict citations, and gate every deploy through a golden eval set. Skipping any of those five costs you 10–30 accuracy points on hard queries.
- Structural chunking with a one-sentence context header beats every embedding upgrade under $10k of engineering.
- Hybrid search (BM25 + dense) with Reciprocal Rank Fusion is the single highest-ROI retrieval change.
- A cross-encoder reranker at $0.001–$0.005 per query cuts hallucinations roughly in half.
- Answers must cite chunk IDs the user can click; ungrounded answers must abstain, not guess.
- A 30-example golden eval run on every deploy prevents 90% of RAG regressions.
Why 9 out of 10 RAG demos die in production
The canonical RAG demo — embed the docs, cosine search, stuff the top-k, ask GPT — works beautifully on the FAQ page and collapses the moment users ask anything that requires reasoning, comparison, or numbers. If you've watched an internal chatbot go from “wow” to “can we turn it off?” in a month, this article is the difference between those two states.
Naïve RAG fails for five predictable reasons: bad chunk boundaries, embedding-only retrieval, no reranker, no citations, and no evals. Fix those five and you have a system that survives real traffic; fix only one or two and you have a demo with better slides.
The rest of this article walks the full production pipeline end-to-end: ingestion, chunking, embeddings, indexing, hybrid retrieval, reranking, query rewriting, grounded generation, citations, evaluation, observability, and cost engineering. Each section links out to the deeper articles on the topic when there's one worth reading.
Ingestion: the boring layer that decides everything
Retrieval quality is upper-bounded by ingestion quality. If your PDFs come in as scrambled text with headers cut in half, no embedding model in the world will save you. Invest here first.
- HTML / Markdown — parse with a real DOM/AST parser, strip nav and footers, and preserve heading hierarchy as metadata. Never regex the HTML.
- PDFs — use a layout-aware extractor (Unstructured, LayoutLMv3, Azure Document Intelligence, or pdfplumber for simple two-column layouts). Raw
pdftotextloses tables and footnotes. - Code — parse with tree-sitter so you can split on function/class boundaries. See Designing REST APIs LLMs can use for why structured metadata matters when the retriever is a model.
- Slack / tickets / chat — group by thread, deduplicate the reply-chain quoting, and attach the original question as context to every reply chunk.
- Databases — export views as row-level markdown snippets; do not embed raw SQL rows.
Store the raw source alongside the extracted text. When you later change chunker or embedder, you must be able to re-derive chunks without re-fetching the world. The modern data stack pieces on this — treat your document lake exactly like an event lake: immutable raw + versioned transforms.
Chunking: structure beats size, every time
Fixed-size 512-token chunks with 50-token overlap is the wrong default for anything more structured than a novel. It splits ideas mid-sentence and forces the retriever to guess which fragment contains the answer.
Three rules govern good chunking:
- Split on structure, not tokens. H2/H3, function definitions, table rows, list items, ticket messages — these are natural units of meaning.
- Keep the parent context. A chunk that says “The rate limit is 60 requests per minute” is useless without knowing which API. Prepend the section path (
API › Billing › Rate limits) into the chunk's metadata and into the text the embedder sees. - Generate a one-sentence context header. Use a cheap model to write one sentence describing what the chunk covers, then prepend it. This one trick lifts retrieval recall more than upgrading from text-embedding-3-small to text-embedding-3-large in every domain I've measured.
def contextualize(chunk: str, section_path: str, doc_title: str) -> str:
# 1 call per chunk, batched, using a small model (gpt-4o-mini / haiku).
prompt = (
f"Doc: {doc_title}\nSection: {section_path}\n\nChunk:\n{chunk}\n\n"
"Write ONE sentence describing what this chunk explains. No preamble."
)
header = cheap_llm(prompt)
return f"[{doc_title} › {section_path}] {header}\n\n{chunk}"Target chunk size after contextualization: 200–500 tokens for prose, 100–300 for code, and one row per chunk for tables. Overlap only when a natural boundary is missing (e.g. long uninterrupted paragraphs).
Embeddings: pick two, benchmark on your own data
The MTEB leaderboard is a lie about your domain. What matters is recall@10 on your queries against your corpus. Pick two candidate models, embed a 5,000-chunk sample with each, run 50 real user queries, and score.
| Use case | Strong default | Cheap alternative | When to switch |
|---|---|---|---|
| English prose + code | OpenAI text-embedding-3-large | text-embedding-3-small | You need on-prem |
| Multilingual (incl. Arabic) | Cohere embed-v3-multilingual | BGE-M3 | You want a smaller model |
| Long documents (>4k tokens) | Voyage-3 | BGE-M3 | Domain-specific retrieval |
| Code-heavy repos | Voyage-code-2 | text-embedding-3-large | You need multilingual code |
| Fully self-hosted | BGE-M3 or E5-large-v2 | MiniLM-L6-v2 | You need SOTA quality |
For multilingual work (Arabic, German, mixed English/Arabic), see Building multilingual AI for Arabic and English — most English-only embedders lose 15–25 points of recall on Arabic queries, and the fix is not just “add a translation layer.”
Embed dimension matters less than you think below 1024 dims. Above that, index cost and memory footprint climb faster than accuracy — see the pgvector benchmarks in the vector database piece linked above.
Indexing: pgvector, then something else, maybe
Below ~500k chunks, Postgres + pgvector with an HNSW index is almost always the right answer. You get transactional writes, joins, RLS, familiar backups, and a query planner your team already understands. Read Postgres for product engineers if you're not already comfortable pushing Postgres this hard.
Above that scale, or when you need distributed sharding or advanced hybrid features, evaluate a dedicated store (Qdrant, Weaviate, Vespa, Pinecone). Migrating from pgvector when you actually hit the wall is a 1-week job. Starting on Pinecone before you need it costs you 12 months of iteration speed.
-- pgvector HNSW index, tuned for 1M rows and p95 < 50ms
create index chunks_embedding_hnsw
on chunks using hnsw (embedding vector_cosine_ops)
with (m = 16, ef_construction = 64);
-- At query time
set hnsw.ef_search = 40; -- higher = better recall, slower
-- Always fetch metadata in the same query; avoid a second round-trip
select id, doc_id, section_path, text,
1 - (embedding <=> $1) as score
from chunks
where tenant_id = $2 -- always filter tenant first
order by embedding <=> $1
limit 40;concurrently.Hybrid search: BM25 + dense, fused with RRF
Dense retrieval is great at meaning and terrible at strings that don't appear in the training data — product SKUs, error codes, acronyms, version numbers. BM25 is the opposite. Running both in parallel and fusing the ranks with Reciprocal Rank Fusion (RRF) beats either alone in every benchmark I've run.
with vec as (
select id, row_number() over (order by embedding <=> $1) as rnk
from chunks where tenant_id = $3 order by embedding <=> $1 limit 40
),
bm as (
select id, row_number() over (order by ts_rank(fts, plainto_tsquery($2)) desc) as rnk
from chunks
where tenant_id = $3 and fts @@ plainto_tsquery($2)
limit 40
)
select coalesce(vec.id, bm.id) as id,
coalesce(1.0/(60 + vec.rnk), 0) + coalesce(1.0/(60 + bm.rnk), 0) as rrf
from vec full outer join bm using (id)
order by rrf desc
limit 20;The constant 60 in RRF is a well-known tuning knob; anywhere from 10 to 100 works. Higher values give BM25 more weight on tail results, which usually helps for technical corpora full of identifiers.
If you're running outside Postgres, most vector stores now ship hybrid natively (Qdrant, Weaviate, Vespa, Elasticsearch dense_vector). Use the built-in — it's faster than doing RRF at the application layer.
Reranking: the highest-ROI upgrade in your stack
Retrieval gets you a shortlist of 20–40 candidates. A cross-encoder reranker (Cohere Rerank 3, BGE-reranker-v2, Voyage rerank) scores each candidate against the query as a pair, and returns a re-ordered list. Keeping the top 4–6 typically cuts hallucinations by 40–60%.
| Reranker | Latency (p50, 20 docs) | Cost per query | When I pick it |
|---|---|---|---|
| Cohere Rerank 3 | 120 ms | $0.002 | Managed, multilingual, best default |
| Voyage rerank-2 | 150 ms | $0.001 | Cheapest managed |
| BGE-reranker-v2-large | 80 ms self-hosted | GPU cost only | On-prem / air-gapped |
| Jina rerank-v2-base | 60 ms self-hosted | GPU cost only | Latency-critical, self-hosted |
Reranking is not optional for anything beyond a 100-document corpus. The cost of one reranker call is orders of magnitude smaller than the cost of a hallucinated LLM answer that a human has to correct downstream.
Query rewriting and multi-query fan-out
Users type “how do I refund?” — but your docs say “issuing a refund to a customer via the merchant dashboard.” The embedding gap between those two strings is real. Two techniques close it:
- HyDE (Hypothetical Document Embeddings) — ask the LLM to draft a hypothetical answer to the query, embed that, and search with the answer-shaped vector.
- Multi-query fan-out — ask the LLM to generate 3–5 rephrasings, retrieve top-k for each, deduplicate by ID, and rerank the union.
Multi-query is my default. HyDE is cheaper (one extra LLM call vs. three or four retrieval round-trips) but hallucinated answers can send retrieval sideways. Measure both on your golden set before committing.
async def multi_query_retrieve(user_q: str, k: int = 8):
variants = await llm.generate_variants(user_q, n=4)
queries = [user_q, *variants]
hits = await asyncio.gather(*(hybrid_search(q, k=20) for q in queries))
merged = dedupe_by_id(flatten(hits))
return await rerank(user_q, merged, top_k=k)See async Python for AI pipelines for how to keep the fan-out latency under 300ms end-to-end.
Grounded generation: answer with citations, always
The output prompt is the last line of defense against hallucination. Three rules:
- The model must answer only from the provided chunks.
- Every claim must cite the chunk ID that supports it, inline as
[chunk_id]. - If no chunk supports an answer, the model must say so — and offer to escalate or ask a clarifying question.
You are a support agent. Answer ONLY from the CONTEXT below.
Cite each claim with its chunk ID like [c17]. If no chunk supports
the answer, say: "I don't see this in our docs — want me to escalate?"
CONTEXT:
[c14] Refunds for annual subscriptions are prorated per unused month.
[c17] Refund requests must be filed within 30 days of the last charge.
[c22] Refunds are issued to the original payment method within 5–7 business days.
USER: How do I get a refund on an annual plan I bought 45 days ago?Post-process the output to render [c17] as a clickable link to the source chunk. This one UI change is the single biggest driver of user trust I've measured — from 40% “I don't trust this” to under 10%.
Evaluation: the 30-example golden set you must build today
You cannot ship RAG without evals. A 30-example golden set — hand-crafted, versioned in Git, and run on every deploy — prevents 90% of regressions. Grow it to 100–500 examples over the first six months.
Each example has:
query— the user question, verbatim.expected_answer— the ideal response, written by a domain expert.required_facts— 1–5 atomic facts the answer must contain.required_chunks— chunk IDs that must appear in the retrieved set (recall gate).forbidden— strings the answer must not contain (e.g. speculation, wrong product names).
Grade with three metrics: retrieval recall (did the required chunks make it into top-k?), answer correctness (LLM-judge against required_facts with a rubric), and groundedness (did every citation actually support its claim?). Read Evaluating LLMs and classical ML for the full evaluation playbook — the tradeoffs between LLM-as-judge, human review, and classical metrics apply directly here.
# Minimal eval loop — run on every PR via CI
def eval_rag(examples: list[Example], pipeline: Callable) -> Report:
results = []
for ex in examples:
answer, cited_chunks = pipeline(ex.query)
results.append({
"query": ex.query,
"recall": len(set(ex.required_chunks) & set(cited_chunks)) / len(ex.required_chunks),
"facts": llm_judge_facts(answer, ex.required_facts),
"grounded": all(fact_in_chunks(a, cited_chunks) for a in split_claims(answer)),
"forbidden": not any(f in answer for f in ex.forbidden),
})
return Report.from_results(results)Observability: what to log on every query
You cannot improve what you don't measure. Log one row per query with these fields:
query,rewrites,retrieved_ids,reranked_idsfinal_answer,cited_ids,abstained(bool)latency_msbroken down by stage (embed / search / rerank / llm)tokens_in,tokens_out,estimated_cost_usduser_feedback(thumbs / rating, when available)
Route logs into a warehouse (BigQuery, Snowflake, or plain Postgres) and build one dashboard per failure mode: abstention rate, recall@10 on tagged eval queries, p95 latency by stage, cost per 1k queries. See Observability for AI systems for the specific alerting thresholds I use, and BigQuery cost control that scales if that's where the logs land.
Cost engineering: where the money actually goes
A typical RAG query costs $0.001–$0.02 depending on retrieval width, reranker choice, and generator model. At 100k queries/day, that's $100–$2000/day. Four levers give you 5–10× compression without measurable quality loss:
- Cache aggressively. Semantic cache (embedding of the query, cosine ≥ 0.95) catches 15–30% of repeat traffic in most support workloads.
- Route by difficulty. A cheap classifier decides small-model vs. large-model. See the routing pattern in the agents-with-n8n article.
- Truncate context. Top-4 reranked chunks are usually enough; the marginal chunk after that costs tokens for near-zero recall lift.
- Batch embed offline. Never embed at query time except for the query itself. Batched embedding is 5–20× cheaper than on-demand.
For a full cost dashboard template (BigQuery + Looker), see the LLM cost dashboard article.
Failure modes you will hit (and the fix for each)
| Symptom | Root cause | Fix |
|---|---|---|
| Right chunks retrieved, wrong answer generated | Prompt allows the model to reason beyond the context | Tighten system prompt; add abstention example; grade groundedness |
| Answer is right on English queries, wrong on Arabic | Embedder or BM25 tokenizer language-blind | Switch to multilingual embedder + ICU tokenizer; see multilingual AI |
| Retrieval quality drops after adding new corpus | Chunk-count skew biases nearest-neighbor search | Filter by source in retrieval; re-run evals per source |
| Latency spikes at p95 | Cold HNSW cache, or unbounded reranker input | Warm the index; cap reranker input at 40 docs; move reranker to same region as DB |
| Users complain of stale answers | No index freshness SLA | Trigger re-embed on doc update; log indexed_at; alert when lag > SLA |
| Costs 2× last month, quality flat | Router regressed to always-large model | Log model_used; chart share by day; alert on drift |
Every failure mode above has bit me in production. The pattern is always the same: the symptom looks like a model problem and the fix is almost always an engineering problem.
What to build first (a two-week plan)
- Week 1, Days 1–2: Ingestion + structural chunking + one embedding model + pgvector index. No reranker yet.
- Days 3–4: Write the 30-example golden set. Run baseline.
- Days 5–7: Add hybrid search (RRF), rerun evals, measure lift.
- Week 2, Days 8–9: Add reranker, tune top-k, rerun evals.
- Days 10–11: Grounded prompt + citation rendering + abstention.
- Days 12–14: Observability, cost logging, and the CI eval gate.
By day 14 you have a system that beats every naïve RAG demo by 20+ points on your own data and is ready for real traffic. Everything after is compounding — better chunkers, better rewrites, better routing.
Frequently asked
Which embedding model should I start with in 2026?
For English + code, start with OpenAI text-embedding-3-large. For multilingual (Arabic, German, mixed), Cohere embed-v3-multilingual or BGE-M3. For self-hosted, BGE-M3. Always benchmark two candidates on your own 5k-chunk sample before committing — MTEB numbers do not transfer to your domain.
How large should the vector store be before I stop using pgvector?
Below ~500k chunks, pgvector with HNSW is fine and gives you SQL joins, transactions, and RLS for free. Between 500k and 5M, pgvector still works but you must tune ef_search and possibly shard by tenant. Above 5M or when you need cross-tenant search with strict latency SLAs, evaluate Qdrant, Weaviate, or Vespa.
Do I need a reranker if my retrieval already looks good?
Yes. A reranker at $0.001–$0.005/query is the single highest-ROI upgrade in the stack — it typically cuts hallucination rates by 40–60% by dropping semantically-close-but-wrong chunks before they reach the LLM. Skip it only if you're deliberately optimizing for sub-100ms latency.
Should I use RAG or fine-tune the model?
For most cases: RAG. Fine-tuning changes the model's style and skills, not its knowledge — and it locks you into today's data. RAG lets you update knowledge without retraining. Fine-tune only for output format, tone, or narrow classification tasks. Full framework in the fine-tuning vs. RAG article linked above.
How do I evaluate RAG in Arabic or another non-English language?
Same 30-example golden-set structure, but write the queries and required_facts in the target language, and use a multilingual LLM-judge (GPT-4o and Claude 3.5+ handle Arabic evaluation well). Recall metrics work identically once your BM25 tokenizer is language-aware. See the multilingual article linked above for the Postgres FTS config.
What's the biggest mistake teams make with RAG?
Skipping evals. Every team that ships without a golden set eventually spends a quarter rebuilding retrieval from scratch after an untraceable regression. Build the 30 examples on day one — it's a two-hour task that saves months.
Building something similar?
I help teams ship production-grade AI agents, n8n workflows, and data platforms. Let's talk about what you're building.
Work with me
Islam Gamal
AI, Data & Automation Engineer. I design and ship production AI agents, n8n workflows, and cloud data platforms — with a focus on reliability, cost, and measurable business impact. Founder of Tashghil and Tek bil Arabi.
More from Islam Gamal
Building Production AI Agents with n8n: Architecture, Guardrails, Evals, and Cost Control
The full n8n agent playbook — reference architecture, memory design, tool routing, JSON-schema guardrails, retries, DLQs, evals, observability, and the small handful of decisions that keep the token bill sane at scale.
Prompt Engineering for Agents: A Structured, Testable, Version-Controlled Approach
Prompt engineering isn't vibes. Treat prompts like code — with schemas, versions, evals, diffs, and rollback. The full production workflow that scales past a single developer, with copy-paste patterns for structured output, few-shot design, LLM-as-judge, and prompt registries.
Evaluating LLMs and Classical ML: A Practical Metrics Guide
The metric you pick decides the model you ship. A deep field guide to choosing, computing, and defending metrics for classification, ranking, generation, RAG, and agents — with the traps that catch senior teams.
Browse every article by Islam Gamal on the author page.