LLMs have a knowledge cutoff and limited context windows. They also hallucinate. Retrieval-Augmented Generation (RAG) addresses these limitations by grounding the model in retrieved documents at inference time — no retraining required.
| Problem | RAG Solution |
|---|---|
| Knowledge cutoff | Retrieve from an up-to-date document store |
| Hallucination | Ground answers in retrieved evidence |
| Context window limits | Only inject the most relevant chunks |
| Source attribution | Citations come from retrieved documents |
| Domain-specific knowledge | Index domain documents once; use always |
A RAG pipeline has two phases:
Documents
│
▼
Chunking
│
▼
Embedding (each chunk → dense vector)
│
▼
Vector Store (index of chunk vectors)
User query
│
▼
Embed query
│
▼
Similarity search in vector store → top-k chunks
│
▼
Build prompt: system + retrieved chunks + query
│
▼
LLM generates answer grounded in retrieved context
How you split documents matters enormously.
| Strategy | Description | When to use |
|---|---|---|
| Fixed-size | Split every N tokens, with M-token overlap | Simple baseline |
| Sentence | Split on sentence boundaries | Preserves semantic units |
| Recursive character | Try paragraph, then sentence, then character | Good general default |
| Semantic | Group sentences by embedding similarity | Best quality; slower |
| Document structure | Split on headers, sections | Structured docs (PDFs, HTML) |
Overlap between chunks (e.g. 50–100 tokens) ensures that information spanning a chunk boundary is not lost.
Not all embedding models are equal for retrieval. Key benchmarks: MTEB (Massive Text Embedding Benchmark).
| Model | Embedding dim | Notes |
|---|---|---|
all-MiniLM-L6-v2 |
384 | Fast, small, good baseline |
all-mpnet-base-v2 |
768 | Better quality |
text-embedding-3-small (OpenAI) |
1536 | Strong, API-based |
nomic-embed-text-v1.5 |
768 | Open-source, competitive |
bge-large-en-v1.5 |
1024 | Strong open-source |
Use asymmetric retrieval: query and document can be embedded differently (bi-encoder approach).
A vector store stores embeddings and supports approximate nearest neighbour (ANN) search.
| Store | Type | Notes |
|---|---|---|
| FAISS | Library | Fast, in-memory, Facebook AI |
| ChromaDB | Embedded DB | Easy setup, good for dev |
| Pinecone | Managed cloud | Scalable, paid |
| Weaviate | Open source | Graph + vector |
| pgvector | PostgreSQL extension | Good if already using PG |
| Qdrant | Open source | Good performance |
For prototyping, FAISS or ChromaDB are standard.
import faiss
import numpy as np
d = 384 # embedding dimension
index = faiss.IndexFlatL2(d)
index.add(np.array(embeddings, dtype='float32'))
# Search
D, I = index.search(np.array([query_embedding], dtype='float32'), k=5)
# I contains indices of top-5 most similar chunks
Embed query and documents; return top-k by cosine similarity. The standard RAG approach.
Keyword-based TF-IDF style scoring. Still competitive for exact-match queries.
Combine dense and sparse scores (e.g. with reciprocal rank fusion). Generally outperforms either alone.
After retrieving top-k candidates, use a cross-encoder to re-rank them. Cross-encoders jointly attend to query and document — more accurate but slower.
Query + Document → CrossEncoder → scalar relevance score
from langchain.document_loaders import DirectoryLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import FAISS
from langchain.chains import RetrievalQA
from langchain_openai import ChatOpenAI
# 1. Load documents
loader = DirectoryLoader("./docs/", glob="**/*.txt")
docs = loader.load()
# 2. Chunk
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = splitter.split_documents(docs)
# 3. Embed and index
embedder = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
vectorstore = FAISS.from_documents(chunks, embedder)
# 4. Build QA chain
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
qa = RetrievalQA.from_chain_type(llm=llm, retriever=vectorstore.as_retriever(search_kwargs={"k": 4}))
# 5. Query
answer = qa.invoke("What are the main findings of the study?")
print(answer["result"])
Ask the LLM to generate a hypothetical answer, then use that as the query embedding. Often improves retrieval quality.
Generate multiple reformulations of the query; retrieve for each; merge and deduplicate.
Index small chunks for precise retrieval, but return the larger parent chunk for context.
The model decides when to retrieve — not every query needs retrieval (Asai et al., 2023).
Evaluate retrieved documents for relevance; trigger web search if retrieved documents are insufficient.
| Metric | Measures |
|---|---|
| Retrieval recall | Are the relevant documents retrieved? |
| Context precision | Are retrieved chunks actually relevant? |
| Faithfulness | Does the answer stay within the retrieved context? |
| Answer relevance | Does the answer address the question? |
Frameworks: RAGAS, TruLens, DeepEval.
See practicals/week09_practical.py: