Splitting and Indexing Documents

First lesson of the RAG in Production course, free to read: about 20 minutes, 3 exercises and 3 quiz questions. This is the catalogue's base lesson; in the student space, it is rewritten for your profile.

Objectives

  • Build a reproducible indexing pipeline: extraction, chunking, metadata, storage
  • Split a document according to its structure (headings, paragraphs) with a maximum size and an overlap
  • Attach to each chunk the metadata needed for citations, access rights, and updates

Indexing pipelineStructural chunkingChunk metadata

From Demo to Production

A RAG prototype fits in a few lines: split a text every 200 words, compute vectors, find the nearest ones, paste them into the prompt. In production, the difficulties are elsewhere: hundreds of heterogeneous documents, daily updates, different access rights depending on the team, and users who want to verify the source of an answer. This course tackles these questions, starting from the foundation: a reliable indexing pipeline.

The Indexing Pipeline

Indexing is a separate process from querying, run as a background task whenever a document is added or modified:

  1. Extract the text (PDF, web pages, documentation tool, tickets). The quality of the extraction determines everything else: repeated page headers, flattened tables, and mixed-up columns degrade search.
  2. Clean: remove repetitive elements, normalize whitespace, keep the headings.
  3. Split into chunks.
  4. Encode each chunk as a vector (lesson 2).
  5. Store text, vector, and metadata in the index.

The pipeline must be reproducible: rerun on the same documents, it produces the same chunks with the same identifiers.

Splitting by Structure

Fixed-size splitting cuts in the middle of sentences and mixes two sections in the same chunk. Company documents almost always have a structure (Markdown or HTML headings, heading styles in a word processor): use it.

def split_sections(markdown, doc_id):
    sections, path, buffer = [], [], []
    for line in markdown.splitlines():
        match = re.match(r"^(#{1,6})\s+(.+)$", line)
        if match:
            # save the previous section, then update the path
            ...
            level = len(match.group(1))
            path[:] = path[:level - 1] + [match.group(2).strip()]
        else:
            buffer.append(line)

The heading path is kept: a paragraph “Carry-over is limited to 5 days” only makes sense with its context “Paid leave > Carry-over”.

Then Bound the Size

A section can be very long. You then group its paragraphs into chunks that do not exceed a maximum size (in words here, in tokens in production), never cutting a paragraph if it fits:

  • add paragraphs one by one as long as the limit is not exceeded;
  • when the next one no longer fits, close the chunk;
  • the new chunk starts with the last paragraph of the previous one (overlap), if that fits within the limit.

Overlap prevents information that straddles two chunks from being unfindable. It has a cost: duplicated text in the index and sometimes in the prompt. A single paragraph longer than the limit forms its own chunk (or is split again by sentences).

Identifiers and Metadata

Each chunk gets a stable identifier, for example politique-conges#3 (document and rank). It is used for citations, evaluation, and updates. You attach metadata to it:

{
    "chunk_id": "politique-conges#3",
    "doc_id": "politique-conges",
    "path": "Paid leave > Carry-over",
    "text": "Carry-over is limited to 5 days...",
    "embed_text": "Paid leave > Carry-over\n\nCarry-over is limited to 5 days...",
    "organization_id": 12, "visibility": "rh", "version": "2026-09",
}

embed_text (path + text) is what you encode; text is what you display and cite. The organization_id and visibility fields will be used for filtering by access rights (lesson 5).

Tuning and Checking

There is no universal ideal size. Chunks that are too small lose their context; too large, they dilute the information and fill the prompt. Start from a reasonable value (a few hundred tokens), then measure on a set of reference questions (lesson 4), comparing several settings. Also inspect random chunks: it is the fastest way to spot a failed extraction.

Key Takeaways

  • Indexing is a reproducible pipeline, separate from querying.
  • Split by structure, bound the size, add an overlap.
  • Stable identifiers and metadata make citations, access rights, and updates possible.

Exercises

Solutions and tests stay on the server. In the student space, your code is run and checked, and the tutor gives hints before the solution.

  1. CodeDifficulty 2Python in the browser3 hints

    Split a Markdown document by sections

    Write split_sections(markdown, doc_id) which returns the list of non-empty sections, each in the form {"doc_id": ..., "path": ..., "text": ...}:

    • a heading line starts with 1 to 6 # followed by a space; its level is the number of #;
    • path is the current heading path, joined by " > ": a heading of level n replaces the heading of level n and removes the deeper levels;
    • text is the content between two headings, lines joined by \n then stripped of leading and trailing whitespace and line breaks; a section without text is ignored;
    • text placed before any heading has an empty path ("").
    import re
    
    
    def split_sections(markdown, doc_id):
        ...
  2. CodeDifficulty 3Python in the browser3 hints

    Group paragraphs with overlap

    Write:

    • pack_paragraphs(paragraphs, max_words, overlap=1) which groups consecutive paragraphs into chunks of at most max_words words (words = split()): add paragraphs as long as the limit is not exceeded; when the next one no longer fits, close the chunk, then the new chunk starts with the last overlap paragraphs of the previous one only if they and the new paragraph fit within the limit (otherwise it starts empty). A paragraph that is too long on its own forms its own chunk. Each chunk is returned as text, paragraphs joined by \n\n;
    • build_chunks(sections, max_words, overlap=1) which, for each section (format of the previous exercise), splits text into paragraphs (separated by \n\n, empty ones ignored), groups them with pack_paragraphs, and returns dictionaries chunk_id ("<doc_id>#<n>", n counted from 0 per document), doc_id, path, text, and embed_text (path + "\n\n" + text, or text alone if the path is empty).
    def pack_paragraphs(paragraphs, max_words, overlap=1):
        ...
    
    
    def build_chunks(sections, max_words, overlap=1):
        ...
  3. Multiple choiceDifficulty 21 hint

    Why encode the heading path?

    A chunk contains only “Carry-over is limited to 5 days.” Why encode “Paid leave > Carry-over” with this text?

    • To reduce the size of the vector
    • Because, without context, the chunk does not mention leave and will be poorly retrieved by a question about carrying over leave
    • Because embedding models refuse short texts
    • To speed up the index

End-of-lesson quiz

  1. What should an indexing pipeline produce when rerun on the same documents?

    • Different chunks to vary the results
    • The same chunks with the same identifiers
    • An empty index
  2. What is the overlap between two chunks for?

    • To prevent information that straddles two chunks from being unfindable
    • To reduce the size of the index
    • To encrypt the text
  3. Which metadata is essential for filtering results according to the user?

    • The length of the chunk
    • The organization and visibility level of the document
    • The color of the heading

The rest of the course

  1. Embeddings and pgvector: Storing and Searching Vectors20 min
  2. Hybrid Search: Keywords, Vectors, and Fusion20 min
  3. Evaluating a RAG: Search, Citations, and Answers20 min
  4. Updates and Access Rights20 min

RAG in Production · Advanced · 1 h 40 min