Home / Articles

Building a Production ETL Pipeline for RAG With LangChain and FAISS

Design chunking, embedding and vector indexing for 40,000 compliance PDFs, with retries, dead-letter queues, Prometheus metrics and measured benchmarks.

Publisher: REACTAPP.TOP18 min read4107 words

This article is published in English.

Sources: Building a Production-Grade ETL Pipeline for RAG: A Low-Level Design Deep Dive

Picture yourself as the GenAI engineer at a mid-sized fintech. The compliance team owns more than 40,000 PDFs, covering regulatory filings, central bank circulars, securities regulator guidelines and internal policies, scattered over SharePoint, S3 and local drives. Analysts burn four to six hours a day searching them by hand. The CTO's request is a chatbot that answers any compliance question from that library in under three seconds.

It's obviously a Retrieval-Augmented Generation job. What's easy to miss is that the quality of the answers is decided long before a question arrives, in the ETL pipeline that prepares the knowledge base. Bad chunking fragments context, naive embedding lets meaning drift and poor indexing makes retrieval slow. A rough rule from experience says that about 80% of RAG quality problems are really ETL problems. The model usually isn't the bottleneck, the pipeline is.

The stack here is Python, LangChain, FAISS, Pinecone and OpenAI embeddings.

The scale involved

  • About 40,000 documents in mixed formats: PDF, DOCX, HTML and plain text.
  • Roughly 120 chunks per document after recursive splitting at 512 tokens.
  • A vector index of 4.8 million embeddings in Pinecone, at 1536 dimensions.
  • P95 retrieval latency of 180 ms once the approximate nearest neighbor index was tuned with ef=200.

Pipeline architecture

The shape is the classic extract, transform, load. Each stage hides domain-specific complexity that's easy to underestimate.

┌────────────────────────────────────────────────────────────────────────────┐
│                    RAG ETL Pipeline                                        │
│                                                                            │
│  [EXTRACT]       [TRANSFORM 1]    [TRANSFORM 2]    [LOAD]                  │
│  Doc Loader  →   Chunker      →   Embedder      →  VectorDB  →  RAG API    │
│  PDF·DOCX·HTML   Semantic splits  Batch encode     Pinecone/FAISS          │
└────────────────────────────────────────────────────────────────────────────┘

Every stage runs as its own idempotent process and writes to a staging store before the next one starts. That buys fault tolerance. If embedding dies at document 30,000, nothing forces a re-extract of all 40,000.

The orchestrator is a dataclass-driven stage runner:

# pipeline/orchestrator.py
# Top-level pipeline orchestration using a dataclass-driven stage runner
from dataclasses import dataclass, field
from typing import List, Optional
import logginglogger = logging.getLogger(__name__)@dataclass
class PipelineConfig:
    source_bucket: str
    chunk_size: int = 512           # tokens, not characters
    chunk_overlap: int = 64         # token overlap between chunks
    embedding_model: str = "text-embedding-3-small"
    embedding_batch_size: int = 256
    vector_store: str = "pinecone"
    index_name: str = "compliance-docs"
    namespace: str = "v2"
    max_workers: int = 8            # parallel PDF extraction workersclass ETLPipeline:
    def __init__(self, config: PipelineConfig):
        self.config = config
        self.loader   = DocumentLoader(config)
        self.chunker  = AdaptiveChunker(config)
        self.embedder = BatchEmbedder(config)
        self.indexer  = VectorIndexer(config)    def run(self, doc_ids: Optional[List[str]] = None) -> dict:
        """Full ETL run. Pass doc_ids for incremental updates."""
        stats = {}        # Stage 1: Extract (parallelised — 2-5s per PDF, 8 workers)
        raw_docs = self.loader.load(doc_ids=doc_ids)
        stats["extracted"] = len(raw_docs)
        logger.info(f"Extracted {len(raw_docs)} documents")        # Stage 2a: Chunk
        chunks = self.chunker.chunk_batch(raw_docs)
        stats["chunks"] = len(chunks)        # Stage 2b: Embed
        embedded = self.embedder.embed_batch(chunks)        # Stage 3: Index
        upserted = self.indexer.upsert(embedded)
        stats["upserted"] = upserted
        return stats

