AI Tutorial
Home All Courses Blog Interview Prep ↗ 0%
1
Curious builder 0 XP earned · 300 to level 2
0 days Finish a lesson to begin
Badge collection
0 of 5 unlocked
small wins to finish your path
Next lesson →

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.

Last updated: 01 Nov, 2026

Definitions

Definition · Document loader

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.

Definition · Chunk

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.

Definition · Chunk overlap

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

FormatCommon Python toolWatch out for
PDFpypdf, pymupdf; unstructured or Docling for complex layoutsHeaders/footers on every page, multi-column text, tables, scanned pages (need OCR)
HTML / webrequests + BeautifulSoup, trafilaturaNavigation menus, cookie banners, scripts
Word / PowerPointpython-docx, python-pptxText in tables, speaker notes
Markdown / textpathlibFront-matter, code blocks
Databases, tickets, CRMSQL or the tool's APICombine fields into readable text; keep ids
load_pdf.py
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.

Python
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))
Refund Policy Annual plans are refundable within 30 days.

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.

Python
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)
1 Backups run every night at 2 AM. Files are kept for 90 2 kept for 90 days. Restores can be started from the web dashboard 3 the web dashboard or the desktop app. Large restores are delivered as 4 are delivered as a download link by email.

Strategy 2 — Recursive splitting on natural boundaries

Definition · Recursive character splitting

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.

Python
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}")
[ 13] 'Refund policy' [ 87] 'Annual plans can be refunded within 30 days of purchase. Monthly plans are not refunded' [ 37] 'Refunds go back to the original card.' [ 57] 'Backups\n\nBackups run nightly. Files are kept for 90 days.'

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.

Python
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"])
Admin guide > Users: Invite users from Settings > Team. Admin guide > Security > Two-factor authentication: Admins must enable 2FA within 7 days.

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 sizeProsConsGood for
Small (100–250 tokens)Precise matches; less noise in the promptMay lose context; more chunks to storeFAQs, definitions, short facts
Medium (250–600 tokens)Balanced—Most documentation — a sensible default
Large (600–1,200 tokens)Keeps full explanations togetherDiluted embeddings; more tokens per answerLong-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.

Watch out

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

FieldUsed for
idStable identifier for updates, deletes, and evaluation.
source, page, url, headingCitations and links.
updated_atFreshness filters and re-indexing.
doc_type, product, languageMetadata filtering at query time.
access (team, role, tenant)Permission filtering so users only retrieve what they may see.
hashSkip re-embedding unchanged chunks; deduplicate.
Python
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])
{'id': 'admin-guide.md#0', 'source': 'admin-guide.md', 'chunk_index': 0, 'text': 'Invite users from Settings > Team.', 'hash': 'e0f1a2cbb563', 'doc_type': 'guide', 'access': 'all-staff', 'updated_at': '2026-10-28'}
Try it
  1. Load three of your own Markdown or text files and chunk them with recursive_split at 300, 600, and 1,000 characters.
  2. For each size, print the number of chunks and look at five random chunks. Which size produces chunks that make sense on their own?
  3. Add heading paths with markdown_sections and compare.
Key takeaways
  • 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.