In Article 17, you learned the theory of RAG. Now we build a complete document Q&A system. Upload PDF documents, ask questions in natural language, and get answers with citations pointing back to the source.

This is Article 22 in the Claude AI — From Zero to Power User series. You should know RAG and Vision before this article.


Architecture

Upload PDF  Parse  Chunk  Embed  Store in Vector DB
                                         
Question  Embed  Search  Top Chunks  Claude  Cited Answer

The system has two pipelines:

  1. Ingestion — Parse PDFs, split into chunks, create embeddings, store in a vector database
  2. Query — Convert the question to an embedding, find similar chunks, ask Claude with the chunks as context

Project Setup

Python

mkdir document-qa && cd document-qa
pip install anthropic openai chromadb pymupdf

TypeScript

mkdir document-qa && cd document-qa
npm init -y
npm install @anthropic-ai/sdk openai chromadb pdf-parse

Step 1: Parse PDF Documents

Python

import fitz  # PyMuPDF

def parse_pdf(pdf_path: str) -> list[dict]:
    """Parse a PDF and return a list of pages with text."""
    doc = fitz.open(pdf_path)
    pages = []

    for page_num in range(len(doc)):
        page = doc[page_num]
        text = page.get_text()

        if text.strip():
            pages.append({
                "page": page_num + 1,
                "text": text.strip(),
                "source": pdf_path,
            })

    doc.close()
    return pages

# Parse a PDF
pages = parse_pdf("company-handbook.pdf")
print(f"Parsed {len(pages)} pages")

TypeScript

import pdf from "pdf-parse";
import { readFileSync } from "fs";

interface Page {
  page: number;
  text: string;
  source: string;
}

async function parsePdf(pdfPath: string): Promise<Page[]> {
  const buffer = readFileSync(pdfPath);
  const data = await pdf(buffer);

  // pdf-parse returns all text at once; split by page breaks
  const pageTexts = data.text.split("\f").filter((t) => t.trim());

  return pageTexts.map((text, i) => ({
    page: i + 1,
    text: text.trim(),
    source: pdfPath,
  }));
}

Step 2: Chunk and Embed

from openai import OpenAI

openai_client = OpenAI()

def chunk_text(text: str, chunk_size: int = 500, overlap: int = 100) -> list[str]:
    """Split text into overlapping chunks."""
    chunks = []
    start = 0
    while start < len(text):
        end = start + chunk_size
        chunk = text[start:end]

        if end < len(text):
            last_period = chunk.rfind(".")
            last_newline = chunk.rfind("\n")
            break_point = max(last_period, last_newline)
            if break_point > chunk_size * 0.3:
                chunk = chunk[:break_point + 1]
                end = start + break_point + 1

        chunks.append(chunk.strip())
        start = end - overlap

    return chunks

def get_embeddings(texts: list[str]) -> list[list[float]]:
    """Get embeddings using OpenAI."""
    response = openai_client.embeddings.create(
        model="text-embedding-3-small",
        input=texts,
    )
    return [item.embedding for item in response.data]

def process_pages(pages: list[dict]) -> list[dict]:
    """Chunk pages and create embeddings."""
    all_chunks = []

    for page in pages:
        chunks = chunk_text(page["text"])
        for i, chunk in enumerate(chunks):
            all_chunks.append({
                "text": chunk,
                "source": page["source"],
                "page": page["page"],
                "chunk_index": i,
            })

    # Batch embed all chunks
    texts = [c["text"] for c in all_chunks]
    embeddings = get_embeddings(texts)

    for i, embedding in enumerate(embeddings):
        all_chunks[i]["embedding"] = embedding

    return all_chunks

Step 3: Store in Vector Database

ChromaDB (Local Development)

import chromadb

chroma = chromadb.PersistentClient(path="./chroma_db")
collection = chroma.get_or_create_collection(
    name="documents",
    metadata={"hnsw:space": "cosine"},
)

def ingest_pdf(pdf_path: str):
    """Full ingestion pipeline: parse → chunk → embed → store."""
    print(f"Parsing {pdf_path}...")
    pages = parse_pdf(pdf_path)

    print(f"Chunking and embedding {len(pages)} pages...")
    chunks = process_pages(pages)

    print(f"Storing {len(chunks)} chunks...")
    collection.add(
        ids=[f"{pdf_path}_p{c['page']}_c{c['chunk_index']}" for c in chunks],
        embeddings=[c["embedding"] for c in chunks],
        documents=[c["text"] for c in chunks],
        metadatas=[{
            "source": c["source"],
            "page": c["page"],
            "chunk_index": c["chunk_index"],
        } for c in chunks],
    )
    print(f"Done. Ingested {len(chunks)} chunks from {pdf_path}")