Flowchart as the source of truth

An ASCII flowchart maps every decision point, from raw document intake through retries and batching to the final write into the vector store.

                        ┌──────────────────────┐
                        │  START: Doc Queue    │
                        └──────────┬───────────┘
                                   │
                        ┌──────────▼───────────┐
                        │  Detect Doc Type     │  ← PDF · DOCX · HTML · MD · TXT
                        └──────────┬───────────┘
                                   │
                        ┌──────────▼───────────┐
                        │   Parseable? (OCR)   │
                        └────┬──────────┬──────┘
                         YES │          │ NO
                             │          ▼
                             │   ┌──────────────────┐
                             │   │ Dead-Letter Queue │  ← PagerDuty alert
                             │   └──────────────────┘
                ┌────────────▼────────────────────┐
   EXTRACT      │  Text Extraction + Cleaning     │
                │  PyMuPDF · python-docx · BS4    │
                │  2–5s / PDF · Parallel workers  │
                └────────────┬────────────────────┘
                             │
                ┌────────────▼────────────────────┐
                │   Select Chunking Strategy      │
                └──────┬────────────┬─────────────┘
                       │            │              │
              Code/Tables    Default (prose)   Long-form
                       │            │              │
              ┌────────▼──┐  ┌──────▼──────┐ ┌───▼─────────┐
 TRANSFORM    │ Fixed-Size│  │  Recursive  │ │  Semantic   │
 (CHUNK)      │ char split│  │  Char Split │ │  (NLTK+emb) │
              └────────┬──┘  └──────┬──────┘ └───┬─────────┘
                       └────────────┼─────────────┘
                                    │ (converge)
                        ┌───────────▼─────────────┐
                        │ Attach Metadata Per Chunk│
                        │ doc_id·page·source·auth  │
                        └───────────┬─────────────┘
                                    │
                        ┌───────────▼─────────────┐
                        │  Token Length Validation │
                        │  Assert chunk ≤ 8191 tok │
                        └───────────┬─────────────┘
                                    │
                        ┌───────────▼─────────────┐
                        │ Group Chunks → Batches   │
                        │ batch_size=256           │
                        └───────────┬─────────────┘
                                    │
                        ┌───────────▼─────────────┐
 TRANSFORM              │   Call Embedding API    │ ◄─────────┐
 (EMBED)                │   async · retry · rate  │           │
                        └───────────┬─────────────┘           │
                                    │                         │
                        ┌───────────▼─────────────┐     ┌─────┴────── ┐
                        │    API Call Success?     │ NO │  Exp Backoff│
                        └──────┬──────────┬────────┘    │  2^n+jitter │
                           YES │          └────────────►└─────────────┘
                               │
                        ┌──────▼──────────────────┐
                        │  Normalize Embeddings   │
                        │  L2 norm · float32      │
                        └──────┬──────────────────┘
                               │
                        ┌──────▼──────────────────┐
                        │  Write to Redis Cache   │
                        │  hash(text)→vec · TTL7d │
                        └──────┬──────────────────┘
                               │
                        ┌──────▼──────────────────┐
 LOAD                   │  Upsert to Vector Store │
 (INDEX)                │  Pinecone · batch=100   │
                        └──────┬──────────────────┘
                               │
                        ┌──────▼──────────────────┐
                        │    Index Updated?        │ NO ──► Retry DLQ (max 5)
                        └──────┬──────────────────┘
                           YES │
                        ┌──────▼──────────────────┐
                        │   Emit Pipeline Metrics  │
                        │   Prometheus · Grafana   │
                        └──────┬──────────────────┘
                               │
                        ┌──────▼──────────────────┐
                        │   Pipeline Complete ✓   │
                        └─────────────────────────┘

Each diamond in that chart corresponds to an actual if/else branch in the code. Drawing it first makes the flowchart the architecture's reference before any implementation exists, and not just documentation.

Chunking

Chunking may be the single most influential decision in the pipeline. A chunk is the unit of retrieval. Make it too big and you waste context window on irrelevant text, make it too small and meaning falls apart. No chunk size is universally right.

Fixed-size splitting

