NERDICATE
← All articles

Tech & AINov 1, 20268 min read

RAG Systems in Python: Build an AI That Can Answer Questions About Your PDFs

Build a RAG system that lets AI answer questions about your PDFs. Learn how to extract text, split documents into chunks, generate embeddings, and retrieve relevant information to produce answers grounded in your own documents. A practical introduction to the technology behind AI-powered document search.

Build a RAG System That Answers Questions About Your PDFs

A retrieval-augmented generation (RAG) system lets a language model answer questions using documents you control. Instead of relying only on what the model learned during training, you retrieve the most relevant passages from your PDFs and provide them to the model as context.

The core pipeline has four stages: extract text, split it into chunks, convert chunks into embeddings, and retrieve the best matches for a query. Each stage can silently damage the final answer, so the real engineering is not “connect an LLM to a PDF” — it is preserving meaning through every transformation.

What RAG actually does

A minimal RAG system follows this flow:

PDFs → text extraction → chunking → embeddings → vector index
                                                        ↓
User question → query embedding → retrieval → LLM answer

At query time, the system embeds the user’s question, searches a vector index for semantically similar chunks, and passes those chunks to the language model as evidence. The model then writes an answer grounded in the retrieved material.

This matters because an LLM does not automatically “look up” your PDFs. It can only use information present in its prompt. Retrieval supplies that evidence; generation turns it into a response.

Step 1: Extract text from PDFs

PDF is a presentation format, not a clean text format. A PDF stores instructions about where to place glyphs, fonts, images, and lines — not necessarily a tidy sequence of paragraphs. Text extraction therefore has to reconstruct reading order, and it will not always succeed.

For text-based PDFs, pypdf is a practical starting point. Its PdfReader exposes pages, and extract_text() returns the textual content of a page.

from pypdf import PdfReader

def extract_pdf_text(path: str) -> list[dict]:
    reader = PdfReader(path)
    pages = []

    for i, page in enumerate(reader.pages):
        text = page.extract_text() or ""
        text = " ".join(text.split())

        if text:
            pages.append({
                "source": path,
                "page": i + 1,
                "text": text,
            })

    return pages

Keep the page number. It is one of the cheapest and most useful pieces of metadata in a RAG system: it lets the assistant cite where an answer came from rather than merely asserting it.

The scanned-PDF problem

If a PDF is a scan, extract_text() may return little or nothing useful. The document contains images of text, not selectable text. You need optical character recognition (OCR), for example Tesseract, often after improving contrast, resolution, and page orientation.

A simple diagnostic:

pages = extract_pdf_text("report.pdf")
empty = sum(1 for p in pages if len(p["text"]) < 40)

print(f"{empty}/{len(pages)} pages produced little or no text")

If many pages are effectively empty, do not build embeddings yet. Fix extraction first. A RAG system cannot retrieve meaning that was never extracted.

Step 2: Chunk the text

Embedding models have input limits, and long passages dilute semantic focus. A 20-page chapter may contain several unrelated ideas; embedding the entire chapter produces one vector that represents all of them poorly.

Chunking divides the document into retrievable units. The goal is not to make chunks as small as possible. It is to make each chunk self-contained enough to be understood while retaining enough surrounding context to be useful.

A common baseline is overlapping character chunks:

def chunk_text(
    text: str,
    chunk_size: int = 1200,
    overlap: int = 200,
) -> list[str]:
    chunks = []
    start = 0

    while start < len(text):
        end = start + chunk_size
        chunk = text[start:end]
        chunks.append(chunk)
        start += chunk_size - overlap

    return chunks

For many document collections, a better default is to split on paragraph or section boundaries first, then merge small pieces until they approach a target size. Fixed-width splitting is easy, but it can cut a sentence, definition, table, or equation in half.

Chunking choices that matter

Approach Strength Risk
Fixed-size characters Simple and predictable Breaks sentences and ideas
Paragraph-based Preserves natural units Some paragraphs may be too long or too short
Heading-aware splitting Keeps sections together Depends on reliable PDF structure
Recursive splitting Balances size and structure More parameters to tune
Semantic chunking Groups related content More complex and computationally costly

A practical starting configuration is 800–1,200 characters per chunk with 10–20% overlap. Then inspect real retrieved chunks. If answers frequently lack context, increase chunk size or overlap. If retrieval returns broad, unfocused passages, reduce chunk size or improve section-aware splitting.

Step 3: Create embeddings

An embedding is a numerical vector that represents the semantic content of a text passage. Similar meanings produce nearby vectors, even when the wording differs.

For example, these questions should ideally be close in embedding space:

  • “How does the model handle missing data?”
  • “What happens when values are absent from the dataset?”

They use different words, but they ask about the same concept. That is the advantage over naive keyword matching.

Sentence Transformers provides a straightforward way to compute embeddings locally. Its models convert text into dense vectors and support similarity computation between embeddings.

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")

def embed_texts(texts: list[str]) -> list[list[float]]:
    return model.encode(
        texts,
        normalize_embeddings=True,
        show_progress_bar=True,
    ).tolist()

Normalisation is useful when you plan to use cosine similarity. For two vectors \mathbf{a} and \mathbf{b}, cosine similarity is:

\cos(\theta) = \frac{\mathbf{a} \cdot \mathbf{b}}{\|\mathbf{a}\|\|\mathbf{b}\|}

If both vectors are normalised, so that \|\mathbf{a}\| = \|\mathbf{b}\| = 1, this simplifies to:

\cos(\theta) = \mathbf{a} \cdot \mathbf{b}

Use the same embedding model for documents and queries. Embeddings from different models occupy different vector spaces; comparing them directly is usually meaningless.

Step 4: Store vectors and retrieve

