Building RAG Systems
RAG (Retrieval-Augmented Generation) is the pattern that makes AI actually useful for your data. It's how you build a chatbot that knows your docs.
---
Why RAG Works
LLMs are trained on public data. They don't know about:
- Your company's documentation
- Your product's features
- Your internal policies
- Anything after their training cutoff
Q: What's our refund policy?
A: "Generally, companies offer 30-day refunds..." (hallucination)
Q: What's our refund policy?
A: "Based on your docs, you offer 14-day refunds for annual plans..." (grounded)
---
Step 1: Document Chunking
You can't embed an entire document. You need to split it into chunks.
Why Chunk?
- Embedding limits: Most models max out at 8K tokens
- Precision: Smaller chunks = more precise retrieval
- Context window: LLMs have limited prompt space
Chunking Strategies
Split every N tokens. Simple but may cut mid-sentence.
Split by paragraphs, then sentences. Respects structure.
Split by topic changes. Best quality, more complex.
Simple Chunker Implementation
def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
"""Split text into overlapping chunks."""
words = text.split()
chunks = []
start = 0
while start < len(words):
end = start + chunk_size
chunk = ' '.join(words[start:end])
chunks.append(chunk)
start = end - overlap # Overlap for context continuity
return chunks
# Example
document = "Your very long document text here..."
chunks = chunk_text(document, chunk_size=300, overlap=30)
print(f"Created {len(chunks)} chunks")
Overlap ensures that if important information spans two chunks, it appears in both. 10-15% overlap is typical.
---
Step 2: The RAG Pipeline
from openai import OpenAI
import psycopg2
client = OpenAI()
conn = psycopg2.connect("postgresql://localhost/mydb")
class RAGPipeline:
def __init__(self):
self._ensure_table()
def _ensure_table(self):
with conn.cursor() as cur:
cur.execute("""
CREATE TABLE IF NOT EXISTS chunks (
id SERIAL PRIMARY KEY,
content TEXT,
source TEXT,
embedding vector(1536)
)
""")
conn.commit()
def ingest_document(self, content: str, source: str):
"""Chunk and store a document."""
chunks = self._chunk_text(content)
for chunk in chunks:
embedding = self._get_embedding(chunk)
with conn.cursor() as cur:
cur.execute(
"INSERT INTO chunks (content, source, embedding) VALUES (%s, %s, %s)",
(chunk, source, embedding)
)
conn.commit()
print(f"Ingested {len(chunks)} chunks from {source}")
def query(self, question: str, top_k: int = 3) -> str:
"""RAG query: retrieve context, then generate answer."""
# 1. Retrieve relevant chunks
context_chunks = self._retrieve(question, top_k)
if not context_chunks:
return "I don't have information about that."
# 2. Build prompt with context
context = "\n\n".join([c['content'] for c in context_chunks])
sources = list(set([c['source'] for c in context_chunks]))
# 3. Generate answer
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": """Answer questions based on the provided context.
If the context doesn't contain the answer, say "I don't have information about that."
Be concise and cite which document the info came from."""
},
{
"role": "user",
"content": f"""Context:
{context}
Question: {question}"""
}
]
)
answer = response.choices[0].message.content
return f"{answer}\n\nSources: {', '.join(sources)}"
def _retrieve(self, query: str, top_k: int) -> list[dict]:
"""Find most relevant chunks."""
query_embedding = self._get_embedding(query)
with conn.cursor() as cur:
cur.execute("""
SELECT content, source, 1 - (embedding <=> %s::vector) AS similarity
FROM chunks
WHERE 1 - (embedding <=> %s::vector) > 0.7
ORDER BY embedding <=> %s::vector
LIMIT %s
""", (query_embedding, query_embedding, query_embedding, top_k))
return [{"content": r[0], "source": r[1], "similarity": r[2]}
for r in cur.fetchall()]
def _chunk_text(self, text: str, size: int = 300, overlap: int = 30) -> list[str]:
words = text.split()
chunks = []
start = 0
while start < len(words):
chunk = ' '.join(words[start:start + size])
chunks.append(chunk)
start += size - overlap
return chunks
def _get_embedding(self, text: str) -> list[float]:
response = client.embeddings.create(
model="text-embedding-3-small",
input=text
)
return response.data[0].embedding
---
Step 3: Using the Pipeline
# Initialize
rag = RAGPipeline()
# Ingest documents
rag.ingest_document("""
Our refund policy: We offer a 14-day money-back guarantee for all annual plans.
Monthly plans can be cancelled anytime but are not refundable.
To request a refund, email support@example.com with your order ID.
""", source="refund-policy.md")
rag.ingest_document("""
Pricing Plans:
- Starter: $9/month or $90/year (save $18)
- Pro: $29/month or $290/year (save $58)
- Enterprise: Custom pricing, contact sales
All plans include 14-day free trial.
""", source="pricing.md")
# Query
answer = rag.query("Can I get my money back if I don't like the product?")
print(answer)
# Output:
# Yes, you can get a refund within 14 days for annual plans.
# Monthly plans are not refundable but can be cancelled anytime.
# Email support@example.com with your order ID to request a refund.
#
# Sources: refund-policy.md
---
RAG Best Practices
200-500 tokens is typical. Too small = missing context. Too large = noise.
Don't return low-confidence chunks. 0.7+ similarity is a good starting point.
Store source, date, category. Helps with filtering and citations.
Ask it to reference which document the answer came from.
---
Common RAG Pitfalls
10+ chunks floods the context. The LLM gets confused. Stick to 3-5 top results.
If no relevant chunks found, the LLM will hallucinate. Always check retrieval results first.
Documents change. Set up a re-ingestion pipeline when source docs update.
---
Key Takeaways
- RAG = Retrieve + Generate - find relevant context, then ask the LLM
- Chunk your documents - 200-500 tokens with some overlap
- Quality retrieval is key - bad retrieval = bad answers
- Handle "no results" - don't let the LLM make things up
Next up: Case Study - building a complete Document Q&A Bot with everything we've learned.