Most RAG tutorials start with PdfReader(...).pages and a text splitter. That works until the agent has to say where an answer came from, or you re-ingest an updated PDF and end up with duplicate vectors.
This tutorial uses PDF Citation Chunker, a pay-per-event Apify Actor. It downloads a public PDF, extracts embedded text page by page, and returns chunks that already carry:
- the source page (
pageStart,pageEnd) and document title - a stable
chunkId(SHA-256 of document hash, page, position, text hash and chunk settings) - a SHA-256 of the chunk text and of the original PDF bytes
- an approximate token count (
estimatedTokens) for planning embedding batches
Disclosure: I built this Actor as founder of EchoEmpire.
What you need
- An Apify account and API token (Console → Settings → API & Integrations)
- Python 3.9+
pip install "apify-client>=1.10,<2" langchain-core numpy
Cost: the listing charges $0.00025 per PDF page that produces usable chunks (FREE tier, as listed on 2026-10-25; check the Pricing tab). At that price, the 48-page document below works out to 48 × $0.00025 = $0.012. Pages with no text are not charged.
Step 1: Run the Actor on a PDF
We'll use NIST's AI Risk Management Framework (AI RMF 1.0), a public-domain PDF.
import os from decimal import Decimal from apify_client import ApifyClient client = ApifyClient(os.environ["APIFY_TOKEN"]) run = client.actor("skilled_glee/pdf-citation-chunker").call( run_input={ "mode": "chunk", "url": "https://nvlpubs.nist.gov/nistpubs/ai/NIST.AI.100-1.pdf", "chunkSize": 3000, # max ~characters per chunk (500–20000) "overlap": 300, # characters carried into the next chunk }, max_total_charge_usd=Decimal("0.05"), # hard cap for this run ) records = client.dataset(run["defaultDatasetId"]).list_items().items summary = client.key_value_store(run["defaultKeyValueStoreId"]).get_record("SUMMARY")["value"] You can also pass urls (up to 25 PDFs) instead of url. Duplicate URLs are removed.
Step 2: Look at what came back
This is the real SUMMARY record from that run:
{ "submittedDocuments": 1, "completedDocuments": 1, "successfulDocuments": 1, "failedDocuments": 0, "totalPages": 48, "totalChunks": 55, "chunkSize": 3000, "overlap": 300, "status": "success", "documentOutcomes": [{"inputUrl": "https://nvlpubs.nist.gov/nistpubs/ai/NIST.AI.100-1.pdf", "status": "success", "pagesProcessed": 48, "chunksProduced": 55, "completed": true}], "unprocessedUrls": [], "processedAt": "2026-10-04T05:19:52.187535+00:00" } And here is one of the 55 chunks (text truncated for the post):
{ "sourceUrl": "https://nvlpubs.nist.gov/nistpubs/ai/NIST.AI.100-1.pdf", "resolvedUrl": "https://nvlpubs.nist.gov/nistpubs/ai/NIST.AI.100-1.pdf", "sourceByteSize": 1946127, "documentSha256": "7576edb531d9848825814ee88e28b1795d3a84b435b4b797d3670eafdc4a89f1", "documentId": "sha256:7576edb531d9848825814ee88e28b1795d3a84b435b4b797d3670eafdc4a89f1", "documentIndex": 1, "documentTitle": "Artificial Intelligence Risk Management Framework (AI RMF 1.0)", "pageStart": 9, "pageEnd": 9, "chunkIndex": 11, "pageChunkIndex": 1, "chunkId": "sha256:eb84854da2821a56236aad788a57454e95d55b20602321416e0bd988da143876", "chunking": { "chunkSize": 3000, "overlap": 300 }, "text": "NIST AI 100-1 AI RMF 1.0\nPart 1: Foundational Information\n1. Framing Risk\nAI risk management offers a path to minimize potential negative impacts of AI systems,\nsuch as threats to civil liberties and rights, while also providing opportunities to maximize\npositive impacts. ...", "characterCount": 2750, "estimatedTokens": 688, "extractionMethod": "pypdf", "processedAt": "2026-10-04T05:19:48.335934+00:00" } Some things to notice:
- The chunks stay page-aware. Chunks don't cross page boundaries, so page 6 gave two chunks (
pageChunkIndex1 and 2, with 2,930 and 477 characters). That's why there are 55 chunks for 48 pages. -
pageStartis the PDF page index, not the printed page label. The text of chunk 9 (PDF page 7) ends with "Page 2", which is the document's printed page number. Decide which one your citations should show. -
estimatedTokensis an approximation and not tied to any one tokenizer. Use it for batching, not billing.
Step 3: Load into a vector store, with citations
Each record already is a chunk, so you don't need a text splitter. Map each record to a LangChain Document and use chunkId as the vector ID:
from langchain_core.documents import Document from langchain_core.embeddings import DeterministicFakeEmbedding # swap for your real embeddings from langchain_core.vectorstores import InMemoryVectorStore # swap for your vector DB KEYS = ("sourceUrl", "documentId", "documentTitle", "pageStart", "pageEnd", "chunkIndex", "chunkId", "documentSha256") docs = [Document(page_content=r["text"], metadata={k: r[k] for k in KEYS}) for r in records] store = InMemoryVectorStore(embedding=DeterministicFakeEmbedding(size=256)) store.add_documents(docs, ids=[d.metadata["chunkId"] for d in docs]) for hit in store.similarity_search("How does the framework define risk?", k=3): m = hit.metadata print(f'[{m["documentTitle"]}, PDF p.{m["pageStart"]}] {m["sourceUrl"]}') Using chunkId as the ID matters. The Actor's README says the ID is stable for the same PDF bytes, page position, text and chunk settings. Re-running on an unchanged PDF therefore upserts the same IDs instead of adding duplicates. If the PDF changes, documentSha256 changes too, so you can tell the versions apart.
Step 4: Let an agent call it through MCP
To let an MCP client (Claude Desktop, Cursor, VS Code…) call the Actor as a tool, add Apify's hosted MCP server and limit it to this Actor:
{ "mcpServers": { "apify-pdf": { "url": "https://mcp.apify.com?tools=skilled_glee/pdf-citation-chunker" } } } The first connection signs in with OAuth. Running the Actor is a paid action, so give your agent a maxTotalChargeUsd cap. For discovery only (no token, nothing runs), use https://mcp.apify.com?tools=search-actors,fetch-actor-details.
Limits worth knowing
- Embedded-text PDFs only. A scanned PDF gets a failed outcome with reason
no_extractable_textand isn't charged. For scans, the same publisher has OCR Citation Chunker. - Public HTTP(S) URLs only (no cookies or auth), 50 MB per PDF, 25 PDFs per run.
- One bad PDF doesn't kill the batch. The
SUMMARYstatus becomespartial_success, and every document's outcome is listed. - If your spending cap is hit, the status is
spending_limit_reachedand the skipped URLs go inunprocessedUrls.
Full code
Runnable Python, JS, LangChain and MCP examples (dry run by default) are here: https://github.com/EchoEmpireUtility-bot/echoempire-apify-examples
Other document chunkers from the same publisher: a 10-K Risk Factors & MD&A extractor for Items 1, 1A, 7 and 7A, and a Subtitle RAG Chunker for SRT/WebVTT with timestamps.
