Chat PDF: asking questions about a PDF through an AI API

Two ways to question a PDF with a model: put the document in context and cache it, or retrieve the relevant parts. Python code and the price of twenty questions.

6 min · · автоматизация · API keys

A chat PDF AI does a simple thing: you upload a document and ask questions in plain words. Ready-made services of this kind exist, but if you have many documents, they are confidential or you need custom logic, it is easier to build it yourself via the API. There are two fundamentally different approaches: put the whole document into the model's context and cache it, or search for the relevant fragments and send only those. We go through both in Python and calculate the cost of a series of questions.

Chat PDF with AI: step 0, extract text from the PDF

If the PDF has a text layer, extract the text with page numbers so the model can cite them:

bash
from pypdf import PdfReader

def pdf_pages(path):
    reader = PdfReader(path)
    return [(i, (p.extract_text() or "").strip())
            for i, p in enumerate(reader.pages, 1)]

pages = pdf_pages("report.pdf")
empty = [n for n, t in pages if len(t) < 20]
print("pages without text:", len(empty))

If there are many empty pages, you have a scan. Then you need either recognition or sending pages as images, covered in detail in how to recognize a PDF with AI. Word documents and Excel spreadsheets reduce to the same scheme: python-docx extracts text from .docx, and openpyxl or pandas reads sheets from .xlsx. After that everything is the same: you get text with labels (page, sheet) and pass it to the model.

Approach A. The whole document in context plus cache

Zukko models have a 1M-token context (GLM 5.3 has 128K, Grok has 256K), so a document of a hundred pages fits whole. Measure the size in advance with count_tokens (see the reference):

bash
import os
import anthropic

client = anthropic.Anthropic(base_url="https://api.zukko.pro",
                             api_key=os.environ["ZUKKO_API_KEY"])
DOC = "\n\n".join(f"[p. {n}]\n{t}" for n, t in pages)
MODEL = "claude-sonnet-5-5"

n = client.messages.count_tokens(
    model=MODEL, messages=[{"role": "user", "content": DOC}]).input_tokens
print("tokens in document:", n)

Now the chat itself. The document goes into system with a cache marker, and questions are appended to the history. The whole repeating prefix (document plus earlier turns) must stay byte for byte identical:

bash
SYSTEM = [
    {"type": "text",
     "text": "Answer only from the document below. After each statement "
             "give the page as [p. N]. If the document has no answer, "
             "write: not found in the document."},
    {"type": "text", "text": DOC, "cache_control": {"type": "ephemeral"}},
]
history = []

def ask(question):
    history.append({"role": "user", "content": question})
    msg = client.messages.create(model=MODEL, max_tokens=1000,
                                 system=SYSTEM, messages=history)
    answer = msg.content[0].text
    history.append({"role": "assistant", "content": answer})
    u = msg.usage
    print("fresh input:", u.input_tokens,
          "from cache:", getattr(u, "cache_read_input_tokens", 0),
          "written:", getattr(u, "cache_creation_input_tokens", 0))
    return answer

On the first question you will see a cache write, on the second and later a cache read. The cache lives for a limited time and is extended on access; see Anthropic's documentation for exact conditions (including the minimum prefix length). If the pause between questions is long, the cache is written again. More on billing: prompt caching: reads and writes in Zukko. The Claude cache discount comes specifically from the Anthropic format; in the OpenAI format all Claude input is billed at the input price.

Pros of this approach: the model sees the whole document, so you can ask questions like "what are the contradictions between sections". Cons: the entire document goes into every request, and the longer it is, the more tokens you pay for on each question.

Approach B. Retrieval by parts

Split the document into fragments, find those relevant to the question and send only them. Without a vector database, search can be done with TF-IDF:

bash
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
from openai import OpenAI

chunks = [(n, t) for n, t in pages if t]
vec = TfidfVectorizer(analyzer="char_wb", ngram_range=(3, 5))
matrix = vec.fit_transform([t for _, t in chunks])

oa = OpenAI(base_url="https://api.zukko.pro/v1",
            api_key=os.environ["ZUKKO_API_KEY"])

def ask_rag(question, k=5):
    scores = cosine_similarity(vec.transform([question]), matrix)[0]
    top = scores.argsort()[::-1][:k]

