DEV Community

Dakota Myers
Dakota Myers

Posted on

Citation-ready RAG from PDFs: page-aware chunks with stable IDs in ~30 lines of Python

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

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"]
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

Some things to notice:

  • The chunks stay page-aware. Chunks don't cross page boundaries, so page 6 gave two chunks (pageChunkIndex 1 and 2, with 2,930 and 477 characters). That's why there are 55 chunks for 48 pages.
  • pageStart is 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.
  • estimatedTokens is 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"]}')
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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_text and 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 SUMMARY status becomes partial_success, and every document's outcome is listed.
  • If your spending cap is hit, the status is spending_limit_reached and the skipped URLs go in unprocessedUrls.

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)

Collapse
 
innokentyb profile image
Kent Bodrov •

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?