Module 8: Final Capstone Project - Complete RAG System

Document Ingestion Pipeline

Description

In this capsule you'll implement the first component of the production-ready RAG system: the Document Ingestion Pipeline. This system loads documents from multiple formats (TXT, MD, JSON, PDF), extracts metadata automatically, and prepares the data for the Module 8 chunking pipeline.

This component is critical because the quality of the RAG depends directly on the quality of the ingestion. A robust loader handles edge cases (empty files, wrong encoding, corrupt formats) and extracts useful metadata for later filtering and ranking.

By the end of this capsule you'll have a production-ready DocumentLoader that you can reuse in any RAG project.


Objectives

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

  • ✅ Implement loaders for TXT, MD, JSON, and PDF
  • ✅ Extract metadata automatically (title, author, date, word count)
  • ✅ Handle encoding errors and corrupt formats
  • ✅ Load entire directories recursively
  • ✅ Validate loaded documents
  • ✅ Implement structured logging

Component architecture

Document Ingestion Pipeline
│
├── DocumentLoader (main class)
│   ├── load(file_path) → Document
│   ├── load_directory(dir_path) → List[Document]
│   └── validate(document) → bool
│
├── Format Loaders (private methods)
│   ├── _load_txt()
│   ├── _load_markdown()
│   ├── _load_json()
│   └── _load_pdf()
│
└── MetadataExtractor (helper class)
    ├── extract_basic(text) → Dict
    ├── extract_from_filename(path) → Dict
    └── estimate_tokens(text) → int

Step 1: Environment setup

1.1: Dependencies

pip install pypdf2 python-dotenv

