Build a PDF Question-Answering AI
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
pip install pypdf chromadb anthropicReal, actual PDF text extraction
# 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_textpypdf 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
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
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
index_pdf("greendesk_master_services_agreement.pdf", document_id="msa-2026")
print(answer_question_about_pdf("What is the termination notice period?", "msa-2026"))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."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
Each real, uploaded PDF gets its OWN Chroma collection
(f"pdf_{document_id}"), rather than one shared collection for
every documentThis 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
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 allThis 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.