How to Convert PDF to Markdown for RAG Pipelines

Uses Any URL to Markdown — Web Pages, PDF, Word, Excel & OCR

Short answer: Send your PDF links to the Any URL to Markdown actor, read the markdown field of each result, then split it by heading into token-sized chunks before embedding. Digital PDFs cost $0.002 per started 50 pages.

Retrieval quality depends heavily on what you feed the embedder. Plain text extracted from a PDF loses the headings that tell you where one topic ends and the next begins, and naive fixed-size splitting cuts sections in half. Markdown keeps that structure, so you can chunk along it.

The usual approach is a stack of libraries: one PDF parser for digital files, an OCR engine for scans, and glue code for the cases where they disagree. The Any URL to Markdown actor reads the PDF text layer with heading and paragraph detection, falls back to Tesseract OCR for pages without text, and returns Markdown plus a token estimate in one row per file.

This guide converts a batch of PDFs, filters out failures, splits the Markdown into heading-aware chunks of a target size in Python, and writes JSONL ready for your vector store.

Step by step

  1. Collect the PDF links

    List the PDFs you want in your knowledge base as URLs. The actor fetches each link itself, so the files must be reachable without a login; password-protected PDFs cannot be read. You can pass up to 1,000 links per run in urls, and duplicates are removed. If your PDFs are on your own storage, use pre-signed or public links.

  2. Set PDF and OCR limits

    maxPdfPages (500 by default) caps how many pages of each PDF are read. Scanned pages are read by OCR when ocr is on, up to maxOcrPages per PDF (20 by default, $0.003 each); pages beyond that are left out and the row is marked truncated. If your corpus is all digital, set ocr to false so scanned files are reported for free instead of being charged.

    {
      "urls": [
        "https://arxiv.org/pdf/1706.03762",
        "https://example.com/reports/annual-report-2025.pdf"
      ],
      "ocr": true,
      "ocrLanguages": ["eng"],
      "maxOcrPages": 50,
      "maxPdfPages": 500
    }
  3. Run the conversion and keep the good rows

    Each result has url, title, markdown, tokenEstimate, route and a status. Failed or unsupported files come back as rows with an error and are not charged. Keep only rows that have Markdown, and log the rest so you can follow up on them.

    import os
    from apify_client import ApifyClient
    
    client = ApifyClient(os.environ["APIFY_TOKEN"])
    run = client.actor("actor-forge/any-url-to-markdown").call(run_input={
        "urls": [
            "https://arxiv.org/pdf/1706.03762",
        ],
        "maxOcrPages": 50,
    })
    
    docs, failed = [], []
    for item in client.dataset(run["defaultDatasetId"]).iterate_items():
        if item.get("markdown"):
            docs.append(item)
        else:
            failed.append((item.get("url"), item.get("error")))
    
    print(f"{len(docs)} converted, {len(failed)} failed")
    for url, err in failed:
        print("FAILED", url, err)
  4. Split the Markdown by heading into token-sized chunks

    Split each document at Markdown headings first, so a chunk never straddles two sections, then pack paragraphs into chunks up to a target size. The actor's tokenEstimate is characters divided by 4, so the same rule is used here to stay consistent. Each chunk keeps the heading path as context, which helps retrieval when a paragraph on its own is ambiguous.

    import re
    
    HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
    
    def est_tokens(text):
        return len(text) // 4  # same rule as tokenEstimate
    
    def split_sections(markdown):
        """Yield (heading_path, body) pairs, one per Markdown section."""
        path, body = [], []
        for line in markdown.splitlines():
            m = HEADING.match(line)
            if m:
                if body:
                    yield " > ".join(path), "\n".join(body).strip()
                level = len(m.group(1))
                path = path[: level - 1] + [m.group(2).strip()]
                body = []
            else:
                body.append(line)
        if body:
            yield " > ".join(path), "\n".join(body).strip()
    
    def chunk_markdown(markdown, max_tokens=500):
        chunks = []
        for heading, body in split_sections(markdown):
            if not body:
                continue
            current = ""
            for para in re.split(r"\n\s*\n", body):
                candidate = (current + "\n\n" + para).strip()
                if current and est_tokens(candidate) > max_tokens:
                    chunks.append({"heading": heading, "text": current})
                    current = para
                else:
                    current = candidate
            if current:
                chunks.append({"heading": heading, "text": current})
        return chunks
  5. Write JSONL for your vector store

    Attach the source URL, title and heading path to every chunk so answers can cite where they came from. JSONL loads easily into most vector databases and into LangChain or LlamaIndex document loaders.

    import json
    
    with open("chunks.jsonl", "w", encoding="utf-8") as f:
        for doc in docs:
            for i, ch in enumerate(chunk_markdown(doc["markdown"], max_tokens=500)):
                f.write(json.dumps({
                    "id": f"{doc['url']}#{i}",
                    "source": doc["url"],
                    "title": doc.get("title"),
                    "heading": ch["heading"],
                    "text": ch["text"],
                }, ensure_ascii=False) + "\n")
  6. Handle heavily scanned or non-English PDFs

    OCR in this actor covers 7 languages (English, German, French, Spanish, Italian, Portuguese and Dutch) and up to maxOcrPages pages per PDF. For long scanned documents, other languages or per-page confidence scores, run those files through PDF OCR instead and chunk its page text the same way. Note that OCR output is plain text without Markdown headings, so the chunker falls back to paragraph packing.

Sample input

{
  "url": "https://arxiv.org/pdf/1706.03762",
  "ocr": true,
  "ocrLanguages": [
    "eng"
  ],
  "maxOcrPages": 20,
  "maxPdfPages": 500
}

Sample output

Each converted URL becomes one row; for PDFs, the markdown column holds the full document and tokenEstimate tells you how much context it will use.