Why:

  • pypdf2: To load PDF files
  • python-dotenv: For configuration (although this component doesn't use API keys)

1.2: File structure

rag-system/
├── src/
│   ├── ingestion/
│   │   ├── __init__.py
│   │   ├── document_loader.py      # Main loader
│   │   └── metadata_extractor.py   # Metadata extractor
│   └── utils/
│       └── logger.py                # Structured logging
├── data/
│   └── documents/                   # Corpus for testing
│       ├── file1.txt
│       ├── file2.md
│       ├── file3.json
│       └── file4.pdf
└── tests/
    └── test_document_loader.py

Step 2: Implement DocumentLoader

2.1: Base class with format detection

Create src/ingestion/document_loader.py:

"""
Document Loader - RAG System
Loads documents from multiple formats with metadata extraction
"""

from pathlib import Path
from typing import List, Dict, Optional
import json
import logging

# PDF support (optional, install with: pip install pypdf2)
try:
    import PyPDF2
    PDF_SUPPORT = True
except ImportError:
    PDF_SUPPORT = False
    logging.warning("PyPDF2 not installed. PDF support disabled.")


class Document:
    """
    Representation of a loaded document
    
    Attributes:
        text: Document content
        source: Path to the original file
        format: File format (txt, md, json, pdf)
        metadata: Additional metadata (title, date, etc.)
    """
    
    def __init__(self, text: str, source: str, format: str, metadata: Optional[Dict] = None):
        self.text = text
        self.source = source
        self.format = format
        self.metadata = metadata or {}
        
        # Auto-generate basic metadata if it doesn't exist
        if 'word_count' not in self.metadata:
            self.metadata['word_count'] = len(text.split())
        if 'char_count' not in self.metadata:
            self.metadata['char_count'] = len(text)
    
    def to_dict(self) -> Dict:
        """Convert to dictionary for serialization"""
        return {
            'text': self.text,
            'source': self.source,
            'format': self.format,
            'metadata': self.metadata
        }
    
    def __repr__(self) -> str:
        return f"Document(source='{self.source}', format='{self.format}', words={self.metadata.get('word_count', 0)})"


class DocumentLoader:
    """
    Loads documents from multiple formats with robust error handling
    
    Supported formats:
    - TXT: Plain text
    - MD: Markdown (extracts the title automatically)
    - JSON: {"text": "...", "metadata": {...}}
    - PDF: Extracts text from PDFs (requires PyPDF2)
    
    Example:
        loader = DocumentLoader()
        
        # Load a single file
        doc = loader.load("document.txt")
        
        # Load an entire directory
        docs = loader.load_directory("./data/documents")
    """
    
    # Supported formats
    SUPPORTED_FORMATS = {'.txt', '.md', '.json'}
    
    def __init__(self, encoding: str = 'utf-8', errors: str = 'replace'):
        """
        Initialize DocumentLoader
        
        Args:
            encoding: Encoding for text files (default: utf-8)
            errors: How to handle encoding errors (replace/ignore/strict)
        """
        self.encoding = encoding
        self.errors = errors
        
        # Add PDF if it's available
        if PDF_SUPPORT:
            self.SUPPORTED_FORMATS.add('.pdf')
        
        # Setup logging
        self.logger = logging.getLogger(__name__)
    
    def load(self, file_path: str) -> Document:
        """
        Load a document from a file
        
        Args:
            file_path: Path to the file
        
        Returns:
            Document object
        
        Raises:
            FileNotFoundError: If the file doesn't exist
            ValueError: If the format is unsupported
        """
        path = Path(file_path)
        
        # Validate that the file exists
        if not path.exists():
            raise FileNotFoundError(f"File not found: {file_path}")
        
        # Validate that it's a file (not a directory)
        if not path.is_file():
            raise ValueError(f"Path is not a file: {file_path}")
        
        # Detect format
        suffix = path.suffix.lower()
        
        if suffix not in self.SUPPORTED_FORMATS:
            raise ValueError(
                f"Unsupported format: {suffix}. "
                f"Supported: {', '.join(self.SUPPORTED_FORMATS)}"
            )
        
        # Delegate to the specific loader
        if suffix == '.txt':
            return self._load_txt(path)
        elif suffix == '.md':
            return self._load_markdown(path)
        elif suffix == '.json':
            return self._load_json(path)
        elif suffix == '.pdf':
            return self._load_pdf(path)
        else:
            raise ValueError(f"No loader for format: {suffix}")
    
    def _load_txt(self, path: Path) -> Document:
        """
        Load a plain text file
        
        Args:
            path: Path to the .txt file
        
        Returns:
            Document object
        """
        try:
            with open(path, 'r', encoding=self.encoding, errors=self.errors) as f:
                text = f.read()
            
            # Validate that it's not empty
            if not text.strip():
                self.logger.warning(f"Empty file: {path}")
            
            # Basic metadata
            metadata = {
                'filename': path.name,
                'extension': path.suffix
            }
            
            return Document(
                text=text,
                source=str(path.absolute()),
                format='txt',
                metadata=metadata
            )
        
        except UnicodeDecodeError as e:
            self.logger.error(f"Encoding error in {path}: {e}")
            raise
        except Exception as e:
            self.logger.error(f"Error loading {path}: {e}")
            raise
    
    def _load_markdown(self, path: Path) -> Document:
        """
        Load a Markdown file and extract the title
        
        Args:
            path: Path to the .md file
        
        Returns:
            Document object with the extracted title
        """
        try:
            with open(path, 'r', encoding=self.encoding, errors=self.errors) as f:
                text = f.read()
            
            # Extract the title (first # header found)
            title = self._extract_markdown_title(text)
            
            # Metadata with the title
            metadata = {
                'filename': path.name,
                'extension': path.suffix,
                'title': title
            }
            
            return Document(
                text=text,
                source=str(path.absolute()),
                format='markdown',
                metadata=metadata
            )
        
        except Exception as e:
            self.logger.error(f"Error loading markdown {path}: {e}")
            raise
    
    @staticmethod
    def _extract_markdown_title(content: str) -> str:
        """
        Extract the Markdown title (first # header)
        
        Args:
            content: Content of the MD file
        
        Returns:
            Extracted title or 'Untitled'
        """
        lines = content.split('\n')
        
        for line in lines:
            # Look for a line that starts with #
            if line.strip().startswith('#'):
                # Remove # and spaces
                title = line.strip().lstrip('#').strip()
                if title:
                    return title
        
        return 'Untitled'
    
    def _load_json(self, path: Path) -> Document:
        """
        Load a structured JSON file
        
        Expected format:
        {
            "text": "document content",
            "metadata": {
                "title": "...",
                "author": "...",
                ...
            }
        }
        
        Args:
            path: Path to the .json file
        
        Returns:
            Document object
        """
        try:
            with open(path, 'r', encoding=self.encoding) as f:
                data = json.load(f)
            
            # Validate the structure
            if 'text' not in data:
                raise ValueError(f"JSON missing 'text' field in {path}")
            
            text = data['text']
            
            # Metadata: combine the JSON metadata + basic metadata
            metadata = data.get('metadata', {})
            metadata['filename'] = path.name
            metadata['extension'] = path.suffix
            
            return Document(
                text=text,
                source=str(path.absolute()),
                format='json',
                metadata=metadata
            )
        
        except json.JSONDecodeError as e:
            self.logger.error(f"Invalid JSON in {path}: {e}")
            raise ValueError(f"Invalid JSON format: {e}")
        except Exception as e:
            self.logger.error(f"Error loading JSON {path}: {e}")
            raise
    
    def _load_pdf(self, path: Path) -> Document:
        """
        Load a PDF file and extract the text
        
        Args:
            path: Path to the .pdf file
        
        Returns:
            Document object with the extracted text
        
        Raises:
            ImportError: If PyPDF2 is not installed
        """
        if not PDF_SUPPORT:
            raise ImportError(
                "PDF support requires PyPDF2. Install with: pip install pypdf2"
            )
        
        try:
            # Open the PDF
            with open(path, 'rb') as f:
                pdf_reader = PyPDF2.PdfReader(f)
                
                # Validate that it has pages
                num_pages = len(pdf_reader.pages)
                if num_pages == 0:
                    raise ValueError(f"PDF has no pages: {path}")
                
                # Extract text from all pages
                text_parts = []
                for page_num in range(num_pages):
                    page = pdf_reader.pages[page_num]
                    text_parts.append(page.extract_text())
                
                text = '\n\n'.join(text_parts)
                
                # PDF metadata
                pdf_info = pdf_reader.metadata or {}
                metadata = {
                    'filename': path.name,
                    'extension': path.suffix,
                    'num_pages': num_pages,
                    'title': pdf_info.get('/Title', 'Untitled'),
                    'author': pdf_info.get('/Author', 'Unknown'),
                    'creator': pdf_info.get('/Creator', 'Unknown')
                }
                
                return Document(
                    text=text,
                    source=str(path.absolute()),
                    format='pdf',
                    metadata=metadata
                )
        
        except Exception as e:
            self.logger.error(f"Error loading PDF {path}: {e}")
            raise
    
    def load_directory(self, dir_path: str, recursive: bool = True) -> List[Document]:
        """
        Load all documents from a directory
        
        Args:
            dir_path: Path to the directory
            recursive: If True, searches subdirectories
        
        Returns:
            List of Documents
        """
        path = Path(dir_path)
        
        if not path.exists():
            raise FileNotFoundError(f"Directory not found: {dir_path}")
        
        if not path.is_dir():
            raise ValueError(f"Path is not a directory: {dir_path}")
        
        documents = []
        
        # Glob pattern based on recursive
        pattern = '**/*' if recursive else '*'
        
        for file_path in path.glob(pattern):
            # Only files with a supported format
            if file_path.is_file() and file_path.suffix.lower() in self.SUPPORTED_FORMATS:
                try:
                    doc = self.load(str(file_path))
                    documents.append(doc)
                    self.logger.info(f"Loaded: {file_path.name}")
                
                except Exception as e:
                    self.logger.warning(f"Skipping {file_path}: {e}")
        
        self.logger.info(f"Loaded {len(documents)} documents from {dir_path}")
        return documents
    
    def validate(self, document: Document) -> bool:
        """
        Validate that a document is well-formed
        
        Args:
            document: Document to validate
        
        Returns:
            True if valid, False otherwise
        """
        # Validate that it has text
        if not document.text or not document.text.strip():
            self.logger.warning(f"Document has no text: {document.source}")
            return False
        
        # Validate the minimum length (e.g. 10 characters)
        if len(document.text) < 10:
            self.logger.warning(f"Document too short: {document.source}")
            return False
        
        # Validate the metadata
        if not document.metadata:
            self.logger.warning(f"Document missing metadata: {document.source}")
            return False
        
        return True


# Usage demo
if __name__ == "__main__":
    # Setup logging
    logging.basicConfig(level=logging.INFO)
    
    # Create the loader
    loader = DocumentLoader()
    
    # Load the example directory
    try:
        docs = loader.load_directory("./data/documents")
        print(f"\n✅ Loaded {len(docs)} documents")
        
        # Show info for each document
        for doc in docs:
            print(f"\n{doc}")
            print(f"  Words: {doc.metadata['word_count']}")
            print(f"  Preview: {doc.text[:100]}...")
    
    except Exception as e:
        print(f"❌ Error: {e}")

Step 3: Advanced Metadata Extractor

Create src/ingestion/metadata_extractor.py:

"""
Metadata Extractor - RAG System
Extracts useful metadata from documents for filtering and ranking
"""

from typing import Dict
import re
from datetime import datetime


class MetadataExtractor:
    """
    Extracts additional metadata from documents
    
    Extracted metadata:
    - Estimated token count (for cost estimation)
    - Language detection (basic)
    - Content type (code, prose, mixed)
    - Creation/modification date
    """
    
    # Tokens per word (approximate for OpenAI)
    TOKENS_PER_WORD = 1.3
    
    def extract(self, document) -> Dict:
        """
        Extract all available metadata
        
        Args:
            document: Document object
        
        Returns:
            Dict with the complete metadata
        """
        text = document.text
        
        return {
            **document.metadata,
            'estimated_tokens': self.estimate_tokens(text),
            'has_code': self.detect_code(text),
            'content_type': self.classify_content(text),
            'extracted_at': datetime.now().isoformat()
        }
    
    def estimate_tokens(self, text: str) -> int:
        """
        Estimate the number of tokens (OpenAI tokenization)
        
        Args:
            text: Text to analyze
        
        Returns:
            Estimated number of tokens
        """
        word_count = len(text.split())
        return int(word_count * self.TOKENS_PER_WORD)
    
    def detect_code(self, text: str) -> bool:
        """
        Detect whether the document contains code
        
        Args:
            text: Text to analyze
        
        Returns:
            True if it contains code blocks
        """
        # Detect markdown code blocks
        if '```' in text:
            return True
        
        # Detect common code patterns
        code_patterns = [
            r'def\s+\w+\(',      # Python functions
            r'function\s+\w+\(', # JS functions
            r'class\s+\w+',      # Class definitions
            r'import\s+\w+',     # Imports
            r'from\s+\w+\s+import' # Python imports
        ]
        
        for pattern in code_patterns:
            if re.search(pattern, text):
                return True
        
        return False
    
    def classify_content(self, text: str) -> str:
        """
        Classify the content type
        
        Args:
            text: Text to analyze
        
        Returns:
            'code', 'documentation', 'prose', 'mixed'
        """
        has_code = self.detect_code(text)
        
        # Detect whether it's documentation (many headers)
        header_count = len(re.findall(r'^#+\s', text, re.MULTILINE))
        is_documentation = header_count > 3
        
        if has_code and is_documentation:
            return 'mixed'  # Documentation with code examples
        elif has_code:
            return 'code'
        elif is_documentation:
            return 'documentation'
        else:
            return 'prose'


# Demo
if __name__ == "__main__":
    from document_loader import Document
    
    # Create an example document
    doc = Document(
        text="""
        # Python Tutorial
        
        ```python
        def hello():
            print("Hello World")
        ```
        """,
        source="example.md",
        format="markdown"
    )
    
    # Extract metadata
    extractor = MetadataExtractor()
    metadata = extractor.extract(doc)
    
    print("Extracted metadata:")
    for key, value in metadata.items():
        print(f"  {key}: {value}")

Troubleshooting

Problem 1: UnicodeDecodeError

Cause: The file has an encoding other than UTF-8

Solution:

# Option 1: Use errors='replace' (replaces invalid characters)
loader = DocumentLoader(encoding='utf-8', errors='replace')

# Option 2: Detect the encoding automatically
import chardet

with open(file_path, 'rb') as f:
    raw_data = f.read()
    detected = chardet.detect(raw_data)
    encoding = detected['encoding']

with open(file_path, 'r', encoding=encoding) as f:
    text = f.read()

Problem 2: Empty PDF extraction

Cause: The PDF is a scanned image (has no extractable text)

Solution:

# Option 1: Use OCR (pytesseract + pdf2image)
from pdf2image import convert_from_path
import pytesseract

images = convert_from_path(pdf_path)
text = '\n'.join([pytesseract.image_to_string(img) for img in images])

# Option 2: Validate that the PDF has text
text = pdf_reader.pages[0].extract_text()
if not text.strip():
    raise ValueError("PDF has no extractable text (might be scanned)")

Problem 3: FileNotFoundError with relative paths

Cause: The working directory is different from what's expected

Solution:

# Always use absolute paths
from pathlib import Path

# Option 1: Resolve a relative path to an absolute one
abs_path = Path(file_path).resolve()

# Option 2: Path relative to the script location
script_dir = Path(__file__).parent
data_path = script_dir / 'data' / 'documents'

Unit tests

Create tests/test_document_loader.py:

import pytest
from src.ingestion.document_loader import DocumentLoader, Document

def test_load_txt():
    """Test loading a TXT file"""
    loader = DocumentLoader()
    doc = loader.load("data/test.txt")
    
    assert isinstance(doc, Document)
    assert doc.format == 'txt'
    assert len(doc.text) > 0

def test_load_directory():
    """Test loading a directory"""
    loader = DocumentLoader()
    docs = loader.load_directory("data/documents")
    
    assert len(docs) > 0
    assert all(isinstance(d, Document) for d in docs)

def test_validate():
    """Test document validation"""
    loader = DocumentLoader()
    
    # Valid document
    valid_doc = Document("Valid content", "test.txt", "txt")
    assert loader.validate(valid_doc) == True
    
    # Empty document
    empty_doc = Document("", "empty.txt", "txt")
    assert loader.validate(empty_doc) == False

Summary

In this capsule you implemented:

  • DocumentLoader with support for TXT, MD, JSON, PDF
  • ✅ Automatic metadata extraction (title, word count, tokens)
  • ✅ Robust error handling (encoding, corrupt formats)
  • ✅ Recursive directory loading
  • ✅ Document validation
  • ✅ Structured logging

Next capsule: Chunking Pipeline - Implement SmartChunker with recursive strategies and metadata enrichment.


Additional Resources

  1. PyPDF2 Documentation - PDF extraction library
  2. Python pathlib - File path manipulation
  3. Chardet - Character encoding detection
  4. Python logging - Structured logging
  5. pytest - Testing framework
  6. LangChain Document Loaders - Alternative with more formats
  7. LlamaIndex SimpleDirectoryReader - Loader with 40+ formats

Module 8 - Capsule 02