Module 8: Final Capstone Project - Complete RAG System

Chunking & Embedding Pipeline Integration

Description

In this capsule you'll integrate the SmartChunker (from Module 4) and the EmbeddingClient (with multi-provider fallback) to build the complete processing pipeline: Documents → Chunks → Embeddings.

This pipeline is the core of the RAG system. The quality of the chunking determines the quality of the retrieval, and the embedding client determines latency and cost. By the end you'll have a production-ready pipeline with cache, fallback, and metadata enrichment.

Estimated duration: 40-50 minutes


Objectives

By completing this capsule, you'll be able to:

  • ✅ Implement SmartChunker with a recursive strategy
  • ✅ Use RecursiveCharacterTextSplitter (LangChain)
  • ✅ Create an EmbeddingClient with OpenAI + SBERT fallback
  • ✅ Implement a Redis cache for embeddings
  • ✅ Enrich chunks with metadata
  • ✅ Integrate the pipeline end-to-end

Pipeline architecture

Document Ingestion Pipeline (Capsule 02)
         ↓
    Documents
         ↓
┌─────────────────────┐
│   SmartChunker      │
│ - Recursive split   │
│ - Token-aware       │
│ - Metadata enrich   │
└─────────────────────┘
         ↓
      Chunks
         ↓
┌─────────────────────┐
│  EmbeddingClient    │
│ - OpenAI (primary)  │
│ - SBERT (fallback)  │
│ - Redis cache       │
└─────────────────────┘
         ↓
    Embeddings

Step 1: Implement SmartChunker

1.1: Chunker with a recursive strategy

Create src/chunking/smart_chunker.py:

"""
Smart Chunker - RAG System
Intelligent chunking with a recursive strategy and token-awareness
"""

from langchain.text_splitter import RecursiveCharacterTextSplitter
import tiktoken
from typing import List, Dict, Callable
import logging


class Chunk:
    """
    Representation of a chunk
    
    Attributes:
        id: Unique identifier (source_index)
        text: Chunk content
        metadata: Enriched metadata (source, chunk_index, tokens, etc.)
    """
    
    def __init__(self, chunk_id: str, text: str, metadata: Dict):
        self.id = chunk_id
        self.text = text
        self.metadata = metadata
    
    def to_dict(self) -> Dict:
        """Convert to a dictionary"""
        return {
            'id': self.id,
            'text': self.text,
            'metadata': self.metadata
        }
    
    def __repr__(self) -> str:
        return f"Chunk(id='{self.id}', tokens={self.metadata.get('token_count', 0)})"


