Home chevron_left Guides chevron_left RAG Systems
database AI architecture

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.

schedule 45 min read
code Python
layers LangChain + Pinecone
signal_cellular_alt Developers

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.

cancel Without RAG
  • The model doesn't know about company documents
  • Hallucinations — made-up answers
  • Knowledge frozen at the training date
  • No sources to verify
check_circle With RAG
  • 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 in a nutshell
Question → [Retrieve relevant docs] → [Augment prompt with docs] → [Generate answer]

The RAG Pipeline has two main stages:

1 Ingestion (Offline)

Load documents ← split into chunks ← create Embeddings ← store in a Vector DB

2 Retrieval (Online)

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:

terminal Prerequisite installation
pip install langchain langchain-community langchain-openai langchain-pinecone pypdf chromadb sentence-transformers
document_loaders.py
# 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.

text_splitting.py
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)
lightbulb

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.

embeddings.py
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])}")
text-embedding-3-small
1536 dimensions
$0.02 / 1M tokens
Balanced and recommended for most
text-embedding-3-large
3072 dimensions
$0.13 / 1M tokens
Highest quality
multilingual-mpnet
768 dimensions
Free, local
Excellent Hebrew support

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.

vector_stores.py
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')}")
info

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.

rag_chain.py
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.

rag_lcel.py
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%.

reranking.py
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.

hyde.py
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.

analytics

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

Monitoring — ongoing monitoring

  • Measure Query latency (p50, p95, p99)
  • Track Retrieval quality — are the sources correct?
  • Feedback loop — let users rate answers
savings

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

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

lightbulb

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".

lightbulb

Incremental Indexing: Don't rebuild the entire index on every update. Track last_modified and update only chunks that changed.

lightbulb

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.

lightbulb

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

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.