Building Your First RAG (Retrieval-Augmented Generation) App
Posted on Wed 19 August 2026 in GenAI
LLMs are trained on static data. Ask one about your internal docs, last week's meeting notes, or a product spec — it has no idea. RAG solves this by fetching relevant content at query time and injecting it into the prompt. The model still does the reasoning; you just give it the right context first.
How RAG works
The pipeline has two phases:
Indexing (offline) — Load your documents, split them into chunks, convert each chunk into a vector embedding, and store those vectors in a vector database.
Retrieval + generation (online) — When a user asks a question, convert the question into an embedding, find the closest-matching chunks in the vector DB, shove those chunks into the prompt alongside the question, and let the LLM answer.
That's it. The LLM never "learns" your docs — it reads them fresh on every query.
Dependencies
pip install langchain langchain-community langchain-openai \
chromadb tiktoken
Step 1 — Load your documents
LangChain ships with loaders for almost every format. For a plain text file:
from langchain_community.document_loaders import TextLoader
loader = TextLoader("docs/handbook.txt")
docs = loader.load()
For a folder of files:
from langchain_community.document_loaders import DirectoryLoader
loader = DirectoryLoader("docs/", glob="**/*.txt")
docs = loader.load()
Each loaded item is a Document with .page_content (the text) and .metadata (source path, page number, etc.).
Step 2 — Split into chunks
Embeddings work best on short, focused passages. Long documents get split up:
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50
)
chunks = splitter.split_documents(docs)
chunk_overlap keeps a window of context at chunk boundaries. Tune chunk_size based on what you're embedding — 500 characters is a reasonable starting point for dense technical text.
Step 3 — Embed and store
Convert chunks to vectors and store them in Chroma (a local vector DB):
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
embeddings = OpenAIEmbeddings()
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory="./chroma_db"
)
persist_directory saves the index to disk so you don't re-embed on every run. Drop it if you want an in-memory store that resets each session.
Step 4 — Build the retrieval chain
LangChain's RetrievalQA chain wraps the retriever and the LLM together:
from langchain_openai import ChatOpenAI
from langchain.chains import RetrievalQA
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
retriever = vectorstore.as_retriever(search_kwargs={"k": 4})
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=retriever,
return_source_documents=True
)
k=4 means retrieve the 4 most similar chunks per query. chain_type="stuff" concatenates them into the prompt. For larger doc sets, map_reduce or refine handle overflow better.
Step 5 — Query it
result = qa_chain.invoke({"query": "What is the refund policy?"})
print(result["result"])
print("\nSources:")
for doc in result["source_documents"]:
print(doc.metadata["source"])
The source_documents field shows exactly which chunks the model drew from — useful for debugging and for building citation UIs.
Full pipeline
import os
from langchain_community.document_loaders import DirectoryLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import Chroma
from langchain.chains import RetrievalQA
os.environ["OPENAI_API_KEY"] = "your-key-here"
# Load
loader = DirectoryLoader("docs/", glob="**/*.txt")
docs = loader.load()
# Split
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = splitter.split_documents(docs)
# Embed + store
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=OpenAIEmbeddings(),
persist_directory="./chroma_db"
)
# Chain
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
retriever = vectorstore.as_retriever(search_kwargs={"k": 4})
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=retriever,
return_source_documents=True
)
# Query
result = qa_chain.invoke({"query": "Summarize the onboarding process"})
print(result["result"])
Common failure modes
Bad retrieval — The right answer exists in your docs but the LLM gives a wrong or vague response. Usually means your chunks are too large or your embeddings aren't granular enough. Shrink chunk_size and re-index.
Hallucination despite good retrieval — The LLM drifts from the retrieved context. Add a system prompt that says: "Answer only using the provided context. If the context doesn't contain the answer, say so."
Slow indexing — Embedding thousands of chunks hits API rate limits fast. Batch your calls, add retry logic, or switch to a local embedding model like sentence-transformers.
Going further
Once the basic pipeline works, two things make the biggest difference in real apps:
Metadata filtering — Tag chunks with source, date, or category at index time, then filter by those fields at retrieval time. Keeps queries scoped and cuts noise.
Hybrid search — Combine vector similarity with keyword search (BM25). Pure vector search misses exact-match queries; hybrid search handles both.