Introduction: What is a Document Q&A Agent?
A Document Q&A Agent is an AI-powered system that can answer questions based on the content of your documents. Instead of relying on a language model's pre-trained knowledge (which may be outdated or hallucinated), the agent retrieves relevant chunks of text from your documents and uses them as context to generate accurate, grounded answers. LangChain provides the orchestration layer that connects document loading, text splitting, embedding generation, vector storage, retrieval, and response generation into a single cohesive pipeline.
At its core, the agent follows a pattern called Retrieval Augmented Generation (RAG): when a user asks a question, the system first retrieves the most relevant passages from the document store, then feeds those passages to a large language model (LLM) along with the original question to produce a well-informed answer.
Why Document Q&A Agents Matter
Document Q&A agents solve critical real-world problems that standalone LLMs cannot address:
- Domain-Specific Knowledge: LLMs are trained on general internet data. Your internal company documents, legal contracts, medical records, or technical manuals contain specialized information the model has never seen. A Q&A agent bridges this gap.
- Grounded Answers with Source Citations: The agent can return the exact source chunks it used, enabling users to verify facts and trace claims back to original documents.
- Overcoming Context Window Limits: Modern documents can span thousands of pages. The agent intelligently selects only the relevant portions, avoiding the token limits of even the largest models.
- Reducing Hallucinations: By grounding responses in retrieved text, the agent dramatically reduces the model's tendency to fabricate plausible-sounding but incorrect information.
- Data Freshness: Update your document store and the agent's knowledge updates instantlyβno retraining or fine-tuning required.
Core Concepts and Architecture
Before diving into code, let's understand the key components of a LangChain Document Q&A system:
- Document Loaders: Ingest documents from various sources (PDFs, web pages, databases, CSV files, etc.) and convert them into LangChain's Document format.
- Text Splitters: Break large documents into smaller chunks that fit within the LLM's context window and the embedding model's input size.
- Embeddings: Convert text chunks into high-dimensional vectors that capture semantic meaning. Similar concepts end up close together in vector space.
- Vector Store: An indexed database that stores embeddings and enables fast similarity search (Chroma, Pinecone, Weaviate, FAISS, etc.).
- Retriever: The interface that, given a query, returns the most relevant document chunks from the vector store.
- Chain/Agent: The orchestration logic that takes a user question, calls the retriever, formats a prompt with the retrieved context, calls the LLM, and returns the answer.
- Memory (optional): Stores conversation history so the agent can handle follow-up questions and maintain context across turns.
The data flow looks like this:
Documents β Load β Split β Embed β Store in Vector DB
β
User Question β Embed β Similarity Search β Retrieved Chunks
β
Question + Chunks β Prompt Template β LLM β Answer
Setting Up Your Environment
First, install the required packages. Create a new Python project and run:
pip install langchain langchain-openai langchain-community chromadb openai tiktoken pypdf unstructured
Set your OpenAI API key as an environment variable:
export OPENAI_API_KEY="your-api-key-here"
Or set it programmatically in your script (not recommended for production):
import os
os.environ["OPENAI_API_KEY"] = "your-api-key-here"
Step-by-Step Implementation
1. Loading Documents
LangChain supports dozens of document loaders. Here we'll demonstrate loading PDFs and text filesβtwo of the most common formats.
Loading a PDF document:
from langchain_community.document_loaders import PyPDFLoader
# Load a PDF file - each page becomes a separate Document object
loader = PyPDFLoader("data/company_handbook.pdf")
documents = loader.load()
print(f"Loaded {len(documents)} pages")
print(f"First page preview: {documents[0].page_content[:200]}...")
Loading multiple PDFs from a directory:
from langchain_community.document_loaders import PyPDFDirectoryLoader
# Load all PDFs in a directory
loader = PyPDFDirectoryLoader("data/pdfs/")
documents = loader.load()
print(f"Loaded {len(documents)} pages from all PDFs")
Loading text files:
from langchain_community.document_loaders import TextLoader
loader = TextLoader("data/notes.txt")
documents = loader.load()
Loading web pages (bonus):
from langchain_community.document_loaders import WebBaseLoader
loader = WebBaseLoader("https://example.com/documentation")
documents = loader.load()
2. Splitting Documents into Chunks
Raw documents are often too large for embedding models and LLMs. We split them into overlapping chunks to preserve context at boundaries while keeping each chunk semantically coherent.
from langchain.text_splitter import RecursiveCharacterTextSplitter
# Create a text splitter with configurable chunk size and overlap
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000, # Maximum characters per chunk
chunk_overlap=200, # Overlap between consecutive chunks
length_function=len,
separators=["\n\n", "\n", ". ", " ", ""] # Split on paragraphs first, then sentences
)
# Split the loaded documents
chunks = text_splitter.split_documents(documents)
print(f"Split {len(documents)} documents into {len(chunks)} chunks")
# Inspect a chunk
print(f"Chunk 0 content ({len(chunks[0].page_content)} chars):")
print(chunks[0].page_content[:300])
print(f"Metadata: {chunks[0].metadata}")
Choosing chunk size and overlap:
- Chunk size: Typically 500β1500 tokens. Smaller chunks improve retrieval precision; larger chunks preserve more context for the LLM to reason over.
- Chunk overlap: 10β20% of chunk size. Overlap prevents losing context at chunk boundariesβa sentence that spans two chunks will appear fully in at least one.
3. Creating Embeddings and Vector Stores
Now we embed each chunk and store the embeddings in a vector database. We'll use OpenAI's embedding model and ChromaDB (an open-source, in-memory vector store perfect for development).
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
# Initialize the embedding model
embeddings = OpenAIEmbeddings(
model="text-embedding-3-small", # Cost-effective, 1536 dimensions
# For higher quality: "text-embedding-3-large" (3072 dimensions)
)
# Create a persistent Chroma vector store from the document chunks
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory="./chroma_db" # Persists to disk for reuse
)
print(f"Vector store created with {vectorstore._collection.count()} embeddings")
Loading an existing persisted vector store:
# On subsequent runs, load from disk instead of re-embedding
vectorstore = Chroma(
persist_directory="./chroma_db",
embedding_function=embeddings
)
Testing similarity search manually:
# Perform a raw similarity search
results = vectorstore.similarity_search(
"What is the company's remote work policy?",
k=4 # Number of chunks to retrieve
)
for i, doc in enumerate(results):
print(f"\n--- Result {i+1} (Source: {doc.metadata.get('source', 'unknown')}) ---")
print(doc.page_content[:200])
4. Building the Retrieval Chain
The retriever wraps the vector store and provides a clean interface. We then build a chain that takes a question, retrieves context, formats a prompt, and calls the LLM.
from langchain_openai import ChatOpenAI
from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain_core.prompts import ChatPromptTemplate
# Convert vectorstore to a retriever
retriever = vectorstore.as_retriever(
search_type="similarity", # Options: "similarity", "mmr", "similarity_score_threshold"
search_kwargs={"k": 4} # Retrieve top 4 chunks
)
# Initialize the LLM
llm = ChatOpenAI(
model="gpt-4o",
temperature=0.1 # Low temperature for factual accuracy
)
# Define the system prompt template
system_prompt = """You are a helpful assistant that answers questions based on the provided context.
Use only the information from the context to answer the question.
If the context doesn't contain the answer, say "I don't have enough information to answer that."
Always cite the source document and page when possible.
Context:
{context}"""
prompt = ChatPromptTemplate.from_messages([
("system", system_prompt),
("human", "{input}")
])
# Create the document combination chain
combine_docs_chain = create_stuff_documents_chain(
llm=llm,
prompt=prompt
)
# Create the full retrieval chain
rag_chain = create_retrieval_chain(
retriever=retriever,
combine_docs_chain=combine_docs_chain
)
print("RAG chain ready!")
Querying the chain:
# Ask a question
response = rag_chain.invoke({
"input": "What benefits does the company offer for remote employees?"
})
print(f"Answer: {response['answer']}")
print(f"\nSources consulted:")
for i, doc in enumerate(response['context']):
source = doc.metadata.get('source', 'unknown')
page = doc.metadata.get('page', 'N/A')
print(f" [{i+1}] {source} (page {page})")
5. Adding Conversational Memory
To handle follow-up questions and maintain conversation context, we add memory that tracks the entire chat history.
from langchain.chains import create_history_aware_retriever
from langchain_core.prompts import MessagesPlaceholder
from langchain_core.messages import HumanMessage, AIMessage
# Prompt to rephrase the question using chat history for better retrieval
contextualize_q_prompt = ChatPromptTemplate.from_messages([
("system", """Given the chat history and the latest user question,
rephrase the question as a standalone query that captures all relevant context.
Do NOT answer the question, just rephrase it.
Standalone query:"""),
MessagesPlaceholder("chat_history"),
("human", "{input}")
])
# Create a history-aware retriever
history_aware_retriever = create_history_aware_retriever(
llm=llm,
retriever=retriever,
prompt=contextualize_q_prompt
)
# Full Q&A prompt with context
qa_prompt = ChatPromptTemplate.from_messages([
("system", system_prompt),
MessagesPlaceholder("chat_history"),
("human", "{input}")
])
# Rebuild the combine chain with the new prompt
combine_docs_chain_with_history = create_stuff_documents_chain(
llm=llm,
prompt=qa_prompt
)
# Final conversational RAG chain
conversational_rag_chain = create_retrieval_chain(
retriever=history_aware_retriever,
combine_docs_chain=combine_docs_chain_with_history
)
6. Putting It All Together: The Complete Agent
Here's the complete, ready-to-run implementation that loads documents, builds the vector store, and provides an interactive Q&A interface with memory:
"""
Complete Document Q&A Agent with LangChain
Loads documents, builds a vector store, and runs an interactive Q&A loop with memory.
"""
import os
from langchain_community.document_loaders import PyPDFDirectoryLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import Chroma
from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.chains import create_history_aware_retriever
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.messages import HumanMessage, AIMessage
# ββ Configuration ββββββββββββββββββββββββββββββββββββββββββ
DATA_DIR = "./documents"
CHROMA_PERSIST_DIR = "./chroma_db"
CHUNK_SIZE = 1000
CHUNK_OVERLAP = 200
RETRIEVAL_K = 4
# ββ Step 1: Load documents ββββββββββββββββββββββββββββββββββ
print("π Loading documents...")
loader = PyPDFDirectoryLoader(DATA_DIR)
documents = loader.load()
print(f" Loaded {len(documents)} document pages")
# ββ Step 2: Split into chunks βββββββββββββββββββββββββββββββ
print("βοΈ Splitting into chunks...")
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=CHUNK_SIZE,
chunk_overlap=CHUNK_OVERLAP,
separators=["\n\n", "\n", ". ", " ", ""]
)
chunks = text_splitter.split_documents(documents)
print(f" Created {len(chunks)} chunks")
# ββ Step 3: Create embeddings and vector store ββββββββββββββ
print("π§ Creating embeddings and vector store...")
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# Only embed if vector store doesn't exist
if os.path.exists(CHROMA_PERSIST_DIR):
print(" Loading existing vector store from disk...")
vectorstore = Chroma(
persist_directory=CHROMA_PERSIST_DIR,
embedding_function=embeddings
)
else:
print(" Creating new vector store (this may take a while)...")
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory=CHROMA_PERSIST_DIR
)
print(f" Vector store ready with {vectorstore._collection.count()} embeddings")
# ββ Step 4: Set up retriever and LLM ββββββββββββββββββββββββ
retriever = vectorstore.as_retriever(
search_type="similarity",
search_kwargs={"k": RETRIEVAL_K}
)
llm = ChatOpenAI(model="gpt-4o", temperature=0.1)
# ββ Step 5: Build prompts βββββββββββββββββββββββββββββββββββ
contextualize_q_prompt = ChatPromptTemplate.from_messages([
("system", """Given the chat history and the latest user question,
rephrase the question into a standalone query that captures all context.
Do NOT answer, only rephrase. Standalone query:"""),
MessagesPlaceholder("chat_history"),
("human", "{input}")
])
qa_system_prompt = """You are a helpful assistant answering questions based on provided context.
Use ONLY the context below. If the answer isn't in the context, say so honestly.
Cite source documents when possible.
Context:
{context}"""
qa_prompt = ChatPromptTemplate.from_messages([
("system", qa_system_prompt),
MessagesPlaceholder("chat_history"),
("human", "{input}")
])
# ββ Step 6: Build chains ββββββββββββββββββββββββββββββββββββ
history_aware_retriever = create_history_aware_retriever(
llm=llm,
retriever=retriever,
prompt=contextualize_q_prompt
)
combine_docs_chain = create_stuff_documents_chain(
llm=llm,
prompt=qa_prompt
)
rag_chain = create_retrieval_chain(
retriever=history_aware_retriever,
combine_docs_chain=combine_docs_chain
)
# ββ Step 7: Interactive Q&A loop ββββββββββββββββββββββββββββ
print("\nβ
Agent ready! Type your questions below.")
print(" Type 'exit' to quit, 'clear' to reset history.\n")
chat_history = []
while True:
question = input("π You: ")
if question.lower() in ("exit", "quit"):
break
if question.lower() == "clear":
chat_history = []
print(" History cleared.\n")
continue
response = rag_chain.invoke({
"input": question,
"chat_history": chat_history
})
answer = response["answer"]
sources = response["context"]
print(f"\nπ€ Agent: {answer}")
print(f"\n π Sources:")
seen = set()
for doc in sources:
source = doc.metadata.get("source", "unknown")
page = doc.metadata.get("page", "N/A")
key = f"{source}:{page}"
if key not in seen:
seen.add(key)
print(f" β’ {source} (page {page})")
print()
# Update history
chat_history.append(HumanMessage(content=question))
chat_history.append(AIMessage(content=answer))
Advanced Techniques
Handling Multiple Document Types
Real-world projects often involve mixed document formats. LangChain's document loaders handle this gracefully:
from langchain_community.document_loaders import (
PyPDFLoader,
TextLoader,
CSVLoader,
UnstructuredMarkdownLoader,
UnstructuredHTMLLoader
)
all_documents = []
# Load PDFs
pdf_loader = PyPDFDirectoryLoader("data/pdfs/")
all_documents.extend(pdf_loader.load())
# Load text files
import glob
for txt_file in glob.glob("data/*.txt"):
loader = TextLoader(txt_file)
all_documents.extend(loader.load())
# Load CSV (each row becomes a document)
csv_loader = CSVLoader("data/products.csv")
all_documents.extend(csv_loader.load())
# Load Markdown
md_loader = UnstructuredMarkdownLoader("data/README.md")
all_documents.extend(md_loader.load())
print(f"Total documents loaded: {len(all_documents)}")
# Now split and embed all_documents as before
Using Different Retrieval Strategies
The basic similarity search works well, but LangChain offers alternative retrieval methods that can improve results:
# Strategy 1: Maximum Marginal Relevance (MMR) - reduces redundancy
mmr_retriever = vectorstore.as_retriever(
search_type="mmr",
search_kwargs={
"k": 6,
"fetch_k": 20, # Fetch more candidates then filter
"lambda_mult": 0.7 # 0 = max diversity, 1 = max similarity
}
)
# Strategy 2: Similarity score threshold - only return highly relevant chunks
threshold_retriever = vectorstore.as_retriever(
search_type="similarity_score_threshold",
search_kwargs={
"k": 10,
"score_threshold": 0.5 # Only return chunks with similarity >= 0.5
}
)
# Strategy 3: Ensemble retriever - combine multiple retrievers
from langchain.retrievers import EnsembleRetriever
keyword_retriever = ... # BM25 or other keyword-based retriever
ensemble = EnsembleRetriever(
retrievers=[mmr_retriever, keyword_retriever],
weights=[0.7, 0.3] # Weighted combination
)
Optimizing with Reranking
For production-quality retrieval, add a reranking step: retrieve a larger pool of candidates, then use a more powerful (but slower) model to rerank and select the best ones:
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CrossEncoderReranker
from langchain_community.cross_encoders import HuggingFaceCrossEncoder
# Initialize a cross-encoder reranker
model = HuggingFaceCrossEncoder(model_name="BAAI/bge-reranker-base")
compressor = CrossEncoderReranker(model=model, top_n=4)
# Wrap the base retriever with reranking
base_retriever = vectorstore.as_retriever(search_kwargs={"k": 20}) # Fetch more
compression_retriever = ContextualCompressionRetriever(
base_compressor=compressor,
base_retriever=base_retriever
)
# Use compression_retriever in your chain instead of the raw retriever
# The retriever now: fetches 20 candidates β reranks them β returns top 4
Building a Full Agent with Tools (Advanced Pattern)
For complex scenarios where the agent needs to decide how to answer (search documents, summarize, compare), use the Agent pattern instead of a fixed chain:
from langchain.agents import create_openai_tools_agent, AgentExecutor
from langchain.tools import tool
from langchain_core.prompts import ChatPromptTemplate
@tool
def search_documents(query: str) -> str:
"""Search the company knowledge base for information relevant to the query."""
docs = retriever.invoke(query)
return "\n\n---\n\n".join([d.page_content for d in docs[:3]])
@tool
def list_document_sources() -> str:
"""Return a list of all document sources available in the knowledge base."""
# Get unique sources from the vector store
sources = set()
for doc_id in vectorstore._collection.get()["metadatas"]:
sources.add(doc_id.get("source", "unknown"))
return "\n".join(sorted(sources))
tools = [search_documents, list_document_sources]
agent_prompt = ChatPromptTemplate.from_messages([
("system", """You are a helpful document Q&A agent.
Use the search_documents tool to find relevant information before answering.
Always cite sources. If multiple documents are relevant, compare them."""),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
("placeholder", "{agent_scratchpad}")
])
agent = create_openai_tools_agent(llm, tools, agent_prompt)
agent_executor = AgentExecutor(
agent=agent,
tools=tools,
verbose=True,
max_iterations=5
)
# Query the agent
result = agent_executor.invoke({
"input": "Compare the remote work policies from 2023 and 2024. What changed?",
"chat_history": []
})
print(result["output"])
Best Practices
- Chunk Size Tuning: Experiment with chunk sizes between 500 and 1500 tokens. Too small loses context; too large dilutes retrieval precision. Run test queries against your actual documents to find the sweet spot.
- Metadata Preservation: Always preserve metadata (source file, page number, section title) through the splitting process. This enables source citations and debugging.
- Embedding Model Choice: Match your embedding model to your domain. OpenAI's
text-embedding-3-smallis fast and cheap for most use cases. For specialized domains (legal, medical, multilingual), consider domain-specific embedding models from HuggingFace. - Prompt Engineering Matters: Write clear system prompts that instruct the LLM to only use provided context, to say "I don't know" when appropriate, and to cite sources. A good prompt dramatically reduces hallucinations.
- Cache Embeddings: Embedding large document collections is expensive. Persist your vector store to disk (Chroma's
persist_directory) and reuse it across runs. - Monitor and Evaluate: Build a test set of questions with expected answers. Use metrics like faithfulness (does the answer match the retrieved context?) and answer relevancy to evaluate your pipeline.
- Handle Rate Limits: When embedding thousands of chunks, implement retry logic with exponential backoff to handle API rate limits gracefully.
- Security: Never expose raw document content in user-facing error messages. Sanitize metadata before displaying to end users.
- Streaming for UX: For production applications, enable streaming responses so users see answers appear token by token rather than waiting for the full response.
- Fallback Strategies: If no relevant chunks are retrieved (similarity scores below threshold), have a fallback response like "I couldn't find relevant information in the documents" rather than letting the LLM guess.
Conclusion
Building a Document Q&A Agent with LangChain transforms static documents into interactive knowledge bases. The RAG architectureβloading, splitting, embedding, storing, retrieving, and generatingβprovides a powerful pattern that combines the strengths of vector search with the reasoning capabilities of large language models. By following this guide, you've built a complete agent capable of ingesting PDFs and other documents, handling conversational context with memory, and delivering grounded, source-cited answers. The modular design of LangChain lets you swap components easily: change the embedding model, switch vector stores from Chroma to Pinecone for production scale, upgrade the LLM, or add reranking for higher precision. Start with the simple pipeline demonstrated here, iterate on your chunk sizes and prompts based on real user feedback, and gradually adopt advanced techniques like agent-based tool use and cross-encoder reranking as your needs grow. The combination of LangChain's orchestration with modern embedding and LLM APIs gives you a production-ready foundation for building intelligent document assistants that truly understand your content.