The simplest approach cuts every N characters or tokens, optionally with overlap. It suits uniform data such as logs and tables, and it wrecks sentence and paragraph boundaries in prose.

# chunking/fixed_size.py
from langchain.text_splitter import CharacterTextSplitter
import tiktokenclass FixedSizeChunker:
    """
    Use for: Code files, CSV-like data, log files.
    Avoid for: Prose documents, legal text, RBI circulars.
    """
    def __init__(self, chunk_size: int = 512, overlap: int = 50):
        self.enc = tiktoken.get_encoding("cl100k_base")
        self.splitter = CharacterTextSplitter(
            chunk_size=chunk_size,
            chunk_overlap=overlap,
            separator="",              # hard cut — no respect for sentences
            length_function=lambda t: len(self.enc.encode(t)),
        )    def chunk(self, text: str, metadata: dict) -> list[dict]:
        chunks = self.splitter.split_text(text)
        return [
            {
                "text": c,
                "token_count": len(self.enc.encode(c)),
                "chunk_idx": i,
                **metadata,
            }
            for i, c in enumerate(chunks)
        ]

Recursive character splitting (the default)

This is the workhorse. It tries the most meaningful separator first (blank line, then newline, then period, then space, then single character) and only falls back to a harsher split when it must. LangChain's RecursiveCharacterTextSplitter implements it. The adaptive version below counts tokens with tiktoken, so no chunk exceeds the 8192-token limit of the embedding model.

# chunking/recursive_splitter.py
from langchain.text_splitter import RecursiveCharacterTextSplitter
import tiktoken
from typing import Listclass AdaptiveChunker:
    """
    Production-grade chunker for compliance PDFs.
    Uses tiktoken to count tokens (not characters), ensuring
    we never exceed the 8192 embedding model context limit.
    """    # Separator hierarchy: try broader splits first
    SEPARATORS = [
        "\n\n\n",   # Section breaks
        "\n\n",     # Paragraph breaks
        ".\n",      # Sentence at line end
        ". ",       # Sentence in paragraph
        "\n",       # Line break
        " ",        # Word boundary (last resort)
    ]    def __init__(self, config):
        self.enc = tiktoken.encoding_for_model("text-embedding-3-small")
        self.splitter = RecursiveCharacterTextSplitter(
            chunk_size=config.chunk_size,        # 512 tokens
            chunk_overlap=config.chunk_overlap,  # 64 tokens
            separators=self.SEPARATORS,
            length_function=self._token_len,
            is_separator_regex=False,
            keep_separator=True,                 # preserve paragraph structure
        )    def _token_len(self, text: str) -> int:
        return len(self.enc.encode(text, disallowed_special=()))    def chunk_document(self, raw_doc: dict) -> List[dict]:
        text = raw_doc["text"]
        splits = self.splitter.split_text(text)        chunks = []
        for idx, chunk_text in enumerate(splits):
            token_count = self._token_len(chunk_text)            # Safety guard — should not happen, but log if it does
            if token_count > 8191:
                raise ValueError(
                    f"Chunk {idx} has {token_count} tokens — exceeds model limit. "
                    f"doc_id={raw_doc['doc_id']}"
                )            chunks.append({
                "chunk_id": f"{raw_doc['doc_id']}_chunk_{idx}",
                "text": chunk_text,
                "token_count": token_count,
                "chunk_index": idx,
                "total_chunks": len(splits),   # for reconstruction context
                # Metadata propagated to vector store
                "doc_id": raw_doc["doc_id"],
                "source": raw_doc["source"],
                "page_number": raw_doc.get("page_number", 0),
                "doc_type": raw_doc["doc_type"],
                "created_date": raw_doc["created_date"],
                "authority": raw_doc.get("authority", "unknown"),  # RBI, SEBI, etc.
            })        return chunks    def chunk_batch(self, raw_docs: List[dict]) -> List[dict]:
        all_chunks = []
        for doc in raw_docs:
            try:
                all_chunks.extend(self.chunk_document(doc))
            except Exception as e:
                logger.error(f"Chunking failed for {doc['doc_id']}: {e}")
        return all_chunks

Semantic chunking

