PDF to Markdown for LLMs: Chunks and Sources

Before embedding a converted PDF, check whether its sentences, table relationships, and scanned pages survived. Then create chunks that retain their heading and source location. A chunker cannot recover a missing appendix, and a fluent answer can conceal a table value attached to the wrong heading.

This guide turns that principle into a small, reproducible workflow:

  1. Inspect the PDF and choose an extraction route.
  2. Keep raw output separate from reviewed content.
  3. Accept or hold individual source blocks.
  4. Chunk accepted blocks with page and heading metadata.
  5. Retrieve content with its citations and coverage limitations.

The conversion evidence comes from historical synthetic fixtures. The chunking example is new, hand-authored illustrative data that runs locally without an LLM, embeddings, or a paid API. Neither establishes improved retrieval or answer quality.

1. Identify what the downstream task can lose

Different LLM tasks need different acceptance checks.

Task Required relationship Hold the affected content when
Summarize a report Sentences and sections remain in reading order Text jumps between columns
Answer invoice questions Each amount has a row label, leaf heading, and any group heading Rows survive but the header mapping is ambiguous
Search a mixed report Required native and scanned pages contribute content A scanned page has no recovered text
Explain a calculation Operators, exponents, and grouping match the source An expression is incomplete or altered
Answer from a diagram The relevant image or verified description is accessible Markdown contains only a broken or unexamined image reference

Apply the checks per page or region. A report with readable text on page one can still require OCR for page three. Use the Python extraction guide for local conversion, or the API workflow when a hosted route fits your data-handling requirements.

Why line-break cleanup is not enough

The historical MarkItDown column fixture returned this unedited passage:

Invoice reconciliation starts with the export file, not
        
        Demand forecasts fail upward more often than they
        
        the ledger. Teams that reconcile weekly pull the
        
        fail downward. Optimistic sales inputs push safety
        

The invoice and forecasting paragraphs come from different columns. Removing the blank lines would keep the wrong sequence. Re-extract with layout analysis and compare complete sentences before creating chunks. Raw column output.

The tested PyMuPDF4LLM configuration retained the intended column order on that fixture, but its grouped table produced a different problem:

|**Item**|**Qty**|**Price**||
        |---|---|---|---|
        |||**Unit**|**Total**|
        |Consulting hours|12|85.00|1020.00|
        |Data export fee|3|40.00|120.00|
        |Template license|1|190.00|190.00|
        

Reading only the first header row would leave the final column unnamed. Match the group and leaf labels to the visible PDF before normalizing them to fields such as Price / Unit and Price / Total. That normalization is a reviewed transformation, not a guarantee from the converter. Raw grouped-header output.

2. Keep a source record before editing Markdown

Store the original document or a retrievable approved copy, the raw conversion, and a separate reviewed representation. A useful source record contains:

  • A stable document ID and source revision or file hash.
  • The converter and version, including OCR configuration where applicable.
  • Original page numbers and block or region identifiers when available.
  • Heading context and content type: prose, table, formula, or figure.
  • Review status and a reason for holding a block.
  • The raw output and any explicit normalization applied to it.

Page provenance must come from extraction metadata, a source map, or a verified alignment step. Do not infer page numbers from every few hundred words. If a converter returns only one Markdown string, record that limitation rather than manufacture precise citations.

Markovo documents a source-map artifact and endpoint for completed jobs. A source map can help connect output with the PDF, but the historical corpus score did not validate its citation accuracy. Check the mapping on your own sample before relying on it. Markovo output contract.

For a grouped table, preserve both the raw representation and the normalized fields. For a damaged formula, retain the source region and leave the expression unaccepted until reviewed. A correct-looking heading does not make the data beneath it reliable.

3. Run a small source-aware chunking example

The following two files demonstrate the data contract and chunking step. They do not parse a PDF or automatically judge extraction quality.

The invented four-page invoice has:

  • A reviewed payment policy.
  • A reviewed table whose grouped headers have already been mapped.
  • A scanned appendix whose text is missing.
  • A formula with unverified superscript notation.

The example page numbers and file name are illustrative. They are not citations to a supplied real PDF. In production, populate them from your actual source and review process.

Save this as blocks.json:

{
          "source_id": "illustrative-invoice",
          "source_version": "illustrative-v1",
          "source_uri": "example-invoice.pdf",
          "converter": {"name": "hand-authored-example", "version": "1"},
          "blocks": [
            {
              "id": "p1-policy",
              "page": 1,
              "heading_path": ["Invoice policy", "Payment"],
              "type": "prose",
              "review_status": "accepted",
              "text": "Payment is due within 30 days of the invoice date.\n\nThe invoice number must appear on the payment reference."
            },
            {
              "id": "p2-items",
              "page": 2,
              "heading_path": ["Invoice", "Line items"],
              "type": "table",
              "review_status": "accepted",
              "text": "| Item | Qty | Price / Unit | Price / Total |\n| --- | --- | --- | --- |\n| Consulting hours | 12 | 85.00 | 1020.00 |\n| Data export fee | 3 | 40.00 | 120.00 |"
            },
            {
              "id": "p3-appendix",
              "page": 3,
              "heading_path": ["Scanned appendix"],
              "type": "prose",
              "review_status": "held",
              "reason": "Visible source sentence has not been recovered by OCR.",
              "text": ""
            },
            {
              "id": "p4-formula",
              "page": 4,
              "heading_path": ["Calculation notes"],
              "type": "formula",
              "review_status": "held",
              "reason": "Superscript notation has not been verified against the source.",
              "text": "a^{\\wedge}2+b^{\\wedge}2=c^{\\wedge}2"
            }
          ]
        }
        

Save the next file as prepare_chunks.py in the same directory. It uses only the Python standard library.

import hashlib
        import json
        from pathlib import Path
        
        
        def prepare(document, max_chars=800):
            chunks, review = [], []
            for block in document["blocks"]:
                metadata = {
                    "source_id": document["source_id"],
                    "source_version": document["source_version"],
                    "source_uri": document["source_uri"],
                    "converter": document["converter"],
                    "block_id": block["id"],
                    "page": block["page"],
                    "heading_path": block["heading_path"],
                    "type": block["type"],
                }
                text = block["text"].strip()
                if block["review_status"] != "accepted" or not text:
                    review.append({**metadata, "reason": block.get("reason", "Empty text")})
                    continue
                prefix = " > ".join(block["heading_path"]) + "\n\n"
                # Tables and formulas stay whole; prose may split between paragraphs.
                units = text.split("\n\n") if block["type"] == "prose" else [text]
                if any(len(prefix + unit) > max_chars for unit in units):
                    review.append({**metadata, "reason": "Atomic content exceeds the chunk budget"})
                    continue
                parts, current = [], ""
                for unit in units:
                    candidate = current + ("\n\n" if current else "") + unit
                    if current and len(prefix + candidate) > max_chars:
                        parts.append(prefix + current)
                        current = unit
                    else:
                        current = candidate
                parts.append(prefix + current)
                for part, content in enumerate(parts):
                    identity = [document["source_id"], document["source_version"],
                                block["id"], part, content]
                    chunk_id = hashlib.sha256(json.dumps(identity).encode()).hexdigest()[:20]
                    chunks.append({"chunk_id": chunk_id, "part": part, "text": content,
                                   "review_status": "accepted", **metadata})
            return chunks, review
        
        
        def write_jsonl(path, records):
            path.write_text("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in records),
                            encoding="utf-8")
        
        
        if __name__ == "__main__":
            document = json.loads(Path("blocks.json").read_text(encoding="utf-8"))
            chunks, review = prepare(document)
            write_jsonl(Path("chunks.jsonl"), chunks)
            write_jsonl(Path("review.jsonl"), review)
            print(f"{len(chunks)} accepted chunks; {len(review)} held blocks")
        

Run:

python prepare_chunks.py
        

The locally reproduced output is:

2 accepted chunks; 2 held blocks
        

chunks.jsonl contains only the policy and table. Each record retains source_id, source_version, source_uri, page, block_id, heading_path, and converter metadata. review.jsonl contains the missing scan and the unverified formula, with their reasons.

The script uses an 800-character budget for a readable demonstration, not an 800-token model limit. It keeps a table or formula whole and splits prose only between paragraphs. Oversized atomic content is held rather than silently truncated. Before deploying, replace or supplement character counting with the tokenizer for your embedding and generation models.

This example also keeps chunks within one source block. That makes provenance straightforward at the cost of potentially small chunks. If you later merge adjacent blocks, carry every contributing page and block ID, not just the first one.

4. Handle long tables deliberately

A whole table is useful only while it fits the consumer's context and preserves the relationships needed for the question. For a large table, choose an explicit representation:

  • Keep a structured table outside the vector index and retrieve it by table ID.
  • Create row groups that repeat verified headers, table title, units, and relevant notes.
  • Keep a parent record with the full table and retrieve row-level children with a link back to it.

