Lab 03: RAG Pipeline from Scratch
Goal
Build a complete RAG pipeline: document ingestion, chunking, embedding, vector storage, retrieval, reranking, and grounded generation with citations.
Prerequisites
pip install openai chromadb sentence-transformers httpx
Lab Files
lab-03-rag-pipeline/
README.md ← this file
ingest.py ← document loader + chunker + embedder
search.py ← retrieval + reranking
app.py ← full Q&A app
evaluate.py ← Ragas-style evaluation
sample_docs/ ← put your documents here
Exercise 1: Ingest Documents
# lab03/ingest.py
import re
from pathlib import Path
from openai import OpenAI
import chromadb
client = OpenAI()
chroma = chromadb.PersistentClient(path="./rag_db")
collection = chroma.get_or_create_collection(
name="docs",
metadata={"hnsw:space": "cosine"}
)
def chunk_text(text: str, max_words: int = 300, overlap: int = 50) -> list[str]:
"""Split text into overlapping word-based chunks."""
words = text.split()
chunks = []
for i in range(0, len(words), max_words - overlap):
chunk_words = words[i:i + max_words]
if len(chunk_words) < 20: # Skip tiny trailing chunks
continue
chunks.append(" ".join(chunk_words))
return chunks
def embed_batch(texts: list[str]) -> list[list[float]]:
"""Embed a batch of texts using OpenAI."""
response = client.embeddings.create(
model="text-embedding-3-small",
input=texts
)
return [e.embedding for e in response.data]
def ingest_file(filepath: str) -> int:
"""Ingest a single file into the vector database."""
text = Path(filepath).read_text(encoding="utf-8", errors="ignore")
# Remove excessive whitespace
text = re.sub(r'\n{3,}', '\n\n', text)
text = re.sub(r' {2,}', ' ', text)
chunks = chunk_text(text)
if not chunks:
return 0
# Embed in batches of 100
all_embeddings = []
for i in range(0, len(chunks), 100):
batch = chunks[i:i + 100]
all_embeddings.extend(embed_batch(batch))
# Generate unique IDs
filename = Path(filepath).stem
ids = [f"{filename}_chunk_{i}" for i in range(len(chunks))]
# Store in ChromaDB
collection.add(
documents=chunks,
embeddings=all_embeddings,
metadatas=[{"source": filepath, "chunk_index": i} for i in range(len(chunks))],
ids=ids
)
return len(chunks)
def ingest_directory(directory: str) -> dict:
"""Ingest all text/markdown files in a directory."""
results = {}
for path in Path(directory).rglob("*"):
if path.suffix in [".txt", ".md", ".rst"]:
try:
count = ingest_file(str(path))
results[str(path)] = count
print(f"✓ {path.name}: {count} chunks")
except Exception as e:
print(f"✗ {path.name}: {e}")
return results
if __name__ == "__main__":
import sys
directory = sys.argv[1] if len(sys.argv) > 1 else "./sample_docs"
print(f"Ingesting documents from {directory}...")
results = ingest_directory(directory)
total = sum(results.values())
print(f"\nDone. Total chunks stored: {total}")
print(f"Collection size: {collection.count()}")
Task: Put 5-10 markdown files about a topic you care about in sample_docs/. Run the ingest. Verify the collection has chunks.
Exercise 2: Retrieval + Reranking
# lab03/search.py
from openai import OpenAI
import chromadb
from sentence_transformers import CrossEncoder
client = OpenAI()
chroma = chromadb.PersistentClient(path="./rag_db")
collection = chroma.get_or_create_collection("docs")
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
def retrieve(query: str, top_k: int = 15) -> list[dict]:
"""Semantic retrieval from vector DB."""
query_embedding = client.embeddings.create(
model="text-embedding-3-small",
input=[query]
).data[0].embedding
results = collection.query(
query_embeddings=[query_embedding],
n_results=min(top_k, collection.count())
)
chunks = []
for doc, meta, dist in zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0]
):
chunks.append({
"content": doc,
"source": meta.get("source", "unknown"),
"retrieval_score": 1 - dist # Convert cosine distance to similarity
})
return chunks
def rerank(query: str, candidates: list[dict], top_n: int = 5) -> list[dict]:
"""Rerank candidates using a cross-encoder."""
if not candidates:
return []
pairs = [(query, c["content"]) for c in candidates]
scores = reranker.predict(pairs)
ranked = sorted(
zip(candidates, scores),
key=lambda x: x[1],
reverse=True
)
result = []
for chunk, score in ranked[:top_n]:
chunk = chunk.copy()
chunk["rerank_score"] = float(score)
result.append(chunk)
return result
def search(query: str, top_n: int = 5) -> list[dict]:
"""Full retrieval + reranking pipeline."""
candidates = retrieve(query, top_k=20)
return rerank(query, candidates, top_n=top_n)
if __name__ == "__main__":
query = input("Search query: ")
results = search(query)
for i, r in enumerate(results):
print(f"\n[{i+1}] Score: {r['rerank_score']:.3f} | {r['source']}")
print(r["content"][:200] + "...")
Exercise 3: Full Q&A App
# lab03/app.py
from openai import OpenAI
from search import search
client = OpenAI()
SYSTEM_PROMPT = """You are a helpful assistant. Answer questions using ONLY the provided context sources.
Rules:
- Cite sources using [1], [2], [3] notation
- If the answer is not in the provided sources, say "I don't have information about this in the provided documents"
- Do not make up information
- Keep answers focused and precise"""
def answer(question: str) -> dict:
"""Full RAG pipeline: retrieve → pack context → generate."""
chunks = search(question, top_n=5)
if not chunks:
return {"answer": "No documents found. Please ingest documents first.", "sources": []}
# Pack context
context = ""
for i, chunk in enumerate(chunks):
context += f"[{i+1}] Source: {chunk['source']}\n{chunk['content']}\n\n"
# Generate
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"Context:\n{context}\nQuestion: {question}"}
],
max_tokens=600,
temperature=0
)
return {
"answer": response.choices[0].message.content,
"sources": [c["source"] for c in chunks],
"chunks_used": len(chunks)
}
if __name__ == "__main__":
print("RAG Q&A System (type 'quit' to exit)\n")
while True:
question = input("Question: ").strip()
if question.lower() in ["quit", "exit", "q"]:
break
result = answer(question)
print(f"\nAnswer: {result['answer']}")
print(f"\nSources used:")
for source in result["sources"]:
print(f" - {source}")
print()
Exercise 4: Evaluate Your RAG System
# lab03/evaluate.py
from openai import OpenAI
from app import answer
client = OpenAI()
def check_faithfulness(question: str, answer_text: str, sources_content: str) -> float:
"""Check if answer is supported by sources using LLM-as-judge."""
response = client.chat.completions.create(
model="gpt-4o",
messages=[{
"role": "user",
"content": f"""Given these source passages and an answer, rate from 0.0 to 1.0 whether ALL claims in the answer are supported by the sources.
Sources:
{sources_content}
Answer: {answer_text}
Score (0.0 = not supported, 1.0 = fully supported). Respond with just the score."""
}],
max_tokens=5,
temperature=0
)
try:
return float(response.choices[0].message.content.strip())
except:
return 0.5
# Build test questions for YOUR documents
TEST_QUESTIONS = [
"What is the main topic covered?",
# Add questions specific to your documents
]
print("Evaluating RAG pipeline...\n")
scores = []
for q in TEST_QUESTIONS:
result = answer(q)
# Run faithfulness check (requires source content — simplified here)
score = 1.0 if result["chunks_used"] > 0 else 0.0
scores.append(score)
print(f"Q: {q}")
print(f"A: {result['answer'][:150]}...")
print(f"Sources used: {result['chunks_used']}")
print()
if scores:
import statistics
print(f"Average pipeline score: {statistics.mean(scores):.2f}")
Deliverables
- 5+ documents ingested successfully
- Search returning relevant results for 5 test queries
- Q&A app working with citations
- Evaluation run on 5 questions
- Failure analysis: note 2 cases where the system failed and explain why
Reflection Questions
- How does chunk size affect recall? What happens if chunks are too small?
- Why is reranking separate from initial retrieval? Why not just use the reranker for all retrieval?
- A user asks a question and gets "I don't have information about this" even though the document exists. What are the possible causes?
- How would you add support for PDF files to the ingestion pipeline?
- How would you implement user-level ACL (user A can only see their documents)?