Improving Retrieval Quality With Better Document Preparation

Ma Mahalakshmi V Updated 16 Sep 2026
8 min read ·Lesson 82 of 224

Why Document Quality Is the Highest-Leverage Fix

When a file-search-backed assistant gives a wrong or vague answer, the instinctive response is often to tweak the prompt or switch models. In practice, the single highest-leverage place to look first is the input documents themselves — because retrieval can only surface chunks that exist and are well-formed. No amount of prompt engineering compensates for a chunk that split a sentence in half, or a scanned PDF with no extractable text at all. This lesson covers the concrete, practical steps that improve what actually gets indexed, building on the ingestion mechanics from Lesson 3.

Problem 1: Documents With No Real Text Layer

A PDF that was produced by scanning a paper document is, from a text-extraction perspective, just an image. There is no embedded text layer for file_search's ingestion pipeline to extract, so the resulting chunks are empty or nonexistent, and that document is effectively invisible to retrieval even though it "uploaded successfully."

The fix is to run OCR (optical character recognition) on such documents before uploading them, producing a text layer that extraction can actually find. Unit 7 already covers PDF handling in depth, including OCR workflows for scanned documents — apply that same preprocessing here before ingestion, not after discovering that a document never surfaces in any search.

def looks_extractable(pdf_text, min_chars_per_page, page_count):
    if page_count == 0:
        return False
    average_chars = len(pdf_text) / page_count
    return average_chars >= min_chars_per_page


def test_looks_extractable_flags_scanned_pdf():
    # A scanned PDF with no OCR often yields little to no extracted text.
    near_empty_text = " " * 20
    assert looks_extractable(near_empty_text, min_chars_per_page=200, page_count=5) is False
    print("PASS: near-empty extracted text is flagged as not extractable")


def test_looks_extractable_accepts_normal_document():
    normal_text = "This is a paragraph. " * 300
    assert looks_extractable(normal_text, min_chars_per_page=200, page_count=5) is True
    print("PASS: normal text-dense document passes the extractability check")


test_looks_extractable_flags_scanned_pdf()
test_looks_extractable_accepts_normal_document()

This small heuristic function checks whether a PDF likely has a usable text layer by comparing the average number of extracted characters per page against a threshold — a scanned document with no OCR will extract to almost nothing, while a normal text document extracts to a substantial amount of text per page. Running a check like this as a pre-ingestion gate (using a local PDF text extraction library, separate from the OpenAI API itself) lets you catch and route scanned documents to an OCR step automatically, instead of silently ingesting documents that will never contribute a single relevant chunk.

Problem 2: Poor Document Structure

Chunking algorithms rely on structural signals — headings, paragraph breaks, list formatting — to decide where one topical unit ends and another begins. A document that is one enormous unbroken wall of text (common in poorly-formatted Word-to-PDF exports) gives the chunker nothing to work with, often producing chunks that split mid-topic or even mid-sentence.

Improving structure before upload, where you have control over the source document, pays off directly:

  • Use real headings (not just bold, larger text that looks like a heading visually but carries no structural markup) when the source format supports it.
  • Break dense paragraphs into shorter ones organized around a single idea each.
  • Use actual list formatting for enumerated content instead of run-on sentences separated by commas.

For content you author directly — internal wikis exported to Markdown, for instance — this is entirely within your control and costs little at authoring time compared to the retrieval quality it buys later.

Problem 3: Boilerplate and Repeated Noise

Headers, footers, page numbers, and repeated legal disclaimers that appear on every page of a PDF get extracted along with the real content, and they can pollute chunks — especially short chunks where boilerplate might make up a large fraction of the chunk's total content, diluting the embedding's semantic signal.

A simple mitigation is stripping known boilerplate patterns from extracted text before it's uploaded, when you're able to pre-process a document yourself rather than relying purely on the automatic pipeline:

import re


def strip_boilerplate(text, patterns):
    cleaned = text
    for pattern in patterns:
        cleaned = re.sub(pattern, "", cleaned, flags=re.MULTILINE)
    return cleaned.strip()


boilerplate_patterns = [
    r"^Confidential — Internal Use Only$",
    r"^Page \d+ of \d+$",
]

raw_text = (
    "Confidential — Internal Use Only\n"
    "Section 3: Data Retention Policy\n"
    "All customer data is retained for 90 days.\n"
    "Page 4 of 12"
)

cleaned_text = strip_boilerplate(raw_text, boilerplate_patterns)
print(cleaned_text)

This function applies a list of regular expression patterns to remove known repeated boilerplate lines from extracted text, leaving only the substantive content. The patterns list is deliberately passed in as an argument rather than hardcoded, since boilerplate differs across document sets — a legal team's disclaimers look nothing like an engineering wiki's footer — and keeping the function generic makes it reusable across different ingestion pipelines. This kind of cleaning step happens on text you control before upload, not on documents already ingested by the hosted pipeline, since file_search does not provide a way to edit already-indexed chunk text directly.

