RAG Is Not Just Vector Search: Building Production Retrieval Systems
Why naive RAG implementations fail in production and how to build retrieval-augmented generation systems that actually work reliably.
On this page
The RAG Hype Cycle
Retrieval-Augmented Generation exploded onto the scene as the answer to LLM hallucination. The idea is seductively simple: instead of relying on the model's parametric knowledge, retrieve relevant documents and include them in the prompt.
The basic pipeline looks like this:
- User asks a question
- Embed the question using an embedding model
- Search a vector database for similar document chunks
- Stuff the top-k results into the prompt
- Let the LLM generate an answer
This works impressively well in demos. It fails impressively badly in production.
Why Naive RAG Fails
The Chunking Problem
Most tutorials chunk documents into fixed-size pieces. A 500-token chunk might split a paragraph mid-sentence, separate a code block from its explanation, or cut a table in half.
The result: retrieved chunks contain partial information that confuses the LLM rather than helping it.
Better approaches:
- Semantic chunking: Split on paragraph or section boundaries
- Hierarchical chunking: Store both large and small chunks, retrieve at multiple granularities
- Overlapping windows: Include context from adjacent chunks
def semantic_chunk(text: str, max_tokens: int = 500) -> list[str]:
paragraphs = text.split("\n\n")
chunks = []
current_chunk = []
current_length = 0
for paragraph in paragraphs:
para_tokens = count_tokens(paragraph)
if current_length + para_tokens > max_tokens and current_chunk:
chunks.append("\n\n".join(current_chunk))
current_chunk = []
current_length = 0
current_chunk.append(paragraph)
current_length += para_tokens
if current_chunk:
chunks.append("\n\n".join(current_chunk))
return chunksThe Relevance Problem
Vector similarity is not the same as relevance. A query about "Python error handling best practices" might retrieve chunks about Python error messages, Python exception classes, or JavaScript error handling — all semantically similar but not all relevant.
Solutions:
- Hybrid search: Combine vector similarity with keyword search (BM25). Vector search captures semantic meaning; keyword search captures exact terminology
- Re-ranking: Use a cross-encoder model to re-score retrieved candidates. This is more expensive but dramatically more accurate
- Metadata filtering: Filter by document type, date, or source before vector search
The Context Window Problem
Stuffing all retrieved chunks into the prompt creates noise. If 3 of 10 retrieved chunks are relevant, the 7 irrelevant ones can distract the LLM and degrade answer quality.
Solutions:
- Aggressive re-ranking: Only keep top 3-5 truly relevant chunks
- Compression: Use an LLM to summarize retrieved chunks before including them
- Citation tracking: Ask the model to cite which chunks it used, then verify
A Production RAG Architecture
Here is what a battle-tested RAG system looks like:
Query → Query Expansion → Hybrid Retrieval → Re-ranking → Context Assembly → LLM → Answer Validation
Query Expansion
The user's query is often too short or ambiguous. Generate 2-3 alternative phrasings:
async def expand_query(query: str) -> list[str]:
prompt = f"""Generate 3 alternative search queries for: "{query}"
Return only the queries, one per line."""
alternatives = await llm.generate(prompt)
return [query] + alternatives.strip().split("\n")Search with all expanded queries and merge results, giving higher weight to chunks that appear across multiple queries.
Hybrid Retrieval
Run both vector search and keyword search in parallel:
async def hybrid_retrieve(query: str, k: int = 20):
vector_results, keyword_results = await asyncio.gather(
vector_store.search(query, k=k),
keyword_store.search(query, k=k),
)
return reciprocal_rank_fusion(vector_results, keyword_results)Reciprocal Rank Fusion (RRF) is a simple and effective way to merge ranked lists from different search methods.
Re-Ranking
Use a cross-encoder model to re-score the merged results:
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
def rerank(query: str, documents: list[str], top_k: int = 5):
pairs = [[query, doc] for doc in documents]
scores = reranker.predict(pairs)
ranked = sorted(zip(scores, documents), reverse=True)
return [doc for _, doc in ranked[:top_k]]Answer Validation
After the LLM generates an answer, validate it:
- Groundedness check: Can every claim in the answer be traced to a retrieved chunk?
- Relevance check: Does the answer actually address the user's question?
- Confidence score: If the model is uncertain, say so rather than hallucinate
Measuring RAG Quality
You need three metrics:
- Retrieval precision: What fraction of retrieved chunks are actually relevant?
- Retrieval recall: What fraction of relevant chunks were retrieved?
- Answer accuracy: Does the final answer correctly reflect the source documents?
Build an evaluation dataset of question-answer pairs with known source documents. Run this after every pipeline change.
Key Lessons
- Chunking strategy matters more than embedding model choice
- Hybrid search outperforms pure vector search in nearly every benchmark
- Re-ranking is the highest-ROI improvement you can make
- Query expansion catches the queries that vector search misses
- Measure everything — without metrics, you are flying blind
- Start simple, iterate based on failure analysis — log every query, retrieved context, and generated answer
RAG is not a single technique. It is a pipeline, and every stage can be optimized. The teams that succeed in production are the ones that instrument every step and iterate relentlessly on the failure cases.
Recommended for you
Understanding Transformer Architecture: The Engine Behind Modern AI
A deep dive into the transformer architecture that powers GPT, BERT, and virtually every modern language model — explained with clarity.
System Design: Building a Real-Time Notification Service at Scale
How to architect a notification system that handles millions of events per second — from WebSockets to message queues to delivery guarantees.
The Art of Code Review: What Senior Engineers Actually Look For
Code reviews are not about catching typos. Here is what experienced engineers focus on and why it makes teams dramatically more effective.
Why Your Microservices Are Slower Than a Monolith (And How to Fix It)
Microservices promise scalability and team autonomy. But without careful design, they deliver latency, complexity, and debugging nightmares.