RAG Systems —
From 0 to Production
RAG (Retrieval Augmented Generation) is the way to turn an LLM into a tool that answers questions based on your documents. This is how you build a Chatbot that knows everything about your company — and doesn't make up answers.
What is RAG and why do you need it?
LLMs like GPT-4 and Claude were trained on general information from the internet up to a certain cut-off date. They know nothing about your company's internal documents, your current procedures, or your support knowledge base — and that's why they "make up" answers when they don't know, a phenomenon called Hallucination.
RAG solves this problem: instead of relying on the model's trained knowledge, we retrieve the relevant information from our documents in real time, insert it into the Prompt, and ask the model to answer based on that information only. The result is a system that answers questions accurately, based on sources, and up to date.
- •The model doesn't know about company documents
- •Hallucinations — made-up answers
- •Knowledge frozen at the training date
- •No sources to verify
- •Based precisely on your documents
- •Sources for every answer
- •As up to date as your Index
- •"I don't know" instead of a guess
The RAG Pipeline has two main stages:
Load documents ← split into chunks ← create Embeddings ← store in a Vector DB
Question ← create an Embedding ← search for similarity ← get context ← generate an answer
Full architecture
Before we get to the code, it's important to understand all the components and how they connect. The following diagram shows the full Pipeline — from the document-loading stage to generating the final answer for the user.
[Documents: PDF / Word / Web / CSV]
|
v
[Document Loader] <-- LangChain loaders
|
v
[Raw Text]
|
v
[Text Splitter] <-- RecursiveCharacterTextSplitter
(chunks ~1000 chars)
|
v
[Chunks]
|
v
[Embedding Model] <-- OpenAI / HuggingFace
(each chunk → vector)
|
v
[Vector Store (Index)] <-- Pinecone / Chroma / FAISS
============ Online Phase ============
[User Question]
|
v
[Same Embedding Model]
|
v
[Query Vector]
|
v
[Similarity Search top-k] <-- cosine similarity
|
v
[Relevant Chunks (context)]
|
v
[Prompt Template] <-- question + context
|
v
[LLM: GPT-4o / Claude]
|
v
[Final Answer + Sources]
Every stage in this architecture is a point you can improve and optimize. Decisions about the chunk size, the chosen Embedding model, and the number of chunks returned (top-k) — all directly affect the quality of the final results.
Document Loaders — loading documents
LangChain comes with dozens of Document Loaders that support various formats. Here are the most common:
pip install langchain langchain-community langchain-openai langchain-pinecone pypdf chromadb sentence-transformers
# install: pip install langchain-community pypdf
from langchain_community.document_loaders import (
PyPDFLoader,
DirectoryLoader,
WebBaseLoader,
CSVLoader,
JSONLoader
)
# a single PDF
loader = PyPDFLoader("company_policy.pdf")
docs = loader.load()
print(f"Loaded {len(docs)} pages")
# a whole folder of PDFs
loader = DirectoryLoader(
"./docs/",
glob="**/*.pdf",
loader_cls=PyPDFLoader
)
docs = loader.load()
print(f"Loaded {len(docs)} pages")
# loading from a URL
loader = WebBaseLoader("https://docs.example.com/api")
docs = loader.load()
# Google Drive (requires OAuth setup)
from langchain_google_community import GoogleDriveLoader
loader = GoogleDriveLoader(
folder_id="your_folder_id",
file_types=["document", "pdf"]
)
docs = loader.load()
Text Splitting — smart chunking
After loading the documents, you need to split them into smaller chunks. This is critical — a chunk that's too big contains too much irrelevant information and raises costs. A chunk that's too small doesn't provide enough context. The recommended values for most cases: chunk_size=1000, chunk_overlap=200.
from langchain.text_splitter import (
RecursiveCharacterTextSplitter,
MarkdownHeaderTextSplitter,
TokenTextSplitter
)
# Recursive — most recommended for most cases
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000, # characters per chunk
chunk_overlap=200, # overlap between chunks — preserves context
separators=["\n\n", "\n", ".", " "] # splitting priority order
)
chunks = splitter.split_documents(docs)
print(f"Created {len(chunks)} chunks")
# by Headers (Markdown) — perfect for technical documentation
headers = [("#", "H1"), ("##", "H2"), ("###", "H3")]
md_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers
)
md_docs = md_splitter.split_text(markdown_text)
# by Tokens — more accurate for calculating API costs
token_splitter = TokenTextSplitter(
chunk_size=250,
chunk_overlap=30
)
token_chunks = token_splitter.split_documents(docs)
Tip: chunk_overlap matters
The overlap ensures information sitting on the boundary of two chunks isn't lost. For texts with long sentences, increase it to 300-400. For code, you can lower it to 50.
Embeddings — the heart of RAG
An Embedding is the conversion of text into a numeric vector (a list of hundreds of numbers). The brilliant idea is that texts with similar meaning get vectors close to each other in space. So when you search "what are the opening hours?", the system finds a chunk with "the service's hours of operation" — even without an exact word match.
There are two main options: OpenAI models that require pay-as-you-go but are excellent in quality, and HuggingFace models that run locally and for free. For Hebrew, a multilingual model is especially recommended.
from langchain_openai import OpenAIEmbeddings
from langchain_community.embeddings import HuggingFaceEmbeddings
import numpy as np
# OpenAI (recommended for quality and accuracy)
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# 1536 dimensions, ~$0.02 per 1M tokens
# text-embedding-3-large — higher quality, 2× more expensive
embeddings_large = OpenAIEmbeddings(model="text-embedding-3-large")
# 3072 dimensions
# HuggingFace (free, runs locally, supports Hebrew!)
embeddings_hf = HuggingFaceEmbeddings(
model_name="sentence-transformers/paraphrase-multilingual-mpnet-base-v2"
)
# 768 dimensions, supports 50+ languages including Hebrew
# checking Cosine Similarity
v1 = embeddings.embed_query("AI Agent")
v2 = embeddings.embed_query("Autonomous AI System")
similarity = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))
print(f"Cosine similarity: {similarity:.3f}") # ~0.85
# a batch Embedding
texts = ["what are the business days?", "how to reset password", "price list"]
vectors = embeddings.embed_documents(texts)
print(f"Shape: {len(vectors)} x {len(vectors[0])}")
Vector Stores & Retrieval
The Vector Store is the database that stores the vectors and enables fast search for the most similar vectors. For local development Chroma is recommended; for production, Pinecone is the most common choice on the market.
from langchain_pinecone import PineconeVectorStore
from langchain_community.vectorstores import Chroma
import os
# === Pinecone (Production) ===
os.environ["PINECONE_API_KEY"] = "your-api-key"
vectorstore = PineconeVectorStore.from_documents(
documents=chunks,
embedding=embeddings,
index_name="my-rag-index",
namespace="v1"
)
# connecting to an existing index
vectorstore = PineconeVectorStore(
index_name="my-rag-index",
embedding=embeddings,
namespace="v1"
)
# === Chroma (Development / Local) ===
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory="./chroma_db"
)
# loading from disk
vectorstore = Chroma(
persist_directory="./chroma_db",
embedding_function=embeddings
)
# === Retriever ===
retriever = vectorstore.as_retriever(
search_type="similarity", # similarity / mmr / similarity_score_threshold
search_kwargs={"k": 4} # number of chunks to return
)
# MMR — diversifying results (prevents duplicates)
retriever_mmr = vectorstore.as_retriever(
search_type="mmr",
search_kwargs={"k": 6, "fetch_k": 20, "lambda_mult": 0.5}
)
# manual search
results = vectorstore.similarity_search("what is the return policy?", k=3)
for doc in results:
print(doc.page_content[:200])
print(f"Source: {doc.metadata.get('source', 'N/A')}")
MMR vs Similarity Search
Similarity Search returns the k most similar — but sometimes they're nearly identical. MMR (Maximal Marginal Relevance) balances relevance and diversity, so the results cover more angles of the topic.
Full RAG Chain — everything together
Now we connect all the components. The Prompt Template is one of the most important things — clear instructions to the LLM on what to do with the context we provided, and especially the instruction to say "I don't know" when the information doesn't exist.
from langchain_openai import ChatOpenAI
from langchain.chains import RetrievalQA
from langchain_core.prompts import PromptTemplate
# Prompt Template — the Prompt that defines how the model behaves
TEMPLATE = """Use the following pieces of Context to answer the question at the end.
If you don't know the answer, say "I don't know" — don't make up answers.
Always answer clearly and in an organized way.
Context:
{context}
Question: {question}
Answer:"""
prompt = PromptTemplate(
template=TEMPLATE,
input_variables=["context", "question"]
)
# LLM
llm = ChatOpenAI(
model="gpt-4o",
temperature=0 # 0 = consistent and deterministic
)
# RAG Chain
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff", # stuff = puts everything into the prompt
retriever=retriever,
return_source_documents=True, # returns sources
chain_type_kwargs={"prompt": prompt}
)
# question!
result = qa_chain.invoke({"query": "what is our return policy?"})
print(result["result"])
print("\nSources:")
for doc in result["source_documents"]:
print(f" - {doc.metadata.get('source', 'unknown')}")
A version with LCEL (LangChain Expression Language)
LCEL is the modern approach in LangChain — composing components with the pipe operator. Flexible and recommended for new projects.
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
# LCEL chain — readable and clean
rag_chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
# question
answer = rag_chain.invoke("what does the contract say about cancellation?")
print(answer)
# Streaming
for chunk in rag_chain.stream("explain the terms of service"):
print(chunk, end="", flush=True)
Advanced RAG — improving performance
Basic RAG reaches good quality, but there are several advanced techniques that can significantly improve accuracy, especially when the corpus is large or the questions are complex.
Reranking — re-sorting by relevance
Similarity Search finds chunks that are close vectorially, but not always the most relevant to the specific question. A Reranker is a separate model that re-ranks the results by true relevance — and improves recall by 20-40%.
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
# Cross-Encoder Reranker — ranks results by relevance
model = HuggingFaceCrossEncoder(
model_name="cross-encoder/ms-marco-MiniLM-L-6-v2"
)
compressor = CrossEncoderReranker(model=model, top_n=3)
# Retriever with Reranking
compression_retriever = ContextualCompressionRetriever(
base_compressor=compressor,
base_retriever=retriever
)
# usage
docs = compression_retriever.invoke("what are the subscription costs?")
print(f"Got {len(docs)} reranked docs")
HyDE — Hypothetical Document Embeddings
A clever idea: instead of searching by the question's vector, the LLM first generates a hypothetical answer, and then we search by that answer's vector. A question and a document are usually not similar vectorially, but two answers to the same topic are. This significantly improves recall.
from langchain.chains import HypotheticalDocumentEmbedder
from langchain_core.prompts import PromptTemplate
# a Prompt for generating a hypothetical document
hyde_prompt = PromptTemplate.from_template(
"Write a detailed paragraph that would answer: {question}"
)
# HyDE Retriever
hyde_embeddings = HypotheticalDocumentEmbedder.from_llm(
llm=llm,
base_embeddings=embeddings,
custom_instructions=hyde_prompt
)
# integration into the vectorstore
hyde_retriever = vectorstore.as_retriever(
search_kwargs={"k": 4}
)
# for search, use hyde_embeddings.embed_query
Multi-Query Retriever — diversifying the questions
The LLM generates several variations of the original question, searches for each of them, and merges the results. Significantly increases recall for complex questions.
from langchain.retrievers.multi_query import MultiQueryRetriever
import logging
logging.basicConfig()
logging.getLogger("langchain.retrievers.multi_query").setLevel(logging.INFO)
multi_retriever = MultiQueryRetriever.from_llm(
retriever=vectorstore.as_retriever(),
llm=llm
)
# generates 3 variations of the question and merges results
unique_docs = multi_retriever.invoke("what are my rights as a customer?")
Production Checklist
Before moving a RAG system to production, there are critical topics you must address. From experience — most production problems stem from four areas: quality evaluation, monitoring, costs and security.
Evaluation — quality assessment
- •RAGAS — an automatic evaluation framework: Faithfulness, Answer Relevancy, Context Recall
- •LangSmith — conversation monitoring, tracing, A/B testing of prompts
- •Build a test question set before go-live
Monitoring — ongoing monitoring
- •Measure Query latency (p50, p95, p99)
- •Track Retrieval quality — are the sources correct?
- •Feedback loop — let users rate answers
Cost Optimization
- •GPTCache — a Cache for repeated questions (saves 60-80%)
- •Calculate tokens per query × price × questions/day
- •Consider gpt-4o-mini for simple cases
Security
- •Don't store PII (personal information) in a Vector DB without encryption
- •Protection against Prompt Injection — validate user input
- •namespace by permissions — each user sees only what they're allowed to
Tips from experience
Metadata Filtering: Add metadata to every chunk (source, date, department) and allow filtering by them. For example: "show only documents from 2024" or "only the HR department".
Incremental Indexing: Don't rebuild the entire index on every update. Track last_modified and update only chunks that changed.
Parent-Child Chunking: Keep chunks small for Embedding, but when you find a match — return the larger parent chunk to the LLM for full context.
Chain of Thought in answers: Add "think step by step before you answer" to the instruction. Increases accuracy by 15-25% for complex questions that require inference.
Summary — what's next?
You've built a full RAG system — from loading documents, through chunking and embedding, to retrieval and generation. This is the foundation on which hundreds of AI products are built today. The next step is to connect the RAG to an Agent that can take actions based on what it found — so we recommend continuing to the AI Agents guide.
A quick summary — architecture decisions
| Topic | Development | Production |
|---|---|---|
| Vector Store | Chroma (local) | Pinecone / pgvector |
| Embedding | multilingual-mpnet | text-embedding-3-small |
| LLM | gpt-4o-mini | gpt-4o / Claude |
| Chunk size | 1000 / overlap 200 | Tailored to the content |
| Retrieval | similarity k=4 | MMR + Reranking |
Useful links
A framework for LLM applications — Chains, Agents, LCEL and LangSmith
A Vector Database for production — Serverless, Namespaces, Metadata Filtering
ReAct Loop, CrewAI, LangGraph — RAG as part of an Agentic system
Official documentation and resources
Ready to build a real RAG system?
Join the AI Agents Development track — a full course teaching LangChain, RAG, CrewAI and LangGraph with 5 real business projects.