~/TechPurAI
~/tutorials/llm-and-advanced-ai/build-a-pdf-question-answering-ai
intermediate·part 11 of 22·3 min read

Build a PDF Question-Answering AI

Updated Aug 16, 2026AI

This series' RAG foundation (parts 1 through 7) worked on plain text documents. This part applies it to a genuinely common, real business need: answering questions about a specific PDF — GreenDesk's real contracts and proposals — without a real human having to read the entire document first.

Real, practical setup

bash
pip install pypdf chromadb anthropic

Real, actual PDF text extraction

python
# pdf_qa.py
from pypdf import PdfReader

def extract_text_from_pdf(filepath: str) -> str:
    reader = PdfReader(filepath)
    full_text = ""
    for page in reader.pages:
        full_text += page.extract_text() + "\n"
    return full_text

pypdf is a real, widely used, open-source library for reading real PDF content in Python — extract_text() pulls the actual, real text content from each page, which is the necessary real first step before anything from this series' earlier RAG work can apply.

Indexing a real, specific document, per-session

python
import chromadb
from improving_retrieval import chunk_document  # part 7's real chunking

def index_pdf(filepath: str, document_id: str) -> None:
    text = extract_text_from_pdf(filepath)
    chunks = chunk_document(text, chunk_size=500, overlap=50)

    client = chromadb.PersistentClient(path="./pdf_index")
    collection = client.get_or_create_collection(f"pdf_{document_id}")

    collection.add(
        documents=chunks,
        ids=[f"{document_id}-chunk-{i}" for i in range(len(chunks))],
    )

This real, direct reuse of part 7's chunk_document() is deliberate — a real contract PDF is exactly the kind of longer document that genuinely needs chunking, unlike Bright Leaf Coffee's short, individual FAQ entries from part 6.

The real, complete Q&A function, scoped to one specific document

python
def answer_question_about_pdf(question: str, document_id: str) -> dict:
    client = chromadb.PersistentClient(path="./pdf_index")
    collection = client.get_collection(f"pdf_{document_id}")

    results = collection.query(query_texts=[question], n_results=3)
    retrieved_chunks = results["documents"][0]

    if not retrieved_chunks:
        return {"answer": "I couldn't find relevant information in this document.", "sources": []}

    context = "\n\n".join(retrieved_chunks)
    ai_client = get_client()
    response = ai_client.messages.create(
        model="claude-sonnet-5",
        max_tokens=512,
        system=(
            f"Answer using ONLY this content from the uploaded "
            f"document:\n\n{context}\n\nIf the document doesn't "
            f"contain the answer, say so directly. Do not use "
            f"outside knowledge."
        ),
        messages=[{"role": "user", "content": question}],
    )
    return {"answer": response.content[0].text, "sources": retrieved_chunks}

A real, concrete example

python
index_pdf("greendesk_master_services_agreement.pdf", document_id="msa-2026")
print(answer_question_about_pdf("What is the termination notice period?", "msa-2026"))
text
Real, expected output:
"According to this document, either party may terminate with 60
days' written notice, except in cases of material breach, which
allows immediate termination."
Why it matters

The explicit "do not use outside knowledge" constraint matters more here than in almost any earlier project in this series — for a real legal document, an answer that blends the actual contract's real terms with the model's own general knowledge about "typical" contract terms is a genuinely dangerous, real failure mode, directly connecting back to the AI Fundamentals series' own hallucination coverage, now in a higher-stakes context.

Real, per-document collection scoping — a deliberate design choice

text
Each real, uploaded PDF gets its OWN Chroma collection
  (f"pdf_{document_id}"), rather than one shared collection for
  every document

This is a genuine, deliberate architectural decision distinct from part 6's single, shared FAQ collection — a real question about "the termination clause" needs to search only the specific, actual contract being asked about, not accidentally retrieve a chunk from an entirely different real GreenDesk contract that happens to be semantically similar.

A real, honest limitation: scanned PDFs

text
This part's extract_text_from_pdf() works correctly for REAL,
  genuine text-based PDFs — it does NOT extract text from a scanned
  image of a document, which contains no real, actual text layer
  to extract at all

This is a real, important, honest gap worth flagging directly rather than glossing over — part 12 covers exactly this scanned-document case, and the genuinely different technique (OCR) it requires.

Next: handling tables and scanned PDFs — the real, practical techniques this part's basic extraction doesn't cover.

VK

Vijay Kumar

Founder of TechPurAI — writing hands-on tutorials and honest tool breakdowns.

LinkedIn ↗
← previous10. When Fine-Tuning Actually Makes Sensenext →12. Handling Tables and Scanned PDFs