Here every sentence gets embedded, and the text is split wherever cosine similarity between neighbors drops sharply. The chunks are coherent, but the process runs 10 to 15 times slower and needs embedding calls just for chunking, effectively doubling API usage. It makes sense only for high-value narrative documents such as long legal opinions, annual reports and policy papers.

# chunking/semantic_chunker.py
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddingsclass SemanticChunkerWrapper:
    """
    Use ONLY for: Long-form legal opinions, annual reports,
    policy white papers where paragraph coherence is critical.    Cost warning: This calls the embedding API TWICE per document
    (once for chunking, once for indexing). Cache aggressively.
    """
    def __init__(self, breakpoint_threshold: float = 0.85):
        embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
        self.splitter = SemanticChunker(
            embeddings=embeddings,
            # Splits when cosine similarity between sentences drops below 0.85
            breakpoint_threshold_type="cosine",
            breakpoint_threshold_amount=breakpoint_threshold,
            buffer_size=1,   # include 1 sentence of context across boundaries
        )    def chunk(self, text: str) -> list[str]:
        return self.splitter.split_text(text)

How they compare:

  • Fixed-size: best for code, logs and tabular data. Fast and cheapest, with low coherence.
  • Recursive (default): best for PDFs, DOCX and HTML in most cases. Fast and cheap, with medium coherence.
  • Semantic: best for long legal and policy documents. High coherence, but 10 to 15 times slower and about twice the API calls.

Embedding generation

Embedding turns a chunk into a dense vector that captures its meaning. The design questions are which model to use, how to batch, whether to cache and how to handle rate limits asynchronously.

The rate-limit arithmetic is sobering. OpenAI's text-embedding-3-small allows one million tokens per minute on Tier 2. With 4.8 million chunks averaging 300 tokens, the total is 1.44 billion tokens. Sequential embedding at that limit would take around 24 hours, whereas async batching with parallel workers gets it to about two and a half hours.

# embedding/batch_embedder.py
import asyncio
import hashlib
import json
import time
from typing import List
import openai
import redis
import numpy as np
from tenacity import retry, stop_after_attempt, wait_exponentialclass BatchEmbedder:
    """
    Async batch embedder with:
    - Redis-backed cache (avoid re-embedding unchanged chunks)
    - Exponential backoff on rate limit errors
    - Token budget management to avoid 429s
    """
    TOKEN_BUDGET_PER_MINUTE = 900_000   # Stay under 1M limit with headroom    def __init__(self, config):
        self.client = openai.AsyncOpenAI()
        self.model = config.embedding_model
        self.batch_size = config.embedding_batch_size  # 256
        self.redis = redis.Redis(host="localhost", decode_responses=True)
        self.tokens_this_minute = 0
        self.minute_start = time.monotonic()    def _cache_key(self, text: str) -> str:
        return f"emb:{hashlib.sha256(text.encode()).hexdigest()[:16]}"    def _from_cache(self, text: str):
        val = self.redis.get(self._cache_key(text))
        return json.loads(val) if val else None    def _to_cache(self, text: str, vector: list, ttl: int = 604800):  # 7 days
        self.redis.setex(self._cache_key(text), ttl, json.dumps(vector))    @retry(
        stop=stop_after_attempt(3),
        wait=wait_exponential(multiplier=1, min=4, max=60),
    )
    async def _call_api(self, texts: List[str]) -> List[List[float]]:
        response = await self.client.embeddings.create(
            model=self.model,
            input=texts,
            encoding_format="float",
        )
        return [item.embedding for item in response.data]    async def _embed_batch(self, chunks: List[dict]) -> List[dict]:
        texts = [c["text"] for c in chunks]
        cache_hits, uncached_texts, uncached_idxs = [], [], []        # Separate cache hits from misses
        for i, text in enumerate(texts):
            cached = self._from_cache(text)
            if cached:
                cache_hits.append((i, cached))
            else:
                uncached_texts.append(text)
                uncached_idxs.append(i)        # Token budget enforcement
        elapsed = time.monotonic() - self.minute_start
        if elapsed > 60:
            self.tokens_this_minute = 0
            self.minute_start = time.monotonic()        estimated_tokens = sum(len(t.split()) * 1.3 for t in uncached_texts)
        if self.tokens_this_minute + estimated_tokens > self.TOKEN_BUDGET_PER_MINUTE:
            sleep_time = 60 - elapsed + 2
            logger.info(f"Token budget hit, sleeping {sleep_time:.1f}s")
            await asyncio.sleep(sleep_time)
            self.tokens_this_minute = 0
            self.minute_start = time.monotonic()        # Embed uncached chunks
        new_vectors = []
        if uncached_texts:
            new_vectors = await self._call_api(uncached_texts)
            self.tokens_this_minute += estimated_tokens
            for text, vec in zip(uncached_texts, new_vectors):
                self._to_cache(text, vec)        # Assemble results in original order
        all_vectors = [None] * len(chunks)
        for i, vec in cache_hits:
            all_vectors[i] = vec
        for i, vec in zip(uncached_idxs, new_vectors):
            all_vectors[i] = vec        # L2 normalize for cosine similarity in FAISS / Pinecone
        for chunk, vector in zip(chunks, all_vectors):
            arr = np.array(vector, dtype=np.float32)
            arr /= np.linalg.norm(arr) + 1e-9
            chunk["embedding"] = arr.tolist()        return chunks    async def embed_batch_async(self, all_chunks: List[dict]) -> List[dict]:
        batches = [
            all_chunks[i : i + self.batch_size]
            for i in range(0, len(all_chunks), self.batch_size)
        ]
        tasks = [self._embed_batch(batch) for batch in batches]
        results = await asyncio.gather(*tasks, return_exceptions=True)        embedded_chunks = []
        for r in results:
            if isinstance(r, Exception):
                logger.error(f"Batch embedding failed: {r}")
            else:
                embedded_chunks.extend(r)
        return embedded_chunks    def embed_batch(self, chunks: List[dict]) -> List[dict]:
        return asyncio.run(self.embed_batch_async(chunks))