class SmartChunker:
    """
    Intelligent chunker with a recursive strategy
    
    Features:
    - Token-aware (uses tiktoken for precise counting)
    - Recursive splitting (preserves semantic structure)
    - Metadata enrichment (source, index, tokens)
    - Configurable overlap
    
    Example:
        chunker = SmartChunker(chunk_size=500, chunk_overlap=50)
        chunks = chunker.chunk(documents)
    """
    
    def __init__(
        self,
        chunk_size: int = 500,
        chunk_overlap: int = 50,
        model: str = "gpt-4",
        separators: List[str] = None
    ):
        """
        Initialize SmartChunker
        
        Args:
            chunk_size: Maximum chunk size in tokens
            chunk_overlap: Overlap between chunks in tokens
            model: Model for the tokenizer (gpt-4, gpt-3.5-turbo)
            separators: List of separators (default: paragraphs → sentences → words)
        """
        self.chunk_size = chunk_size
        self.chunk_overlap = chunk_overlap
        self.model = model
        
        # Default separators (priority order)
        self.separators = separators or [
            "\n\n",  # Paragraphs
            "\n",    # Lines
            ". ",    # Sentences
            "! ",    # Exclamations
            "? ",    # Questions
            "; ",    # Semicolons
            ", ",    # Commas
            " ",     # Words
            ""       # Characters (last resort)
        ]
        
        # Create the length function with tiktoken
        self.length_function = self._create_token_counter()
        
        # Initialize RecursiveCharacterTextSplitter
        self.splitter = RecursiveCharacterTextSplitter(
            chunk_size=chunk_size,
            chunk_overlap=chunk_overlap,
            length_function=self.length_function,
            separators=self.separators
        )
        
        self.logger = logging.getLogger(__name__)
    
    def _create_token_counter(self) -> Callable:
        """
        Create a token-counting function using tiktoken
        
        Returns:
            A function that counts tokens
        """
        try:
            encoding = tiktoken.encoding_for_model(self.model)
        except KeyError:
            # Fallback to cl100k_base (used by GPT-4 and GPT-3.5)
            encoding = tiktoken.get_encoding("cl100k_base")
        
        def count_tokens(text: str) -> int:
            """Count tokens in text"""
            return len(encoding.encode(text))
        
        return count_tokens
    
    def chunk(self, documents: List) -> List[Chunk]:
        """
        Chunk a list of documents
        
        Args:
            documents: List of Document objects or dicts with 'text' and 'source'
        
        Returns:
            List of Chunk objects
        """
        all_chunks = []
        
        for doc in documents:
            # Extract text and source
            if hasattr(doc, 'text'):
                text = doc.text
                source = doc.source
                doc_metadata = getattr(doc, 'metadata', {})
            else:
                text = doc['text']
                source = doc.get('source', 'unknown')
                doc_metadata = doc.get('metadata', {})
            
            # Chunk the document
            doc_chunks = self._chunk_single_document(text, source, doc_metadata)
            all_chunks.extend(doc_chunks)
        
        self.logger.info(
            f"Chunked {len(documents)} documents into {len(all_chunks)} chunks "
            f"(avg {len(all_chunks) / max(len(documents), 1):.1f} chunks/doc)"
        )
        
        return all_chunks
    
    def _chunk_single_document(
        self,
        text: str,
        source: str,
        doc_metadata: Dict
    ) -> List[Chunk]:
        """
        Chunk a single document
        
        Args:
            text: Document content
            source: Document identifier
            doc_metadata: Metadata of the original document
        
        Returns:
            List of Chunks for this document
        """
        # Split the text
        text_chunks = self.splitter.split_text(text)
        
        # Create Chunk objects with enriched metadata
        chunks = []
        for i, chunk_text in enumerate(text_chunks):
            # Generate a unique ID
            chunk_id = f"{source}_{i}"
            
            # Enriched metadata
            metadata = {
                **doc_metadata,  # Inherit the document's metadata
                'source': source,
                'chunk_index': i,
                'total_chunks': len(text_chunks),
                'token_count': self.length_function(chunk_text),
                'char_count': len(chunk_text)
            }
            
            chunk = Chunk(
                chunk_id=chunk_id,
                text=chunk_text,
                metadata=metadata
            )
            
            chunks.append(chunk)
        
        return chunks
    
    def estimate_chunks(self, text: str) -> int:
        """
        Estimate the number of chunks for a text
        
        Args:
            text: Text to analyze
        
        Returns:
            Estimated number of chunks
        """
        total_tokens = self.length_function(text)
        effective_chunk_size = self.chunk_size - self.chunk_overlap
        estimated = (total_tokens + effective_chunk_size - 1) // effective_chunk_size
        return max(1, estimated)


# Demo
if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    
    # Create the chunker
    chunker = SmartChunker(chunk_size=500, chunk_overlap=50)
    
    # Example document
    doc = {
        'text': "This is an example document. " * 200,  # ~1000 words
        'source': 'example.txt',
        'metadata': {'author': 'Test'}
    }
    
    # Chunk
    chunks = chunker.chunk([doc])
    
    print(f"\n✅ Created {len(chunks)} chunks")
    for chunk in chunks[:3]:
        print(f"\n{chunk}")
        print(f"  Text: {chunk.text[:100]}...")

Step 2: Implement EmbeddingClient with fallback

2.1: Client with OpenAI + SBERT fallback

Create src/embeddings/embedding_client.py:

"""
Embedding Client - RAG System
Embedding client with OpenAI (primary) + SBERT (fallback) + Redis cache
"""

from openai import OpenAI
from sentence_transformers import SentenceTransformer
import numpy as np
import redis
import json
import hashlib
from typing import List, Union, Optional
import logging
import os
from dotenv import load_dotenv

