Skip to main content

Module 4 — Document loaders and splitting

The assistant now knows how to compose chains, but it has nothing to reason on. This module gives it a corpus: the reimbursement policy as a set of documents to retrieve from, and the incoming receipts as documents to extract fields from. Course 18 covered ingestion in depth from the RAG angle; this module shows how LangChain packages the same job into two interfaces — DocumentLoader and TextSplitter — that plug straight into the retriever of module 5.

The Document object

Every loader in LangChain returns a list of Document objects. Each document has two things:

Document(
page_content="Meal ceiling for internal travel is 25 EUR ...",
metadata={"source": "policy.pdf", "page": 4, "section": "3.2"},
)

The metadata field is not decoration. It is what module 5's citations point at, what course 18's permission filters read, and what the incremental reindex of a nightly job diffs. Emit metadata at load time, not later — the fifteen minutes you save now cost days when you try to reconstruct it after the fact.

PDF loaders

The reimbursement policy is a PDF. LangChain wraps several parsers; the two that matter in 2026 are PyMuPDFLoader (fast, best for two-column and complex layouts) and PyPDFLoader (pure Python, slower, sufficient for simple layouts).

from langchain_community.document_loaders import PyMuPDFLoader

docs = PyMuPDFLoader("policy.pdf").load()
print(len(docs), "pages")
print(docs[0].metadata)
# {'source': 'policy.pdf', 'page': 0, 'total_pages': 32, ...}

Two failure modes you must expect and log. First, a scanned PDF returns pages with empty page_content and no exception — the file is valid, it just has no selectable text. Route those to OCR (UnstructuredPDFLoader with the OCR strategy, or pytesseract directly). Second, two-column layouts sometimes read top-of-left then top-of-right mid-sentence; PyMuPDF handles it better than PyPDF, and course 18 explained why.

Web and office loaders

Receipts arrive as PDFs, images or web pages. LangChain covers each with an interface identical to the PDF one.

from langchain_community.document_loaders import (
WebBaseLoader, UnstructuredWordDocumentLoader, UnstructuredEmailLoader,
)

web_docs = WebBaseLoader(["https://intranet/policy"]).load()
doc_docs = UnstructuredWordDocumentLoader("addendum.docx").load()

WebBaseLoader fetches HTML and strips boilerplate; give it a list of URLs, not a spider, because a spider is a project of its own. The Unstructured family handles Word, PowerPoint, HTML, email and dozens more formats through a single library; it is a heavy dependency, but it is the pragmatic answer when the corpus is mixed. For images, either an OCR loader or a vision-capable model called explicitly — LangChain does not auto-OCR anything.

Recursive character splitting

A 32-page policy is one document but it will never fit in a prompt, and even if it did, retrieval on a whole page returns imprecise passages. Split first.

The default splitter in 2026 is RecursiveCharacterTextSplitter. It splits on a list of separators in priority order: try to split on double newlines (paragraphs), then single newlines (lines), then spaces, then characters. It stops as soon as chunks fit under the size limit.

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
chunk_size=800, # roughly 200-300 tokens for English
chunk_overlap=120, # 15 % overlap
separators=["\n\n", "\n", ". ", " ", ""],
)
chunks = splitter.split_documents(docs)
print(len(chunks), "chunks from", len(docs), "pages")

The three parameters that decide quality:

  • chunk_size in characters, not tokens; a good default for policy text is 600 to 1000 characters. Too small and chunks lose the surrounding sentence; too big and retrieval returns an imprecise passage.
  • chunk_overlap copies the last N characters of chunk k into the start of chunk k+1. Without overlap, a query landing on the seam retrieves half a sentence. 10 to 20 percent is the standard range; going above 25 percent fills the index with near-duplicates.
  • separators in decreasing granularity. The default respects paragraphs, then sentences, then words. On code, add \nclass , \ndef ; on markdown, MarkdownHeaderTextSplitter is the specialised sibling.

The rule course 18 gave in module 3 stands here unchanged: count in tokens for cost budgets, count in characters for splitter parameters, and pick both explicitly.

Preserving structure

A chunker that respects lines but not tables will slice a table between its header row and its data — the classic RAG failure. Two mitigations.

Prepend the heading trail to each chunk's page_content, extracted at load time. A chunk saying "Retention: seven years" is near-meaningless in isolation; the same chunk prefixed with # Reimbursement policy > 3. Records > 3.2 Retention embeds far better and is far more readable when cited.

Treat tables as atomic blocks. Either keep the whole table in one chunk, or repeat the header row at the start of every chunk that carries a fragment of the table.

Wiring the assistant

The assistant of this course now has two ingestion pipelines running side by side.

# 1) The reference corpus, indexed once and reused
policy_docs = PyMuPDFLoader("policy.pdf").load()
policy_chunks = splitter.split_documents(policy_docs)
# -> handed to the vector store in module 5

# 2) The receipts, one document per file, no chunking
receipt_docs = PyMuPDFLoader("receipts/2026-09-04-berlin.pdf").load()
# -> handed to the extraction chain of module 2

The distinction is worth naming. Reference documents are chunked and embedded for retrieval. Task documents — the receipts — flow straight into an extraction chain and are not stored. Mixing the two in one pipeline is a common bug.

Do not chunk receipts

A receipt is small, structured, task-specific. Chunking it splits fields you need together and pollutes the index with rows nobody will query for. Keep it whole and pass it to a structured-output chain.

In summary

  • Every loader returns Document objects with page_content and metadata; emit source, page and section at load time because reconstructing them later is expensive.
  • PyMuPDFLoader is the pragmatic default for PDFs; expect and log empty pages from scanned files that need OCR.
  • RecursiveCharacterTextSplitter with 600 to 1000-character chunks and 10 to 20 percent overlap is the working range; prepend the heading trail to each chunk to preserve context.
  • Distinguish reference documents (chunked, indexed for retrieval) from task documents like receipts (kept whole, fed to an extraction chain).

Next module: putting those chunks in a vector store and building the retrieval chain that answers policy questions.