The rule to apply is never to embed the same chunk twice. A Redis-backed cache cuts incremental runs by 70 to 90 percent on collections that rarely change.

Indexing

Indexing is where embedded chunks end up in a searchable store. Two decisions dominate: the metadata schema (for filtered retrieval) and the index type (Flat, HNSW or IVF).

Metadata separates a toy RAG system from a production one. Take the question about what the securities regulator said on insider trading in 2023. The system should pre-filter on authority=SEBI and year=2023 before running the nearest-neighbor search at all, which shrinks the search space and sharpens precision.

# indexing/vector_indexer.py
from pinecone import Pinecone, ServerlessSpec
from typing import List
import uuid# ── Pinecone Metadata Schema ──────────────────────────────────────────
# These fields are indexed for filtered retrieval.
# Only add fields you will actually filter on — excess metadata costs money.
METADATA_SCHEMA = {
    "doc_id":       str,    # for document-level retrieval
    "chunk_index":  int,    # for ordered reconstruction
    "total_chunks": int,    # for context window sizing
    "source":       str,    # S3 path or SharePoint URL
    "doc_type":     str,    # "circular", "guideline", "policy"
    "authority":    str,    # "RBI", "SEBI", "IRDAI", "internal"
    "year":         int,    # publication year (for temporal filters)
    "page_number":  int,    # for citation in UI
    "token_count":  int,    # for context window management at query time
}class VectorIndexer:
    def __init__(self, config):
        self.pc = Pinecone(api_key=config.pinecone_api_key)
        self.index_name = config.index_name
        self.namespace = config.namespace
        self._ensure_index()    def _ensure_index(self):
        existing = [i.name for i in self.pc.list_indexes()]
        if self.index_name not in existing:
            self.pc.create_index(
                name=self.index_name,
                dimension=1536,                  # text-embedding-3-small output dim
                metric="cosine",
                spec=ServerlessSpec(cloud="aws", region="us-east-1")
            )
            logger.info(f"Created Pinecone index: {self.index_name}")
        self.index = self.pc.Index(self.index_name)    def _build_vectors(self, embedded_chunks: List[dict]) -> list:
        """
        Convert embedded chunk dicts to Pinecone upsert format.
        Each vector = (id, embedding, metadata).
        """
        vectors = []
        for chunk in embedded_chunks:
            vector_id = chunk.get("chunk_id") or str(uuid.uuid4())
            metadata = {
                k: chunk[k]
                for k in METADATA_SCHEMA.keys()
                if k in chunk
            }
            # Store a snippet for quick UI display without fetching full doc
            metadata["text_preview"] = chunk["text"][:200]            vectors.append({
                "id": vector_id,
                "values": chunk["embedding"],
                "metadata": metadata,
            })
        return vectors    def upsert(self, embedded_chunks: List[dict]) -> int:
        vectors = self._build_vectors(embedded_chunks)
        total_upserted = 0        # Pinecone upsert in batches of 100
        for i in range(0, len(vectors), 100):
            batch = vectors[i : i + 100]
            self.index.upsert(vectors=batch, namespace=self.namespace)
            total_upserted += len(batch)        logger.info(f"Upserted {total_upserted} vectors to '{self.namespace}'")
        return total_upserted    def query(
        self,
        query_vector: list,
        top_k: int = 10,
        filters: dict = None
    ) -> list:
        """
        Filtered ANN search. Example:
        filters = {"authority": {"$in": ["SEBI", "RBI"]}, "year": {"$gte": 2022}}
        """
        result = self.index.query(
            vector=query_vector,
            top_k=top_k,
            namespace=self.namespace,
            filter=filters,
            include_metadata=True,
            include_values=False,    # we don't need the vectors back
        )
        return result.matches