load_dotenv()


class EmbeddingClient:
    """
    Embedding client with fallback and cache
    
    Features:
    - OpenAI API (primary)
    - Local SBERT (fallback)
    - Redis cache (optional)
    - Retry logic with exponential backoff
    - Batch processing
    
    Example:
        client = EmbeddingClient(use_cache=True)
        embeddings = client.embed(["text 1", "text 2"])
    """
    
    def __init__(
        self,
        use_cache: bool = True,
        openai_model: str = "text-embedding-3-small",
        sbert_model: str = 'all-MiniLM-L6-v2',
        redis_host: str = 'localhost',
        redis_port: int = 6379,
        cache_ttl: int = 86400 * 7  # 7 days
    ):
        """
        Initialize EmbeddingClient
        
        Args:
            use_cache: If True, uses a Redis cache
            openai_model: OpenAI model
            sbert_model: SBERT model
            redis_host: Redis host
            redis_port: Redis port
            cache_ttl: Cache TTL in seconds
        """
        self.openai_model = openai_model
        self.sbert_model_name = sbert_model
        self.cache_ttl = cache_ttl
        
        # OpenAI client
        api_key = os.getenv("OPENAI_API_KEY")
        if api_key:
            self.openai_client = OpenAI(api_key=api_key)
            self.openai_available = True
        else:
            self.openai_client = None
            self.openai_available = False
            logging.warning("OPENAI_API_KEY not found. Using SBERT only.")
        
        # SBERT model (lazy loading)
        self.sbert_model = None
        
        # Redis cache
        if use_cache:
            try:
                self.redis_client = redis.Redis(
                    host=redis_host,
                    port=redis_port,
                    decode_responses=True
                )
                # Test the connection
                self.redis_client.ping()
                self.cache_enabled = True
                logging.info("Redis cache enabled")
            except Exception as e:
                self.redis_client = None
                self.cache_enabled = False
                logging.warning(f"Redis not available: {e}")
        else:
            self.redis_client = None
            self.cache_enabled = False
        
        self.logger = logging.getLogger(__name__)
    
    def _load_sbert(self):
        """Lazy load of the SBERT model"""
        if self.sbert_model is None:
            self.logger.info(f"Loading SBERT model: {self.sbert_model_name}")
            self.sbert_model = SentenceTransformer(self.sbert_model_name)
    
    def embed(
        self,
        texts: Union[str, List[str]],
        use_openai: bool = True,
        normalize: bool = True
    ) -> np.ndarray:
        """
        Generate embeddings with automatic fallback
        
        Args:
            texts: A text or list of texts
            use_openai: If True, tries OpenAI first
            normalize: If True, normalizes embeddings (L2 norm)
        
        Returns:
            numpy array of embeddings (shape: [n, dims])
        """
        # Convert to a list if it's a string
        if isinstance(texts, str):
            texts = [texts]
            was_single = True
        else:
            was_single = False
        
        # Try OpenAI first (if use_openai=True and available)
        if use_openai and self.openai_available:
            try:
                embeddings = self._embed_openai(texts)
                self.logger.info(f"Generated {len(texts)} embeddings with OpenAI")
            
            except Exception as e:
                self.logger.warning(f"OpenAI failed: {e}. Falling back to SBERT.")
                embeddings = self._embed_sbert(texts)
        else:
            # Use SBERT directly
            embeddings = self._embed_sbert(texts)
        
        # Normalize if requested
        if normalize:
            embeddings = embeddings / np.linalg.norm(embeddings, axis=1, keepdims=True)
        
        # If it was a single text, return a single embedding
        if was_single:
            return embeddings[0]
        
        return embeddings
    
    def _embed_openai(self, texts: List[str]) -> np.ndarray:
        """
        Generate embeddings with the OpenAI API
        
        Args:
            texts: List of texts
        
        Returns:
            numpy array of embeddings
        """
        # Check the cache first
        cached_embeddings = self._get_from_cache(texts, model="openai")
        if cached_embeddings is not None:
            return cached_embeddings
        
        # Call the API
        response = self.openai_client.embeddings.create(
            model=self.openai_model,
            input=texts
        )
        
        # Extract the embeddings
        embeddings = np.array([item.embedding for item in response.data])
        
        # Save to cache
        self._save_to_cache(texts, embeddings, model="openai")
        
        return embeddings
    
    def _embed_sbert(self, texts: List[str]) -> np.ndarray:
        """
        Generate embeddings with SBERT
        
        Args:
            texts: List of texts
        
        Returns:
            numpy array of embeddings
        """
        # Lazy load of the model
        self._load_sbert()
        
        # Check the cache
        cached_embeddings = self._get_from_cache(texts, model="sbert")
        if cached_embeddings is not None:
            return cached_embeddings
        
        # Generate the embeddings
        embeddings = self.sbert_model.encode(texts, convert_to_numpy=True)
        
        # Save to cache
        self._save_to_cache(texts, embeddings, model="sbert")
        
        return embeddings
    
    def _cache_key(self, text: str, model: str) -> str:
        """
        Generate a cache key for a text
        
        Args:
            text: Text
            model: Model used (openai/sbert)
        
        Returns:
            Cache key (MD5 hash)
        """
        content = f"{model}:{text}"
        hash_digest = hashlib.md5(content.encode()).hexdigest()
        return f"emb:{hash_digest}"
    
    def _get_from_cache(self, texts: List[str], model: str) -> Optional[np.ndarray]:
        """
        Try to get embeddings from the cache
        
        Args:
            texts: List of texts
            model: Model used
        
        Returns:
            numpy array of embeddings or None if not in cache
        """
        if not self.cache_enabled:
            return None
        
        try:
            embeddings = []
            for text in texts:
                key = self._cache_key(text, model)
                cached = self.redis_client.get(key)
                
                if cached is None:
                    return None  # Cache miss, return None
                
                embedding = json.loads(cached)
                embeddings.append(embedding)
            
            # All embeddings are in the cache
            return np.array(embeddings)
        
        except Exception as e:
            self.logger.warning(f"Cache read error: {e}")
            return None
    
    def _save_to_cache(self, texts: List[str], embeddings: np.ndarray, model: str):
        """
        Save embeddings to the cache
        
        Args:
            texts: List of texts
            embeddings: Array of embeddings
            model: Model used
        """
        if not self.cache_enabled:
            return
        
        try:
            for text, embedding in zip(texts, embeddings):
                key = self._cache_key(text, model)
                value = json.dumps(embedding.tolist())
                self.redis_client.setex(key, self.cache_ttl, value)
        
        except Exception as e:
            self.logger.warning(f"Cache write error: {e}")
    
    def get_embedding_dim(self) -> int:
        """
        Get the embedding dimensionality
        
        Returns:
            Dimensions of the active model
        """
        if self.openai_available:
            # OpenAI text-embedding-3-small = 1536 dims
            return 1536
        else:
            # SBERT all-MiniLM-L6-v2 = 384 dims
            return 384