# Ingest documents
ingest_pdf("company-handbook.pdf")
ingest_pdf("product-manual.pdf")

pgvector (Production)

For production, use pgvector with PostgreSQL:

import psycopg2
from pgvector.psycopg2 import register_vector

conn = psycopg2.connect("postgresql://user:pass@localhost/docqa")
register_vector(conn)

cur = conn.cursor()

# Create table
cur.execute("""
    CREATE TABLE IF NOT EXISTS chunks (
        id SERIAL PRIMARY KEY,
        text TEXT NOT NULL,
        source TEXT NOT NULL,
        page INTEGER,
        chunk_index INTEGER,
        embedding vector(1536)
    )
""")

# Create index
cur.execute("""
    CREATE INDEX IF NOT EXISTS chunks_embedding_idx
    ON chunks USING ivfflat (embedding vector_cosine_ops)
    WITH (lists = 100)
""")

def store_chunks_pgvector(chunks: list[dict]):
    """Store chunks in pgvector."""
    for chunk in chunks:
        cur.execute(
            "INSERT INTO chunks (text, source, page, chunk_index, embedding) VALUES (%s, %s, %s, %s, %s)",
            (chunk["text"], chunk["source"], chunk["page"], chunk["chunk_index"], chunk["embedding"]),
        )
    conn.commit()

def search_pgvector(query_embedding: list[float], top_k: int = 5) -> list[dict]:
    """Search for similar chunks in pgvector."""
    cur.execute(
        "SELECT text, source, page, 1 - (embedding <=> %s) AS similarity FROM chunks ORDER BY embedding <=> %s LIMIT %s",
        (query_embedding, query_embedding, top_k),
    )
    rows = cur.fetchall()
    return [{"text": r[0], "source": r[1], "page": r[2], "similarity": r[3]} for r in rows]

Step 4: Query with Claude

Python

import anthropic

claude = anthropic.Anthropic()

def ask(question: str, top_k: int = 5) -> dict:
    """Ask a question about your documents."""
    # Step 1: Embed the question
    query_embedding = get_embeddings([question])[0]

    # Step 2: Find relevant chunks
    results = collection.query(
        query_embeddings=[query_embedding],
        n_results=top_k,
    )

    chunks = results["documents"][0]
    metadatas = results["metadatas"][0]

    # Step 3: Build context with source references
    context_parts = []
    for i, (chunk, meta) in enumerate(zip(chunks, metadatas)):
        context_parts.append(
            f"[Source {i+1}: {meta['source']}, Page {meta['page']}]\n{chunk}"
        )
    context = "\n\n---\n\n".join(context_parts)

    # Step 4: Ask Claude
    response = claude.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=2048,
        system="""You are a document Q&A assistant. Answer questions based ONLY on the provided context.

<rules>
- Answer only from the provided sources
- Cite sources using [Source N] notation
- If the answer is not in the context, say "I could not find this in the provided documents"
- Be concise and direct
- If multiple sources support the answer, cite all of them
</rules>""",
        messages=[
            {
                "role": "user",
                "content": f"<context>\n{context}\n</context>\n\n<question>\n{question}\n</question>",
            }
        ],
    )

    answer = response.content[0].text

    return {
        "answer": answer,
        "sources": [
            {"source": m["source"], "page": m["page"]}
            for m in metadatas
        ],
        "tokens": {
            "input": response.usage.input_tokens,
            "output": response.usage.output_tokens,
        },
    }

# Ask questions
result = ask("What is the company vacation policy?")
print(f"Answer: {result['answer']}")
print(f"\nSources:")
for s in result["sources"]:
    print(f"  - {s['source']}, Page {s['page']}")

TypeScript

import Anthropic from "@anthropic-ai/sdk";

const claude = new Anthropic();

interface QAResult {
  answer: string;
  sources: Array<{ source: string; page: number }>;
  tokens: { input: number; output: number };
}

