Building a PDF Question-Answering Application

Ma Mahalakshmi V Updated 16 Sep 2026
7 min read ·Lesson 81 of 224

Application Shape

This lesson builds a complete, small end-to-end application: a command-line tool that ingests one or more PDFs into a vector store and answers questions about them with citations. It draws together file upload (Lesson 3), request construction (Lesson 4), and citation handling — while noting where this differs from the input_file approach from Unit 7.

Unit 7 showed sending a PDF's contents directly in a request via input_file, which works well for a single document read once. This lesson's approach — indexing PDFs into a vector store — is the right choice instead when you have multiple documents, want to ask many questions across a session without re-sending the full document every time, or need the document to persist across separate requests and even separate users.

Step 1: Ingesting the PDFs

from openai import OpenAI

client = OpenAI()


def create_knowledge_base(pdf_paths, store_name):
    store = client.vector_stores.create(name=store_name)

    for path in pdf_paths:
        with open(path, "rb") as f:
            result = client.vector_stores.files.upload_and_poll(
                vector_store_id=store.id,
                file=f,
            )
        status_label = "OK" if result.status == "completed" else result.status
        print(f"{path}: {status_label}")

    return store.id


store_id = create_knowledge_base(
    pdf_paths=["contract-2026.pdf", "amendment-a.pdf", "amendment-b.pdf"],
    store_name="contract-review-session",
)
print("Vector store ready:", store_id)

This function creates one fresh vector store for the session and uploads each given PDF into it, printing a simple status line per file so ingestion problems are visible immediately rather than discovered later. Creating a dedicated store per "session" or per "case" (as opposed to reusing one giant shared store) is a reasonable pattern here specifically because this application's documents — a contract and its amendments — form a self-contained unit that doesn't need to be searched alongside unrelated material; it also makes cleanup trivial once the review is done.

Step 2: Asking Questions With Citations

def ask_question(store_id, question):
    response = client.responses.create(
        model="gpt-5.6-terra",
        instructions=(
            "You are a document analysis assistant. Answer strictly using "
            "information found in the provided documents via file search. "
            "If the documents do not contain enough information to answer "
            "confidently, say so explicitly instead of guessing."
        ),
        input=question,
        tools=[{"type": "file_search", "vector_store_ids": [store_id]}],
    )

    citations = []
    for item in response.output:
        if item.type == "message":
            for block in item.content:
                for annotation in getattr(block, "annotations", []):
                    filename = getattr(annotation, "filename", "unknown file")
                    citations.append(filename)

    return response.output_text, citations


answer, sources = ask_question(store_id, "What is the termination notice period?")
print("Answer:", answer)
print("Sources:", sorted(set(sources)))

Note: The exact item.type values and annotation attribute names (filename, and any others available) are specific to the current API version — verify these against official documentation before relying on the exact field names in production.

ask_question sends the user's question along with strict grounding instructions, then walks the structured response.output to pull out every citation attached to the answer text. Deduplicating with sorted(set(citations)) produces a clean list of which source documents actually contributed to the answer — useful both for building a "Sources" section in a UI and for the kind of evidence-checking covered in Lesson 8. Separating the "get the answer" and "extract the citations" concerns into one function that returns both, rather than just printing the text, makes this logic reusable in a web backend, a CLI, or a test, none of which is possible if the function only prints.

Step 3: A Minimal Interactive Loop

def run_qa_session(store_id):
    print("Ask questions about the loaded documents. Type 'quit' to exit.")
    while True:
        question = input("\n> ").strip()
        if question.lower() in {"quit", "exit"}:
            break
        if not question:
            continue

        answer, sources = ask_question(store_id, question)
        print(answer)
        if sources:
            print("Sources:", ", ".join(sorted(set(sources))))
        else:
            print("Sources: none identified")


if __name__ == "__main__":
    run_qa_session(store_id)

This wraps ask_question in a simple read-input, call, print-output loop — the smallest useful interface for testing a document Q&A system interactively during development. The if __name__ == "__main__": guard is standard Python practice: it ensures run_qa_session only executes when this file is run directly as a script, not when it's imported as a module elsewhere (for example, by the test functions shown next, or by a future web framework wrapping this same logic).

Handling Multi-Document Questions

