Module 7: Use Cases
2. Document Q&A
Description
Document Q&A is the most in-demand pattern in multimodal AI: a user uploads a document (PDF, document image, Word) and asks questions about its content. The system extracts text and images, indexes the content, retrieves the relevant fragments, and generates an answer citing the sources.
Why it matters: This pattern is the foundation of legal assistants, technical support systems, educational platforms, and compliance tools. It's the use case that most justifies investments in multimodal AI because it solves a real problem: "I have 200 pages of documentation and I need an answer in 5 seconds".
Connection with the module: Document Q&A combines Module 3 (document extraction), Module 6 (RAG with embeddings), and Module 1-2 (generation with LLM). Capsule 08 (Use Case Selector) will use this pipeline as one of its routing destinations.
Visual Pipeline
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Document │────▶│ Extract │────▶│ Chunking │
│ (PDF/img) │ │ text+imgs │ │ │
└──────────────┘ └──────────────┘ └──────┬───────┘
│
▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Answer │◀────│ LLM │◀────│ Retrieval │
│ + sources │ │ (generate) │ │ (search) │
└──────────────┘ └──────────────┘ └──────┬───────┘
▲
│
┌──────────────┐
│ User's │
│ question │
└──────────────┘
Pipeline stages:
- Load document — Open PDF, scanned image, or text
- Extract content — Text with PyMuPDF, embedded images, tables
- Chunking — Split into manageable-sized fragments
- Index — Create embeddings and store in a vector store
- Query — Receive the user's question
- Retrieval — Search for the most relevant chunks by similarity
- Generate — LLM produces an answer based on the retrieved chunks
- Cite sources — Include references to the chunks used
Step 1: Load and Extract Content
Text extraction
import fitz
def extract_text_from_pdf(pdf_path: str) -> list[dict]:
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()
})
doc.close()
return pages
Image extraction
import base64
from pathlib import Path
def extract_images_from_pdf(pdf_path: str, output_dir: str = "/tmp/doc_images") -> list[dict]:
Path(output_dir).mkdir(parents=True, exist_ok=True)
doc = fitz.open(pdf_path)
images = []
for page_num in range(len(doc)):
page = doc[page_num]
image_list = page.get_images(full=True)
for img_idx, img_info in enumerate(image_list):
xref = img_info[0]
base_image = doc.extract_image(xref)
image_bytes = base_image["image"]
ext = base_image["ext"]
img_path = f"{output_dir}/page{page_num + 1}_img{img_idx + 1}.{ext}"
with open(img_path, "wb") as f:
f.write(image_bytes)
images.append({
"page": page_num + 1,
"path": img_path,
"size_bytes": len(image_bytes)
})
doc.close()
return images
Combined extraction
def extract_document(pdf_path: str) -> dict:
pages = extract_text_from_pdf(pdf_path)
images = extract_images_from_pdf(pdf_path)
return {
"pages": pages,
"images": images,
"total_pages": len(pages),
"total_images": len(images)
}
Step 2: Smart Chunking
Chunking is critical. Chunks that are too big dilute relevance; chunks that are too small lose context.
Chunking by size with overlap
def chunk_text(text: str, chunk_size: int = 1500, overlap: int = 200) -> list[str]:
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunk = text[start:end]
if chunk.strip():
chunks.append(chunk.strip())
start = end - overlap
return chunks
Chunking by pages with metadata
def chunk_pages(pages: list[dict], chunk_size: int = 1500, overlap: int = 200) -> list[dict]:
chunks = []
for page in pages:
page_chunks = chunk_text(page["text"], chunk_size, overlap)
for i, chunk in enumerate(page_chunks):
chunks.append({
"text": chunk,
"page": page["page"],
"chunk_index": i,
"source": f"Page {page['page']}, fragment {i + 1}"
})
return chunks
Semantic chunking (by paragraphs)
def chunk_by_paragraphs(text: str, max_chunk_size: int = 1500) -> list[str]:
paragraphs = text.split("\n\n")
chunks = []
current_chunk = ""
for para in paragraphs:
if len(current_chunk) + len(para) + 2 <= max_chunk_size:
current_chunk += para + "\n\n"
else:
if current_chunk.strip():
chunks.append(current_chunk.strip())
current_chunk = para + "\n\n"
if current_chunk.strip():
chunks.append(current_chunk.strip())
return chunks
Step 3: Index with Embeddings
With ChromaDB
import chromadb
from openai import OpenAI
client = OpenAI()
def create_document_index(chunks: list[dict], collection_name: str = "document_qa") -> chromadb.Collection:
chroma_client = chromadb.Client()
try:
chroma_client.delete_collection(collection_name)
except Exception:
pass
collection = chroma_client.create_collection(
name=collection_name,
metadata={"hnsw:space": "cosine"}
)
texts = [c["text"] for c in chunks]
embeddings = get_embeddings_batch(texts)
collection.add(
documents=texts,
embeddings=embeddings,
metadatas=[{"page": c["page"], "source": c["source"]} for c in chunks],
ids=[f"chunk_{i}" for i in range(len(chunks))]
)
return collection
def get_embeddings_batch(texts: list[str], model: str = "text-embedding-3-small") -> list[list[float]]:
response = client.embeddings.create(
model=model,
input=texts
)
return [item.embedding for item in response.data]
Index image descriptions (multimodal RAG)
def index_document_images(images: list[dict], collection: chromadb.Collection, start_id: int = 10000) -> None:
for i, img in enumerate(images):
description = describe_image_for_indexing(img["path"])
embedding = get_embeddings_batch([description])[0]
collection.add(
documents=[description],
embeddings=[embedding],
metadatas=[{
"page": img["page"],
"source": f"Image on page {img['page']}",
"type": "image"
}],
ids=[f"img_{start_id + i}"]
)
def describe_image_for_indexing(image_path: str) -> str:
with open(image_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "Describe this image in detail for indexing. Include all visible data, text, and structure."},
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}}
]
}],
max_tokens=300
)
return response.choices[0].message.content
Step 4: Retrieval
def retrieve_relevant_chunks(
collection: chromadb.Collection,
question: str,
n_results: int = 5
) -> list[dict]:
query_embedding = get_embeddings_batch([question])[0]
results = collection.query(
query_embeddings=[query_embedding],
n_results=n_results,
include=["documents", "metadatas", "distances"]
)
chunks = []
for i in range(len(results["documents"][0])):
chunks.append({
"text": results["documents"][0][i],
"metadata": results["metadatas"][0][i],
"distance": results["distances"][0][i]
})
return chunks
Step 5: Generate an Answer with Sources
def generate_answer(question: str, chunks: list[dict]) -> dict:
context_parts = []
for i, chunk in enumerate(chunks):
source = chunk["metadata"].get("source", f"Fragment {i + 1}")
context_parts.append(f"[{source}]\n{chunk['text']}")
context = "\n\n---\n\n".join(context_parts)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "You are a document Q&A assistant. Answer ONLY based on the provided context. If you can't find the answer, say so explicitly. Cite sources in brackets [Page X, fragment Y]."
},
{
"role": "user",
"content": f"Context:\n\n{context}\n\n---\n\nQuestion: {question}"
}
],
max_tokens=500,
temperature=0.1
)
return {
"answer": response.choices[0].message.content,
"sources": [c["metadata"] for c in chunks],
"model": "gpt-4o-mini",
"chunks_used": len(chunks)
}
Complete Pipeline
def document_qa_pipeline(pdf_path: str, question: str) -> dict:
doc_content = extract_document(pdf_path)
chunks = chunk_pages(doc_content["pages"])
collection = create_document_index(chunks)
if doc_content["images"]:
index_document_images(doc_content["images"], collection)
relevant = retrieve_relevant_chunks(collection, question)
answer = generate_answer(question, relevant)
answer["document"] = pdf_path
answer["total_pages"] = doc_content["total_pages"]
return answer
Usage:
result = document_qa_pipeline(
"service_contract.pdf",
"What is the penalty clause for breach of contract?"
)
print(result["answer"])
print(f"Sources: {result['sources']}")
Multi-Turn Conversation
A useful Q&A system keeps context between questions. The user asks "What is the penalty clause?" and then "And how much is the amount?", and the system understands that "the amount" refers to the penalty.
class DocumentQASession:
def __init__(self, pdf_path: str):
self.pdf_path = pdf_path
self.history: list[dict] = []
self.collection = None
self._setup()
def _setup(self):
doc_content = extract_document(self.pdf_path)
chunks = chunk_pages(doc_content["pages"])
self.collection = create_document_index(chunks)
if doc_content["images"]:
index_document_images(doc_content["images"], self.collection)
def ask(self, question: str) -> dict:
contextualized_question = self._contextualize(question)
relevant = retrieve_relevant_chunks(self.collection, contextualized_question)
context_parts = []
for i, chunk in enumerate(relevant):
source = chunk["metadata"].get("source", f"Fragment {i + 1}")
context_parts.append(f"[{source}]\n{chunk['text']}")
context = "\n\n---\n\n".join(context_parts)
messages = [
{
"role": "system",
"content": "You are a document Q&A assistant. Answer ONLY based on the provided context. Cite sources in brackets. Stay consistent with the previous conversation."
}
]
for h in self.history[-6:]:
messages.append({"role": "user", "content": h["question"]})
messages.append({"role": "assistant", "content": h["answer"]})
messages.append({
"role": "user",
"content": f"Document context:\n\n{context}\n\n---\n\nQuestion: {question}"
})
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
max_tokens=500,
temperature=0.1
)
answer = response.choices[0].message.content
self.history.append({
"question": question,
"answer": answer,
"sources": [c["metadata"] for c in relevant]
})
return {
"answer": answer,
"sources": [c["metadata"] for c in relevant],
"turn": len(self.history)
}
def _contextualize(self, question: str) -> str:
if not self.history:
return question
recent = self.history[-3:]
history_text = "\n".join(
f"Q: {h['question']}\nA: {h['answer'][:200]}"
for h in recent
)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": f"History:\n{history_text}\n\nNew question: {question}\n\nRewrite the question so it's self-contained (no ambiguous pronouns). If it's already clear, repeat it as is."
}],
max_tokens=100,
temperature=0
)
return response.choices[0].message.content
Usage:
session = DocumentQASession("technical_manual.pdf")
r1 = session.ask("What hardware requirements are mentioned?")
print(r1["answer"])
r2 = session.ask("And the software ones?")
print(r2["answer"])
r3 = session.ask("Are they compatible with each other?")
print(r3["answer"])
Pipeline Variations
With reranking
After retrieval, reorder by relevance using a second model:
def rerank_chunks(question: str, chunks: list[dict], top_k: int = 3) -> list[dict]:
prompt_parts = []
for i, chunk in enumerate(chunks):
prompt_parts.append(f"[{i}] {chunk['text'][:300]}")
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": f"Question: {question}\n\nFragments:\n" + "\n\n".join(prompt_parts) +
f"\n\nOrder the indices from most to least relevant. Only the numbers separated by commas."
}],
max_tokens=50,
temperature=0
)
try:
indices = [int(x.strip()) for x in response.choices[0].message.content.split(",")]
return [chunks[i] for i in indices[:top_k] if i < len(chunks)]
except (ValueError, IndexError):
return chunks[:top_k]
With structured answer
def generate_structured_answer(question: str, chunks: list[dict]) -> dict:
context = "\n\n".join(c["text"] for c in chunks)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "Answer in JSON format with: answer (string), confidence (low/medium/high), key_points (array of strings), sources_used (array of strings)."
},
{
"role": "user",
"content": f"Context:\n{context}\n\nQuestion: {question}"
}
],
max_tokens=500,
temperature=0,
response_format={"type": "json_object"}
)
import json
return json.loads(response.choices[0].message.content)
Troubleshooting
Problem 1: Made-up answers (hallucinations)
Symptom: The model answers with information that isn't in the document.
Cause: Insufficient context or a prompt that doesn't restrict the model.
Solution:
system_prompt = (
"Answer EXCLUSIVELY with information from the provided context. "
"If the answer is not in the context, reply: "
"'I didn't find information about this in the document.' "
"NEVER make up data."
)
Problem 2: Poor retrieval (irrelevant chunks)
Symptom: The retrieved chunks don't contain the answer even though the document does have it.
Cause: Chunks that are too big, insufficient overlap, or embeddings that don't capture the semantics.
Solution:
- Reduce
chunk_sizeto 800-1000 characters - Increase
overlapto 200-300 - Use
text-embedding-3-largeinstead ofsmall - Implement reranking (see previous variation)
Problem 3: Scanned PDFs with no text
Symptom: extract_text_from_pdf returns empty pages.
Cause: The PDF is a scanned image, it has no embedded text.
Solution:
def extract_with_ocr_fallback(pdf_path: str) -> list[dict]:
pages = extract_text_from_pdf(pdf_path)
empty_pages = [p for p in pages if len(p["text"].strip()) < 50]
if len(empty_pages) > len(pages) * 0.5:
return extract_via_vision(pdf_path)
return pages
def extract_via_vision(pdf_path: str) -> list[dict]:
doc = fitz.open(pdf_path)
pages = []
for page_num in range(len(doc)):
page = doc[page_num]
pix = page.get_pixmap(dpi=200)
img_bytes = pix.tobytes("png")
b64 = base64.b64encode(img_bytes).decode()
response = client.chat.completions.create(
model="gpt-4o",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "Extract all the visible text on this page. Keep the structure."},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
]
}],
max_tokens=2000
)
pages.append({
"page": page_num + 1,
"text": response.choices[0].message.content
})
doc.close()
return pages
Problem 4: Very long documents (100+ pages)
Symptom: Indexing takes a long time or exceeds the embeddings API limits.
Solution:
def index_large_document(chunks: list[dict], batch_size: int = 100) -> chromadb.Collection:
chroma_client = chromadb.Client()
collection = chroma_client.create_collection("large_doc")
for i in range(0, len(chunks), batch_size):
batch = chunks[i:i + batch_size]
texts = [c["text"] for c in batch]
embeddings = get_embeddings_batch(texts)
collection.add(
documents=texts,
embeddings=embeddings,
metadatas=[{"page": c["page"], "source": c["source"]} for c in batch],
ids=[f"chunk_{i + j}" for j in range(len(batch))]
)
return collection
Problem 5: Context exceeds the LLM's token limit
Symptom: Error maximum context length exceeded.
Solution:
def trim_context_to_limit(chunks: list[dict], max_chars: int = 12000) -> list[dict]:
trimmed = []
total = 0
for chunk in chunks:
if total + len(chunk["text"]) > max_chars:
break
trimmed.append(chunk)
total += len(chunk["text"])
return trimmed
Exercises
Exercise 1: Document Q&A with confidence score
Modify generate_answer so it includes a confidence score (low/medium/high) based on the distance of the retrieved chunks.
Hint: If the average distance is < 0.3, high confidence; < 0.5, medium; > 0.5, low.
See solution
def generate_answer_with_confidence(question: str, chunks: list[dict]) -> dict:
avg_distance = sum(c["distance"] for c in chunks) / len(chunks) if chunks else 1.0
if avg_distance < 0.3:
confidence = "high"
elif avg_distance < 0.5:
confidence = "medium"
else:
confidence = "low"
context_parts = []
for i, chunk in enumerate(chunks):
source = chunk["metadata"].get("source", f"Fragment {i + 1}")
context_parts.append(f"[{source}]\n{chunk['text']}")
context = "\n\n---\n\n".join(context_parts)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "Answer ONLY based on the context. Cite sources. If the information is insufficient, say so."
},
{
"role": "user",
"content": f"Context:\n\n{context}\n\n---\n\nQuestion: {question}"
}
],
max_tokens=500,
temperature=0.1
)
return {
"answer": response.choices[0].message.content,
"confidence": confidence,
"avg_distance": round(avg_distance, 3),
"sources": [c["metadata"] for c in chunks]
}
Exercise 2: Retrieval with page filter
Implement a function that lets the user restrict the search to specific pages of the document.
Hint: Use ChromaDB's where parameter to filter by metadata.
See solution
def retrieve_from_pages(
collection: chromadb.Collection,
question: str,
pages: list[int] = None,
n_results: int = 5
) -> list[dict]:
query_embedding = get_embeddings_batch([question])[0]
query_params = {
"query_embeddings": [query_embedding],
"n_results": n_results,
"include": ["documents", "metadatas", "distances"]
}
if pages:
query_params["where"] = {"page": {"$in": pages}}
results = collection.query(**query_params)
chunks = []
for i in range(len(results["documents"][0])):
chunks.append({
"text": results["documents"][0][i],
"metadata": results["metadatas"][0][i],
"distance": results["distances"][0][i]
})
return chunks
Exercise 3: Compare two documents
Create a pipeline that takes two PDFs and a question, indexes both, and generates a comparative answer.
Hint: Use prefixes in the IDs and metadata to distinguish which document each chunk comes from.
See solution
def compare_documents_qa(pdf_path_a: str, pdf_path_b: str, question: str) -> dict:
chroma_client = chromadb.Client()
collection = chroma_client.create_collection("compare_docs")
for label, pdf_path in [("Document A", pdf_path_a), ("Document B", pdf_path_b)]:
pages = extract_text_from_pdf(pdf_path)
chunks = chunk_pages(pages)
texts = [c["text"] for c in chunks]
embeddings = get_embeddings_batch(texts)
collection.add(
documents=texts,
embeddings=embeddings,
metadatas=[{
"page": c["page"],
"source": f"{label}, {c['source']}",
"document": label
} for c in chunks],
ids=[f"{label.lower().replace(' ', '_')}_{i}" for i in range(len(chunks))]
)
relevant = retrieve_relevant_chunks(collection, question, n_results=8)
context_parts = []
for chunk in relevant:
source = chunk["metadata"]["source"]
context_parts.append(f"[{source}]\n{chunk['text']}")
context = "\n\n---\n\n".join(context_parts)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "Compare the information from Document A and Document B to answer. Cite which document says what."
},
{
"role": "user",
"content": f"Context:\n\n{context}\n\n---\n\nComparative question: {question}"
}
],
max_tokens=600,
temperature=0.1
)
return {
"answer": response.choices[0].message.content,
"sources": [c["metadata"] for c in relevant]
}
Additional Resources
- RAG Guide — LangChain — Reference implementation
- ChromaDB Documentation — Vector store used in the examples
- OpenAI Embeddings — Embedding models
- PyMuPDF Documentation — PDF extraction