async function ask(question: string, topK: number = 5): Promise<QAResult> {
  const queryEmbedding = (await getEmbeddings([question]))[0];

  const collection = await chroma.getCollection({ name: "documents" });
  const results = await collection.query({
    queryEmbeddings: [queryEmbedding],
    nResults: topK,
  });

  const chunks = results.documents[0];
  const metadatas = results.metadatas[0];

  const context = chunks
    .map(
      (chunk, i) =>
        `[Source ${i + 1}: ${metadatas[i]?.source}, Page ${metadatas[i]?.page}]\n${chunk}`
    )
    .join("\n\n---\n\n");

  const response = await claude.messages.create({
    model: "claude-sonnet-4-6",
    max_tokens: 2048,
    system: `You are a document Q&A assistant. Answer questions based ONLY on the provided context.

<rules>
- Answer only from the provided sources
- Cite sources using [Source N] notation
- If the answer is not in the context, say "I could not find this in the provided documents"
- Be concise and direct
</rules>`,
    messages: [
      {
        role: "user",
        content: `<context>\n${context}\n</context>\n\n<question>\n${question}\n</question>`,
      },
    ],
  });

  const answer =
    response.content[0].type === "text" ? response.content[0].text : "";

  return {
    answer,
    sources: metadatas.map((m: any) => ({ source: m.source, page: m.page })),
    tokens: {
      input: response.usage.input_tokens,
      output: response.usage.output_tokens,
    },
  };
}

Step 5: Conversation with Memory

Let users ask follow-up questions about documents:

class DocumentChat:
    """Conversational document Q&A with memory."""

    def __init__(self):
        self.messages: list[dict] = []
        self.system = """You are a document Q&A assistant.
Answer based on the provided context. Cite sources with [Source N].
If you cannot find the answer, say so."""

    def ask(self, question: str, top_k: int = 5) -> str:
        """Ask a question with conversation history."""
        # Get relevant chunks
        query_embedding = get_embeddings([question])[0]
        results = collection.query(
            query_embeddings=[query_embedding],
            n_results=top_k,
        )

        chunks = results["documents"][0]
        metadatas = results["metadatas"][0]

        context = "\n\n---\n\n".join(
            f"[Source {i+1}: {m['source']}, Page {m['page']}]\n{c}"
            for i, (c, m) in enumerate(zip(chunks, metadatas))
        )

        # Add user message with context
        self.messages.append({
            "role": "user",
            "content": f"<context>\n{context}\n</context>\n\n{question}",
        })

        response = claude.messages.create(
            model="claude-sonnet-4-6",
            max_tokens=2048,
            system=self.system,
            messages=self.messages,
        )

        answer = response.content[0].text
        self.messages.append({"role": "assistant", "content": answer})

        return answer

# Usage
chat = DocumentChat()
print(chat.ask("What is the vacation policy?"))
print(chat.ask("How many days for senior employees?"))  # Follow-up
print(chat.ask("Does this apply to contractors?"))  # Another follow-up

Prompt Caching for Cheaper Queries

Cache the system prompt and frequently accessed document context:

def ask_with_caching(question: str, document_context: str) -> str:
    """Ask with prompt caching for cheaper repeated queries."""
    response = claude.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=2048,
        system=[
            {
                "type": "text",
                "text": "You are a document Q&A assistant. Answer only from context. Cite with [Source N].",
            },
            {
                "type": "text",
                "text": f"<documents>\n{document_context}\n</documents>",
                "cache_control": {"type": "ephemeral"},
            },
        ],
        messages=[{"role": "user", "content": question}],
    )

    return response.content[0].text

Cached tokens cost 90% less. For a system where users ask many questions about the same documents, this saves significant money.


Cost Breakdown

OperationCost
Parse 100-page PDFFree (local)
Embed 200 chunks~$0.004 (text-embedding-3-small)
Single query (no cache)~$0.02 (Sonnet 4.6)
Single query (cached)~$0.005 (Sonnet 4.6)
50 queries/day (cached)~$0.25/day

For a small team asking 50 questions per day about company documents, expect about $8/month.


Summary

ComponentTechnology
PDF parsingPyMuPDF (Python), pdf-parse (TypeScript)
Chunking500 chars, 100 overlap, sentence boundary
EmbeddingsOpenAI text-embedding-3-small
Vector DB (dev)ChromaDB (local, persistent)
Vector DB (prod)pgvector (PostgreSQL)
GenerationClaude Sonnet 4.6
Citations[Source N] notation in prompt
CachingPrompt caching for document context

What’s Next?

In the next article, we will build an AI-powered blog writer that researches topics, creates outlines, and writes full articles.

Next: Build an AI-Powered Blog Writer