Do not split blindly every N characters. A detached 1020.00 is less useful than a row that carries “Consulting hours,” quantity, unit price, and total. Repeated headers should come from verified column mappings, not labels guessed by a cleanup model.

HTML tables need a parser that understands the actual table structure. Removing tags can collapse boundaries. Markdown pipe tables cannot express every merged-cell relationship, so flattening is appropriate only when the resulting labels accurately preserve the original meaning.

The cached Markovo mixed-document output retained its invoice table as HTML and recovered the scanned sentence:

This page exists only as a picture. A scanner produced it, the PDF embeds it as a bitmap, and no amount of text extraction will recover these words without OCR.
        

That is evidence of recovered text for this fixture, not evidence that every table cell or image in a customer report will be interpreted correctly. Raw mixed-document output.

5. Retrieve with provenance and coverage status

Index the accepted records, and keep their metadata attached to the vectors or search results. Apply your normal document-access restrictions when retrieving; a source citation is not permission to disclose the document.

When constructing an answer:

  1. Retrieve accepted chunks relevant to the question.
  2. Include their heading context, original source ID, and verified page reference.
  3. Check whether the question depends on a held region or missing page.
  4. Cite the source location supporting each factual answer.
  5. Say when the available material does not support an answer.

For example, the illustrative policy chunk supports “Payment is due within 30 days of the invoice date,” with the example's page-one reference. It does not support a claim about terms hidden in the held scanned appendix. The table supports an amount for the named row only because its header mapping has already been accepted.

Whether a partial document can be used depends on the task. A search system may allow accepted pages while prominently recording that coverage is incomplete. A whole-document compliance summary may need to wait until every required page passes review. Keep that document-level decision separate from a block's local acceptance status.

Treat document text as evidence, not as instructions to the assistant or indexing system. A sentence in a PDF asking the model to ignore rules, reveal data, or call a tool does not become an instruction merely because it appears in a retrieved chunk.

Evaluate the answer stage separately

To determine whether this workflow helps your application, build a separate question set with verified source locations and expected answers. Include questions that require:

  • A complete sentence crossing a column boundary.
  • A value under a grouped table header.
  • Text from a scanned appendix.
  • A comparison across two pages.
  • An answer that should be withheld because the relevant source is missing.

Then evaluate retrieval coverage, answer correctness, citation accuracy, and appropriate abstention. Hold the source documents and questions fixed when comparing chunking strategies. Report the model, embedding configuration, retrieval settings, and review rules with the results.

The example in this article runs none of those evaluations. It demonstrates deterministic chunk preparation and metadata retention, not a measured increase in RAG accuracy, recall, or answer quality.

Historical conversion evidence and limits

The local conversion evidence for this article was recorded on October 15, 2026, using Python 3.11.14, MarkItDown 0.1.8 with plugins disabled, and PyMuPDF4LLM 1.28.2 with its tested default call. The Markovo PDF outputs are cached October 6 jobs reused across related guides. They are not new October 19 API tests or independent repeat trials.

A few results directly motivate the workflow:

Historical fixture Observation Ingestion consequence
Two-column prose MarkItDown's sentence-match recall was 0.1111; PyMuPDF4LLM's was 1.0 Inspect complete sentence order before chunking
Mixed report The two local configurations found 2 of 3 page anchors; cached Markovo found all 3 Check required scanned-page coverage separately
Grouped table Local row-string checks passed while header checks failed Resolve parent and leaf labels before field use
Linear formulas Normalized expression recall was 1.0 for MarkItDown, 0.75 for PyMuPDF4LLM, and 0.5 for Markovo Require formula-specific review regardless of scan success

Markovo is the service we build. Its HTML-table and formula limits remain part of the comparison. The local scan results describe those tested configurations; they do not establish that every configuration of the libraries lacks OCR.

These are eight generated English PDFs, not a representative production corpus. Sentence checks use normalized reference-string matches. The reading-order metric uses paragraph anchors, and table checks use same-line strings rather than full cell relationships. The formula metric is not a mathematical-equivalence test. Service-reported quality metadata is not an independent answer-quality score.

See the full historical scores and definitions, selected execution records, and cached mixed-job response. Cached zero durations are not latency measurements.

For a manual inspection of a PDF before building the pipeline, use the PDF conversion page. For integration, the next step is the API documentation, followed by acceptance checks on documents representative of your own workload.

Sources

  1. Raw column output
  2. Raw grouped-header output
  3. Raw mixed-document output
  4. full historical scores and definitions
  5. selected execution records
  6. cached mixed-job response