┌─────────────────────────────────────────────────────────────┐
│ OFFLINE (Indexing) │
│ Documents → Chunking → Embeddings → Vector Database │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ ONLINE (Query) │
│ User Query → Embeddings → Similarity Search → Top-K Chunks │
│ │
│ Prompt: Context + Query → LLM → Response │
└─────────────────────────────────────────────────────────────┘
RAG solves the "knowledge cutoff" problem. The LLM doesn't need to know your data – it just needs to read relevant chunks at query time.
Documents are too long to fit in context windows. Chunking determines what information is retrieved. Bad chunking = irrelevant retrieval = bad answers.
| Strategy | Description | Best for | Pros/Cons |
|---|---|---|---|
| Fixed-size chunking | Split every N characters/tokens | Simple documents, consistent format | ✅ Simple ❌ Might break sentences |
| Semantic chunking | Split at sentence boundaries, paragraphs, or semantic meaning | Prose, articles, books | ✅ Respects natural boundaries ❌ More complex |
| Recursive chunking | Try paragraph → sentence → character if too long | Mixed content (markdown, code, prose) | ✅ Adaptive ❌ Slower |
| Document-specific | Use document structure (headers, lists, tables) | HTML, Markdown, PDF with structure | ✅ Preserves context ❌ Format-specific |
from langchain.text_splitter import RecursiveCharacterTextSplitter
# Recommended for most documents
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000, # characters per chunk
chunk_overlap=200, # overlap between chunks (preserves context)
separators=["\n\n", "\n", ".", "!", "?", ",", " ", ""]
)
chunks = text_splitter.split_text(document)
| Model | Dimensions | Context Length | MTEB Score | Cost per 1M tokens |
|---|---|---|---|---|
| text-embedding-3-small | 1536 | 8192 | 62.3 | $0.02 |
| text-embedding-3-large | 3072 | 8192 | 64.6 | $0.13 |
| BAAI/bge-large-en | 1024 | 512 | 63.7 | Free (local) |
| intfloat/e5-mistral-7b | 4096 | 32768 | 66.0 | Needs GPU |
from openai import OpenAI
client = OpenAI()
response = client.embeddings.create(
model="text-embedding-3-small",
input="Your text to embed",
dimensions=1536 # Can reduce dimensionality to save cost
)
embedding = response.data[0].embedding
# Combined vector + keyword search
response = client.query.get(
"Document", ["text", "source"]
).with_hybrid(
query="What is the refund policy?",
alpha=0.5 # 0 = pure keyword, 1 = pure vector
).with_limit(10).do()
from langchain.retrievers.multi_query import MultiQueryRetriever
retriever = MultiQueryRetriever.from_llm(
retriever=vectorstore.as_retriever(),
llm=ChatOpenAI(model="gpt-4"),
prompt="Generate 3 different versions of this question..."
)
# Automatically generates multiple queries and combines results
docs = retriever.get_relevant_documents(query)
Initial retrieval (embedding similarity) is fast but can be noisy. Reranking uses a more expensive but more accurate model to reorder results.
from sentence_transformers import CrossEncoder
# Load cross-encoder model (more accurate but slower)
reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
# Initial retrieval (fast)
initial_results = vectorstore.similarity_search(query, k=20)
# Rerank pairs (query, document)
pairs = [(query, doc.page_content) for doc in initial_results]
scores = reranker.predict(pairs)
# Sort by reranking score and get top k
top_docs = sorted(zip(initial_results, scores), key=lambda x: x[1], reverse=True)[:5]
| Method | Speed | Accuracy | Cost |
|---|---|---|---|
| Vector similarity | Very fast | Good | Low ($) |
| Cross-encoder rerank | Slow | Excellent | Higher (but only on top results) |
| LLM-as-reranker | Very slow | Best | High ($$$) |
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_relevancy
# Prepare dataset with questions, answers, contexts, ground_truth
dataset = Dataset.from_dict({
"question": questions,
"answer": generated_answers,
"contexts": retrieved_contexts,
"ground_truth": ground_truths
})
result = evaluate(dataset, metrics=[faithfulness, answer_relevancy])
print(result) # Scores between 0-1
Take a long document (e.g., a news article). Try chunk sizes of 256, 512, and 1024 tokens. Ask the same question to each version. Which chunk size gave the best answer?
You're building a RAG system for 1 million short support tickets (average 200 words each). Which embedding model would you choose and why?
Using LangChain, Chroma, and OpenAI, build a RAG system that answers questions from a PDF of your choice.
from langchain.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import Chroma
from langchain.chains import RetrievalQA
from langchain.chat_models import ChatOpenAI
# Load PDF
loader = PyPDFLoader("your_document.pdf")
documents = loader.load()
# Split
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
docs = text_splitter.split_documents(documents)
# Embed and store
embeddings = OpenAIEmbeddings()
vectorstore = Chroma.from_documents(docs, embeddings)
# Create QA chain
qa_chain = RetrievalQA.from_chain_type(
llm=ChatOpenAI(model="gpt-4"),
retriever=vectorstore.as_retriever(search_kwargs={"k": 4})
)
# Ask
answer = qa_chain.run("What is the main topic of this document?")
print(answer)