FAISS for on-premise setups

When data can't leave for a cloud vector database, which is common in banking, finance and government, FAISS is the standard answer. Choosing the index type matters a lot.

# indexing/faiss_indexer.py
import faiss
import numpy as npclass FAISSIndexer:
    def __init__(self, dim: int = 1536, n_vectors: int = 5_000_000):
        self.dim = dim
        # Index selection logic based on corpus size:
        # < 1M vectors    → IndexFlatIP         (exact, fast enough)
        # 1M–10M vectors  → IndexHNSWFlat       (M=32, efConstruction=64)
        # > 10M vectors   → IndexIVFPQ          (nlist=4096, m=32)
        if n_vectors < 1_000_000:
            self.index = faiss.IndexFlatIP(dim)
        elif n_vectors < 10_000_000:
            self.index = faiss.IndexHNSWFlat(dim, 32)   # M=32 edges per node
            self.index.hnsw.efConstruction = 64
            self.index.hnsw.efSearch = 200               # higher = more accurate, slower
        else:
            quantizer = faiss.IndexFlatIP(dim)
            self.index = faiss.IndexIVFPQ(quantizer, dim, 4096, 32, 8)        # GPU offload for faster build (comment out if no GPU)
        res = faiss.StandardGpuResources()
        self.index = faiss.index_cpu_to_gpu(res, 0, self.index)    def add(self, vectors: np.ndarray):
        # Vectors must be float32, L2 normalized for cosine similarity
        faiss.normalize_L2(vectors)
        self.index.add(vectors)    def search(self, query: np.ndarray, k: int = 10):
        faiss.normalize_L2(query)
        distances, indices = self.index.search(query, k)
        return distances, indices    def save(self, path: str):
        cpu_index = faiss.index_gpu_to_cpu(self.index)
        faiss.write_index(cpu_index, path)    def load(self, path: str):
        self.index = faiss.read_index(path)
  • IndexFlatIP suits corpora under 1 million vectors, gives exact (100%) recall, runs at medium speed and uses a lot of memory.
  • IndexHNSWFlat suits 1 to 10 million vectors, reaches about 98.5% recall@10, queries fast and uses medium memory.
  • IndexIVFPQ suits more than 10 million vectors, reaches 94 to 96% recall, is very fast and uses little memory thanks to 4 to 8 bit compression.

Failure handling and retries

Every stage of a production pipeline will fail sometimes. What matters is failing gracefully: set aside the bad documents without halting the run, and leave a clear audit trail for manual review. A dead-letter queue does that job, tagging each failure with the stage where it happened.