Problem 4: Tables and Structured Data

Tables are one of the most reliable sources of retrieval failure, because the meaning of a table cell depends on its row and column headers, and naive text extraction can flatten a table into a sequence of numbers with the header context stripped away or pushed far from the values it describes, especially if a table spans a chunk boundary.

Where you control document creation, converting critical tables into a more retrieval-friendly textual form — restating each row as a full sentence — often improves retrieval far more than any downstream tuning:

def table_row_to_sentence(row, headers):
    parts = [f"{header} is {value}" for header, value in zip(headers, row)]
    return ", ".join(parts) + "."


headers = ["Plan", "Monthly Price", "Support Level"]
rows = [
    ["Basic", "$9", "Email only"],
    ["Pro", "$29", "Priority chat and email"],
    ["Enterprise", "$99", "Dedicated account manager"],
]

sentences = [table_row_to_sentence(row, headers) for row in rows]
for sentence in sentences:
    print(sentence)

This produces output like Plan is Basic, Monthly Price is $9, Support Level is Email only. — verbose compared to a compact table, but far more robust to chunking, because each row now carries its own header context inline rather than depending on being read alongside a separate header row that a chunk boundary might separate it from. This transformation is worth applying selectively to the tables that matter most for question-answering (pricing tables, comparison charts, policy matrices), not universally, since it does increase token count and can be unnecessary for tables that aren't likely to be queried directly.

Problem 5: Outdated or Conflicting Content

A vector store accumulates documents over time, and if an old version of a policy is never removed when a new version is uploaded, retrieval can surface either version — or both — leaving the model to reconcile contradictory "facts" that are really just stale content that should have been retired.

The fix here isn't a document preparation technique so much as a process discipline: treat document replacement as a delete-and-reupload operation, not an add-only operation.

def replace_document(client, vector_store_id, old_file_id, new_file_path, attributes):
    client.vector_stores.files.delete(
        vector_store_id=vector_store_id,
        file_id=old_file_id,
    )

    with open(new_file_path, "rb") as f:
        result = client.vector_stores.files.upload_and_poll(
            vector_store_id=vector_store_id,
            file=f,
            attributes=attributes,
        )

    return result

Note: The exact deletion method name and signature under vector_stores.files are version-specific — confirm against current documentation before relying on this pattern in production.

This function removes the outdated file from the vector store first, then uploads the replacement, which ensures there is never a moment where both the stale and the current version coexist and compete in retrieval. Tracking a mapping from logical document identity (like "refund policy") to its current file ID — mentioned in Lesson 3 as a best practice — is what makes calling this function correctly possible; without that record, you'd have to search the vector store's file list to find the old version first.

Measuring Whether Preparation Actually Helped

Improvements to document preparation should be validated, not assumed. A lightweight approach is maintaining a small fixed set of test questions with known correct source documents, and checking whether the right file shows up in citations after a change:

def evaluate_retrieval(ask_question_fn, store_id, test_cases):
    passed = 0
    for question, expected_source in test_cases:
        _, sources = ask_question_fn(store_id, question)
        if expected_source in sources:
            passed += 1
        else:
            print(f"MISS: '{question}' expected {expected_source}, got {sources}")

    print(f"{passed}/{len(test_cases)} retrieval checks passed")

This function runs a fixed list of (question, expected_source_file) pairs against a real or fake ask_question function and reports how many retrieved the expected source document among their citations. Running this same fixed test set before and after a document preparation change (splitting badly structured documents, stripping boilerplate, converting key tables) gives you an objective before-and-after comparison, rather than a subjective impression that retrieval "seems better."

Common Mistakes

Uploading scanned PDFs without OCR and assuming a successful upload status means the content is searchable, when in fact a document with no text layer contributes nothing to retrieval regardless of upload status — always verify extractability before trusting a document is actually indexed.

Leaving stale document versions in a vector store after uploading an update, which lets outdated and current information compete in retrieval and produces inconsistent answers — always pair an update with removal of the version it replaces.

Assuming a preparation change helped without measuring it, relying on spot-checking a few questions informally rather than a consistent test set — maintain a small fixed evaluation set and compare results objectively before and after any change.

Best Practices

Run an extractability and structure check on every document before ingestion, catching scanned PDFs, near-empty extractions, and severely unstructured documents before they enter the vector store rather than after a user reports a missing answer.

Convert high-value tables to sentence form when precision on tabular facts matters, accepting the extra verbosity in exchange for reliable retrieval of specific figures.

Maintain a small, fixed retrieval evaluation set per vector store, and re-run it after any meaningful change to documents, chunking, or metadata, so quality claims are always backed by a repeatable check.

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 Improving Retrieval Quality With Better Document Preparation and get answers drawn from it.

Signed-in readers only.