Loading and chunking
Chunking decides what your retriever can find. Chunks that are too big bury the answer in noise; chunks that are too small lose the context that makes them meaningful; chunks that cut across a table or sentence break the facts apart. This lesson covers loading documents into clean text, the main chunking strategies with runnable implementations, how to choose size and overlap, and the metadata every chunk should carry.
Definitions
Code that reads a source — a PDF, web page, Word file, Markdown file, database row, or ticket — and extracts its text plus metadata such as title, URL, author, and last-modified date.
A contiguous passage of a document, typically 100–1,000 tokens, that is embedded and retrieved as one unit. Each chunk carries metadata pointing back to its source.
Text repeated at the end of one chunk and the start of the next, so information near a boundary appears whole in at least one chunk.
Loading documents
| Format | Common Python tool | Watch out for |
|---|---|---|
pypdf, pymupdf; unstructured or Docling for complex layouts | Headers/footers on every page, multi-column text, tables, scanned pages (need OCR) | |
| HTML / web | requests + BeautifulSoup, trafilatura | Navigation menus, cookie banners, scripts |
| Word / PowerPoint | python-docx, python-pptx | Text in tables, speaker notes |
| Markdown / text | pathlib | Front-matter, code blocks |
| Databases, tickets, CRM | SQL or the tool's API | Combine fields into readable text; keep ids |
from pathlib import Path
from pypdf import PdfReader
def load_pdf(path: Path) -> list[dict]:
reader = PdfReader(path)
pages = []
for number, page in enumerate(reader.pages, start=1):
text = page.extract_text() or ""
pages.append({"source": path.name, "page": number, "text": text})
return pages
Keep the page number: it becomes part of the citation (“handbook.pdf, page 12”), which users trust far more than a bare file name.
Cleaning
Extracted text is noisy. Remove repeated boilerplate and normalise whitespace — but do not strip meaning such as numbers, headings, or list structure.
import re
def clean(text: str) -> str:
text = re.sub(r"Page \d+ of \d+", " ", text) # page footers
text = re.sub(r"CONFIDENTIAL - INTERNAL USE", " ", text) # repeated banners
text = re.sub(r"-\n(\w)", r"\1", text) # re-join hyphenated line breaks
text = re.sub(r"[ \t]+", " ", text) # collapse spaces, keep newlines
text = re.sub(r"\n{3,}", "\n\n", text) # at most one blank line
return text.strip()
raw = "Refund Policy\n\n\n\nAnnual plans are refun-\ndable within 30 days. Page 3 of 9\nCONFIDENTIAL - INTERNAL USE"
print(clean(raw))
Strategy 1 — Fixed-size chunks with overlap
The simplest strategy: slide a window of N words (or tokens) with some overlap. It is predictable and works for uniform prose, but it ignores structure and can cut sentences in half.
def fixed_chunks(text: str, size: int = 12, overlap: int = 3) -> list[str]:
words = text.split()
step = size - overlap
out = []
for start in range(0, len(words), step):
out.append(" ".join(words[start:start + size]))
if start + size >= len(words):
break
return out
text = ("Backups run every night at 2 AM. Files are kept for 90 days. "
"Restores can be started from the web dashboard or the desktop app. "
"Large restores are delivered as a download link by email.")
for i, chunk in enumerate(fixed_chunks(text), 1):
print(i, chunk)
Strategy 2 — Recursive splitting on natural boundaries
Split on the largest natural separator first (blank lines between paragraphs), and only if a piece is still too long, split it on smaller separators (single newlines, then sentences, then words). Pieces are then merged up to the size limit. This keeps paragraphs and sentences intact whenever possible, and is the default in many frameworks.
import re
SEPARATORS = ["\n\n", "\n", ". ", " "]
def recursive_split(text: str, max_chars: int = 120, seps=SEPARATORS) -> list[str]:
if len(text) <= max_chars:
return [text.strip()] if text.strip() else []
sep, *rest = seps
parts = text.split(sep)
chunks, current = [], ""
for part in parts:
candidate = f"{current}{sep}{part}" if current else part
if len(candidate) <= max_chars:
current = candidate
continue
if current:
chunks.append(current.strip())
if len(part) > max_chars and rest:
chunks.extend(recursive_split(part, max_chars, rest))
current = ""
else:
current = part
if current.strip():
chunks.append(current.strip())
return chunks
doc = """Refund policy
Annual plans can be refunded within 30 days of purchase. Monthly plans are not refunded. Refunds go back to the original card.
Backups
Backups run nightly. Files are kept for 90 days."""
for c in recursive_split(doc):
print(f"[{len(c):>3}] {c!r}")
Paragraphs stayed whole where they fit; the long refund paragraph was split at a sentence boundary instead of mid-sentence. Notice that the second chunk lost its final period: str.split consumes the separator. Production splitters (for example LangChain's RecursiveCharacterTextSplitter) have an option to keep it.
Strategy 3 — Structure-aware chunking
Documents with headings (Markdown, HTML, well-formatted PDFs) carry valuable structure. Split by section and prepend the heading path to each chunk so it stays meaningful on its own.
def markdown_sections(md: str) -> list[dict]:
sections, path, buffer = [], [], []
def flush():
body = " ".join(line.strip() for line in buffer if line.strip())
if body:
sections.append({"heading": " > ".join(path), "text": f"{' > '.join(path)}: {body}"})
buffer.clear()
for line in md.splitlines():
if line.startswith("#"):
flush()
level = len(line) - len(line.lstrip("#"))
path[:] = path[: level - 1] + [line.lstrip("#").strip()]
else:
buffer.append(line)
flush()
return sections
md = """# Admin guide
## Users
Invite users from Settings > Team.
## Security
### Two-factor authentication
Admins must enable 2FA within 7 days.
"""
for s in markdown_sections(md):
print(s["text"])
Without the heading, the chunk “Admins must enable 2FA within 7 days” might still be retrievable — but a chunk like “Enable it within 7 days” would be useless. Heading context fixes that.
Strategy 4 — Semantic and contextual chunking
- Semantic chunking embeds each sentence and starts a new chunk where the meaning shifts (similarity between neighbouring sentences drops). Better boundaries, more compute.
- Contextual chunk headers: an LLM writes a one-sentence summary of where the chunk sits in the document, prepended before embedding. This can significantly improve retrieval for chunks that are ambiguous on their own.
- Special content: keep tables whole (or convert rows to sentences), keep code blocks whole, and treat FAQs as one question+answer per chunk.
Choosing chunk size and overlap
| Chunk size | Pros | Cons | Good for |
|---|---|---|---|
| Small (100–250 tokens) | Precise matches; less noise in the prompt | May lose context; more chunks to store | FAQs, definitions, short facts |
| Medium (250–600 tokens) | Balanced | — | Most documentation — a sensible default |
| Large (600–1,200 tokens) | Keeps full explanations together | Diluted embeddings; more tokens per answer | Long-form reasoning, legal or technical passages |
Start around 400 tokens with 10–15% overlap, then tune with a retrieval evaluation (see Retrieval patterns). There is no universal best size — it depends on your documents and questions.
Embedding models have their own input limits (often 512 to 8,192 tokens). Text beyond the limit is truncated silently by some libraries, so the end of an over-long chunk is simply never embedded. Keep chunks comfortably under the limit.
Metadata: every chunk needs it
| Field | Used for |
|---|---|
id | Stable identifier for updates, deletes, and evaluation. |
source, page, url, heading | Citations and links. |
updated_at | Freshness filters and re-indexing. |
doc_type, product, language | Metadata filtering at query time. |
access (team, role, tenant) | Permission filtering so users only retrieve what they may see. |
hash | Skip re-embedding unchanged chunks; deduplicate. |
import hashlib
def make_chunk_records(source: str, chunks: list[str], **meta) -> list[dict]:
records = []
for i, text in enumerate(chunks):
records.append({
"id": f"{source}#{i}",
"source": source,
"chunk_index": i,
"text": text,
"hash": hashlib.sha256(text.encode()).hexdigest()[:12],
**meta,
})
return records
recs = make_chunk_records("admin-guide.md", ["Invite users from Settings > Team."],
doc_type="guide", access="all-staff", updated_at="2026-10-28")
print(recs[0])
- Load three of your own Markdown or text files and chunk them with
recursive_splitat 300, 600, and 1,000 characters. - For each size, print the number of chunks and look at five random chunks. Which size produces chunks that make sense on their own?
- Add heading paths with
markdown_sectionsand compare.
- Load documents with format-specific tools and keep provenance such as page numbers and URLs.
- Clean boilerplate and whitespace without removing meaning.
- Prefer recursive or structure-aware chunking over blind fixed-size windows.
- Start near 400 tokens with 10–15% overlap and tune with retrieval evaluations.
- Attach rich metadata to every chunk: id, source, location, dates, filters, permissions, and a hash.