Document Q&A System

Ma Mahalakshmi V Updated 19 Sep 2026
7 min read ·Lesson 211 of 224

Project 2: Build a Document Question-Answering System

This project builds a system that answers questions grounded in a specific set of documents rather than the model's general knowledge. The primary approach here uses the File Search tool covered in Unit 16, which handles chunking, embedding, and retrieval inside a managed vector store. The direct PDF-handling approach from Unit 7 is noted as an alternative at the end for cases where File Search's managed pipeline is not the right fit.

Scope and Design Decisions

The system ingests a folder of PDFs and text files — think internal policy documents, product manuals, or a knowledge base — and answers natural-language questions with citations back to the source documents. Three decisions define the shape of the implementation:

  1. File Search over manual chunking. Building a custom chunking and embedding pipeline (Unit 20's approach) gives more control, but File Search's vector store handles document parsing, chunking, and retrieval as a managed service, which is the right trade-off when the corpus is standard document formats and the team does not need to tune the retrieval algorithm itself.
  2. Citations are structured, not narrative. Instead of asking the model to mention its sources in prose, the response schema (Unit 6) forces every answer to include an explicit list of source file names, so the citation cannot be silently dropped by the model.
  3. Unanswerable questions are a first-class outcome. The system must distinguish "the documents contain the answer" from "the documents don't cover this," rather than letting the model guess from general knowledge, which would defeat the purpose of grounding.

Building the Vector Store

from openai import OpenAI
from pathlib import Path

client = OpenAI()

def build_vector_store(name: str, document_dir: str) -> str:
    vector_store = client.vector_stores.create(name=name)

    file_ids = []
    for path in Path(document_dir).glob("*"):
        if path.suffix.lower() not in {".pdf", ".txt", ".md"}:
            continue
        with open(path, "rb") as f:
            uploaded = client.files.create(file=f, purpose="assistants")
        file_ids.append(uploaded.id)

    client.vector_stores.file_batches.create_and_poll(
        vector_store_id=vector_store.id,
        file_ids=file_ids,
    )
    return vector_store.id

This function does two distinct jobs, deliberately separated. First it uploads each raw file to get an OpenAI file ID — the file upload step is format-agnostic, so PDFs and plain text go through identically. Then it attaches those file IDs to a vector store in a single batch call. create_and_poll blocks until OpenAI finishes parsing and embedding every file in the batch, which matters because querying a vector store before indexing completes silently returns incomplete results rather than an error — polling to completion here avoids a subtle race condition in any code that runs immediately afterward.

Note: File Search chunking behavior and default chunk sizes are managed by OpenAI and can change between SDK versions. If retrieval quality on a specific corpus needs tuning, check current documentation for configurable chunking parameters before assuming the defaults are fixed.

Structured, Citation-Backed Answers

from pydantic import BaseModel

class SourcedAnswer(BaseModel):
    answer: str
    is_answerable_from_documents: bool
    source_files: list[str]

def ask_question(vector_store_id: str, question: str) -> SourcedAnswer:
    response = client.responses.parse(
        model="gpt-5.6-terra",
        input=[
            {
                "role": "system",
                "content": (
                    "Answer only using the attached documents. If the documents "
                    "do not contain the answer, set is_answerable_from_documents "
                    "to false and leave the answer generic."
                ),
            },
            {"role": "user", "content": question},
        ],
        tools=[{"type": "file_search", "vector_store_ids": [vector_store_id]}],
        text_format=SourcedAnswer,
    )
    return response.output_parsed

responses.parse combines tool use and structured output in one call, exactly as in Unit 6: the model can invoke file_search as many times as it needs while reasoning, and the final answer is still coerced into the SourcedAnswer schema before it reaches application code. The is_answerable_from_documents field is what actually enforces grounding — it gives the model an explicit place to admit "not in the documents" instead of quietly falling back to pretrained knowledge, which is the single most common failure mode of naive RAG systems built without this field.

source_files depends on the model correctly naming files it retrieved from, which is reliable for well-separated documents but can blur when several files cover overlapping topics. A stricter implementation would cross-reference response.output[i].content[j].annotations — the File Search tool attaches file citation annotations directly to the output text — and use those annotations as the source of truth instead of trusting the model's own listing in the schema.

def ask_question_with_verified_citations(vector_store_id: str, question: str) -> dict:
    response = client.responses.create(
        model="gpt-5.6-terra",
        input=[{"role": "user", "content": question}],
        tools=[{"type": "file_search", "vector_store_ids": [vector_store_id]}],
    )
    text_output = response.output_text
    cited_files = set()
    for item in response.output:
        if item.type != "message":
            continue
        for content in item.content:
            for annotation in getattr(content, "annotations", []):
                if annotation.type == "file_citation":
                    cited_files.add(annotation.filename)
    return {"answer": text_output, "verified_sources": sorted(cited_files)}

This second version trades the clean Pydantic schema for citations pulled directly from the API's own annotation metadata — a stronger guarantee, since the citation is generated by the retrieval system rather than the language model's free-text description of what it used. Production systems that need auditable sourcing (compliance, legal, medical) should prefer annotation-based citations over model-reported ones; systems where citations are a nice-to-have for user trust can use the simpler structured-output version.

Handling Document Updates

def replace_document(vector_store_id: str, old_filename: str, new_path: str) -> None:
    files = client.vector_stores.files.list(vector_store_id=vector_store_id)
    for f in files.data:
        file_info = client.files.retrieve(f.id)
        if file_info.filename == old_filename:
            client.vector_stores.files.delete(vector_store_id=vector_store_id, file_id=f.id)
            client.files.delete(f.id)

    with open(new_path, "rb") as fh:
        uploaded = client.files.create(file=fh, purpose="assistants")
    client.vector_stores.files.create_and_poll(
        vector_store_id=vector_store_id, file_id=uploaded.id
    )

Document sets change over time, and this is the operation most tutorials skip. Deleting the old vector store file entry and the underlying file object separately matters — removing only the vector store association leaves an orphaned file object accumulating storage cost, while removing only the file object can leave a stale reference in the vector store. Re-indexing after replacement, rather than trying to patch an existing embedding, is correct because embeddings are computed from full document content and cannot be partially updated.

Testing Without a Real Vector Store

class FakeParsedResponse:
    def __init__(self, parsed):
        self.output_parsed = parsed

def test_unanswerable_flag_is_respected(monkeypatch_parse):
    fake_answer = SourcedAnswer(
        answer="The provided documents do not cover this topic.",
        is_answerable_from_documents=False,
        source_files=[],
    )

    def fake_parse(**kwargs):
        return FakeParsedResponse(fake_answer)

    monkeypatch_parse(fake_parse)
    result = ask_question("fake-store-id", "What is the CEO's favorite color?")
    assert result.is_answerable_from_documents is False
    assert result.source_files == []
    print("PASS: unanswerable questions are flagged instead of guessed")

def _make_monkeypatch():
    original = client.responses.parse
    def apply(fn):
        client.responses.parse = fn
    def restore():
        client.responses.parse = original
    return apply, restore

monkeypatch_parse, restore_parse = _make_monkeypatch()
test_unanswerable_flag_is_respected(monkeypatch_parse)
restore_parse()

The test replaces client.responses.parse with a fake function returning a hand-built SourcedAnswer, which lets ask_question's logic be verified — that it returns exactly what the API layer gives it — without a network call, a real vector store, or real documents. This is the same substitution-based testing pattern as earlier units: swap the SDK boundary, not the business logic.

Alternative Approach: Direct PDF Extraction

For a small, static set of PDFs where full control over chunking matters more than convenience — for example, tables that need custom parsing — the Unit 7 approach of extracting text directly (with pypdf or a similar library) and feeding relevant excerpts into the prompt as context remains valid, especially combined with the manual embedding-based retrieval from Unit 20 for corpora too large to fit in a single context window. File Search is preferred by default in this project because it removes the operational burden of running that pipeline yourself.

Extending This Project

Add per-user access control by creating one vector store per permission tier and selecting which store to query based on the requester's role, and add a feedback loop that logs which citations users click through to, which is valuable data for identifying documents that are frequently retrieved but poorly worded.

Common Mistakes

  • Querying a vector store immediately after starting file uploads. Indexing is asynchronous; querying before it completes returns partial or empty results. Always poll to completion with create_and_poll before serving queries.
  • Trusting model-reported source files over API-provided citation annotations. The model can misname or omit sources in free text; annotation objects from the File Search tool are a stronger source of truth for anything audit-sensitive.
  • Deleting only the vector store association when removing a document. This leaves an orphaned file object in storage. Delete both the vector store file link and the underlying file object.

Best Practices

  • Give the model an explicit way to say "not in the documents." A boolean or enum field for answerability is what actually prevents hallucinated answers dressed up as grounded ones.
  • Re-index rather than patch when documents change. Embeddings are computed over full content; there is no safe partial update.
  • Match the retrieval approach to corpus size and control needs. File Search for standard documents at moderate scale; custom embedding pipelines when retrieval tuning or non-standard formats are required.

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 Document Q&A System and get answers drawn from it.

Signed-in readers only.