Module 1: The Complete RAG Pipeline (Architecture Overview)

Project: Baseline RAG System

Project overview

This is the Module 1 mini-project: building a complete baseline RAG system with ChromaDB + OpenAI. This baseline is the comparison point for every optimization you'll implement in modules 2-8.

The goal is NOT to build the best possible RAG (you'll get there gradually in the following modules), but to establish a baseline that works, that's measured, and that's documented. You need concrete numbers (precision: 68%, latency: 800ms) to validate that the advanced techniques actually improve the system.

This project teaches you the complete RAG flow: (1) Indexing (chunking + embeddings + storage), (2) Retrieval (query + search), (3) Generation (context + LLM), (4) Evaluation (manual metrics). By the end you'll have a working system and documented baseline metrics.


🎯 Project objectives

The main objective:

Build a baseline RAG system with measurable performance to compare against the improvements in modules 2-8.

The specific objectives:

  1. ✅ Implement the complete pipeline (Indexing → Retrieval → Generation)
  2. ✅ Use simple components (fixed-size chunking, direct query, cosine similarity)
  3. ✅ Measure baseline performance (latency, precision, recall)
  4. ✅ Document your architecture decisions
  5. ✅ Set up a production-ready environment (.env, .gitignore, requirements.txt)

📋 Technical requirements

The tech stack:

  • Python: 3.10+
  • Vector DB: ChromaDB 0.4.22 (local, free)
  • Embeddings: OpenAI text-embedding-ada-002
  • LLM: OpenAI GPT-3.5-turbo
  • Chunking: Fixed-size (a simple baseline)
  • Environment: python-dotenv for the API keys

The project's dataset:

You'll use the FastAPI documentation (50-100 pages) as your test corpus.


🛠️ Setting up the project

Step 1: The directory structure

mkdir rag_baseline_project
cd rag_baseline_project

# The structure
rag_baseline_project/
├── .env                    # API keys (DO NOT commit)
├── .gitignore              # Ignore .env, venv, etc.
├── requirements.txt        # Pinned dependencies
├── data/
│   └── fastapi_docs.txt    # The document corpus
├── src/
│   ├── indexing.py         # The indexing pipeline
│   ├── retrieval.py        # The retrieval pipeline
│   ├── generation.py       # The generation pipeline
│   └── evaluation.py       # Metrics and benchmarking
├── notebooks/
│   └── rag_baseline_demo.ipynb  # An interactive demo
└── README.md               # The project documentation

Step 2: Install the dependencies

# Create a virtual environment
python -m venv venv
source venv/bin/activate  # On Windows: venv\Scripts\activate

# Install the dependencies
pip install chromadb==0.4.22 openai==1.12.0 python-dotenv==1.0.0

# Save the requirements
pip freeze > requirements.txt

requirements.txt:

chromadb==0.4.22
openai==1.12.0
python-dotenv==1.0.0

Step 3: Configure the API keys

.env:

OPENAI_API_KEY=sk-proj-...your-key-here...

.gitignore:

# Environment
.env
venv/
__pycache__/
*.pyc

# Data (if it contains sensitive info)
# data/

# ChromaDB persistence
chroma_db/

# Jupyter notebook checkpoints
.ipynb_checkpoints/

Step 4: Verify the setup

# test_setup.py
import chromadb
from openai import OpenAI
from dotenv import load_dotenv
import os

load_dotenv()

# Test the OpenAI API key
openai_key = os.getenv("OPENAI_API_KEY")
if not openai_key:
    print("❌ OPENAI_API_KEY not found in .env")
else:
    print("✅ OPENAI_API_KEY configured")

# Test ChromaDB
try:
    client = chromadb.Client()
    print("✅ ChromaDB works correctly")
except Exception as e:
    print(f"❌ ChromaDB error: {e}")

# Test the OpenAI embeddings
try:
    client = OpenAI()
    response = client.embeddings.create(
        model="text-embedding-ada-002",
        input="Test"
    )
    print(f"✅ OpenAI embeddings works (dimensions: {len(response.data[0].embedding)})")
except Exception as e:
    print(f"❌ OpenAI error: {e}")

print("\n🎉 Setup complete! Ready for the baseline project.")

Run it:

python test_setup.py

Expected output:

✅ OPENAI_API_KEY configured
✅ ChromaDB works correctly
✅ OpenAI embeddings works (dimensions: 1536)

🎉 Setup complete! Ready for the baseline project.

📦 Implementing the pipeline

Step 1: The indexing pipeline

src/indexing.py:

"""
The indexing pipeline: Chunking → Embeddings → Storage
"""
import chromadb
from openai import OpenAI
from typing import List
import os
from dotenv import load_dotenv

load_dotenv()


class BaselineIndexingPipeline:
    """The baseline indexing pipeline with fixed-size chunking"""
    
    def __init__(self, chunk_size: int = 500):
        self.chunk_size = chunk_size
        self.openai_client = OpenAI()
        self.chroma_client = chromadb.PersistentClient(path="./chroma_db")
    
    def chunk_document(self, document: str) -> List[str]:
        """
        Fixed-size chunking (a simple baseline).
        
        Args:
            document: the document's full text
        
        Returns:
            A list of fixed-size chunks
        """
        chunks = []
        for i in range(0, len(document), self.chunk_size):
            chunk = document[i:i + self.chunk_size]
            chunks.append(chunk)
        
        return chunks
    
    def create_embeddings(self, chunks: List[str]) -> List[List[float]]:
        """
        Create the embeddings with OpenAI text-embedding-ada-002.
        
        Args:
            chunks: a list of text chunks
        
        Returns:
            A list of embeddings (1536D vectors)
        """
        embeddings = []
        
        for chunk in chunks:
            response = self.openai_client.embeddings.create(
                model="text-embedding-ada-002",
                input=chunk
            )
            embeddings.append(response.data[0].embedding)
        
        return embeddings
    
    def store_in_chromadb(
        self,
        chunks: List[str],
        embeddings: List[List[float]],
        collection_name: str = "fastapi_docs"
    ):
        """
        Store the chunks and embeddings in ChromaDB.
        
        Args:
            chunks: a list of text chunks
            embeddings: a list of embeddings
            collection_name: the collection's name
        """
        # Create or fetch the collection
        try:
            collection = self.chroma_client.get_collection(collection_name)
            print(f"Collection '{collection_name}' already exists. Clearing it...")
            self.chroma_client.delete_collection(collection_name)
        except:
            pass
        
        collection = self.chroma_client.create_collection(collection_name)
        
        # Add the documents
        collection.add(
            documents=chunks,
            embeddings=embeddings,
            ids=[f"chunk_{i}" for i in range(len(chunks))]
        )
        
        print(f"✅ Stored {len(chunks)} chunks in the ChromaDB collection '{collection_name}'")
    
    def index_document(self, document: str, collection_name: str = "fastapi_docs"):
        """
        The complete indexing pipeline.
        
        Args:
            document: the full document to index
            collection_name: the collection's name in ChromaDB
        """
        print("🔄 Starting the indexing pipeline...")
        
        # 1. Chunking
        print(f"   1/3 Chunking the document (chunk_size={self.chunk_size})...")
        chunks = self.chunk_document(document)
        print(f"       ✅ Created {len(chunks)} chunks")
        
        # 2. Embeddings
        print(f"   2/3 Creating the embeddings...")
        embeddings = self.create_embeddings(chunks)
        print(f"       ✅ Created {len(embeddings)} embeddings (1536D each)")
        
        # 3. Storage
        print(f"   3/3 Storing them in ChromaDB...")
        self.store_in_chromadb(chunks, embeddings, collection_name)
        
        print(f"✅ Indexing pipeline complete!\n")


# Example usage
if __name__ == "__main__":
    # Read the sample document
    with open("data/fastapi_docs.txt", "r", encoding="utf-8") as f:
        document = f.read()
    
    print(f"Document loaded: {len(document)} characters\n")
    
    # Index it
    pipeline = BaselineIndexingPipeline(chunk_size=500)
    pipeline.index_document(document)

Step 2: The retrieval pipeline

src/retrieval.py:

"""
The retrieval pipeline: Query → Embeddings → Search → Top-K
"""
import chromadb
from openai import OpenAI
from typing import List, Dict
from dotenv import load_dotenv

load_dotenv()


class BaselineRetrievalPipeline:
    """The baseline retrieval pipeline with semantic search"""
    
    def __init__(self, collection_name: str = "fastapi_docs"):
        self.openai_client = OpenAI()
        self.chroma_client = chromadb.PersistentClient(path="./chroma_db")
        self.collection = self.chroma_client.get_collection(collection_name)
    
    def create_query_embedding(self, query: str) -> List[float]:
        """
        Create the embedding for the user's query.
        
        Args:
            query: the query in natural language
        
        Returns:
            The embedding (a 1536D vector)
        """
        response = self.openai_client.embeddings.create(
            model="text-embedding-ada-002",
            input=query
        )
        return response.data[0].embedding
    
    def search(self, query: str, top_k: int = 5) -> Dict:
        """
        Semantic search in ChromaDB.
        
        Args:
            query: the user's query
            top_k: how many documents to retrieve
        
        Returns:
            A dict with the documents, IDs and distances
        """
        # Create the query's embedding
        query_embedding = self.create_query_embedding(query)
        
        # Search in ChromaDB
        results = self.collection.query(
            query_embeddings=[query_embedding],
            n_results=top_k
        )
        
        return {
            "documents": results['documents'][0],
            "ids": results['ids'][0],
            "distances": results['distances'][0]
        }
    
    def retrieve(self, query: str, top_k: int = 5) -> Dict:
        """
        The complete retrieval pipeline.
        
        Args:
            query: the user's query
            top_k: how many documents to retrieve
        
        Returns:
            A dict with the retrieved documents and their metadata
        """
        print(f"🔍 Retrieving the top-{top_k} documents for the query: '{query}'\n")
        
        results = self.search(query, top_k)
        
        print(f"✅ Found {len(results['documents'])} relevant documents:")
        for i, (doc, distance) in enumerate(zip(results['documents'], results['distances']), 1):
            print(f"   {i}. (distance: {distance:.3f}) {doc[:100]}...")
        
        print()
        return results


# Example usage
if __name__ == "__main__":
    retrieval = BaselineRetrievalPipeline()
    
    # Test queries
    queries = [
        "What is FastAPI?",
        "How to create a POST endpoint in FastAPI?",
        "FastAPI authentication with OAuth2"
    ]
    
    for query in queries:
        results = retrieval.retrieve(query, top_k=3)
        print("-" * 80 + "\n")

Step 3: The generation pipeline

src/generation.py:

"""
The generation pipeline: Context + Query → LLM → Response
"""
from openai import OpenAI
from typing import Dict, List
from dotenv import load_dotenv

load_dotenv()


class BaselineGenerationPipeline:
    """The baseline generation pipeline with GPT-3.5-turbo"""
    
    def __init__(self, model: str = "gpt-3.5-turbo"):
        self.openai_client = OpenAI()
        self.model = model
    
    def create_context(self, documents: List[str]) -> str:
        """
        Build the context from the retrieved documents.
        
        Args:
            documents: a list of documents (chunks)
        
        Returns:
            The formatted context
        """
        context = "\n\n".join([
            f"Document {i+1}:\n{doc}"
            for i, doc in enumerate(documents)
        ])
        return context
    
    def create_prompt(self, query: str, context: str) -> str:
        """
        Build the LLM prompt with the context and the query.
        
        Args:
            query: the user's query
            context: the context from the retrieved documents
        
        Returns:
            The complete prompt
        """
        prompt = f"""Use the following context to answer the user's question.

Context:
{context}

Question: {query}

Instructions:
- Answer based ONLY on the provided context
- If the context doesn't contain the information, say "I don't have enough information in the context"
- Be concise but complete
- Mention the documents you used (Document 1, Document 2, etc.)

Answer:"""
        return prompt
    
    def generate(self, query: str, documents: List[str]) -> Dict:
        """
        The complete generation pipeline.
        
        Args:
            query: the user's query
            documents: the retrieved documents
        
        Returns:
            A dict with the answer, its metadata and the token usage
        """
        print(f"🤖 Generating the answer with {self.model}...\n")
        
        # Build the context and the prompt
        context = self.create_context(documents)
        prompt = self.create_prompt(query, context)
        
        # Call the LLM
        response = self.openai_client.chat.completions.create(
            model=self.model,
            messages=[
                {"role": "system", "content": "You are a helpful assistant that answers questions based on the provided context."},
                {"role": "user", "content": prompt}
            ],
            temperature=0.0  # Deterministic
        )
        
        answer = response.choices[0].message.content
        tokens_used = response.usage.total_tokens
        
        print(f"✅ Answer generated ({tokens_used} tokens):\n")
        print(f"{answer}\n")
        
        return {
            "answer": answer,
            "model": self.model,
            "tokens": tokens_used,
            "query": query,
            "num_docs": len(documents)
        }


# Example usage
if __name__ == "__main__":
    from retrieval import BaselineRetrievalPipeline
    
    # Retrieval
    retrieval = BaselineRetrievalPipeline()
    query = "What is FastAPI?"
    results = retrieval.retrieve(query, top_k=3)
    
    # Generation
    generation = BaselineGenerationPipeline()
    response = generation.generate(query, results['documents'])

Step 4: The complete RAG system

src/rag_system.py:

"""
The complete RAG system: Retrieval + Generation, integrated
"""
from retrieval import BaselineRetrievalPipeline
from generation import BaselineGenerationPipeline
import time
from typing import Dict


class BaselineRAGSystem:
    """The complete baseline RAG system"""
    
    def __init__(self):
        self.retrieval = BaselineRetrievalPipeline()
        self.generation = BaselineGenerationPipeline()
    
    def query(self, user_query: str, top_k: int = 5, verbose: bool = True) -> Dict:
        """
        A complete query against the RAG system.
        
        Args:
            user_query: the user's question
            top_k: how many documents to retrieve
            verbose: print the logs
        
        Returns:
            A dict with the answer and its metadata
        """
        start_time = time.time()
        
        if verbose:
            print("=" * 80)
            print(f"RAG BASELINE SYSTEM")
            print("=" * 80)
            print(f"Query: {user_query}\n")
        
        # 1. Retrieval
        retrieval_start = time.time()
        retrieval_results = self.retrieval.retrieve(user_query, top_k)
        retrieval_time = (time.time() - retrieval_start) * 1000  # ms
        
        # 2. Generation
        generation_start = time.time()
        generation_results = self.generation.generate(
            user_query,
            retrieval_results['documents']
        )
        generation_time = (time.time() - generation_start) * 1000  # ms
        
        total_time = (time.time() - start_time) * 1000  # ms
        
        # The complete result
        result = {
            "query": user_query,
            "answer": generation_results['answer'],
            "sources": [
                {"id": id, "text": doc[:100] + "..."}
                for id, doc in zip(retrieval_results['ids'], retrieval_results['documents'])
            ],
            "performance": {
                "retrieval_ms": round(retrieval_time, 2),
                "generation_ms": round(generation_time, 2),
                "total_ms": round(total_time, 2)
            },
            "metadata": {
                "model": generation_results['model'],
                "tokens": generation_results['tokens'],
                "top_k": top_k
            }
        }
        
        if verbose:
            print("=" * 80)
            print(f"Performance:")
            print(f"  - Retrieval: {result['performance']['retrieval_ms']}ms")
            print(f"  - Generation: {result['performance']['generation_ms']}ms")
            print(f"  - Total: {result['performance']['total_ms']}ms")
            print("=" * 80 + "\n")
        
        return result


# Example usage
if __name__ == "__main__":
    rag = BaselineRAGSystem()
    
    # Test queries
    queries = [
        "What is FastAPI?",
        "How to create a POST endpoint?",
        "What is dependency injection in FastAPI?"
    ]
    
    for query in queries:
        result = rag.query(query, top_k=3)
        print("\n" + "="*80 + "\n")

📊 Evaluation and benchmarking

src/evaluation.py:

"""
Evaluation: measuring the baseline RAG system's performance
"""
from rag_system import BaselineRAGSystem
import time
import statistics
from typing import List, Dict


class RAGEvaluator:
    """A RAG system evaluator"""
    
    def __init__(self, rag_system: BaselineRAGSystem):
        self.rag_system = rag_system
    
    def benchmark_latency(self, queries: List[str], top_k: int = 5) -> Dict:
        """
        Measure the system's latency across several queries.
        
        Args:
            queries: a list of test queries
            top_k: how many documents to retrieve
        
        Returns:
            A dict with the latency metrics
        """
        latencies = []
        
        print(f"🔄 Benchmarking latency with {len(queries)} queries...\n")
        
        for query in queries:
            result = self.rag_system.query(query, top_k=top_k, verbose=False)
            latencies.append(result['performance']['total_ms'])
        
        # Compute the metrics
        p50 = statistics.median(latencies)
        p95 = statistics.quantiles(latencies, n=20)[18] if len(latencies) >= 20 else max(latencies)
        avg = statistics.mean(latencies)
        
        results = {
            "num_queries": len(queries),
            "latency_p50_ms": round(p50, 2),
            "latency_p95_ms": round(p95, 2),
            "latency_avg_ms": round(avg, 2),
            "latency_min_ms": round(min(latencies), 2),
            "latency_max_ms": round(max(latencies), 2)
        }
        
        print(f"✅ Latency Benchmark Results:")
        print(f"   - P50 (median): {results['latency_p50_ms']}ms")
        print(f"   - P95: {results['latency_p95_ms']}ms")
        print(f"   - Average: {results['latency_avg_ms']}ms")
        print(f"   - Min: {results['latency_min_ms']}ms")
        print(f"   - Max: {results['latency_max_ms']}ms\n")
        
        return results
    
    def manual_precision_evaluation(
        self,
        query: str,
        top_k: int = 5
    ) -> float:
        """
        Evaluate precision by hand (for the baseline, with no golden dataset).
        
        Args:
            query: the test query
            top_k: how many documents were retrieved
        
        Returns:
            The precision score (0.0 - 1.0)
        """
        result = self.rag_system.query(query, top_k=top_k, verbose=False)
        
        print(f"\nQuery: {query}")
        print(f"\nRetrieved documents (top-{top_k}):")
        for i, source in enumerate(result['sources'], 1):
            print(f"{i}. {source['text']}")
        
        # Ask for the manual evaluation
        print(f"\nHow many of the {top_k} documents are relevant?")
        relevant = int(input("Number of relevant docs (0-5): "))
        
        precision = relevant / top_k
        print(f"Precision: {precision:.2%}\n")
        
        return precision


# Example usage
if __name__ == "__main__":
    rag = BaselineRAGSystem()
    evaluator = RAGEvaluator(rag)
    
    # Test queries
    test_queries = [
        "What is FastAPI?",
        "How to create a POST endpoint?",
        "What is dependency injection?",
        "How to add authentication?",
        "How to handle errors in FastAPI?",
        "What is async/await in FastAPI?",
        "How to validate request body?",
        "What are path parameters?",
        "How to return JSON response?",
        "What is Pydantic in FastAPI?"
    ]
    
    # Benchmark the latency
    latency_results = evaluator.benchmark_latency(test_queries)
    
    # Manual precision evaluation (3 random queries)
    print("\n" + "="*80)
    print("Manual Precision Evaluation (3 queries)")
    print("="*80)
    
    precisions = []
    for query in test_queries[:3]:
        precision = evaluator.manual_precision_evaluation(query, top_k=5)
        precisions.append(precision)
    
    avg_precision = statistics.mean(precisions)
    print(f"\nAverage Precision@5: {avg_precision:.2%}")

🎯 Running the project

Step 1: Prepare the dataset

# Download the FastAPI docs (example)
curl https://fastapi.tiangolo.com/ > data/fastapi_docs.txt

# Or build the dataset by hand with ~50-100 paragraphs of docs

Step 2: Index the documents

python src/indexing.py

Expected output:

Document loaded: 125000 characters

🔄 Starting the indexing pipeline...
   1/3 Chunking the document (chunk_size=500)...
       ✅ Created 250 chunks
   2/3 Creating the embeddings...
       ✅ Created 250 embeddings (1536D each)
   3/3 Storing them in ChromaDB...
✅ Stored 250 chunks in the ChromaDB collection 'fastapi_docs'
✅ Indexing pipeline complete!

Step 3: Test the retrieval

python src/retrieval.py

Step 4: Test the complete system

python src/rag_system.py

Step 5: Evaluate the performance

python src/evaluation.py

Expected output:

🔄 Benchmarking latency with 10 queries...

✅ Latency Benchmark Results:
   - P50 (median): 720ms
   - P95: 850ms
   - Average: 735ms
   - Min: 650ms
   - Max: 920ms

================================================================================
Manual Precision Evaluation (3 queries)
================================================================================
Query: What is FastAPI?
...
Precision: 80%

Average Precision@5: 70%

📈 The baseline metrics you should expect

Performance targets (baseline):

MetricTargetWhy
Latency P50700-800msRetrieval (~50ms) + Embeddings (~50ms) + LLM (~600ms)
Latency P95800-1,000msLLM outliers
Precision@565-75%Fixed-size chunking + no re-ranking
Recall@50N/A (manual)Requires a golden dataset
Cost per query$0.002Embeddings ($0.0001) + LLM ($0.002)

Document your results:

README.md:

# RAG Baseline System - Module 1

## Baseline Performance (my system)

| Metric | Result |
|---------|-----------|
| Latency P50 | 720ms |
| Latency P95 | 850ms |
| Precision@5 | 70% |
| Cost per query | $0.0021 |

## Architecture Decisions

- **Chunking:** Fixed-size (500 chars) - a simple baseline
- **Embeddings:** OpenAI ada-002 - high quality
- **Vector DB:** ChromaDB local - free, plenty for 250 chunks
- **LLM:** GPT-3.5-turbo - a good cost/quality balance
- **Re-ranking:** No (simple baseline)

## Next Steps (Modules 2-8)

1. **Module 2:** Improve the chunking (recursive) → Target: +10% precision
2. **Module 3:** Add query expansion → Target: +15% recall
3. **Module 4:** Implement re-ranking → Target: +20% precision

🎯 Success criteria

✅ The project is complete if:

  1. The RAG system works end-to-end (query → answer)
  2. The baseline metrics are documented (latency, precision)
  3. The code is organized into separate modules (indexing, retrieval, generation)
  4. The environment setup is production-ready (.env, .gitignore, requirements.txt)
  5. The README covers your architecture decisions and next steps

🚀 Bonus (optional):

  • An interactive notebook (Jupyter) for the demo
  • Unit tests for each component
  • A CLI for interactive queries
  • A comparison across several chunk sizes (300, 500, 700)

📚 Project deliverables

  1. Source code (the complete src/ folder)
  2. README.md with your decisions and metrics
  3. requirements.txt with the exact dependencies
  4. Benchmark results, documented
  5. A demo notebook (optional)

🎯 Summary

The concepts you applied:

  • ✅ The complete pipeline: Indexing → Retrieval → Generation
  • ✅ Baseline components: fixed-size chunking, direct query, cosine similarity
  • ✅ The stack: ChromaDB + OpenAI + Python
  • ✅ Evaluation: latency benchmarking + manual precision
  • ✅ Documentation: a README with the architecture and the metrics

The baseline metrics you should expect:

  • Latency P50: ~700-800ms
  • Precision@5: ~65-75%
  • Enough to compare against the improvements in modules 2-8

What's next:

Module 2 optimizes chunking (recursive, semantic, structural) to improve precision by +10-15% over this baseline.


📚 Additional resources

  1. ChromaDB Getting Started - The official documentation
  2. OpenAI Embeddings Guide - Best practices
  3. LangChain RAG Tutorial - The official tutorial
  4. Building RAG from Scratch - Pinecone's guide
  5. RAG Evaluation Best Practices - The methodology
  6. Python Project Structure - Best practices

Created: February 6, 2026
Version: 1.0