Overview
Building a RAG system from scratch requires decisions at every layer: which document loader handles your file types, how chunks are sized and overlapped, which embedding model to use, whether to host a vector store or use a managed service, how to rerank results, and how to construct the prompt that binds retrieved context to the user query. Each decision compounds — a wrong chunk size reduces retrieval quality, which makes reranking less effective, which degrades answers even with a capable LLM.
The system in this lesson uses a pragmatic production-ready stack: PyPDFLoader for document ingestion, TokenTextSplitter with tiktoken-based token counting to guarantee no chunk exceeds the model context limit, OpenAI text-embedding-3-small for quality embeddings without model hosting overhead, Pinecone as the managed vector store, a CrossEncoder reranker to improve relevance after initial retrieval, and GPT-4 for generation. This combination handles the tradeoffs most production teams face and produces a system that is straightforward to monitor and debug.
The testing section uses RAGAS, which computes four metrics: faithfulness (does the answer match retrieved documents?), answer relevancy (does the answer address the question?), context precision (is retrieved context relevant?), and context recall (did retrieval find the needed information?). Run RAGAS on a 50-question golden test set before shipping and after every significant change to the pipeline. It is the only reliable way to catch retrieval regressions before users do.
System Architecture
RAG System Architecture:
┌─ Document Ingestion Layer ─┐
│ Load → Clean → Chunk │
└──────────┬──────────────────┘
│
┌──────────▼──────────────────┐
│ Embedding Layer │
│ tokenize → embed (batch) │
└──────────┬──────────────────┘
│
┌──────────▼──────────────────┐
│ Vector DB (Pinecone) │
│ Index & Store │
└──────────┬──────────────────┘
│ (Query Time)
┌──────────▼──────────────────┐
│ Retrieval Layer │
│ search → rerank → filter │
└──────────┬──────────────────┘
│
┌──────────▼──────────────────┐
│ Prompt Construction │
│ format docs → build prompt │
└──────────┬──────────────────┘
│
┌──────────▼──────────────────┐
│ LLM Generation │
│ call GPT-4 → get answer │
└──────────┬──────────────────┘
│
┌──────────▼──────────────────┐
│ Output Processing │
│ cite sources → verify │
└──────────────────────────────┘
Python Implementation
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitters import TokenTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_pinecone import PineconeVectorStore
from langchain.chains import RetrievalQA
from sentence_transformers import CrossEncoder
class RAGSystem:
def __init__(self):
self.embeddings = OpenAIEmbeddings(
model="text-embedding-3-small"
)
self.llm = ChatOpenAI(model="gpt-4", temperature=0.3)
self.vector_store = PineconeVectorStore.from_existing_index(
index_name="my-rag",
embedding=self.embeddings
)
self.reranker = CrossEncoder("cross-encoder/qnli-distilroberta-base")
def index_documents(self, file_paths):
"""Load, chunk, and index documents."""
docs = []
for path in file_paths:
loader = PyPDFLoader(path)
docs.extend(loader.load())
splitter = TokenTextSplitter(
chunk_size=400,
chunk_overlap=40,
encoding_name="cl100k_base"
)
chunks = splitter.split_documents(docs)
# Add to vector store
self.vector_store.add_documents(chunks)
print(f"Indexed {len(chunks)} chunks")
def retrieve_and_rank(self, query, k=10):
"""Retrieve and rerank documents."""
# Dense search
dense_results = self.vector_store.similarity_search_with_score(
query, k=k*2
)
# Rerank
docs = [doc for doc, _ in dense_results]
pairs = [[query, doc.page_content] for doc in docs]
scores = self.reranker.predict(pairs)
# Sort by reranker score
ranked = sorted(zip(docs, scores), key=lambda x: -x[1])
return [doc for doc, _ in ranked[:k]]
def generate_answer(self, query):
"""Generate answer from retrieved docs."""
# Retrieve
docs = self.retrieve_and_rank(query)
# Build prompt
context = "\n".join([
f"[{i}] {doc.page_content}"
for i, doc in enumerate(docs)
])
prompt = f"""Answer using ONLY these documents. Cite sources.
DOCUMENTS:
{context}
QUESTION: {query}
ANSWER:"""
# Generate
response = self.llm.predict(text=prompt)
return response, docs
# Usage
rag = RAGSystem()
rag.index_documents(["tax_guide.pdf", "faq.pdf"])
answer, sources = rag.generate_answer("How do I file taxes?")
Testing & Validation
def test_rag_system():
"""Test retrieval and generation quality."""
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy
rag = RAGSystem()
# Test cases
test_data = [
{
"query": "How do I file taxes?",
"ground_truth": "You file taxes using Form 1040..."
},
{
"query": "What deductions can I claim?",
"ground_truth": "Common deductions include..."
}
]
results = []
for test in test_data:
answer, docs = rag.generate_answer(test["query"])
results.append({
"query": test["query"],
"answer": answer,
"context": "\n".join([d.page_content for d in docs]),
"ground_truth": test["ground_truth"]
})
# Evaluate
eval_result = evaluate(
results,
metrics=[faithfulness, answer_relevancy]
)
print(f"Faithfulness: {eval_result['faithfulness']}")
print(f"Relevancy: {eval_result['answer_relevancy']}")
# Assert minimum quality
assert eval_result['faithfulness'] > 0.7
assert eval_result['answer_relevancy'] > 0.75
Error Handling
def safe_rag_query(rag, query, max_retries=2):
"""Robust query with fallbacks."""
try:
answer, docs = rag.generate_answer(query)
# Verify answer
if not answer or len(answer) < 10:
raise ValueError("Empty answer")
# Check for hallucinations
if "don't know" in answer.lower():
return {
"answer": answer,
"status": "limited_info",
"sources": docs
}
return {
"answer": answer,
"status": "success",
"sources": docs
}
except Exception as e:
print(f"Error: {e}")
return {
"answer": "I encountered an error. Please try again.",
"status": "error",
"sources": []
}
Production Checklist
Indexing: Vector DB indexed and searchable ✓
Quality: Eval metrics pass (Recall@5 ≥0.8) ✓
Latency: Response time <2 seconds ✓
Cost: Under budget per 1K queries ✓
Monitoring: Logging and alerts set up ✓
Security: API keys secured, input validated ✓
Notes
LangChain vs LlamaIndex: pick based on data connectors
LangChain has more third-party integrations and a larger community. LlamaIndex has stronger built-in data connectors for complex document types (nested PDFs, structured databases, hierarchical document stores) and better support for index-over-index patterns like recursive retrieval. If your data is predominantly PDFs and web pages, either works. If you are dealing with structured databases or complex document hierarchies, LlamaIndex is worth the learning curve.
RAGAS requires ground truth answers — invest in the dataset early
RAGAS computes context recall by comparing retrieved context to reference answers, which means you need a golden dataset of question-answer pairs written by domain experts. Creating 50 high-quality pairs upfront feels slow, but it pays dividends on every subsequent pipeline change. Teams that skip this step spend far more time doing manual spot checks after each deployment and still miss regressions.
Set LLM temperature low for retrieval-grounded generation
RAG generation should be deterministic and document-grounded. A temperature of 0.0–0.3 reduces the LLM's tendency to extrapolate beyond the retrieved context. High temperature (0.7+) is appropriate for creative tasks but increases hallucination rate in RAG — the model "fills gaps" with plausible-sounding fabrications rather than staying within the bounds of what was retrieved.
Pinecone free tier has capacity limits — plan for paid tier
Pinecone's free tier supports 1 index and ~100K vectors. For a pilot over a small document set this is fine, but production workloads routinely exceed this limit. Budget for the starter tier ($70+/month) before beginning production development so you are not surprised by capacity limits mid-project. Alternatively, use a self-hosted Qdrant instance from the start if infrastructure management is acceptable.
Complete RAG System FAQ
What components make up a complete RAG system?
A complete RAG system has five stages: document loader (ingests raw files), chunker (splits text), embedder (converts to vectors), vector store (indexes and retrieves), and LLM (generates the final answer).
How long does it take to build a RAG system from scratch?
A basic working prototype takes 2–4 hours using LangChain or LlamaIndex. A production-grade system with evaluation, monitoring, caching, and proper ingestion pipelines takes 2–4 weeks.
What Python libraries are best for building RAG?
LangChain and LlamaIndex are the most popular orchestration libraries. For vector storage, FAISS is best for local development; Qdrant or Pinecone for production. Use OpenAI or sentence-transformers for embeddings.
How do I test a complete RAG system?
Create a golden test set of 20–50 question-answer pairs from your documents. Run RAGAS metrics (faithfulness, answer relevancy, context precision) to get an objective score before shipping.
Can I build a RAG system without OpenAI?
Yes. Use Ollama for a local LLM, sentence-transformers for local embeddings, and FAISS for in-process vector storage. This gives you a fully offline, free-to-run RAG pipeline.