# pipeline/fault_handler.py
from enum import Enum
from dataclasses import dataclass, field
from datetime import datetimeclass FailureStage(Enum):
    EXTRACTION = "extraction"
    CHUNKING   = "chunking"
    EMBEDDING  = "embedding"
    INDEXING   = "indexing"@dataclass
class PipelineFailure:
    doc_id: str
    stage: FailureStage
    error_type: str
    error_message: str
    timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
    retry_count: int = 0
    is_retriable: bool = Trueclass DeadLetterQueue:
    """Stores failed items for later inspection and retry."""
    def __init__(self, redis_client, key_prefix="dlq"):
        self.r = redis_client
        self.prefix = key_prefix    def push(self, failure: PipelineFailure):
        key = f"{self.prefix}:{failure.stage.value}"
        self.r.lpush(key, json.dumps(failure.__dict__))
        logger.warning(
            f"DLQ push: doc={failure.doc_id} stage={failure.stage.value} "
            f"err={failure.error_type}: {failure.error_message[:80]}"
        )    def pop_batch(self, stage: FailureStage, n: int = 50) -> list:
        key = f"{self.prefix}:{stage.value}"
        items = self.r.lrange(key, 0, n - 1)
        self.r.ltrim(key, n, -1)
        return [json.loads(i) for i in items]# ── Categorize errors for smart retry decisions ────────────────────
RETRIABLE_ERRORS = {
    "openai.RateLimitError",
    "openai.APITimeoutError",
    "pinecone.exceptions.PineconeApiException",
    "ConnectionError",
}NON_RETRIABLE_ERRORS = {
    "UnicodeDecodeError",     # Corrupted doc — send to DLQ permanently
    "ValueError",             # Bad chunking logic — needs code fix
    "pdfminer.pdfpage.PDFTextExtractionNotAllowed",  # Encrypted PDF
}def is_retriable(exc: Exception) -> bool:
    return type(exc).__name__ in RETRIABLE_ERRORS

Monitoring

A pipeline with no metrics is a black box. Track at least throughput, latency, error rate and cost per document, exposed to Prometheus and charted in Grafana.

# monitoring/metrics.py
from prometheus_client import Counter, Histogram, Gauge, start_http_server# ── Define metrics ─────────────────────────────────────────────────
docs_processed = Counter(
    "etl_docs_processed_total",
    "Total documents processed",
    ["status"],           # labels: "success" or "failed"
)
chunks_created = Counter(
    "etl_chunks_created_total",
    "Total chunks created"
)
embedding_latency = Histogram(
    "etl_embedding_seconds",
    "Embedding API call latency",
    buckets=[0.1, 0.25, 0.5, 1.0, 2.5, 5.0],
)
index_queue_depth = Gauge(
    "etl_index_queue_depth",
    "Vectors waiting to be indexed"
)
embedding_cache_hit_rate = Gauge(
    "etl_cache_hit_rate",
    "Redis cache hit rate for embeddings"
)
dlq_depth = Gauge(
    "etl_dlq_depth",
    "Dead-letter queue depth",
    ["stage"]
)# Usage in BatchEmbedder:
with embedding_latency.time():   # measures duration automatically
    vectors = await self._call_api(uncached_texts)# Start Prometheus metrics endpoint on port 8001
start_http_server(8001)

Useful dashboard panels:

  • Documents per minute over time.
  • A histogram of P95 embedding latency.
  • Redis cache hit rate, targeting above 80%.
  • Dead-letter queue depth by stage, alerting if it's non-zero for more than five minutes.
  • Cost per document, meaning tokens times price per token.
  • FAISS or Pinecone query latency at retrieval time (P50, P95, P99).

Measured results

These figures come from the compliance system after tuning, on an AWS c5.4xlarge (16 vCPU, 32 GB of RAM) with async embedding workers.

PDF extraction

PyMuPDF without OCR takes 2 to 5 seconds per document, depending on page count and layout, and the work parallelizes cleanly. With 8 concurrent workers, extracting 40,000 PDFs takes about:

40,000 docs × avg 3.5s / 8 workers = ~4.9 hours wall-clock
(scales linearly — 16 workers → ~2.4 hours)

