Basic RAG

Introduction
- Large Language Models (LLMs) are good at writing fluent answers, but they only “know” what was in their training data. They can also hallucinate — invent facts that sound confident but are wrong.
- RAG (Retrieval-Augmented Generation) is a simple idea: before the model answers, we retrieve relevant documents from a knowledge base, then ask the model to generate an answer using those documents as context.
- Think of it like an open-book exam. Without RAG, the LLM answers from memory. With RAG, it can look up the right pages first, then write the answer.
Why RAG matters
- Up-to-date information: You can add new PDFs, notes, or web pages without retraining the whole LLM.
- Private / domain data: Course notes, company docs, or research papers that were never in the model’s training set.
- More grounded answers: The model is steered by retrieved text, so answers can cite or stick closer to your sources.
The basic RAG pipeline
A minimal RAG system usually has four steps:
- Ingest — collect documents (PDF, Markdown, text, etc.).
- Chunk — split long documents into smaller pieces so search stays precise.
- Retrieve — convert the user question into a vector (embedding), then find the most similar chunks in a vector store.
- Generate — send the question + retrieved chunks to an LLM and get the final answer.
User question
|
v
[Embedding + search] --> top-k relevant chunks
|
v
[LLM prompt: question + chunks] --> answer
- In short: Retrieve first, then generate. That is the core of basic RAG.
Step by Step Coding guide:
- The following code using langchain version 1.2.8. The code might be changed if you switch between different langchain version.
Step 1: Ingest/Load Documents
- Here we use PyPDFLoader and DirectoryLoader to load single/multiple pdf files
from langchain_community.document_loaders import PyPDFLoader, DirectoryLoader
loader = DirectoryLoader(
"mdocs",
glob="*.pdf", # match PDFs (use ** for recursion)
loader_cls=PyPDFLoader, # use PyPDF for each matched file
show_progress=True,
use_multithreading=True,
max_concurrency=8,
)
docs = loader.load()
print(len(docs), docs[0].metadata)
- Other supported format are: TextLoader for txt file or other text based like python file, or Docx2txtLoader for MS Word files
Step 2: Split loaded documents into chunks
- LLMs and embedding models can only process a limited number of tokens (a token ~ 3/4 of a word) at a time.
- Long PDFs often exceed that limit, so we split the text into smaller chunks before indexing and retrieval.
- We will use RecursiveCharacterTextSplitter for this task. (In addition, you can also use other splitter method like TokenTextSplitter, CharacterTextSplitter)
from langchain_text_splitters import RecursiveCharacterTextSplitter
- Before splitting, we set two important parameters:
-
chunk_size: the maximum length of each chunk (here measured in characters). Smaller chunks make search more precise; larger chunks keep more surrounding context. -
chunk_overlap: how many characters are shared between neighboring chunks. Overlap reduces the chance that a sentence or idea is cut in half at a chunk boundary.
-
- Rule of thumb: start with a modest overlap (often about 10–20% of
chunk_size). For a quick demo we use small values; for real PDFs you often use larger sizes (for examplechunk_size=1000,chunk_overlap=200).
chunk_size = 26
chunk_overlap = 4
r_splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap
)
splits = r_splitter.split_documents(docs)
print(len(splits), splits[0].page_content[:200])
Step 3a: Embed chunks and retrieve similar text
- Retrieve means: turn text into numbers (embeddings), store those vectors, then for a new question find the chunks whose vectors are closest (most similar in meaning).
- An embedding is a list of numbers that represents the meaning of a piece of text. Similar sentences land near each other in that vector space.
- A vector store is a database for those embeddings. For a basic local demo we use FAISS (you can also use Chroma, LanceDB, etc.).
from langchain_community.vectorstores import FAISS
from langchain_ollama.embeddings import OllamaEmbeddings
oembedding = OllamaEmbeddings(model="mxbai-embed-large:335m")
db_faiss = FAISS.from_documents(
documents=splits,
embedding=oembedding
)
db_faiss.save_local("docs/faiss_new")
- Ask a question and pull the top-k most similar chunks:
query = "What is the main topic of the document?"
docs_and_scores = db_faiss.similarity_search_with_score(query, k=5)
for i, d in enumerate(docs_and_scores):
print(f"--- chunk {i+1} ---")
print(d.page_content)
print(d.metadata)
- What just happened:
- The query was converted to an embedding with the same model used for the chunks.
- FAISS compared that vector to all stored chunk vectors.
- It returned the
k=5closest chunks — these become the context for the LLM in Step 4.
- Tip: if results look weak, try a larger
chunk_size, a different embedding model, or a clearer query. Retrieval quality drives RAG quality.
Step 3b: Reranker
- Vector search is fast, but the top-k list is not always perfectly ordered. A chunk can be “nearby” in embedding space and still be a weak match for the exact question.
- A reranker is a second, more careful model. It looks at the question and each candidate chunk together, scores how well they match, then reorders (or drops) chunks before we send them to the LLM.
- Simple picture:
Question
|
v
FAISS retrieve many candidates (e.g. top 20) <-- fast, approximate
|
v
Reranker scores each (question, chunk) pair <-- slower, more accurate
|
v
Keep the best few chunks (e.g. top 5) --> LLM
- Why use it: better context for generation, fewer irrelevant chunks, often fewer hallucinations from noisy retrieval.
- Trade-off: reranking adds compute time, so we usually retrieve a larger
kfirst, then keep a smaller final set.
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
pairs = [(query, d[0].page_content) for d in docs_and_scores]
scores = reranker.predict(pairs)
reranked_docs = [
doc for _, doc in sorted(
zip(scores, docs_and_scores),
key=lambda x: x[0],
reverse=True
)
]
top_docs = reranked_docs
Step 4: Generate
- Generate is the last step: we give the LLM the user question plus the retrieved (and reranked) chunks as context, and ask it to answer using that context.
- Without this step, RAG is only search. With it, the model writes a fluent answer grounded in your documents (the open-book part of the exam).
- Keep the prompt simple for beginners: instruct the model to use only the provided context, and to say when the context is not enough.
from langchain_community.chat_models import ChatOllama
from langchain_classic.chains.question_answering import load_qa_chain
from langchain_core.prompts import ChatPromptTemplate
llm = ChatOllama(model="gemma3:4b", temperature=0)
# Build context string from top docs
docs_only = [doc for doc, score in top_docs[:3]]
context = "\n\n".join([doc.page_content for doc in docs_only])
# Create prompt and chain
prompt = ChatPromptTemplate.from_template(
"Based on the following context, answer the question.\n\n"
"Context: {context}\n\n"
"Question: {question}\n\n"
"Answer:"
)
chain = prompt | llm | StrOutputParser()
# Invoke
response = chain.invoke({"context": context, "question": query})
print(response)
- What just happened:
- The top chunks were joined into one context string.
- A prompt wrapped the context and the question with clear instructions.
- The LLM generated the final answer from that prompt.
- Optional: print the chunks you used so readers can check grounding:
for i, (doc, score) in enumerate(top_docs[:5]):
print(f"--- context chunk {i+1} (rerank score related) ---")
print(doc.page_content[:300])
- Tip: if answers ignore the docs, lower
temperature, tighten the system prompt, or improve Steps 3a–3b so the context is more relevant.