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