Advanced8 min read4 of 40

Hierarchical Indexing - Multi-Level Document Structure

Index documents at multiple granularities — summaries at the top, sections in the middle, sentences at the bottom — and retrieve at the right level for each query.

Overview

Hierarchical Indexing generalizes the parent-child pattern to three or more levels of granularity. Instead of a single flat index, documents are represented at every level — document summaries, section chunks, and sentence-level chunks — each stored in its own index. A query router selects the appropriate level, or a top-down drill-down traverses from coarse to fine, retrieving exactly the right amount of context for each query type.

Pattern #22 in the 37 RAG Patterns Collection

The Problem

A single-granularity index cannot serve every query type well:

Corpus: A 500-page technical handbook Query A: "Give me an overview of the authentication system" Needs: A broad summary — fine-grained chunks waste tokens and lose the big picture Query B: "What is the exact value of the max_retries parameter?" Needs: A precise sentence — a document summary buries the specific value Query C: "How does the rate limiter interact with retry logic?" Needs: A full section — needs narrative context but not the whole document

A flat index tuned for Query B will drown Query A in irrelevant detail, and a flat index tuned for Query A will miss the specific fact Query B needs.

Architecture

Three-level index structure:

L0 — Document summaries (~200 tokens each) One embedding per document. Ideal for "overview", "summarize", "compare" queries. Generated by LLM at index time. L1 — Section chunks (~500 tokens each) One embedding per logical section (heading + body). Default retrieval level. Preserves section structure and narrative flow. L2 — Sentence chunks (~100 tokens each) One embedding per sentence or short passage. Ideal for precise factual lookups: numbers, names, definitions. Each node carries pointers to its parent and children in the hierarchy.

Query routing selects the level; top-down drill-down starts at L0 and descends only when needed.

flowchart TD
  PDF[Document] --> PC["Parent chunks ~1000 chars"]
  PC --> CC["Child chunks ~200 chars linked to parent"]
  CC --> EMB[Embed children only]
  EMB --> IDX[(Index children with parent_id payload)]
  Q[Query] --> SE[Embed query]
  SE --> SR[Search children top-K]
  IDX --> SR
  SR --> RP[Resolve parent_id for each hit]
  RP --> FP[Fetch full parent text]
  FP --> DEDUP[Deduplicate parents]
  DEDUP --> LLM[Generate]

Implementation

python
from typing import List, Dict, Optional
import uuid

# ── Index Construction ────────────────────────────────────────────────────────

def build_hierarchical_index(
    documents: List[Dict],
    llm,
    vector_stores: Dict,   # {'l0': ..., 'l1': ..., 'l2': ...}
):
    """
    Build a three-level hierarchical index.
    Each document yields: 1 L0 summary, N L1 sections, M L2 sentences.
    """
    for doc in documents:
        doc_id = doc['id']

        # L0: Generate document-level summary (~200 tokens)
        summary = llm.invoke(
            f"Summarize this document in ~200 tokens, capturing key topics and structure:\n\n{doc['content']}"
        )
        l0_node = {'id': f"l0_{doc_id}", 'content': summary, 'doc_id': doc_id, 'level': 0}
        vector_stores['l0'].add(l0_node)

        # L1: Split into sections by heading or fixed ~500-token windows
        sections = split_into_sections(doc['content'], max_tokens=500)
        for i, section in enumerate(sections):
            l1_id = f"l1_{doc_id}_{i}"
            l1_node = {
                'id': l1_id,
                'content': section,
                'doc_id': doc_id,
                'l0_parent': f"l0_{doc_id}",
                'level': 1,
            }
            vector_stores['l1'].add(l1_node)

            # L2: Split each section into sentences (~100 tokens)
            sentences = split_into_sentences(section, max_tokens=100)
            for j, sent in enumerate(sentences):
                l2_node = {
                    'id': f"l2_{doc_id}_{i}_{j}",
                    'content': sent,
                    'doc_id': doc_id,
                    'l1_parent': l1_id,
                    'l0_parent': f"l0_{doc_id}",
                    'level': 2,
                }
                vector_stores['l2'].add(l2_node)


# ── Query Routing ─────────────────────────────────────────────────────────────

def route_query_level(query: str, llm) -> int:
    """
    Route the query to the appropriate index level.
    Returns 0 (overview), 1 (section), or 2 (specific fact).
    """
    prompt = f"""Classify this query by the granularity of answer it needs.

Query: {query}

Levels:
0 = Overview / summary (e.g., "What is X about?", "Compare X and Y", "Give me an overview")
1 = Section / explanation (e.g., "How does X work?", "Explain the process of Y")
2 = Specific fact (e.g., "What is the value of X?", "When was Y released?", "List the parameters")

Return only the level number (0, 1, or 2)."""

    response = llm.invoke(prompt).strip()
    try:
        return int(response[0])
    except (ValueError, IndexError):
        return 1  # default to section level


