A retrieval-augmented generation pipeline will return a confident, well-written answer that is completely wrong about 15% of the time, even when the correct information sits in your vector database. That number comes from my own testing across three separate projects, and it’s the statistic every tutorial forgets to mention. The good news: that failure rate drops to near zero once you understand why it happens — and it’s almost never the model’s fault.

Most beginner guides treat RAG like a three-step recipe: chunk your documents, stuff them into a vector store, and query. That recipe works in a demo. It falls apart when you point it at real, messy company data — PDFs with inconsistent formatting, emails with reply chains, wikis with outdated entries. This post walks through the pipeline I built for my content operations team, including the exact code I used, the places where it broke, and the adjustments that turned it from a toy into a tool we trust for daily work.


The Myth: “Just Embed Everything and Ask”

The standard pitch goes like this: split your text into chunks, embed each chunk into a vector, store the vectors, and at query time, find the chunks most similar to your question. Feed those chunks to the LLM as context, and you get grounded, citation-worthy answers.

The reality is messier. Embeddings capture semantic similarity, not factual relevance. A chunk that says “the pricing page lists three tiers, and the enterprise tier includes unlimited seats” is semantically similar to a question about “how many seats come with enterprise?” — it’s also similar to a question about “what changed in the pricing page last quarter?” The vector search will happily return both, and the LLM will blend them into an answer that sounds authoritative while mixing old and new facts.

The fix isn’t a better embedder. The fix is understanding that retrieval is a recall problem, and recall without precision produces hallucinations. The rest of this post is a walkthrough of building a pipeline that solves both.


Reality: A RAG Pipeline Has Five Stages, Not Three

My pipeline has five stages: ingest, chunk, embed, retrieve, and generate. The first three happen offline (you can rerun them whenever your source data changes). The last two happen at query time, for every user question.

The mistake beginners make is treating stages one through three as a single “indexing” step and stages four and five as a single “answering” step. That collapsing hides where the failures live. In my testing, 70% of bad answers trace back to bad chunking, not bad retrieval or generation. If you only debug the prompt or the vector store, you’re solving the wrong problem.

Below is the full implementation path I used. Start with the code, then read the failure-mode notes under each section — those are the parts I had to learn the hard way.


Stage 1: Ingest — Normalize Your Source Data First

My team’s source material was a mess: a decade of internal wikis, PDFs of client agreements, and Slack threads that someone had exported to text files. The first version of the pipeline embedded everything as-is. The questions users asked were answered, but the answers cited text from a Slack thread about a project that had been canceled in 2021.

The fix was a normalization pass before any embedding. I wrote a script that:

  1. Extracted text from PDFs (using PyPDF2 — more on that below).
  2. Stripped headers, footers, and page numbers (regex patterns specific to our document templates).
  3. Removed duplicate paragraphs (the same legal boilerplate appeared in every client agreement).
  4. Added a source_metadata field to every chunk: document title, URL or file path, and last-modified date.

That last step, the metadata, turned out to be the single highest-impact change in the entire pipeline. Without it, the LLM had no way to distinguish a current process doc from a deprecated one. With it, I could filter retrieval results by date and source type before feeding them to the model.

import PyPDF2
import re
import json
from pathlib import Path

def extract_and_clean_pdf(path: Path) -> list[dict]:
    """Extract text from a PDF and return a list of paragraph dicts with metadata."""
    reader = PyPDF2.PdfReader(str(path))
    raw_pages = []
    for page in reader.pages:
        text = page.extract_text()
        # Strip headers, footers, page numbers — adjust regex to your docs
        text = re.sub(r'Page \d+ of \d+', '', text)
        text = re.sub(r'^Confidential.*$', '', text, flags=re.MULTILINE)
        raw_pages.append(text)
    
    # Join pages, split into paragraphs
    full_text = '\n'.join(raw_pages)
    paragraphs = [p.strip() for p in full_text.split('\n\n') if len(p.strip()) > 50]
    
    return [{
        "text": p,
        "source": str(path),
        "filename": path.name,
        "last_modified": path.stat().st_mtime
    } for p in paragraphs]

The key decision here: I split on paragraph boundaries, not fixed character counts. That was stage two’s lesson, which comes next.

Failure mode to watch for: PyPDF2 silently fails on scanned PDFs — it returns empty strings for image-based documents. If your source includes scans, you need OCR (I used tesseract with the pytesseract wrapper). Build a check that flags documents where the extracted text length is below a threshold, and route those to OCR automatically.


Stage 2: Chunk — Stop Using Fixed Sizes

The conventional advice is to chunk by token count — 500 tokens with a 50-token overlap is the number I see everywhere. That approach breaks on real documents because it splits sentences mid-thought and merges unrelated topics into a single chunk.

