Building a PDF Question-Answering Application

Ma Mahalakshmi V Updated 16 Sep 2026
7 min read

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.out 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 Mem 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
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.