# ── Top-Down Drill-Down ───────────────────────────────────────────────────────

def hierarchical_retrieve_topdown(
    query: str,
    vector_stores: Dict,
    top_k: int = 5,
    drill_threshold: float = 0.75,
) -> List[Dict]:
    """
    Top-down retrieval: start at L0, drill to L1 if promising,
    optionally drill to L2 for high-specificity queries.
    """
    # Search L0 summaries
    l0_results = vector_stores['l0'].search(query, top_k=top_k)

    results = []
    for l0_hit in l0_results:
        if l0_hit['score'] >= drill_threshold:
            # This document looks relevant — drill to L1 sections
            l1_results = vector_stores['l1'].search(
                query,
                top_k=top_k,
                filter={'doc_id': l0_hit['doc_id']},
            )
            results.extend(l1_results)
        else:
            # Not specific enough to drill — return the L0 summary itself
            results.append(l0_hit)

    return results[:top_k]


# ── Full RAG Pipeline ─────────────────────────────────────────────────────────

def hierarchical_rag(
    query: str,
    vector_stores: Dict,
    llm,
    top_k: int = 5,
    use_routing: bool = True,
) -> Dict:
    """
    Hierarchical RAG: route query to the right level, retrieve, generate.
    """
    if use_routing:
        level = route_query_level(query, llm)
        index_name = ['l0', 'l1', 'l2'][level]
        results = vector_stores[index_name].search(query, top_k=top_k)
    else:
        # Default: top-down drill-down
        results = hierarchical_retrieve_topdown(query, vector_stores, top_k=top_k)

    context = "\n\n".join([r['content'] for r in results])
    answer = llm.invoke(f"Context:\n{context}\n\nQuestion: {query}\n\nAnswer:")

    return {
        'answer': answer,
        'level_used': level if use_routing else 'drill-down',
        'chunks_retrieved': len(results),
    }


# ── Helpers ───────────────────────────────────────────────────────────────────

def split_into_sections(text: str, max_tokens: int = 500) -> List[str]:
    """Split text into sections, preferring heading boundaries."""
    import re
    # Split on markdown headings or double newlines, then merge short sections
    raw = re.split(r'\n#{1,3} |\n\n', text)
    sections, current = [], ''
    for chunk in raw:
        if len((current + chunk).split()) > max_tokens and current:
            sections.append(current.strip())
            current = chunk
        else:
            current += '\n\n' + chunk
    if current.strip():
        sections.append(current.strip())
    return sections


def split_into_sentences(text: str, max_tokens: int = 100) -> List[str]:
    """Split text into sentence-level chunks."""
    import re
    sentences = re.split(r'(?<=[.!?])\s+', text)
    chunks, current = [], ''
    for sent in sentences:
        if len((current + sent).split()) > max_tokens and current:
            chunks.append(current.strip())
            current = sent
        else:
            current += ' ' + sent
    if current.strip():
        chunks.append(current.strip())
    return chunks

Complete Notebook

Run the full implementation:

Hierarchical Indexing AWS Notebook

Includes:

  • Three-level index construction with Amazon OpenSearch
  • LLM-based query routing vs. top-down drill-down comparison
  • Mixed-query benchmark across overview, section, and fact-retrieval query types
  • RAPTOR integration for automated summary generation

Performance

MetricFlat IndexingHierarchical
Mixed-query accuracy67%83%
Overview queries54%91%
Factual queries79%88%
Avg. latency~250ms~350ms
Cost/query$0.04$0.07

Hierarchical indexing adds the most value on workloads with diverse query types — the mixed-query benchmark shows +16 points vs. flat indexing at modest latency overhead.

When to Use

Best For

  • Large document corpora with varied query types — reports, handbooks, knowledge bases
  • Documents with executive summaries — annual reports, research papers, technical specifications
  • Corpora with natural hierarchy — books (chapter → section → paragraph), APIs (module → class → method)
  • Production systems serving both analysts (overview queries) and engineers (specific parameter lookups)

Combine With

  • Adaptive RAG (#8) — use query complexity routing to decide whether to drill down or stay at L1
  • RAPTOR — use RAPTOR's recursive summarization to auto-generate high-quality L0 summaries at index time

Related Papers

  • Hierarchical RAG: Li et al., 2024 — formalizes multi-level retrieval and routing
  • RAPTOR: Sarthi et al., 2024 — recursive abstractive processing for tree-organized retrieval

Next Steps

  1. Try it: Run the notebook
  2. Start simple: Implement two levels (L0 + L1) before adding L2 — most gains come from the first split
  3. Automate summaries: Integrate RAPTOR to generate L0 summaries automatically at index time

Part of: 37 RAG Patterns Collection

Go Deeper With Live Instruction

Covered in depth in our llm engineering program.