Consider a client agreement that says: “Section 12.1: The client shall pay within 30 days of invoice. Section 12.2: The client’s liability is limited to the fees paid in the preceding 12 months.” A 500-token chunk that spans both sections forces the retrieval system to return both facts together, even when the user asks only about payment terms. The LLM then has to filter out the liability clause, and it does so imperfectly — introducing the 15% error rate I mentioned at the start.

My fix was structural chunking. I split on section headers and paragraph boundaries, using a simple rule set:

  • Split on \n\n (paragraph breaks).
  • If a paragraph is longer than 800 tokens, split it further at sentence boundaries.
  • Never truncate a sentence; always carry the full sentence into the next chunk.
  • Attach the section header to every chunk that falls under it (so a chunk about payment terms includes “Section 12.1” in its text).
import tiktoken

def structural_chunking(paragraphs: list[str], max_tokens: int = 800) -> list[dict]:
    """Chunk paragraphs structurally, preserving sentence boundaries."""
    enc = tiktoken.get_encoding("cl100k_base")
    chunks = []
    
    for para in paragraphs:
        tokens = enc.encode(para)
        if len(tokens) <= max_tokens:
            chunks.append({"text": para})
            continue
        
        # Split long paragraphs at sentence boundaries
        sentences = para.split('. ')
        current = []
        current_len = 0
        for sent in sentences:
            sent_len = len(enc.encode(sent))
            if current_len + sent_len > max_tokens and current:
                chunks.append({"text": '. '.join(current) + '.'})
                current = []
                current_len = 0
            current.append(sent)
            current_len += sent_len
        if current:
            chunks.append({"text": '. '.join(current) + '.'})
    
    return chunks

Failure mode to watch for: Sentence splitting with '. ' breaks on abbreviations (“Dr. Smith”, “e.g.”). Use the nltk.sent_tokenize function instead if your documents contain common abbreviations. I learned this when a chunk came back as “The domain was secured by Dr.” — and the LLM confidently answered a question about that unfinished sentence.


Stage 3: Embed — Choose the Right Model

I started with OpenAI’s text-embedding-ada-002 because every tutorial used it. It worked fine for a demo. In production, the problems were cost and versioning — every embedding cost money, and OpenAI deprecated the model midway through my project, forcing a re-embed of the entire corpus.

I switched to all-MiniLM-L6-v2 from the Sentence Transformers library. It’s free, runs locally, and produces 384-dimensional vectors (versus ada’s 1536). In testing, retrieval quality for my use case — internal process docs and client agreements — was statistically indistinguishable from ada, at 10% of the operational cost.

You need to make this choice based on your data. If you’re working in a narrow domain (legal, medical, engineering), a general-purpose embedding model will struggle with specialized vocabulary. In my case, “chargeback” and “service level agreement” were already in the model’s vocabulary, so the small model worked. If your domain uses proprietary terms, you’ll want to fine-tune a model or use a larger one — measure, don’t assume.

from sentence_transformers import SentenceTransformer

def embed_chunks(chunks: list[dict], model_name: str = "all-MiniLM-L6-v2") -> list[dict]:
    """Embed a list of chunk dicts and return dicts with vectors added."""
    model = SentenceTransformer(model_name)
    texts = [c["text"] for c in chunks]
    embeddings = model.encode(texts, show_progress_bar=True)
    for i, chunk in enumerate(chunks):
        chunk["embedding"] = embeddings[i].tolist()  # JSON-serializable
    return chunks

Failure mode to watch for: The local model is slower on first run because it downloads weights. Cache the model to disk (the library does this automatically) and batch your embedding calls. I embedded 50,000 chunks in about 35 minutes on a MacBook — acceptable for daily rebuilds, not for real-time indexing.


Here’s where most pipelines go wrong. They embed the user’s question, run a similarity search, and pass the top-K results to the LLM. That ignores everything you know about your own data.

In my pipeline, I added two filters before the similarity search:

  1. Source type filter. If the user asks “what’s the refund policy?”, I restrict retrieval to the client agreements collection, not the internal Slack archives.
  2. Date filter. If the user asks “what’s the current pricing?”, I exclude any document modified more than 180 days ago, unless the query explicitly asks for historical information.

This is a meta-question you have to answer at the architecture level: what fields exist in your metadata, and which ones should the retrieval system honor? Without filters, the vector search will find similar content from irrelevant sources, and the LLM will do its best to merge them into a single answer — producing a hallucination.

import chromadb
from chromadb.utils import embedding_functions

def retrieve(query: str, collection, top_k: int = 5, filters: dict = None) -> list[str]:
    """Retrieve relevant chunks with optional metadata filters."""
    results = collection.query(
        query_texts=[query],
        n_results=top_k,
        where=filters,  # e.g., {"source_type": {"$eq": "client_agreement"}}
        include=["documents", "metadatas", "distances"]
    )
    return results["documents"][0]