By file type: a simple 1 to 5 page circular takes 2 to 3 seconds because the text layer is clean. A 20 to 50 page guideline with multiple columns and embedded tables takes 3 to 4 seconds. A 100-plus page annual report takes 4 to 5 seconds, since dense layouts and many images have to be skipped. Encrypted or scanned PDFs go to the dead-letter queue, because they need OCR.

OCR is its own pipeline. Scanned files that need Tesseract or AWS Textract take 15 to 45 seconds each and belong in an isolated worker pool, so they never hold up the fast path.

Stage by stage, naive versus optimized

  • PDF extraction: 2 to 5 seconds per document either way, but 8 parallel workers in a ThreadPoolExecutor instead of a sequential loop. The work is limited by waiting on disk and network, not by CPU.
  • Chunking: from 850 to 6,200 chunks per second, by using a pre-compiled tiktoken encoder and not re-initializing it per document.
  • Embedding: from 120 to 14,400 chunks per minute, with async batches of 256 and a Redis cache hitting 87%.
  • FAISS indexing: from 400 to 8,000 vectors per second with GPU-accelerated HNSW builds.
  • Query latency (P95): from 820 ms down to 180 ms, with ef=200, a pre-warmed in-memory index and metadata pre-filtering.

Parallel extraction

The loader fans documents out across a thread pool:

# loader/parallel_pdf_loader.py
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List, Optional
import fitz  # PyMuPDFclass DocumentLoader:
    """
    Parallel PDF extraction using ThreadPoolExecutor.
    PDF parsing is I/O-bound (disk reads + text decode),
    so threads outperform processes here.    Benchmarks (c5.4xlarge, non-OCR PDFs):
      - 1 worker:  ~2–5s per doc, sequential
      - 8 workers: ~2–5s per doc, 8x throughput
      - 16 workers: diminishing returns past disk I/O saturation
    """    def __init__(self, config):
        self.max_workers = config.max_workers  # default: 8    def _extract_single(self, doc_path: str) -> dict:
        """Extract text from a single non-OCR PDF via PyMuPDF."""
        doc = fitz.open(doc_path)
        pages_text = []
        for page in doc:
            pages_text.append(page.get_text("text"))
        doc.close()        full_text = "\n\n".join(pages_text)
        return {
            "doc_id": doc_path,
            "text": full_text,
            "page_count": len(pages_text),
            "source": doc_path,
            "doc_type": "pdf",
        }    def load(
        self,
        doc_paths: List[str],
        doc_ids: Optional[List[str]] = None
    ) -> List[dict]:
        raw_docs, failed = [], []        with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
            future_to_path = {
                executor.submit(self._extract_single, path): path
                for path in doc_paths
            }
            for future in as_completed(future_to_path):
                path = future_to_path[future]
                try:
                    raw_docs.append(future.result())
                except Exception as e:
                    logger.error(f"Extraction failed: {path} → {e}")
                    failed.append(path)        logger.info(
            f"Extracted {len(raw_docs)} docs, "
            f"{len(failed)} failures routed to DLQ"
        )
        return raw_docs

Lessons from running it at scale

Building production RAG is mostly ETL engineering, and a few lessons stand out.

  • Chunking strategy influences retrieval quality more than model choice does. Study your document types before picking a splitter.
  • Measure length in tokens, not characters. Use tiktoken. Character counts mean nothing to an embedding model.
  • Design the metadata schema before writing any ETL code. Filters can't be added to an already-indexed corpus without re-ingesting it.
  • Cache embeddings aggressively. With Redis, incremental runs on updated documents cost 10 to 15 percent of the first full run.
  • Use a dead-letter queue. One bad PDF shouldn't crash the run. Isolate it, log it, alert and replay later.
  • Parallelize non-OCR extraction. At 2 to 5 seconds per PDF across 8 workers, 40,000 documents can be ingested overnight. Never mix scanned-document OCR into the same pool.
  • Treat index type as permanent. Moving from IndexFlatIP to IndexHNSWFlat means a full rebuild, so decide early from the projected corpus size.
  • Instrument from day one. At 3 AM, the only way to learn why retrieval quality slipped is to have latency histograms and queue-depth alerts already running.