A realistic scenario for this application is a question that spans the base contract and one of its amendments — for example, "does Amendment A change the termination notice period from the original contract?" Because both documents live in the same vector store, a single file_search call can retrieve relevant chunks from both, and the model is responsible for reconciling them:

answer, sources = ask_question(
    store_id,
    "Does Amendment A change the termination notice period defined in the original contract?",
)
print(answer)
print("Sources:", sorted(set(sources)))

This is functionally identical code to the single-document case — the same ask_question function — which is precisely the benefit of indexing all related documents into one vector store rather than writing separate logic per document. The model can retrieve and reason across chunks from both the contract and the amendment in a single request, citing whichever document actually contains the relevant clause. If the amendment doesn't address termination notice at all, the well-grounded instructions from Step 2 should produce an answer noting the original contract's terms apply and that the amendment is silent on that point — which is exactly the kind of "confirm absence of evidence" behavior Lesson 8 examines in more depth.

Building in Session Cleanup

Because this application creates a dedicated vector store per session, it should also support tearing that store down when the session ends, both to avoid unbounded storage growth and, in shared or multi-tenant applications, to avoid leaving sensitive document content indexed indefinitely:

def cleanup_knowledge_base(store_id):
    client.vector_stores.delete(store_id)
    print(f"Deleted vector store {store_id}")

Note: The exact deletion method and whether deleting a vector store also deletes the underlying uploaded files (versus only removing them from the store) are version-specific behaviors — confirm current semantics against official documentation, especially before relying on deletion for data-retention or compliance requirements.

Calling this function at the end of a review session (or on a scheduled cleanup job for sessions older than some retention window) keeps storage usage proportional to active work rather than growing indefinitely. In a real production system handling sensitive documents like contracts, this kind of explicit lifecycle management is not optional — it's a data-retention requirement, and it's much easier to get right if you build it into the application from the start rather than retrofitting it after documents have already accumulated for months.

Testing the Application Logic

The parts of this application worth unit testing are the pure logic — citation extraction and deduplication — not the live API call itself:

class FakeAnnotation:
    def __init__(self, filename):
        self.filename = filename


class FakeContentBlock:
    def __init__(self, annotations):
        self.annotations = annotations


class FakeMessageItem:
    def __init__(self, annotations_per_block):
        self.type = "message"
        self.content = [FakeContentBlock(a) for a in annotations_per_block]


class FakeResponse:
    def __init__(self, output_text, output):
        self.output_text = output_text
        self.output = output


def extract_citations(response):
    citations = []
    for item in response.output:
        if item.type == "message":
            for block in item.content:
                for annotation in getattr(block, "annotations", []):
                    citations.append(getattr(annotation, "filename", "unknown file"))
    return citations


def test_extract_citations_deduplicates_across_blocks():
    fake_response = FakeResponse(
        output_text="The notice period is 30 days per the amendment.",
        output=[
            FakeMessageItem([
                [FakeAnnotation("contract-2026.pdf")],
                [FakeAnnotation("amendment-a.pdf"), FakeAnnotation("amendment-a.pdf")],
            ]),
        ],
    )

    citations = extract_citations(fake_response)

    assert sorted(set(citations)) == ["amendment-a.pdf", "contract-2026.pdf"]
    print("PASS: extract_citations pulls filenames from every content block")


test_extract_citations_deduplicates_across_blocks()

This test builds a small hierarchy of fake objects mirroring the real response shape closely enough to exercise extract_citations — a standalone version of the citation logic from ask_question — without any network call. It confirms citations are correctly gathered across multiple content blocks and that duplicate filenames collapse to one entry when deduplicated. Structuring citation extraction as its own testable function, separate from the API call that produces the response, is what makes this kind of fast, reliable test possible.

Common Mistakes

Sending the full PDF text as part of input on every question instead of relying on file_search, which reintroduces the exact context-window and cost problems RAG exists to solve — once documents are indexed in a vector store, let retrieval pull only the relevant chunks per question.

Never surfacing citations to the end user, which makes it impossible for anyone to verify an answer against the source document — always extract and display which files (and ideally which sections) contributed to an answer, especially for documents like contracts where accuracy has real consequences.