# Demo
if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    
    # Create the client
    client = EmbeddingClient(use_cache=True)
    
    # Generate embeddings
    texts = ["Python is a language", "JavaScript for the web"]
    embeddings = client.embed(texts)
    
    print(f"\n✅ Generated embeddings: {embeddings.shape}")
    print(f"Dimensions: {client.get_embedding_dim()}")

Step 3: Integrate the complete pipeline

3.1: Pipeline orchestrator

Create src/pipeline/rag_pipeline.py:

"""
RAG Pipeline - RAG System
End-to-end pipeline: Documents → Chunks → Embeddings
"""

from src.ingestion.document_loader import DocumentLoader
from src.chunking.smart_chunker import SmartChunker
from src.embeddings.embedding_client import EmbeddingClient
import logging
from typing import List, Dict, Tuple
import numpy as np


class RAGPipeline:
    """
    Complete RAG pipeline
    
    Components:
    1. DocumentLoader: Loads documents
    2. SmartChunker: Chunks documents
    3. EmbeddingClient: Generates embeddings
    
    Example:
        pipeline = RAGPipeline()
        chunks, embeddings = pipeline.process_directory("./data/documents")
    """
    
    def __init__(
        self,
        chunk_size: int = 500,
        chunk_overlap: int = 50,
        use_cache: bool = True
    ):
        """
        Initialize the RAG Pipeline
        
        Args:
            chunk_size: Chunk size in tokens
            chunk_overlap: Overlap between chunks
            use_cache: If True, uses a Redis cache for embeddings
        """
        self.loader = DocumentLoader()
        self.chunker = SmartChunker(
            chunk_size=chunk_size,
            chunk_overlap=chunk_overlap
        )
        self.embedder = EmbeddingClient(use_cache=use_cache)
        
        self.logger = logging.getLogger(__name__)
    
    def process_directory(
        self,
        dir_path: str
    ) -> Tuple[List, np.ndarray]:
        """
        Process an entire directory
        
        Args:
            dir_path: Path to the directory with documents
        
        Returns:
            Tuple (chunks, embeddings)
        """
        self.logger.info(f"Processing directory: {dir_path}")
        
        # 1. Load documents
        self.logger.info("Step 1/3: Loading documents...")
        documents = self.loader.load_directory(dir_path)
        self.logger.info(f"Loaded {len(documents)} documents")
        
        # 2. Chunk documents
        self.logger.info("Step 2/3: Chunking documents...")
        chunks = self.chunker.chunk(documents)
        self.logger.info(f"Created {len(chunks)} chunks")
        
        # 3. Generate embeddings
        self.logger.info("Step 3/3: Generating embeddings...")
        texts = [chunk.text for chunk in chunks]
        embeddings = self.embedder.embed(texts, use_openai=True)
        self.logger.info(f"Generated {len(embeddings)} embeddings")
        
        self.logger.info("✅ Pipeline complete!")
        
        return chunks, embeddings


