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
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.Set PDF and OCR limits
maxPdfPages(500 by default) caps how many pages of each PDF are read. Scanned pages are read by OCR whenocris on, up tomaxOcrPagesper 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, setocrto 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 }Run the conversion and keep the good rows
Each result has
url,title,markdown,tokenEstimate,routeand astatus. Failed or unsupported files come back as rows with anerrorand 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)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
tokenEstimateis 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 chunksWrite 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")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
maxOcrPagespages 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.
| url | detectedFormat | route | title | tokenEstimate | markdown |
|---|---|---|---|---|---|
| https://arxiv.org/pdf/1706.03762 | pdf-text | Attention Is All You Need | 9600 | Provided proper attribution is provided, Google hereby grants permission to reproduce the tables and figures… | |
| https://en.wikipedia.org/wiki/Retrieval-augmented_generation | html | html | Retrieval-augmented generation - Wikipedia | 5811 | Retrieval-augmented generation (RAG) is a technique that enables large language models… |
| https://calibre-ebook.com/downloads/demos/demo.docx | docx | office | DOCX Demo | 2604 | # 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.xlsx | xlsx | office | Sample Spreadsheet | 96 | ## 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 for | When | Price | Per 1,000 | Worked example |
|---|---|---|---|---|
| Page result | Charged per web page converted to Markdown | $0.001 | $1 | 1,000 web pages = $1.00 |
| Document converted | Charged per PDF (each started 50 text pages), Word, Excel, PowerPoint, OpenDocument, EPUB, CSV or JSON file converted | $0.002 | $2 | A 15-page PDF = $0.002; a 120-page PDF = $0.006; 1,000 Word files = $2.00 |
| Page ocr | Charged per image, or scanned PDF page, read by OCR | $0.003 | $3 | A 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 pageTips and pitfalls
- Check
tokenEstimatebefore chunking to spot unusually large files; very large results are cut at about 3.5 million characters and marked truncated. - Tables in digital PDFs are detected as layout, but complex tables can still come out imperfect; spot-check a few before indexing financial reports.
- Keep
respectRobotsTxton for public sites. If a host answers 429 or 403, the remaining URLs on that host are skipped as free error rows, so spread big batches over time. - Files are limited to 50 MB. Split or compress larger PDFs before converting.
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 resultPDF 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 ocrRelated 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…