RAG Deep Dive: Chunking, Indexing, Hybrid Search, and Reranking in Production
Retrieval-Augmented Generation (RAG) is often described as “connect your LLM to your data.” That description understates the engineering problem. A production RAG system involves at least six distinct subsystems — each with its own failure modes, tuning parameters, and quality metrics. Getting one of them wrong can degrade the entire pipeline even if the model is state-of-the-art.
This article walks through each layer of a serious RAG system: document ingestion, chunking strategy, embedding, indexing, hybrid retrieval, reranking, and prompt injection. The goal is to give you the vocabulary and intuition to make good engineering decisions at each stage.
1. The RAG Architecture at a Glance
A RAG system sits between the user and the LLM. Its job is to find the most relevant content from a knowledge source and give that content to the model as grounded context.
flowchart LR
A[User Query] --> B[Query Encoder]
B --> C{Retriever}
C --> D[Dense Search\nVector DB]
C --> E[Sparse Search\nBM25 / ElasticSearch]
D --> F[Fusion & Reranker]
E --> F
F --> G[Top-K Chunks]
G --> H[Prompt Builder]
H --> I[LLM]
I --> J[Grounded Response]
The quality of the final response depends on every step. A powerful LLM cannot compensate for a poor retriever that returns irrelevant chunks.
2. Document Ingestion and Preprocessing
Before chunking, documents must be cleaned and normalized. Common issues in raw documents:
- boilerplate headers, footers, and watermarks
- non-semantic whitespace and formatting artifacts
- tables and figures that do not parse cleanly as text
- HTML or PDF extraction errors
A minimal preprocessing pipeline should:
- extract text faithfully (PyMuPDF for PDFs, BeautifulSoup for HTML)
- strip boilerplate using heuristics (repeated short lines, page numbers)
- normalize whitespace
- preserve section structure metadata (title, section, page) as document-level tags
Metadata is critical. Every chunk you store should carry the document ID, section title, page number, and source URL. You will use these for citation and for filtering during retrieval.
3. Chunking Strategies
Chunking determines the unit of retrieval. The choice has a direct impact on retrieval precision and context coherence.
Fixed-length with overlap
Split every N tokens with an M-token overlap. Simple and fast. Works well for uniform prose where logical units are hard to detect automatically.
1
2
3
4
5
6
7
8
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=64,
length_function=len,
)
chunks = splitter.split_text(document_text)
The overlap prevents splitting a concept across two chunks with no shared context.
Structure-aware chunking
Split on logical boundaries: headings, paragraphs, list items, or code blocks. Requires document structure information (Markdown, HTML, or PDF structure extraction).
This is usually better than fixed-length for technical documentation, because it preserves the semantic unit the author intended.
Semantic chunking
Use an embedding model to detect topic boundaries. Measure cosine similarity between consecutive sentence embeddings. Split when similarity drops below a threshold.
1
2
3
4
5
6
7
8
9
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
chunker = SemanticChunker(
embeddings=OpenAIEmbeddings(),
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=95,
)
chunks = chunker.split_text(document_text)
Semantic chunking is more expensive (requires embedding during ingestion) but produces more coherent chunks. Recommended for heterogeneous corpora.
Hierarchical (parent-child) chunking
Store both small chunks (for precise retrieval) and larger parent sections (for coherent context). Retrieve the small chunk to determine relevance, but inject the parent section into the prompt.
1
2
3
4
5
Document
└── Section (parent chunk, ~2000 tokens)
├── Paragraph A (child chunk, ~200 tokens)
├── Paragraph B (child chunk, ~200 tokens)
└── Paragraph C (child chunk, ~200 tokens)
This gives you the precision of small chunks with the coherence of larger context windows.
Choosing a strategy
| Use case | Recommended strategy |
|---|---|
| Uniform long-form prose | Fixed-length with overlap |
| Structured technical docs | Structure-aware |
| Heterogeneous corpus | Semantic |
| Complex reasoning tasks | Hierarchical (parent-child) |
| Code repositories | File-level + function-level |
4. Embedding Models
The embedding model maps chunks and queries into a shared vector space. The choice matters enormously for retrieval quality.
Key dimensions to evaluate
- Retrieval benchmark scores: MTEB leaderboard (Massive Text Embedding Benchmark) provides standardized scores.
- Context window: some models support up to 8192 tokens; others max out at 512. Shorter windows are a problem for large chunks.
- Dimensionality: higher-dimensional embeddings capture more information but cost more storage and compute.
- Language coverage: if your corpus is multilingual, use a multilingual model (e.g.,
multilingual-e5-large). - Domain fit: general-purpose models (OpenAI
text-embedding-3-large, Cohereembed-v3) are strong defaults; domain-specific fine-tuned models can outperform them on specialized corpora.
Asymmetric retrieval
Query and document should sometimes be encoded differently. Models like bge-large-en-v1.5 support instruction prefixes:
1
2
3
4
5
query_instruction = "Represent this sentence for searching relevant passages: "
doc_instruction = "Represent this passage for retrieval: "
query_embedding = embed(query_instruction + user_query)
doc_embedding = embed(doc_instruction + chunk_text)
This asymmetric encoding improves retrieval for information-seeking queries.
5. Indexing and the Vector Database
Once you have embeddings, you store them in a vector index that supports approximate nearest-neighbor search.
ANN index types
| Index type | Characteristics |
|---|---|
| Flat (exact) | Perfect recall, O(n) scan — only for small datasets |
| IVF (Inverted File) | Cluster-based partitioning, fast for large datasets |
| HNSW (Hierarchical NSW) | Graph-based, very high recall, good insert speed |
| ScaNN / DiskANN | Optimized for billion-scale datasets |
Most production vector databases (Pinecone, Weaviate, Qdrant) default to HNSW. You rarely need to tune the index algorithm directly, but you should understand the recall vs. latency tradeoff.
Metadata filtering
Pre-filtering by metadata dramatically improves precision and reduces irrelevant retrieval:
1
2
3
4
5
6
7
8
9
results = collection.query(
query_embeddings=[query_embedding],
n_results=20,
where={
"source": "hr-policy",
"year": {"$gte": 2023},
"language": "en"
}
)
Always store filterable metadata at index time. Retroactively adding filters to a large index is expensive.
6. Hybrid Search: Dense + Sparse
Pure vector search misses exact keyword matches. A user querying “CVE-2024-1234” or “Q3 2024 revenue” gets better results from a keyword-based lexical search engine than from dense retrieval alone.
Hybrid search combines both:
- Dense retrieval: semantic similarity via embeddings (recall for paraphrases)
- Sparse retrieval: BM25 or TF-IDF (precision for exact terms, rare words, codes)
Reciprocal Rank Fusion (RRF)
RRF is the standard fusion method. It does not require score normalization and is robust across retrieval systems:
\[\text{RRF}(d) = \sum_{r \in R} \frac{1}{k + r(d)}\]where $r(d)$ is the rank of document $d$ in retrieval system $r$ and $k$ is a constant (typically 60).
1
2
3
4
5
6
7
def reciprocal_rank_fusion(dense_results, sparse_results, k=60):
scores = {}
for rank, doc_id in enumerate(dense_results):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
for rank, doc_id in enumerate(sparse_results):
scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank + 1)
return sorted(scores.items(), key=lambda x: x[1], reverse=True)
Most modern vector databases (OpenSearch, Weaviate, Qdrant) support hybrid search natively.
7. Reranking
After retrieval you typically have 20–50 candidates. A reranker scores each (query, chunk) pair more precisely than the embedding-based retrieval — but at higher cost.
Cross-encoder reranking
A cross-encoder takes the full (query, passage) pair and outputs a relevance score. It is much slower than bi-encoder retrieval but significantly more accurate.
1
2
3
4
5
6
7
8
9
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
pairs = [(query, chunk) for chunk in candidates]
scores = reranker.predict(pairs)
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
top_k = [chunk for chunk, _ in ranked[:5]]
Cohere Rerank API
For a managed option:
1
2
3
4
5
6
7
8
9
import cohere
co = cohere.Client(api_key)
response = co.rerank(
model="rerank-english-v3.0",
query=user_query,
documents=candidates,
top_n=5,
)
When to use a reranker
- Always use a reranker when precision matters more than latency.
- Use a lightweight model (MiniLM-based) for latency-constrained paths.
- Use a heavy model (Cohere, BGE-reranker-large) for offline or low-traffic critical tasks.
8. Query Transformation
Raw user queries are often noisy, ambiguous, or incomplete. Transforming the query before retrieval improves recall.
HyDE (Hypothetical Document Embeddings)
Generate a hypothetical answer to the query, then embed that answer for retrieval. The hypothesis is more likely to match document-style text than the original question.
1
2
3
4
5
def hyde_retrieve(query, llm, retriever):
hypothetical_doc = llm.generate(
f"Write a passage that would answer this question: {query}"
)
return retriever.retrieve(hypothetical_doc)
Multi-query retrieval
Generate N reformulations of the query, retrieve for each, and fuse results:
1
2
3
4
5
reformulations = llm.generate(
f"Generate 3 different ways to ask this question:\n{query}"
)
all_results = [retriever.retrieve(q) for q in reformulations]
merged = reciprocal_rank_fusion(*all_results)
Step-back prompting
Rephrase the query at a higher level of abstraction. “What are the side effects of ibuprofen for pregnant women?” becomes “What are the general safety considerations for NSAIDs during pregnancy?” — retrieving broader context that contains the specific answer.
9. Context Injection and Prompt Construction
Retrieved chunks must be assembled into a prompt that gives the model enough context without overwhelming it.
Ordering matters
Position chunks with the most relevant content at the beginning and end of the context window. Research on “lost in the middle” shows LLMs attend less to content in the middle of long contexts.
Citation anchoring
Include chunk identifiers in the injected context and instruct the model to cite them:
1
2
3
4
5
6
7
8
You are an assistant answering questions based on the provided context.
For each claim, cite the source using [DOC-1], [DOC-2], etc.
Context:
[DOC-1] {chunk_1_text}
[DOC-2] {chunk_2_text}
Question: {user_query}
Faithfulness constraint
Explicitly instruct the model not to use knowledge outside the provided context:
1
2
3
Answer based ONLY on the context provided above.
If the answer is not in the context, respond with:
"I don't have enough information to answer this question."
10. Evaluating a RAG Pipeline
Evaluation must cover each subsystem independently.
Retrieval metrics
| Metric | What it measures |
|---|---|
| Recall@k | Fraction of relevant docs in top-k |
| Precision@k | Fraction of top-k that are relevant |
| MRR | Mean reciprocal rank of first relevant result |
| NDCG | Graded relevance rank quality |
Generation metrics (RAGAS)
| Metric | What it measures |
|---|---|
| Faithfulness | Is the answer grounded in retrieved context? |
| Answer relevance | Does the answer address the question? |
| Context precision | Are the retrieved chunks relevant? |
| Context recall | Is the relevant information retrieved? |
1
2
3
4
5
6
7
8
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision
result = evaluate(
dataset,
metrics=[faithfulness, answer_relevancy, context_precision],
)
print(result)
End-to-end evaluation
Use LLM-as-a-judge to score (question, context, answer) triples:
1
2
3
4
5
6
7
8
9
10
11
judge_prompt = """
Rate the answer on a scale of 1-5:
1 = completely wrong or hallucinated
5 = fully correct, grounded in context, and complete
Question: {question}
Context: {context}
Answer: {answer}
Score:
"""
11. Common Failure Patterns and Fixes
| Failure | Symptom | Fix |
|---|---|---|
| Chunk boundary cuts key info | Answer misses part of the relevant passage | Add overlap, use structure-aware chunking |
| Retriever returns irrelevant chunks | LLM hallucinates or says “I don’t know” | Add reranker, improve embedding model, add metadata filters |
| Exact term not retrieved by dense | Acronyms, codes, rare terms missed | Add BM25 hybrid search |
| Context overflow | LLM ignores part of the context | Reduce chunk count, use hierarchical chunking |
| LLM ignores retrieved context | Answers from parametric memory | Strengthen faithfulness constraint in system prompt |
| Slow retrieval latency | User-facing p95 > 500ms | Add ANN index, enable metadata pre-filtering, cache common queries |
Putting It All Together
A production RAG system is an engineering product, not a model parameter. The model is one component. The chunking strategy, embedding model, index configuration, hybrid search fusion, reranker, and prompt template all contribute to the final answer quality and operational cost.
12. Corrective RAG (CRAG)
Corrective RAG adds a self-evaluation step after retrieval — if retrieved documents are judged irrelevant, the system queries a web search engine for supplementary context:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
from langchain_openai import ChatOpenAI
from langchain_community.tools import TavilySearchResults
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
web = TavilySearchResults(max_results=3)
def evaluate_relevance(question: str, docs: list[dict]) -> str:
"""Returns 'relevant', 'ambiguous', or 'irrelevant'."""
doc_texts = "\n\n".join(d["text"][:500] for d in docs)
prompt = f"""
Does the following context adequately address the question?
Question: {question}
Context: {doc_texts}
Respond with one word: relevant, ambiguous, or irrelevant.
"""
return llm.invoke(prompt).content.strip().lower()
def corrective_rag(question: str, retriever, llm) -> str:
# Step 1: Initial retrieval
docs = retriever.retrieve(question, top_k=5)
relevance = evaluate_relevance(question, docs)
if relevance == "irrelevant":
# Step 2a: Fall back to web search
web_results = web.invoke(question)
context = "\n\n".join(r["content"] for r in web_results)
elif relevance == "ambiguous":
# Step 2b: Combine local + web
web_results = web.invoke(question)
web_context = "\n\n".join(r["content"] for r in web_results)
local_context = "\n\n".join(d["text"] for d in docs)
context = local_context + "\n\n[Web search supplement]\n" + web_context
else:
context = "\n\n".join(d["text"] for d in docs)
# Step 3: Generate answer
return llm.invoke(f"Context:\n{context}\n\nQuestion: {question}\n\nAnswer:").content
13. Self-RAG
Self-RAG trains the model to decide when to retrieve, what to retrieve, and how to critique its own output using special reflection tokens:
1
2
3
4
[Retrieve]: Should I retrieve documents for this question?
[Relevant]: Is this retrieved document relevant?
[Supported]: Is this claim supported by the context?
[Utility]: Is this response useful?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# Simplified Self-RAG inference loop
def self_rag(question: str, retriever, llm, max_steps: int = 5) -> str:
context = []
draft = ""
final_answer = None
for step in range(max_steps):
# Prompt with Self-RAG tokens
prompt = f"""
Question: {question}
Previous context: {chr(10).join(context)}
Current draft: {draft}
Should you retrieve more information? If yes, what query?
Format: retrieve
"""
decision_text = llm.invoke(prompt).content
import json
try:
decision = json.loads(decision_text)
except json.JSONDecodeError:
break
if not decision.get("retrieve", False):
# Model decided it has enough context
final_answer = llm.invoke(
f"Based on this context:\n{chr(10).join(context)}\n\nAnswer: {question}"
).content
break
# Retrieve
query = decision.get("query", question)
docs = retriever.retrieve(query, top_k=3)
context.extend([d["text"] for d in docs])
return final_answer or draft
14. Agentic RAG with LangGraph
Production RAG systems often need multiple retrieval strategies, dynamic planning, and fallback behavior:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated
from langgraph.graph.message import add_messages
class RAGState(TypedDict):
question: str
retrieved_docs: list[dict]
answer: str | None
retrieval_score: float
retry_count: int
web_searched: bool
def retrieve_node(state: RAGState) -> RAGState:
docs = retriever.retrieve(state["question"], top_k=5)
return {"retrieved_docs": docs}
def evaluate_retrieval(state: RAGState) -> RAGState:
score = evaluate_retrieval_quality(state["question"], state["retrieved_docs"])
return {"retrieval_score": score}
def grade_retrieval(state: RAGState) -> str:
if state["retrieval_score"] >= 0.7:
return "generate"
if not state["web_searched"]:
return "web_search"
return "generate" # use what we have, acknowledge uncertainty
def web_search_node(state: RAGState) -> RAGState:
results = web_tool.invoke(state["question"])
extra = [{"text": r["content"], "source": r["url"]} for r in results]
return {
"retrieved_docs": state["retrieved_docs"] + extra,
"web_searched": True,
}
def generate_node(state: RAGState) -> RAGState:
context = "\n\n".join(d["text"] for d in state["retrieved_docs"][:5])
answer = llm.invoke(f"Context:\n{context}\n\nQuestion: {state['question']}\n\nAnswer:").content
return {"answer": answer}
graph = StateGraph(RAGState)
graph.add_node("retrieve", retrieve_node)
graph.add_node("evaluate", evaluate_retrieval)
graph.add_node("web_search", web_search_node)
graph.add_node("generate", generate_node)
graph.set_entry_point("retrieve")
graph.add_edge("retrieve", "evaluate")
graph.add_conditional_edges("evaluate", grade_retrieval, {
"generate": "generate",
"web_search": "web_search",
})
graph.add_edge("web_search", "evaluate")
graph.add_edge("generate", END)
app = graph.compile()
result = app.invoke({"question": "What is the latest LLM benchmark result?", "retry_count": 0, "web_searched": False})
print(result["answer"])
15. RAG Production Infrastructure
End-to-end document ingestion pipeline
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
from pathlib import Path
from langchain_community.document_loaders import (
PyPDFLoader, UnstructuredMarkdownLoader, TextLoader
)
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
import hashlib
class ProductionRAGPipeline:
def __init__(self, persist_dir: str = "./chroma_db"):
self.embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
self.vectorstore = Chroma(
persist_directory=persist_dir,
embedding_function=self.embeddings,
)
self.splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=100,
separators=["\n## ", "\n### ", "\n\n", "\n", ". "],
)
self._indexed: set[str] = set()
def _doc_hash(self, path: str) -> str:
return hashlib.md5(Path(path).read_bytes()).hexdigest()
def ingest(self, file_path: str, metadata: dict = None) -> int:
doc_hash = self._doc_hash(file_path)
if doc_hash in self._indexed:
return 0 # already indexed, skip
ext = Path(file_path).suffix.lower()
loader = {
".pdf": PyPDFLoader,
".md": UnstructuredMarkdownLoader,
".txt": TextLoader,
}.get(ext, TextLoader)(file_path)
docs = loader.load()
chunks = self.splitter.split_documents(docs)
for chunk in chunks:
chunk.metadata.update({
"source": file_path,
"doc_hash": doc_hash,
**(metadata or {}),
})
self.vectorstore.add_documents(chunks)
self._indexed.add(doc_hash)
return len(chunks)
def ingest_directory(self, directory: str, glob: str = "**/*.pdf") -> dict:
stats = {"total_files": 0, "total_chunks": 0, "skipped": 0}
for path in Path(directory).glob(glob):
n = self.ingest(str(path))
stats["total_files"] += 1
stats["total_chunks"] += n
if n == 0:
stats["skipped"] += 1
return stats
def query(self, question: str, top_k: int = 5, filters: dict = None) -> list:
search_kwargs = {"k": top_k}
if filters:
search_kwargs["filter"] = filters
return self.vectorstore.similarity_search_with_score(question, **search_kwargs)
16. RAG Observability and Monitoring
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import time
import json
from dataclasses import dataclass
@dataclass
class RAGTrace:
request_id: str
question: str
retrieval_ms: float
reranking_ms: float
generation_ms: float
total_ms: float
doc_count: int
top_doc_score: float
prompt_tokens: int
completion_tokens: int
faithfulness: float | None # from LLM judge
def trace_rag_pipeline(question: str, retriever, llm, judge=None) -> dict:
import uuid
request_id = str(uuid.uuid4())
t_start = time.perf_counter()
# Retrieval
t0 = time.perf_counter()
docs = retriever.retrieve(question, top_k=5)
ret_ms = (time.perf_counter() - t0) * 1000
# Generation
context = "\n\n".join(d["text"] for d in docs)
t1 = time.perf_counter()
response = llm.invoke(f"Context:\n{context}\n\nQ: {question}")
gen_ms = (time.perf_counter() - t1) * 1000
answer = response.content
# Optional faithfulness check
faith = None
if judge:
faith = judge.check_faithfulness(context, answer)
trace = RAGTrace(
request_id=request_id,
question=question[:100],
retrieval_ms=round(ret_ms, 1),
reranking_ms=0,
generation_ms=round(gen_ms, 1),
total_ms=round((time.perf_counter() - t_start) * 1000, 1),
doc_count=len(docs),
top_doc_score=docs[0]["score"] if docs else 0,
prompt_tokens=response.usage.prompt_tokens,
completion_tokens=response.usage.completion_tokens,
faithfulness=faith,
)
# Log structured trace
print(json.dumps({
"request_id": trace.request_id,
"retrieval_ms": trace.retrieval_ms,
"generation_ms": trace.generation_ms,
"total_ms": trace.total_ms,
"doc_count": trace.doc_count,
"faithfulness": trace.faithfulness,
}))
return {"answer": answer, "trace": trace}
RAG Optimization Roadmap
Start with a simple baseline and add complexity only where evaluation shows it’s needed:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
Phase 1 — Baseline (Day 1):
Fixed-length chunks (512 tokens, 64 overlap)
+ Single embedding model (text-embedding-3-small)
+ Vector-only retrieval (k=5)
+ Simple prompt template
Measure: RAGAS faithfulness, answer relevancy
Phase 2 — Retrieval Quality (Week 1):
+ BM25 hybrid search (0.3 BM25 : 0.7 dense)
+ Cross-encoder reranker (MiniLM)
+ Metadata filtering (date, source, department)
Measure: Context precision, context recall, MRR
Phase 3 — Chunking (Week 2):
+ Structure-aware chunking (heading boundaries)
+ Parent-child indexing (retrieve small, inject large)
+ Semantic chunking for heterogeneous corpora
Measure: Chunk boundary failure rate, faithfulness delta
Phase 4 — Query Enhancement (Week 3):
+ HyDE for low-overlap queries
+ Multi-query retrieval for ambiguous questions
+ Step-back prompting for abstract questions
Measure: Recall@k improvement on hard queries
Phase 5 — Advanced (Month 2):
+ Corrective RAG (web search fallback)
+ Agentic RAG with LangGraph
+ Graph RAG for relational domains
Measure: Full RAGAS suite, human eval on hard cases
RAG Evaluation Suite
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
from ragas import evaluate
from ragas.metrics import (
faithfulness,
answer_relevancy,
context_precision,
context_recall,
answer_correctness,
)
from datasets import Dataset
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from ragas.llms import LangchainLLMWrapper
from ragas.embeddings import LangchainEmbeddingsWrapper
class RAGEvaluationSuite:
def __init__(self, rag_fn, judge_model: str = "gpt-4o-mini"):
self.rag_fn = rag_fn
self.judge_llm = LangchainLLMWrapper(ChatOpenAI(model=judge_model))
self.judge_emb = LangchainEmbeddingsWrapper(OpenAIEmbeddings())
def run(self, test_cases: list[dict], baseline: dict = None) -> dict:
results = []
for case in test_cases:
rag_output = self.rag_fn(case["question"])
results.append({
"question": case["question"],
"answer": rag_output["answer"],
"contexts": rag_output["contexts"],
"ground_truth": case.get("ground_truth", ""),
})
dataset = Dataset.from_list(results)
scores = evaluate(
dataset,
metrics=[faithfulness, answer_relevancy, context_precision, context_recall, answer_correctness],
llm=self.judge_llm,
embeddings=self.judge_emb,
)
result_dict = scores.to_pandas().mean(numeric_only=True).to_dict()
if baseline:
result_dict["deltas"] = {
k: round(result_dict.get(k, 0) - baseline.get(k, 0), 4)
for k in baseline
}
return result_dict
End-to-End Production RAG: Complete Code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
from sentence_transformers import CrossEncoder
import time, json
class ProductionRAG:
def __init__(self, docs, model: str = "gpt-4o-mini"):
self.llm = ChatOpenAI(model=model, temperature=0)
emb = OpenAIEmbeddings(model="text-embedding-3-small")
splitter = RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=64)
chunks = splitter.split_documents(docs)
# Hybrid retrieval
vectorstore = Chroma.from_documents(chunks, emb)
bm25 = BM25Retriever.from_documents(chunks, k=10)
dense = vectorstore.as_retriever(search_kwargs={"k": 10})
self.retriever = EnsembleRetriever(retrievers=[bm25, dense], weights=[0.3, 0.7])
self.reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
PROMPT = ChatPromptTemplate.from_messages([
("system", """Answer based ONLY on the context. Cite sources as [Source N].
If the answer is not in the context, say: "I don't have information about this."
Context:
{context}"""),
("human", "{question}"),
])
self.chain = (
{
"context": lambda x: self._retrieve_and_rerank(x["question"]),
"question": RunnablePassthrough(),
}
| PROMPT
| self.llm
| StrOutputParser()
)
def _retrieve_and_rerank(self, query: str, top_k: int = 5) -> str:
candidates = self.retriever.invoke(query)
pairs = [(query, d.page_content) for d in candidates]
scores = self.reranker.predict(pairs)
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)[:top_k]
return "\n\n".join(
f"[Source {i+1}] {doc.page_content}"
for i, (doc, _) in enumerate(ranked)
)
def query(self, question: str) -> dict:
t0 = time.perf_counter()
answer = self.chain.invoke({"question": question})
return {"answer": answer, "latency_ms": round((time.perf_counter() - t0) * 1000)}
The best RAG systems are built iteratively: start with a simple baseline (fixed-length chunks, single-vector retrieval, no reranker), measure with RAGAS, identify the weakest layer, and improve that layer first. For challenging queries, Corrective RAG, Self-RAG, and Agentic RAG patterns provide increasingly powerful retrieval strategies. Avoid adding complexity before you have evidence that a simpler approach has failed — and always maintain evaluation infrastructure so that each improvement can be verified quantitatively. The optimization roadmap and production code template above provide a practical path from baseline to production-grade RAG that applies to most enterprise knowledge retrieval use cases.
RAG System Engineering Principles
- Evaluate each layer independently: Retrieval quality, reranker quality, and generation quality are distinct metrics
- Never skip the reranker for production: Vector similarity is fast but imprecise; reranking is cheap and accurate
- Hybrid search is the baseline: BM25 handles exact matches that dense retrieval misses
- Chunk size affects everything: Too small = semantic fragmentation; too large = irrelevant content injected
- Faithfulness is your primary SLO: A fast, relevant, but hallucinated answer is worse than no answer
- Metadata is first-class: Filter by date, source, department, access level — don’t let retrieval be purely semantic
- Context ordering matters: Critical evidence belongs at the beginning or end, not the middle
- Corrective RAG is your insurance: When retrieval fails, web search or abstention is better than hallucination
A production RAG system that implements all eight principles will consistently outperform one that relies on a better model alone.
RAG Deep Dive Conclusion
RAG is the most widely deployed pattern for connecting LLMs to organizational knowledge. At its core, it is a retrieval engineering problem as much as a generation engineering problem. The document chunking strategy, embedding model, indexing configuration, hybrid search fusion, reranker, and prompt construction each contribute measurably to the final quality. The RAGAS evaluation framework makes these contributions measurable and comparable across experiments. The advanced patterns—Corrective RAG, Self-RAG, Agentic RAG, and Graph RAG—provide escalating levels of sophistication for queries that straightforward vector retrieval cannot answer. Build iteratively, measure everything, and never mistake retrieval quality for generation quality.
RAG Deep Dive: Key Takeaways
- Hybrid search (BM25 + dense) consistently outperforms pure vector search
- Always add a cross-encoder reranker before injecting context—it’s cheap and effective
- Chunk size affects everything: start with 512 tokens, 64 overlap, and tune from there
- Faithfulness is your primary quality SLO; hallucinations hurt more than gaps
- Evaluate retrieval independently from generation—they fail for different reasons
- Corrective RAG (web search fallback) improves recall on out-of-knowledge-base queries
- For enterprise knowledge bases, Graph RAG handles relationship-heavy queries that vector search cannot
- RAGAS provides the standard evaluation suite: faithfulness, relevance, precision, recall
RAG System Quick Reference
Chunking Strategy Selection
| Document Type | Strategy | chunk_size | chunk_overlap |
|---|---|---|---|
| Legal contracts | Structure-aware (by section) | 1000 | 100 |
| Technical manuals | Structure-aware (by heading) | 512 | 64 |
| News articles | Fixed-length | 512 | 64 |
| Research papers | Hierarchical (abstract + sections) | 800 | 80 |
| Code documentation | File-level + function-level | 256 | 32 |
| Product FAQs | Q&A aware (split on questions) | 256 | 0 |
| Mixed corpus | Semantic chunking | 400–600 | 50 |
RAGAS Score Targets
| Metric | Acceptable | Good | Excellent |
|---|---|---|---|
| Faithfulness | > 0.70 | > 0.80 | > 0.90 |
| Answer Relevancy | > 0.65 | > 0.75 | > 0.85 |
| Context Precision | > 0.60 | > 0.70 | > 0.80 |
| Context Recall | > 0.55 | > 0.70 | > 0.80 |
Production Engineering Notes
Building production LLM systems requires continuous learning and adaptation. The patterns and tools in this ecosystem evolve rapidly, but certain engineering principles remain constant:
On reliability: The most reliable systems are built with defense in depth � multiple independent validation layers, graceful degradation paths, circuit breakers, and comprehensive observability. No single component should be a single point of failure.
On evaluation: Automated evaluation enables speed; human evaluation provides ground truth. Calibrate your automated judges regularly against human labels. A 10-point quality regression detected in CI costs hours to fix; the same regression discovered after deployment costs days or weeks of user trust.
On iteration: LLM systems improve through measurement, not intuition. Every prompt change, model upgrade, and retrieval configuration should be evaluated against a representative dataset before deployment. Blind experimentation is expensive; instrumented experimentation is invaluable.
On operations: Monitor cost, latency, and quality as a unified picture. A system that is fast and cheap but inaccurate is not production-grade. A system that is accurate but 10x over budget is not sustainable. The engineering goal is acceptable quality at acceptable cost within acceptable latency.
RAG is the most adopted LLM design pattern in production. The RAGAS leaderboard and MTEB benchmark are the standard references for tracking retrieval quality improvements over time.
RAG Resources
- RAGAS Documentation — Standard RAG evaluation framework
- LangChain RAG Tutorial — End-to-end RAG implementation
- LlamaIndex — Alternative RAG framework with rich document connectors
- Sentence Transformers — Open-source embedding and cross-encoder models
- Qdrant — High-performance vector database
- MTEB Leaderboard — Embedding model benchmarks
- Chroma — Local-first vector store for development
- Weaviate — Multi-modal vector database with built-in hybrid search
