RAG Explained: How to Give Your LLM a Memory It Can Actually Trust
You're building a chatbot for a company's internal documentation - hundreds of pages of engineering specs, HR policies, product guides. You plug in ChatGPT, write a clean system prompt, launch a demo. Someone asks "What's the reimbursement limit for travel expenses?" and the model confidently answers with a number.
The number is wrong. It's not from your docs - it's from the model's general training data, some pattern absorbed from the internet years ago that roughly sounds like a reasonable reimbursement policy.
This is the fundamental problem with raw LLMs in production: they don't know your data. They know what was in their training set - a snapshot of text from months or years ago, with no awareness of your internal docs, your product specs, your latest pricing, your customer records, or anything else that lives inside your organization. And when they don't know something, they don't say "I don't know" - they say something plausible-sounding that might be completely fabricated.
The fix has a name: Retrieval-Augmented Generation, almost universally abbreviated as RAG. The idea is elegant: before asking the model to answer a question, find the relevant pieces of your actual data and hand them to the model as context. Now, instead of guessing, the model is summarizing and reasoning over the real information you provided.
The gap between "RAG sounds simple" and "RAG actually works reliably in production" is where most teams spend weeks of engineering time. Understanding the full pipeline - every stage, every design decision, every place it quietly breaks - is what this article is about.
What We'll Cover
- The RAG Mental Model - Why It Works at All
- The Full RAG Development Pipeline (Both Phases)
- Document Loaders - Getting Your Data In
- Text Splitting and Chunking - The Stage Everyone Underestimates
- Embeddings and Vector Stores - Where Your Data Lives at Query Time
- Retrievers - Finding the Right Chunks When It Matters
- Putting It All Together - A Working RAG Chain
- Where RAG Breaks and How to Diagnose It
Chapter 1: The RAG Mental Model - Why It Works at All
Before code, you need the right mental model, because RAG is counterintuitive until it clicks.
Think about how you answer questions as a consultant. If someone asks you a question about a client's contract terms, you don't try to recall everything from memory - you pull up the contract, skim for the relevant section and answer based on what's in front of you. You're not generating from memory; you're reading and summarizing from a source.
RAG turns an LLM into that consultant. You build a system that:
- Ingests your documents (PDFs, web pages, databases, whatever) and stores them in a searchable format.
- When a user asks a question, retrieves the most relevant pieces of those documents.
- Augments the model's prompt with those retrieved pieces.
- Lets the model generate an answer based on the context you just handed it.
The LLM's role shifts from "know everything" to "read what I gave you and reason over it." That's a job LLMs are genuinely very good at.
Without RAG: User question โ LLM (guesses from training data) โ Response (possibly hallucinated)
With RAG: User question โ Retrieve relevant docs โ LLM (reads your actual docs) โ Grounded response
This is why RAG is the dominant pattern for enterprise AI applications. It doesn't eliminate the LLM's tendency to hallucinate - but it gives the model true ground to stand on, and makes it much easier to audit: if the answer is wrong, you can trace it back to exactly which chunk was retrieved and why.
The full pipeline has two distinct phases that most beginners conflate into one. Let's separate them.
Chapter 2: The Full RAG Development Pipeline - Two Phases
Almost every RAG confusion I've seen in teams comes from mixing up what happens before a user ever sends a message versus what happens when they do. These are two fundamentally different processes with different triggers, different costs, and different failure modes.
Phase 1 - Indexing (Offline, Runs Once)
This is the preparation phase. You run it ahead of time - sometimes once, sometimes on a schedule, sometimes triggered by new document uploads. It does not happen in response to user queries.
[ Your Raw Documents ]
โ
โผ
[ Document Loader ] โ Pull documents in from wherever they live
โ
โผ
[ Text Splitter ] โ Break documents into manageable chunks
โ
โผ
[ Embedding Model ] โ Convert each chunk into a numeric vector
โ
โผ
[ Vector Store ] โ Store vectors + original text in a searchable database
The result: a vector store that contains your entire knowledge base in a format where "find me content similar to this question" is a fast, cheap operation.
Phase 2 - Retrieval + Generation (Online, Runs Per Query)
This is the live path. It runs every time a user sends a message.
[ User Question ]
โ
โผ
[ Embedding Model ] โ Convert the question into a vector
โ
โผ
[ Vector Store Retriever ] โ Find the chunks most similar to the question
โ
โผ
[ Prompt Template ] โ Inject retrieved chunks + question into a prompt
โ
โผ
[ LLM ] โ Generates answer grounded in the retrieved context
โ
โผ
[ Response to User ]
These two phases have completely different performance characteristics. Indexing is slow and expensive per document but runs offline. Retrieval + generation must be fast - it's the live user experience.
Every design decision in RAG falls into one of these two phases. Keep this separation in mind as we go through each component.
Chapter 3: Document Loaders - Getting Your Data In
The first problem in any real RAG system is always the same: your data doesn't live in a clean text file. It lives in PDFs, Word documents, Notion pages, Confluence wikis, Google Drive folders, databases, Slack threads, web pages, and half a dozen other formats - each requiring different handling to extract usable text.
Document loaders in LangChain are the abstraction that handles this - they give you a consistent load() interface regardless of the source, returning a list of Document objects (each containing a page_content string and a metadata dict).
from langchain_community.document_loaders import (
PyPDFLoader, # PDFs - extracts text page by page
WebBaseLoader, # Web pages - fetches and strips HTML
TextLoader, # Plain .txt files
CSVLoader, # Spreadsheets - each row becomes a Document
UnstructuredWordDocumentLoader, # .docx files
NotionDirectoryLoader, # Exported Notion workspace
)
# Every loader shares the same interface - .load() returns List[Document]
# Load a PDF - returns one Document per page, with page number in metadata
pdf_loader = PyPDFLoader("engineering_spec.pdf")
pdf_docs = pdf_loader.load()
# pdf_docs[0].page_content โ "Chapter 1: System Architecture..."
# pdf_docs[0].metadata โ {"source": "engineering_spec.pdf", "page": 0}
# Load a web page - strips HTML, extracts main text content
web_loader = WebBaseLoader("https://docs.mycompany.com/api-reference")
web_docs = web_loader.load()
# Load a CSV - useful for structured data like FAQs or product catalogs
csv_loader = CSVLoader("product_faq.csv", source_column="url")
csv_docs = csv_loader.load()
The metadata dict is not an afterthought - it's critical for production systems. Every document you ingest should carry metadata: its source URL, file name, creation date, author, document type, whatever is relevant. You'll use this metadata later for filtering ("only retrieve from documents tagged HR policy") and for citations in your responses ("according to engineering_spec.pdf, page 12").
# Always inspect what your loader actually produced before moving on.
# Loaders fail silently in surprising ways - PDFs with scanned images return empty
# page_content strings; HTML with JavaScript-rendered content may come back blank.
for doc in pdf_docs[:3]:
print(f"Content length: {len(doc.page_content)} chars")
print(f"Metadata: {doc.metadata}")
print(f"Preview: {doc.page_content[:200]}")
print("---")
โ ๏ธ Heads up: PDFs are the most common source and the most problematic. Scanned PDFs (images of text rather than actual text) will return empty strings with most loaders - you'll need OCR processing before the text is even extractable. Always validate that your loader is actually getting text, not silence.
Chapter 4: Text Splitting and Chunking - The Stage Everyone Underestimates
Here's where most RAG beginners make their biggest architectural mistake, because this stage looks trivial and isn't.
You have your documents loaded. They might be entire 80-page PDFs or multi-thousand-word web pages. You can't stuff all of that into a prompt - LLMs have context limits, and even if you could, you don't want to. You want to retrieve the specific relevant section, not an entire document.
So you split documents into chunks - smaller pieces of text that can be independently retrieved and included in a prompt. The decisions you make here - how big each chunk is, how they overlap, how they respect the document's natural structure - have a bigger impact on RAG quality than almost any other single variable.
Chunk Size: The Core Trade-off
This is a genuine tension with no universal right answer:
- Too small (e.g., 100 tokens): Each chunk has very little context. You might retrieve a sentence that perfectly matches the query but doesn't contain enough surrounding information to answer it meaningfully. The model gets fragments.
- Too large (e.g., 2000 tokens): Each chunk contains more context, but semantic similarity search becomes less precise - a chunk about three different topics might score as a moderate match for many queries rather than a strong match for any. You also use more of your LLM's context window per retrieved chunk.
- Sweet spot in practice: 200-500 tokens per chunk for most knowledge-base use cases, with experimentation required for your specific content type and query patterns.
Chunk Overlap: Handling Boundary Problems
When you split at token/character boundaries, you'll slice through sentences, sometimes mid-thought. The sentence that contains the key information might end up half in chunk 12 and half in chunk 13, and neither chunk retrieves well. Overlap addresses this by repeating the last N tokens of each chunk at the start of the next.
from langchain.text_splitter import RecursiveCharacterTextSplitter
# The workhorse of LangChain splitting - tries to split on natural boundaries
# first (paragraphs โ sentences โ words) before falling back to raw characters.
splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # Target max characters per chunk
chunk_overlap=50, # Last 50 chars of chunk N repeated at start of chunk N+1
separators=["\n\n", "\n", ".", " ", ""] # Try these in order
)
chunks = splitter.split_documents(pdf_docs)
print(f"Original: {len(pdf_docs)} pages")
print(f"After splitting: {len(chunks)} chunks")
# Good habit: sanity-check your chunks before continuing
for i, chunk in enumerate(chunks[:3]):
print(f"\n--- Chunk {i} ({len(chunk.page_content)} chars) ---")
print(chunk.page_content)
Splitting Strategies: Not All Text Is Flat Prose
RecursiveCharacterTextSplitter is the general-purpose default, but different content types benefit from different strategies:
from langchain.text_splitter import (
RecursiveCharacterTextSplitter, # General prose - paragraphs, articles, docs
MarkdownHeaderTextSplitter, # Markdown - splits on headings, preserves structure
Language, # Code - splits on functions/classes, not mid-line
)
# For technical documentation in Markdown - splits on headers rather than character count
md_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=[
("#", "h1"),
("##", "h2"),
("###", "h3"),
]
)
# Each resulting chunk carries header metadata - great for citations later
# For code repositories - respects function/class boundaries
code_splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.PYTHON,
chunk_size=1000,
chunk_overlap=100,
)
๐ก Pro tip: Add the source metadata to every chunk explicitly - once you've split 200 documents into 5,000 chunks, you need to know which chunk came from where. LangChain's splitters preserve metadata from the parent Document, but always verify.
Chapter 5: Embeddings and Vector Stores - Where Your Data Lives
You now have thousands of text chunks. The next problem: how do you find the chunks relevant to a specific user question, fast, at query time? The answer is semantic search, and it works through embeddings.
What Embeddings Actually Are
Remember from the first article: each chunk of text can be converted into a vector - a list of hundreds or thousands of numbers - that represents its meaning in mathematical space. This conversion is done by an embedding model (a different, smaller model than your LLM - its only job is text โ vector).
The key property: semantically similar text produces mathematically similar vectors. "How do I reset my password?" and "Steps to change my account credentials" are worded completely differently but mean similar things - their embedding vectors will be close together in vector space, close enough to find each other via similarity search.
This is why RAG can retrieve relevant content even when the user's question uses completely different words from your documentation. You're not searching for matching keywords; you're searching for matching meaning.
from langchain_openai import OpenAIEmbeddings
from langchain_community.embeddings import HuggingFaceEmbeddings
# OpenAI's embedding model - cloud API, very good quality
openai_embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# Or a local HuggingFace model - free, runs on your hardware, great for cost-sensitive
# or privacy-sensitive use cases (your documents never leave your infrastructure)
local_embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
# What actually happens under the hood (you usually don't call this directly)
Comments
No comments yet. Start the discussion.