LLMs hallucinate. They confidently state incorrect facts, invent API endpoints that don't exist, and cite documentation that was never written. This isn't a bug that will be fixed with the next model release — it's a fundamental property of how generative models work. They predict plausible text, not truthful text.
RAG (Retrieval-Augmented Generation) is the most practical solution to this problem. Instead of relying on the model's training data alone, you retrieve relevant documents from your own knowledge base and feed them into the context window. The model generates answers grounded in real, verifiable data. It's the difference between asking someone to answer from memory versus giving them the reference material first.
I've built RAG systems for internal documentation search, customer support bots, and code-aware developer tools. Here's a complete guide covering architecture, implementation, and the real-world gotchas you'll encounter.
What Is RAG?
Retrieval-Augmented Generation combines two capabilities: retrieval (finding relevant information) and generation (producing natural language responses). Instead of the model relying entirely on its training data, RAG gives it access to external knowledge at inference time.
The concept is simple: before generating a response, search your knowledge base for relevant documents and include them in the prompt. The model then uses those documents as source material rather than generating from memory.
Without RAG:
User: "What's our refund policy for enterprise customers?"
LLM: [makes something up based on general training data]
With RAG:
User: "What's our refund policy for enterprise customers?"
System: [retrieves actual policy document from knowledge base]
LLM: [generates answer grounded in the real policy document]
"Enterprise customers can request a full refund within 30 days..."
Source: internal-docs/refund-policy-v3.mdWhy RAG? Comparing Approaches
There are three main approaches to giving an LLM access to custom knowledge. Each has different tradeoffs:
| Approach | RAG | Fine-Tuning | Large Context |
|---|---|---|---|
| Data freshness | Real-time updates | Requires retraining | Current (in-context) |
| Cost | Low (embeddings cheap) | High (training runs) | High (token costs) |
| Setup complexity | Medium | High | Low |
| Scalability | Millions of docs | Limited by training | Limited by window |
| Hallucination risk | Low (grounded) | Medium | Low (if relevant) |
| Source attribution | Yes (track sources) | No | Manual |
| Best for | Knowledge bases, docs | Behavior changes | Small, fixed datasets |
RAG wins for most production applications because knowledge changes frequently, you need source attribution, and the approach scales to millions of documents without retraining. Fine-tuning is better when you need to change how the model responds (tone, format, reasoning style) rather than what it knows.
How RAG Works: The Pipeline
Every RAG system follows the same five-step pipeline. Understanding each step helps you optimize the system as a whole.
RAG Pipeline: ┌─────────────────────────────────────────────────────────┐ │ 1. QUERY User asks a question │ │ ↓ │ │ 2. EMBED Convert question to vector │ │ ↓ │ │ 3. SEARCH Find similar vectors in database │ │ ↓ │ │ 4. RETRIEVE Pull matching document chunks │ │ ↓ │ │ 5. GENERATE LLM answers using retrieved context │ └─────────────────────────────────────────────────────────┘ Indexing Phase (offline, done once): Documents → Chunk → Embed → Store in Vector DB Query Phase (real-time, per request): Question → Embed → Search → Retrieve → Augment Prompt → Generate
The indexing phase processes your documents ahead of time. The query phase happens in real-time for each user request. Keeping these separate is important — indexing can be slow and expensive, but queries need to be fast.
RAG Architecture Components
Vector Database
A vector database stores document embeddings and enables fast similarity search. When you query with a question's embedding, it returns the most semantically similar documents — even if they don't share exact keywords with the query.
Unlike traditional keyword search (which matches exact words), vector search understands meaning. "How do I authenticate?" matches documents about "login flow" and "bearer tokens" because they're semantically related.
Embeddings
Embeddings are numerical representations of text that capture semantic meaning. Similar texts produce similar vectors. The embedding model converts both your documents (during indexing) and queries (at search time) into the same vector space.
// Conceptual view of embeddings
embed("How do I reset my password?") → [0.12, -0.34, 0.78, ...] // 1536 dimensions
embed("Password recovery process") → [0.11, -0.32, 0.76, ...] // Very similar!
embed("What's for lunch today?") → [-0.45, 0.67, -0.21, ...] // Very different
// Similarity is measured by cosine distance between vectors
cosineSimilarity(query, doc1) = 0.94 // Highly relevant
cosineSimilarity(query, doc2) = 0.23 // Not relevantChunking
Documents must be split into chunks before embedding. Chunk size matters: too large and you retrieve irrelevant content alongside relevant content. Too small and you lose context that makes the chunk meaningful.
Retrieval
The retrieval step finds the top-K most relevant chunks for a given query. Advanced systems add re-ranking (a second pass that scores results for relevance), metadata filtering (only search documents from a specific source), and hybrid search (combining vector similarity with keyword matching).
Generation
The final step constructs a prompt containing the retrieved context and the user's question, then sends it to the LLM. The system prompt instructs the model to answer only from the provided context and to cite sources.
Step-by-Step Implementation (Node.js)
Here's a complete RAG implementation using OpenAI embeddings and Pinecone as the vector database:
Step 1: Document Indexing
import OpenAI from 'openai';
import { Pinecone } from '@pinecone-database/pinecone';
import { readFileSync, readdirSync } from 'fs';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const pinecone = new Pinecone({ apiKey: process.env.PINECONE_API_KEY });
const index = pinecone.index('knowledge-base');
// Chunk documents into smaller pieces
function chunkDocument(text, metadata, chunkSize = 500, overlap = 50) {
const sentences = text.split(/(?<=[.!?])\s+/);
const chunks = [];
let current = [];
let currentLength = 0;
for (const sentence of sentences) {
const words = sentence.split(/\s+/).length;
if (currentLength + words > chunkSize && current.length > 0) {
chunks.push({
text: current.join(' '),
metadata: { ...metadata, chunkIndex: chunks.length }
});
// Keep last few sentences for overlap
const overlapSentences = current.slice(-2);
current = [...overlapSentences, sentence];
currentLength = current.join(' ').split(/\s+/).length;
} else {
current.push(sentence);
currentLength += words;
}
}
if (current.length > 0) {
chunks.push({ text: current.join(' '), metadata: { ...metadata, chunkIndex: chunks.length } });
}
return chunks;
}
// Embed and store chunks
async function indexDocuments(docsDir) {
const files = readdirSync(docsDir).filter(f => f.endsWith('.md'));
for (const file of files) {
const content = readFileSync(`${docsDir}/${file}`, 'utf-8');
const chunks = chunkDocument(content, { source: file, indexed: new Date().toISOString() });
// Batch embed (max 2048 inputs per request)
const embedResponse = await openai.embeddings.create({
model: "text-embedding-3-small",
input: chunks.map(c => c.text)
});
// Upsert to Pinecone
const vectors = chunks.map((chunk, i) => ({
id: `${file}-chunk-${i}`,
values: embedResponse.data[i].embedding,
metadata: { ...chunk.metadata, text: chunk.text }
}));
await index.upsert(vectors);
console.log(`Indexed ${chunks.length} chunks from ${file}`);
}
}Step 2: Query and Retrieval
async function retrieveContext(question, topK = 5) {
// Embed the question
const queryEmbedding = await openai.embeddings.create({
model: "text-embedding-3-small",
input: question
});
// Search vector database
const results = await index.query({
vector: queryEmbedding.data[0].embedding,
topK,
includeMetadata: true
});
// Return chunks with relevance scores
return results.matches.map(match => ({
text: match.metadata.text,
source: match.metadata.source,
score: match.score
}));
}Step 3: Augmented Generation
async function ragQuery(question) {
// Retrieve relevant context
const context = await retrieveContext(question);
// Build augmented prompt
const contextText = context
.map((c, i) => `[Source ${i + 1}: ${c.source}]\n${c.text}`)
.join('\n\n---\n\n');
// Generate answer grounded in retrieved documents
const response = await openai.chat.completions.create({
model: "gpt-4o",
temperature: 0.2,
messages: [
{
role: "system",
content: `You are a helpful assistant that answers questions based on the provided context documents.
Rules:
- Answer ONLY based on the provided context
- If the context doesn't contain the answer, say "I don't have information about that in my knowledge base"
- Cite sources using [Source N] format
- Be concise and direct
- Never make up information not present in the context`
},
{
role: "user",
content: `Context Documents:\n\n${contextText}\n\n---\n\nQuestion: ${question}`
}
]
});
return {
answer: response.choices[0].message.content,
sources: context.map(c => c.source),
relevanceScores: context.map(c => c.score)
};
}
// Usage
const result = await ragQuery("How do I configure SSO for our application?");
console.log(result.answer); // Grounded answer citing specific docs
console.log(result.sources); // ["sso-setup-guide.md", "auth-config.md"]Alternative: Using pgvector (PostgreSQL)
-- Setup pgvector extension CREATE EXTENSION IF NOT EXISTS vector; -- Create table for document chunks CREATE TABLE document_chunks ( id SERIAL PRIMARY KEY, content TEXT NOT NULL, source VARCHAR(255), chunk_index INTEGER, embedding vector(1536), -- OpenAI embedding dimensions created_at TIMESTAMP DEFAULT NOW() ); -- Create index for fast similarity search CREATE INDEX ON document_chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100); -- Query: find top 5 most similar chunks SELECT content, source, 1 - (embedding <=> $1::vector) as similarity FROM document_chunks WHERE 1 - (embedding <=> $1::vector) > 0.7 -- minimum threshold ORDER BY embedding <=> $1::vector LIMIT 5;
Chunking Strategies
How you split documents has a massive impact on retrieval quality. There's no one-size-fits-all approach — the best strategy depends on your content type.
Fixed-Size Chunking
Split text every N tokens/words with overlap between chunks. Simple to implement and works reasonably well for uniform content like documentation.
// Fixed-size chunking with overlap
function fixedChunk(text, size = 400, overlap = 50) {
const words = text.split(/\s+/);
const chunks = [];
for (let i = 0; i < words.length; i += size - overlap) {
chunks.push(words.slice(i, i + size).join(' '));
}
return chunks;
}
// Pro: Simple, predictable chunk sizes
// Con: May split mid-sentence or mid-conceptSemantic Chunking
Split at natural boundaries — paragraphs, sections, headers. Each chunk represents a complete thought or concept. Better for structured content with clear sections.
// Semantic chunking: split by markdown headers
function semanticChunk(markdown) {
const sections = markdown.split(/^#{1,3}\s+/m);
return sections
.filter(s => s.trim().length > 50) // Skip tiny fragments
.map(section => {
// Include the header in the chunk for context
const lines = section.split('\n');
return lines.join('\n').trim();
});
}
// Pro: Preserves complete ideas and context
// Con: Variable chunk sizes, some chunks may be too largeRecursive Chunking
Start with large separators (double newlines, headers), then recursively split any chunk that's still too large using progressively smaller separators (single newlines, sentences, words). This is what LangChain's RecursiveCharacterTextSplitter uses.
// Recursive chunking: progressively split large chunks
function recursiveChunk(text, maxSize = 500, separators = ['\n\n', '\n', '. ', ' ']) {
if (text.split(/\s+/).length <= maxSize) return [text];
const separator = separators[0];
const parts = text.split(separator);
const chunks = [];
let current = '';
for (const part of parts) {
if ((current + part).split(/\s+/).length > maxSize) {
if (current) chunks.push(current.trim());
// Recursively split if still too large
if (part.split(/\s+/).length > maxSize && separators.length > 1) {
chunks.push(...recursiveChunk(part, maxSize, separators.slice(1)));
} else {
current = part;
}
} else {
current += separator + part;
}
}
if (current.trim()) chunks.push(current.trim());
return chunks;
}
// Pro: Best balance of preserving context and controlling size
// Con: More complex implementationVector Databases Compared
Choosing a vector database depends on your scale, infrastructure, and team preferences:
| Database | Type | Best For | Pricing | Key Feature |
|---|---|---|---|---|
| Pinecone | Managed cloud | Fast prototyping, production SaaS | Free tier + pay per use | Serverless, zero ops |
| Weaviate | Self-hosted / cloud | Complex queries, hybrid search | Open-source + cloud option | GraphQL API, modules |
| pgvector | PostgreSQL extension | Teams already using Postgres | Free (part of your DB) | No new infrastructure |
| Qdrant | Self-hosted / cloud | High performance, filtering | Open-source + cloud | Rust-based, fast |
| ChromaDB | Embedded / self-hosted | Local development, small scale | Free, open-source | Simple API, Python-native |
| Milvus | Self-hosted / cloud | Large-scale enterprise | Open-source + Zilliz cloud | Billion-vector scale |
My recommendation for most teams: start with pgvector if you already use PostgreSQL (zero new infrastructure). Move to Pinecone or Qdrant when you hit scale limits or need specialized features like hybrid search or advanced filtering. Use ChromaDB for local prototyping and testing.
RAG Evaluation Metrics
Building a RAG system is step one. Knowing whether it actually works well requires measurement. Here are the key metrics:
Retrieval Precision
What percentage of retrieved chunks are actually relevant to the question?
relevant_retrieved / total_retrieved
Target: > 0.8 (80% of retrieved docs should be relevant)
Retrieval Recall
What percentage of all relevant documents were actually retrieved?
relevant_retrieved / total_relevant
Target: > 0.7 (find at least 70% of relevant docs)
Faithfulness
Does the generated answer only use information from the retrieved context? No hallucinated facts?
claims_supported_by_context / total_claims
Target: > 0.9 (90%+ of claims should be grounded)
Answer Relevance
Does the generated answer actually address the user's question?
LLM-judged relevance score (0-1)
Target: > 0.85
Context Utilization
How much of the retrieved context is actually used in the answer?
context_sentences_used / total_context_sentences
Target: > 0.5 (avoid retrieving irrelevant chunks)
Tools like RAGAS, DeepEval, and LangSmith can automate these evaluations. Start by building a test set of 50-100 question/answer pairs with known correct sources, then measure how your system performs against them.
Common RAG Mistakes
❌ Chunks too large or too small
Large chunks (2000+ words) retrieve irrelevant content alongside relevant content. Tiny chunks (50 words) lose context needed to understand the content.
✓ Fix: Start with 300-500 words per chunk with 50-word overlap. Adjust based on retrieval precision metrics.
❌ No overlap between chunks
Without overlap, information at chunk boundaries gets split across two chunks and neither chunk has the full context.
✓ Fix: Use 10-20% overlap between consecutive chunks. This ensures boundary content appears in at least one complete chunk.
❌ Retrieving too few or too many chunks
Too few (1-2) risks missing relevant information. Too many (20+) floods the context with noise and overwhelms the model.
✓ Fix: Start with 5 chunks. Add a re-ranking step to filter the top results by relevance score before passing to the LLM.
❌ No metadata filtering
Searching the entire knowledge base for every query when you could narrow results by source, date, or category first.
✓ Fix: Store metadata (source, date, category, access level) with each chunk. Filter by metadata before semantic search.
❌ Using the wrong embedding model for your content
Generic embedding models may not capture domain-specific nuances. Code, legal text, and medical content have different semantic structures.
✓ Fix: Test multiple embedding models on your specific content. Consider domain-specific models for specialized applications.
❌ Not handling 'I don't know' cases
When retrieved context doesn't actually answer the question, the model may hallucinate an answer anyway rather than admitting uncertainty.
✓ Fix: Set a minimum relevance score threshold. If no results pass (e.g., score < 0.7), return 'I don't have information about that' instead of generating.
Best Practices
Start simple — basic chunking + embeddings + top-5 retrieval works surprisingly well for most use cases
Add overlap between chunks (10-20%) to avoid losing information at boundaries
Use hybrid search (vector + keyword) for better recall on exact terms, names, and IDs
Implement a re-ranking step to filter retrieved chunks before passing to the LLM
Store source metadata and return citations — users need to verify AI-generated answers
Set relevance score thresholds and handle low-confidence queries gracefully
Build an evaluation dataset early — you can't improve what you don't measure
Keep your indexing pipeline idempotent so you can re-index without duplicates when documents change
Frequently Asked Questions
What is RAG in AI?
RAG (Retrieval-Augmented Generation) combines information retrieval with text generation. Instead of relying solely on training data, the model retrieves relevant documents from an external knowledge base and uses them to generate grounded, accurate responses.
When should I use RAG vs fine-tuning?
Use RAG when you need up-to-date information, have frequently changing data, or want source attribution. Use fine-tuning when you need to change the model's behavior, tone, or teach it specialized reasoning patterns that don't change often.
What is a vector database?
A vector database stores data as high-dimensional numerical vectors (embeddings) and enables fast similarity search. It returns semantically similar documents even if they don't share exact keywords with the query.
How do embeddings work in RAG?
Embeddings convert text into numerical vectors that capture semantic meaning. Both documents and queries are embedded into the same space, allowing similarity search to find relevant content regardless of exact wording.
What is chunking in RAG?
Chunking splits documents into smaller pieces before embedding. Good chunking ensures each piece is self-contained and meaningful. Common strategies include fixed-size with overlap, semantic chunking by section, and recursive splitting.
How many chunks should I retrieve for a RAG query?
Typically 3-10 chunks work well. Start with 5 and adjust based on answer quality. Fewer chunks mean higher relevance concentration; more chunks provide broader coverage but risk diluting quality.
Can RAG eliminate hallucinations completely?
RAG significantly reduces hallucinations by grounding responses in real documents, but doesn't eliminate them entirely. The model can still misinterpret retrieved content. Evaluation metrics like faithfulness help measure grounding quality.
What embedding model should I use for RAG?
OpenAI's text-embedding-3-small offers good quality-to-cost ratio for most applications. For higher quality, use text-embedding-3-large. Open-source alternatives like sentence-transformers work well for self-hosted deployments.
Related Articles
AI Context Windows Explained
Why token limits matter and how to manage them
Prompt Engineering for Developers
Practical techniques that work in production
How AI Coding Agents Work
Architecture, workflow & future of AI programming tools
AI Agents vs AI Workflows
When to use autonomous agents vs structured pipelines
Conclusion
RAG is the most practical way to build AI applications that are grounded in facts rather than trained guesses. The architecture is straightforward: embed your documents, store them in a vector database, retrieve relevant chunks at query time, and let the LLM generate answers from real source material.
The key insight is that RAG separates what the model knows (reasoning ability, language understanding) from what it can access (your specific knowledge base). This means you get the power of a frontier LLM applied to your own data without expensive fine-tuning or hoping the model memorized your docs during training.
Start simple. Basic chunking with OpenAI embeddings and a vector database handles most use cases. Add complexity — re-ranking, hybrid search, metadata filtering — only when your evaluation metrics show you need it. And always measure: a RAG system without evaluation is just a more expensive search engine.