For a small personal knowledge base, a vector index does not need to be complicated. FAISS is a library for efficient similarity search and clustering of dense vectors, including nearest-neighbour search over embeddings.

import faiss
import numpy as np
from sentence_transformers import SentenceTransformer

class PdfRagIndex:
    def __init__(self, embedding_model: str = "all-MiniLM-L6-v2"):
        self.model = SentenceTransformer(embedding_model)
        self.chunks: list[dict] = []
        self.index = None

    def add_documents(self, pdf_paths: list[str]):
        records = []

        for path in pdf_paths:
            for page in extract_pdf_text(path):
                for chunk in chunk_text(page["text"]):
                    records.append({
                        "source": page["source"],
                        "page": page["page"],
                        "text": chunk,
                    })

        texts = [record["text"] for record in records]

        vectors = np.array(
            self.model.encode(texts, normalize_embeddings=True),
            dtype="float32",
        )

        self.chunks = records
        self.index = faiss.IndexFlatIP(vectors.shape)[1]
        self.index.add(vectors)

    def search(self, query: str, k: int = 5) -> list[dict]:
        query_vector = np.array(
            [self.model.encode(query, normalize_embeddings=True)],
            dtype="float32",
        )

        scores, ids = self.index.search(query_vector, k)

        results = []
        for score, i in zip(scores, ids):
            result = self.chunks[i].copy()
            result["score"] = float(score)
            results.append(result)

        return results

IndexFlatIP performs exact inner-product search. For normalised vectors, that ranks results by cosine similarity. It is a strong default for small and medium collections; FAISS also provides approximate indexes for larger datasets where memory, latency, and accuracy must be balanced.

Step 5: Turn retrieval into an answer

Retrieval alone does not answer the question. The final prompt should make the evidence visible, restrict the model to that evidence, and require citations.

def build_prompt(question: str, results: list[dict]) -> str:
    context = "\n\n".join(
        f"[{i + 1}] Source: {result['source']}, page {result['page']}\n{result['text']}"
        for i, result in enumerate(results)
    )

    return f"""Answer the question using only the context below.

If the context does not contain enough information, say so clearly.
Cite the relevant sources using their numbered references.

Context:
{context}

Question:
{question}

Answer:"""

A useful answer should distinguish three cases:

  • The retrieved context contains a direct answer.
  • The context contains partial evidence, but the conclusion requires interpretation.
  • The context does not contain enough information.

The third case is not a failure. “I cannot find this in the provided documents” is often the most valuable output a RAG system can produce.

Evaluation: the part people skip

A RAG demo can look impressive while retrieving the wrong passages. Before trusting it, build a small evaluation set of real questions and expected source passages.

Measure at least:

  • Retrieval quality: Did the correct chunk appear in the top k results?
  • Answer faithfulness: Does the answer follow from the retrieved context?
  • Citation accuracy: Do the cited pages actually support the claim?
  • Refusal behaviour: Does the system admit insufficient evidence instead of inventing an answer?
  • Robustness: Does it handle paraphrases, typos, multi-part questions, and ambiguous terms?

For retrieval, a simple metric is recall@k:

\text{recall@}k = \frac{\text{number of relevant chunks retrieved in top } k} {\text{total number of relevant chunks}}

If the correct evidence is not retrieved, no prompt template or powerful LLM can reliably repair the problem. Retrieval is the foundation; generation is the presentation layer.

Common failure modes

Failure Symptom Likely fix
Poor extraction Empty pages, jumbled text, broken tables Use OCR, layout-aware parsing, or a different extractor
Bad chunk boundaries Answers miss context or cite fragments Use section-aware or recursive chunking
Wrong embedding model Paraphrases do not retrieve well Test domain-specific or higher-quality embedding models
Too few retrieved chunks The model lacks evidence Increase k, improve chunking, or add reranking
Too many retrieved chunks Context becomes noisy and expensive Use reranking and stricter relevance thresholds
Hallucination Confident answer without support Require citations and allow explicit refusal
Stale index New PDFs are ignored Rebuild or incrementally update the index

Where to improve first

If your system gives weak answers, improve components in this order:

  1. Extraction: Verify that the text is complete and readable.
  2. Chunking: Check whether retrieved chunks contain enough context.
  3. Retrieval: Test whether the correct source appears for known questions.
  4. Prompting: Require citations, prohibit unsupported claims, and permit refusal.
  5. Reranking: Add a cross-encoder reranker after initial vector retrieval.
  6. Evaluation: Expand a question set based on real user queries.

A cross-encoder reranker can be particularly effective. Vector retrieval quickly narrows thousands of chunks to, say, 50 candidates; a reranker then scores each query–chunk pair more carefully and returns the strongest few.

The essential idea

RAG is not magic context injection. It is an information-retrieval system with a language model at the end.

The quality of its answers is bounded by the quality of its evidence. Extract the text accurately, preserve meaningful boundaries, embed comparable units, retrieve with a measurable objective, and force the model to show its sources. Do that, and a PDF question-answering system becomes genuinely useful rather than merely plausible.

Keep exploring

Tech & AINov 4, 20267 min

What Actually Happens When You Connect to a VPN?

A VPN does not make you invisible online. It creates an encrypted tunnel between your device and a VPN server, hides your IP address from websites, and shifts trust from your ISP to the VPN provider. Here is what changes — and what does not.

Tech & AIOct 28, 20266 min

Artificial Intelligence, or an Alien Form of Intelligence?

What if artificial intelligence is not a lesser version of human intelligence, but something fundamentally different? Explore why machines solve problems in unfamiliar ways, what this reveals about intelligence, and why thinking like a human may not be the ultimate standard.

← All articles