Improving Retrieval Quality With Better Document Preparation

Ma Mahalakshmi V Updated 16 Sep 2026
8 min read

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

Signed-in readers only.