urldetectedFormatroutetitletokenEstimatemarkdown
https://arxiv.org/pdf/1706.03762pdfpdf-textAttention Is All You Need9600Provided proper attribution is provided, Google hereby grants permission to reproduce the tables and figures…
https://en.wikipedia.org/wiki/Retrieval-augmented_generationhtmlhtmlRetrieval-augmented generation - Wikipedia5811Retrieval-augmented generation (RAG) is a technique that enables large language models…
https://calibre-ebook.com/downloads/demos/demo.docxdocxofficeDOCX Demo2604# Demonstration of DOCX support in calibre\n\nThis document demonstrates the ability of the calibre DOCX Input plugin…
https://raw.githubusercontent.com/apache/poi/trunk/test-data/spreadsheet/SampleSS.xlsxxlsxofficeSample Spreadsheet96## First Sheet\n\n| Test spreadsheet | column_B |\n| --- | --- |\n| 2nd row | 2nd row 2nd column |…

Run it from code

cURL

curl -X POST "https://api.apify.com/v2/acts/actor-forge~any-url-to-markdown/run-sync-get-dataset-items" \
  -H "Authorization: Bearer $APIFY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://arxiv.org/pdf/1706.03762", "ocr": true, "ocrLanguages": ["eng"], "maxOcrPages": 20, "maxPdfPages": 500}'

JavaScript (Node.js)

// Node.js 18+ (built-in fetch). Set APIFY_TOKEN in your environment.
const input = {
  "url": "https://arxiv.org/pdf/1706.03762",
  "ocr": true,
  "ocrLanguages": [
    "eng"
  ],
  "maxOcrPages": 20,
  "maxPdfPages": 500
};

const res = await fetch(
  "https://api.apify.com/v2/acts/actor-forge~any-url-to-markdown/run-sync-get-dataset-items",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.APIFY_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(input),
  }
);
const items = await res.json();
console.log(items.length, items[0]);

Python

# pip install apify-client   (set APIFY_TOKEN in your environment)
import os
from apify_client import ApifyClient

client = ApifyClient(os.environ["APIFY_TOKEN"])
run_input = {
    "url": "https://arxiv.org/pdf/1706.03762",
    "ocr": True,
    "ocrLanguages": [
        "eng"
    ],
    "maxOcrPages": 20,
    "maxPdfPages": 500
}

run = client.actor("actor-forge/any-url-to-markdown").call(run_input=run_input)
for item in client.dataset(run["defaultDatasetId"]).iterate_items():
    print(item)

The run-sync-get-dataset-items endpoint waits for the run (up to 5 minutes) and returns the dataset rows directly. For longer runs, start the run asynchronously and read the dataset when it finishes, as the Python example does. Replace APIFY_TOKEN with your own token from the Apify console.

What it costs

Converting 1,000 digital PDFs of up to 50 pages each costs 1,000 x $0.002 = $2.00. If 100 of those were 120-page reports instead, each counts three times, adding 100 x 2 x $0.002 = $0.40, and every scanned page read by OCR adds $0.003.

Charged forWhenPricePer 1,000Worked example
Page resultCharged per web page converted to Markdown$0.001$11,000 web pages = $1.00
Document convertedCharged per PDF (each started 50 text pages), Word, Excel, PowerPoint, OpenDocument, EPUB, CSV or JSON file converted$0.002$2A 15-page PDF = $0.002; a 120-page PDF = $0.006; 1,000 Word files = $2.00
Page ocrCharged per image, or scanned PDF page, read by OCR$0.003$3A 10-page scanned PDF = $0.03
Actor Start (platform start fee)Charged when the Actor starts running. Number of events charged depends on Actor memory (one event per GB, minimum one event).$0.00005—One run at 1 GB = $0.00005

Ready to try it? Any URL to Markdown — Web Pages, PDF, Word, Excel & OCR runs on Apify, pay per result.

Run on Apify → Read the full actor page

Tips and pitfalls

FAQ

Why convert PDFs to Markdown instead of plain text for RAG?

Markdown keeps headings, lists and tables, so you can split chunks at section boundaries and store the heading path as context. Plain text loses that structure and forces fixed-size splitting.

What chunk size should I use?

It depends on your embedding model and how specific your questions are. The code above uses about 500 estimated tokens per chunk as a starting point; measure retrieval quality on real questions and adjust.

Does it work with scanned PDFs?

Yes, pages without a text layer are read with Tesseract OCR when ocr is on, up to maxOcrPages per PDF. OCR'd text has no Markdown headings, so heading-based splitting does less for those pages.

Can I convert Word and PowerPoint files in the same run?

Yes. The same actor converts Word, Excel, PowerPoint, OpenDocument text and spreadsheets, EPUB, CSV and JSON, and the chunking code works on their Markdown too.

Actors used in this guide

Any URL to Markdown — Web Pages, PDF, Word, Excel & OCR

Turn any URL into clean, LLM-ready Markdown: web pages, PDFs, Word, Excel, PowerPoint, EPUB, CSV and JSON, plus OCR for images and scanned PDFs. One…

$1 per 1,000 · page result

PDF OCR — Scanned PDF to Searchable Text (29 Languages)

OCR scanned PDFs to text page by page with Tesseract 5 in 29 languages. Pages that already have a text layer are read directly at a tenth of the…

$3 per 1,000 · page ocr

Related guides

How to OCR Scanned PDFs to Text with an API

Send scanned PDF links to the PDF OCR actor, get text back one row per page with a confidence score, and use that score to flag pages for review.…

How to Extract Text from Images and Screenshots in Bulk

Send image URLs to the Image to Text OCR actor, use the sparse segmentation mode for screenshots, and read the text and confidence per image. It…

All guides