Character n-grams are chosen because word-level TF-IDF copes poorly with Russian morphology. This is the simplest variant; for complex documents vector search is used, as in the RAG pipeline. The weak point of the approach: questions whose answer is spread across the whole document ("summarize all sections") may be missed by the search.

What to choose

A: document in contextB: retrieval by parts
Questions about the whole documentGoodWeak
Pinpoint questionsGoodGood
Document sizeLimited by contextAny
Price of a series of questionsGrows with document sizeAlmost independent of size
ComplexityMinimalSearch needed

A practical order: start with approach A for documents up to a hundred pages, and move to B for large archives and dozens of files.

What a series of questions costs

Formula: (fresh input × input price + cache read × read price + cache write × write price + output × output price) / 1,000,000. Assumptions (for simplicity we ignore the growth of the dialogue history): a 60,000-token document (measure yours with count_tokens), 20 questions of 300 tokens, answers of 400 tokens (8,000 output tokens). Zukko prices at October 18, 2026.

claude-sonnet-5-5, input 0.4, cache read 0.05, cache write 0.5, output 2 ($ per 1M):

  • Without cache: 20 × 60,300 = 1,206,000 tokens × 0.4 / 1,000,000 = 0.4824, plus output 8,000 × 2 / 1,000,000 = 0.016. Total about $0.50.
  • With cache (Anthropic format): write 60,000 × 0.5 / 1,000,000 = 0.03; reads 19 × 60,000 = 1,140,000 × 0.05 / 1,000,000 = 0.057; questions 20 × 300 = 6,000 × 0.4 / 1,000,000 = 0.0024; output 0.016. Total about $0.105.
  • Approach B (5 fragments, about 2,800 tokens per question): 56,000 × 0.4 / 1,000,000 + 0.016 = about $0.038.

gpt-6.1-sol (input 0.27, cache read 0.013, output 1.32): with no cache hits, 1,206,000 × 0.27 / 1,000,000 + 8,000 × 1.32 / 1,000,000 = 0.3256 + 0.0106, about $0.34. If the cache worked from the second question: first question 60,300 × 0.27 / 1,000,000 = 0.0163, reads 1,140,000 × 0.013 / 1,000,000 = 0.0148, remaining input 5,700 × 0.27 / 1,000,000 = 0.0015, output 0.0106; total about $0.043. For GPT, cache reads are visible in both formats, but a write is not charged separately.

deepseek-v4.1-flash (input 0.013, output 0.038, cache read 0.013): 1,206,000 × 0.013 / 1,000,000 + 8,000 × 0.038 / 1,000,000 = 0.0157 + 0.0003, about $0.016. There is no cache discount here, but the price is low anyway.

The conclusion is simple: with a long document and a series of questions the gap between "no cache" and "with cache" is a multiple, so do not forget it. But from the numbers and the strength index (Sonnet 5.5 has 48, GPT-6.1 Sol 52, DeepSeek V4.1 Flash 39) you cannot tell who answers better on your PDF; test on ten of your own questions. Current prices: GET https://api.zukko.pro/v1/models.

Connecting

Create a key in the dashboard and use the format you need: the Anthropic SDK with base URL https://api.zukko.pro or the OpenAI SDK with https://api.zukko.pro/v1. Top-ups start at $1: SBP (the payment page adds 8.5% on top), cryptocurrency or a MasterCard.

FAQ

Can I load confidential documents into such a chat?

The text goes to the model provider. Decide in advance what may be sent, anonymize data and read the providers' terms; we make no claims about them.

Why does the model answer "not found" when the answer is in the PDF?

Either the text was not extracted (a scan, complex layout, tables), or with retrieval the right fragment did not make it into the selection. Check the extracted text and increase the number of fragments.

How do I avoid invented answers?

Require a page reference and check quotes with code; treat an answer without a reference as invalid.

Which AI should analyze a PDF file?

For the test, take three models from different price groups and ten real questions about your documents. Decide by result and price.

zukko

Pay 25×
less

for Claude Code & ChatGPT

One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.

Get API keyDocumentation

api.zukko.pro · fetch_*

Chat PDF with AI: ask questions about a PDF via API · Zukko