Quick takeaways
- Start with clean source documents and real questions before writing code.
- Chunking and embedding choices matter more than the vector database brand.
- Retrieval and generation should be tested separately, then together.
- Use a small golden test set from day one so you can measure progress.
Build a RAG app step by step
Fireship shows a fast, practical RAG build using LangChain and vector search.
What you will build
This tutorial walks through a minimal RAG pipeline in Python. You will load documents, split them into chunks, embed them, store them in a vector index, retrieve relevant chunks for a question, and generate an answer with a language model.
The examples are intentionally simple so you can adapt them to any framework or provider.
Prerequisites
- Python 3.10 or newer
- A small set of text documents or a single text file
- An embedding model and an LLM API or local model
- Basic familiarity with HTTP APIs and Python
Data preparation
Clean text is the foundation of good RAG. Remove broken encoding, duplicate sections, and boilerplate headers. Save each cleaned document as plain text or as a record with metadata.
# Load documents into a simple list
import glob
docs = []
for path in glob.glob("data/*.txt"):
with open(path, "r", encoding="utf-8") as f:
docs.append({
"source": path,
"text": f.read()
})
Chunking
Chunking splits documents into passages small enough to retrieve precisely but large enough to retain meaning. Start with paragraph or fixed-size chunks with overlap.
def chunk_text(text, chunk_size=300, overlap=50):
words = text.split()
chunks = []
for i in range(0, len(words), chunk_size - overlap):
chunk = " ".join(words[i:i + chunk_size])
if chunk:
chunks.append(chunk)
return chunks
chunked_docs = []
for doc in docs:
for chunk in chunk_text(doc["text"]):
chunked_docs.append({
"source": doc["source"],
"text": chunk
})
Embeddings
Turn each chunk into a dense vector using an embedding model. Many providers offer small, cheap embedding models that work well for RAG.
import requests
def embed(texts, api_key):
response = requests.post(
"https://api.embedding-provider.example/v1/embeddings",
headers={"Authorization": f"Bearer {api_key}"},
json={"model": "text-embedding-3-small", "input": texts}
)
response.raise_for_status()
return [item["embedding"] for item in response.json()["data"]]
vectors = embed([d["text"] for d in chunked_docs], api_key="YOUR_KEY")
Vector database
You can use a managed vector database, a local vector store, or even a NumPy matrix for a prototype. The goal is the same: given an embedding, return the closest chunks.
import numpy as np
class VectorStore:
def __init__(self):
self.vectors = []
self.chunks = []
def add(self, chunks, vectors):
self.chunks.extend(chunks)
self.vectors.extend(vectors)
def search(self, query_vector, top_k=5):
scores = np.dot(self.vectors, query_vector)
top_indices = np.argsort(scores)[::-1][:top_k]
return [self.chunks[i] for i in top_indices]
store = VectorStore()
store.add(chunked_docs, vectors)
Retrieval
To answer a question, embed it and search the vector store. Consider adding keyword boosting or re-ranking once the basics work.
def retrieve(question, store, top_k=5):
query_vector = embed([question], api_key="YOUR_KEY")[0]
return store.search(query_vector, top_k=top_k)
question = "What is the refund policy?"
context_chunks = retrieve(question, store)
Generation
Combine the retrieved chunks with a prompt that tells the model to answer using only the provided context. Ask it to cite sources or refuse if the context is insufficient.
def build_prompt(question, chunks):
context = "\n\n".join(
f"Source: {c['source']}\n{c['text']}" for c in chunks
)
return f"""Answer the question using only the context below.
If the context does not contain the answer, say you do not know.
Context:
{context}
Question: {question}
Answer:"""
def generate(prompt, api_key):
response = requests.post(
"https://api.llm-provider.example/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}"},
json={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": prompt}]
}
)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
prompt = build_prompt(question, context_chunks)
answer = generate(prompt, api_key="YOUR_KEY")
print(answer)
Evaluation
Measure retrieval and generation separately. Create a small set of questions with known-good answers and source chunks.
test_set = [
{
"question": "What is the refund policy?",
"expected_sources": ["data/refunds.txt"],
"expected_answer": "Refunds are issued within 30 days."
}
]
for item in test_set:
chunks = retrieve(item["question"], store)
sources = {c["source"] for c in chunks}
retrieved_expected = bool(sources.intersection(set(item["expected_sources"])))
print(item["question"], "retrieved:", retrieved_expected)
Full pipeline
Wrap the pieces into a single function so you can iterate on chunking, embedding, and prompts without rewriting everything.
def ask(question):
chunks = retrieve(question, store)
prompt = build_prompt(question, chunks)
return generate(prompt, api_key="YOUR_KEY")
print(ask("How do I reset my password?"))
Once this works, add re-ranking, hybrid search, source citations, and a more rigorous evaluation framework.
Without AI vs. with AI
| Task | Without AI | With AI |
|---|---|---|
| Document loading | Files are read one-off with ad-hoc scripts and inconsistent encoding. | A loader reads files, extracts text, and preserves source metadata for every chunk. |
| Chunking | Text is split arbitrarily, cutting sentences and losing context. | Chunks use size, overlap, and semantic boundaries so each chunk is meaningful. |
| Embedding | Developers write custom vectorization code from scratch. | A standard embedding API turns chunks into vectors with one call. |
| Retrieval | Search is hardcoded to one method with no way to test quality. | A vector store returns ranked candidates and can be evaluated against a test set. |
| Answer generation | The model answers from memory without source constraints. | The prompt instructs the model to use only retrieved context and cite sources. |
FAQ
Do I need a vector database?
No. For prototypes, an in-memory vector store works fine. Move to a database when scale, persistence, or concurrent users matter.
Which embedding model should I use?
Start with a small, well-supported model from your provider. Test retrieval quality before upgrading to a larger model.
What chunk size is best?
Most RAG systems start with a few hundred tokens per chunk. Tune based on your documents and retrieval scores.
Can I use local models instead of APIs?
Yes. Local embeddings and LLMs work well for privacy-sensitive or offline use cases, though they may need more setup.
How do I know the pipeline is improving?
Track retrieval accuracy, answer faithfulness, and user-reported errors on a fixed test set. Do not rely on intuition alone.
What is the minimum code needed for a RAG prototype?
Load documents, chunk them, embed them, store them, retrieve top-k chunks, and call a language model with the context.
Should I use LangChain, LlamaIndex, or plain Python?
Use a framework to move fast and learn patterns. Switch to plain Python when you need tight control and want to avoid abstraction overhead.
How do I debug bad RAG answers?
Check retrieval first. If the right chunks are missing, fix chunking or search. If the right chunks are present, fix the prompt or generation model.
Can I deploy this tutorial code to production?
Not as-is. Add error handling, monitoring, authentication, evaluation, and source hygiene before production use.
What should I build after this tutorial?
Add evaluation, hybrid search, and source citations. Then expand to more knowledge bases and a review workflow.