Leaving session-scoped vector stores around indefinitely, accumulating storage costs and retaining potentially sensitive documents longer than necessary — build cleanup into the application's lifecycle from the start.

Best Practices

Group genuinely related documents (a contract and its amendments) into one vector store so cross-document questions can be answered in a single retrieval call, rather than forcing the model to reason without seeing related documents.

Always pair a document Q&A assistant with explicit grounding instructions and citation extraction, since the entire value of this kind of application over a general-purpose chat model is that its answers are traceable back to specific source text.

Treat session or case-scoped vector stores as ephemeral resources with an explicit lifecycle — create them deliberately, and delete them deliberately once their purpose is served, rather than letting them accumulate as an afterthought.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Introduction to the OpenAI SDK Setting Up Python Creating an API Key Your First Call — client.responses.create() and response.output_text Understanding Billing, Credits, and What a Request Costs Why Responses Replaced Chat Completions Anatomy of a Request: model, input, and instructions Anatomy of a Response: The Typed output Array, Not Just Text Roles: User, Assistant, and Developer/System Choosing a Model, and Reading the Models Page Instead of Memorizing Names Instructions vs. Input Writing Prompts That Get Consistent Results Few-Shot Examples Reasoning Models and the reasoning Parameter Debugging a Prompt That Misbehaves Why Streaming Matters for User Experience stream=True and Iterating Over Events Handling the Event Types You Actually Care About Background Mode for Long-Running Jobs Project — Add Live Streaming to Your Chatbot The Problem With Parsing Free Text JSON Schema and Strict Mode Pydantic Models With the SDK's Parse Helpers Handling Refusals and Validation Failures Project — A Resume-to-JSON Extractor Working With input_image input_file, PDFs, and the Files API Image Generation Speech-to-Text and Text-to-Speech Project: A PDF Question-Answering Script What Function Calling Is Defining a Tool Schema The Full Loop Multiple Tools Errors, Timeouts, and Untrusted Arguments Project: A Weather Assistant Web Search File Search and Vector Stores Code Interpreter Remote MCP Servers and Connectors Project: A Research Assistant What an Embedding Is, Without the Maths Generating and Storing Embeddings Similarity Search From Scratch Hosted Vector Stores vs. Rolling Your Own A Small RAG App Over a Folder of Notes Agents vs. a Single API Call — When You Need One pip install openai Giving Agents Tools Handoffs and Multi-Agent Triage Guardrails and Approvals Tracing and Observing What Your Agent Did A Multi-Agent Support Desk Error Codes and What Each One Means Retries, Timeouts, and Backoff Rate Limits and Spend Limits Prompt Caching and Cost Optimisation The Batch API for Bulk Work Async Clients and Concurrency Moderation and Safety Best Practices Designing the App Backend With FastAPI Streaming to a Simple Frontend Deploying and a Cost/Safety Checklist Why Web Search Is Useful for Current Information Using the Web Search Tool with the Responses API Configuring Search Behavior for Application Use Cases Understanding Citations and Source Attribution Where to Go Next Building a Research Assistant with Web Search Combining Web Search with Structured Outputs Handling Conflicting or Low-Quality Web Sources Reducing Unsupported Claims with Grounded Generation Testing Freshness-Sensitive AI Answers Production Considerations for Web-Grounded Applications Understanding File Search and Retrieval-Augmented Generation Creating and Organizing Vector Stores Uploading Documents for Retrieval Connecting Vector Stores to Responses API Requests Designing Document Metadata and Filtering Strategies Building a PDF Question-Answering Application Improving Retrieval Quality With Better Document Preparation Handling Missing Evidence and Retrieval Failures Combining File Search With Web Search Building a Production Knowledge-Base Assistant What the Code Interpreter Tool Is Designed For Running Python-Based Analysis Through the OpenAI SDK Uploading Datasets for Analysis Analyzing CSV and Spreadsheet Data Generating Charts and Data Summaries Handling Generated Files and Downloadable Artifacts Building a Data-Analysis Assistant Combining Code Execution with Structured Outputs Validating Generated Calculations and Results Security and Sandbox Considerations for Code Execution Understanding Multimodal Input with the OpenAI SDK Sending Images to a Model Image Analysis from URLs and Uploaded Files Extracting Text and Information from Screenshots Building an Image-Question-Answering Application Combining Image Input with Structured Output Analyzing Multiple Images in One Request Handling Image Quality and Input Limitations Designing Multimodal Prompts for Reliable Results Building a Practical Vision-Powered Python Application Understanding Speech-to-Text and Text-to-Speech Workflows Transcribing Audio with the OpenAI SDK Working with Uploaded Audio Files Handling Timestamps and Transcription Metadata Building a Meeting Transcription Workflow Generating Spoken Responses from Text Handling Long Audio and Processing Failures Combining Audio with Text and Tool Calling Building an End-to-End Python Voice Application What Embeddings Are and When to Use Them Generating Embeddings With the OpenAI API Preparing Text for Embedding Comparing Vectors With Cosine Similarity Building a Simple Semantic Search Engine in Python Storing Embeddings in a Database Metadata Filtering for Semantic Search Chunking Strategies for Better Retrieval Evaluating Semantic Search Quality Building a Document Similarity Application When Batch Processing Makes Sense Designing Large-Volume AI Processing Pipelines Using Asynchronous Python with the OpenAI SDK Running Concurrent Requests Safely Controlling Concurrency and Avoiding Rate Limits Tracking Batch Job Progress Handling Partial Failures in Bulk Workloads Retrying Failed Items Without Duplicating Successful Work Designing Resumable AI Processing Jobs Building a Production Batch-Processing Pipeline Batch Processing Makes Sense Large-Scale AI Processing Pipelines Async Python with OpenAI SDK Safe Concurrent Requests Concurrency & Rate Limits Batch Progress Tracking Partial Failure Handling Safe Retry Handling Resumable AI Jobs Production Batch Pipeline System–User Data Separation Reusable App Instructions Prompt Templates & Variables Extraction & Classification Prompts Summarization & Transformation Prompts Explicit Output Requirements Prompt Version Management Prompt Testing & Evaluation Reusable Python Prompt Library API Key Security Secure API Key Storage Secure Secret Management Prompt Injection Prevention Trusted vs. Untrusted Content Tool Argument Validation Sensitive Data Handling Secure Logging AI Action Authorization Production AI Security Checklist Why AI Applications Need Evaluation Beyond Unit Tests Unit Testing OpenAI SDK Integration Code Mocking API Responses in Python Tests Testing Structured Outputs Against Schemas Testing Tool-Calling Workflows Building a Small Evaluation Dataset Measuring Accuracy, Consistency, and Failure Rates Regression Testing Prompts and Model Changes Human Evaluation Versus Automated Evaluation Creating a Repeatable Evaluation Pipeline AI Request Monitoring Token Cost Management Usage Metrics Design Reducing Model Calls Prompt & Context Optimization Model Selection & Optimization AI Caching Strategies Interactive Latency Optimization Usage Dashboards & Budget Alerts Performance & Cost Checklist Every API Call Starts Fresh Fixing API Statelessness Server-Side Conversation Memory Limits of Response Chaining What We're Building Conversation Memory Challenges Preparing an OpenAI SDK Application for Deployment Environment-Specific Configuration for Development and Production Deploying a Python AI Service with Docker Container Health Checks and Startup Configuration Managing Secrets in Cloud Deployments Background Workers for Long-Running AI Tasks Queues and Asynchronous Job Architectures Scaling AI Workloads Horizontally Monitoring Production Incidents and Failures Production Deployment Checklist for OpenAI SDK Applications Reusable OpenAI Service Classes AI Client Dependency Injection Typed AI Responses Python Configuration Management AI Request Decorators Centralized AI Error Handling Clean SDK Abstractions Reusable OpenAI Utilities Internal AI Python Libraries SDK Integration Maintenance Production AI Chatbot Document Q&A System Web Research Assistant Customer Support Agent AI Data Analysis Assistant Image Analysis App Meeting Transcription & Summary Semantic Document Search Multi-Tool AI Agent Production OpenAI SDK App Why "It Looked Fine When I Tested It" Isn't Enough Timing Note Status Note Pre-Decision Status Note Current Availability Note
Ask about this post
AI Ask about this post

Ask questions about Building a PDF Question-Answering Application and get answers drawn from it.

Signed-in readers only.