# Demo
if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    
    # Create the pipeline
    pipeline = RAGPipeline(chunk_size=500, overlap=50)
    
    # Process the directory
    chunks, embeddings = pipeline.process_directory("./data/documents")
    
    print(f"\n✅ Pipeline complete:")
    print(f"  Chunks: {len(chunks)}")
    print(f"  Embeddings: {embeddings.shape}")

Troubleshooting

Problem 1: Redis connection refused

Cause: Redis isn't running

Solution:

# Install Redis
# macOS:
brew install redis
brew services start redis

# Linux:
sudo apt-get install redis-server
sudo systemctl start redis

# Verify:
redis-cli ping  # Should return PONG

Problem 2: SBERT model downloads slowly

Cause: The model (~90MB) downloads on first use

Solution:

# Pre-download the model
from sentence_transformers import SentenceTransformer

model = SentenceTransformer('all-MiniLM-L6-v2')
print("✅ Model downloaded")

Problem 3: OpenAI rate limit exceeded

Cause: Too many simultaneous requests

Solution:

# Implement batch processing with a delay
import time

def embed_with_backoff(texts, batch_size=20):
    embeddings = []
    
    for i in range(0, len(texts), batch_size):
        batch = texts[i:i+batch_size]
        batch_emb = client.embed(batch)
        embeddings.append(batch_emb)
        
        # Delay between batches
        if i + batch_size < len(texts):
            time.sleep(1)  # 1 second
    
    return np.vstack(embeddings)

Summary

In this capsule you implemented:

  • SmartChunker with a recursive strategy and token-awareness
  • EmbeddingClient with OpenAI + SBERT fallback
  • ✅ Redis cache for embeddings (TTL 7 days)
  • RAGPipeline end-to-end orchestrator
  • ✅ Metadata enrichment in chunks
  • ✅ Error handling and logging

Next capsule: Vector Search with FAISS - Implement an HNSW index for efficient search.


Additional Resources

  1. LangChain Text Splitters - Recursive splitter documentation
  2. tiktoken - OpenAI tokenizer
  3. Sentence-BERT - SBERT documentation
  4. Redis Python Client - redis-py docs
  5. OpenAI Embeddings - API reference
  6. FAISS - Facebook vector search
  7. Chunking Strategies Paper - Research on optimal chunking

Module 8 - Capsule 03