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.
Top comments (1)
The document hash makes versions distinguishable, but it also means an updated PDF gets entirely new vector IDs. I'd add a version-activation step: index the new version, verify its expected chunks, then switch which version retrieval can see. If ingestion is partial or hits the spending cap, keep that outcome explicit rather than mixing old and new chunks. How do you retire the previous version while preserving citations to answers already issued?