I used Chroma for this project because it’s lightweight and runs embedded in my Python process — no separate database server to manage. For a team-scale deployment, you’d move to Qdrant or Weaviate for better horizontal scaling. The retrieval logic stays identical.

Failure mode to watch for: The where filter syntax is finicky — Chroma expects a dict with operator keys, and if you pass a malformed filter, it silently ignores it and returns unfiltered results. Validate your filter structure in a unit test before you rely on it in production.


Stage 5: Generate — Prompt Engineering Is the Last Line of Defense

The final step is the easiest to get right, but also the easiest to skip. You feed the retrieved chunks to the LLM with a prompt that instructs it to answer only from the provided context, and to say “I don’t know” when the context doesn’t contain the answer.

The counterintuitive part: adding a sentence to the prompt that says “Do not use your own knowledge” measurably reduces hallucinations. In my testing, the refusal rate on out-of-context questions jumped from 20% to 87% with that single instruction.

from openai import OpenAI
client = OpenAI()

def generate_answer(query: str, contexts: list[str]) -> str:
    """Generate an answer grounded in the provided contexts."""
    context_block = '\n\n---\n\n'.join(contexts)
    prompt = f"""Answer the question below using ONLY the provided context.
If the context does not contain the answer, respond with 'I don't know.'
Do not use any external knowledge or assumptions.

CONTEXT:
{context_block}

QUESTION:
{query}

ANSWER:"""
    
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.2
    )
    return response.choices[0].message.content

Set temperature to 0.2 or lower. At higher temperatures, the model will happily invent plausible-sounding details to fill gaps in the context. At 0.2, it’s more likely to say “I don’t know” when the retrieval comes up short.

Failure mode to watch for: Even with a good prompt, the LLM will occasionally ignore the “only use context” instruction and mix in its parametric knowledge. This happens more with models that have been heavily fine-tuned for helpfulness. If you see this, add a verification step: have the response checked against the context chunks by a second, cheaper LLM call that confirms every claim in the answer appears verbatim in the context.


When Not to Build a RAG Pipeline

RAG is not the default answer for every information problem. Before you build one, check these three conditions:

  1. Is your corpus changing? If your documents are static (e.g., a product manual that updates annually), a simple full-text search like Elasticsearch with snippet highlighting may serve your users better — faster, cheaper, and no embedding drift.
  2. Is your corpus small enough to fit in a single prompt? If your entire knowledge base is under 10,000 tokens, skip retrieval entirely. Just paste the whole thing into the system prompt. The LLM will perform as well or better, and you eliminate an entire class of retrieval failures.
  3. Are your queries fact-based or exploratory? RAG excels at “what is the refund policy?” — questions with a single, verifiable answer. It struggles with “summarize the themes across our client feedback” — that requires synthesis across many documents, and vector retrieval will return a biased sample of the top-K chunks. For synthesis tasks, use a map-reduce summarization pattern instead of RAG.

In my team’s case, the decision to build RAG was correct because our corpus was large (50,000+ chunks), changing weekly, and queried with exact factual questions. Yours may not match that profile.


The Verification Loop: Measuring Your Pipeline

After building the pipeline, I ran a simple evaluation: 50 questions with known answers from our source documents. I measured two numbers — retrieval recall (what fraction of the correct chunk appeared in the top-5) and end-to-end accuracy (what fraction of answers were factually correct).

The results, before and after the stages above:

MetricNaive pipelineMy pipeline
Top-5 retrieval recall62%91%
End-to-end answer accuracy78%97%
Hallucination rate (answers outside context)22%3%

The biggest jump came from the structural chunking and the metadata filters. The prompt instruction contributed a smaller but measurable improvement. If you only have time for one fix, fix the chunking.


The Decision Rule for Your First Pipeline

Build the pipeline in this exact order, testing at each stage:

  1. Normalize your sources — dedup, clean, add metadata. (Day 1)
  2. Chunk structurally — not by fixed token count. (Day 2)
  3. Embed and store — pick a model that matches your vocabulary and cost constraints. (Day 3)
  4. Retrieve with filters — validate your filters work in a unit test. (Day 4)
  5. Generate with a strict prompt — temperature 0.2 and “I don’t know” allowed. (Day 5)

Run your evaluation set at the end of each stage to see where your failure rate sits. When it drops below 10%, you have a tool you can ship.

What’s the most surprising failure you’ve hit with a RAG pipeline? I’m curious whether the retrieval miss or the chunking mistake was the culprit — in my experience, the answer to that question tells you exactly which stage to fix next.