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
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 chunksComplete 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
| Metric | Flat Indexing | Hierarchical |
|---|---|---|
| Mixed-query accuracy | 67% | 83% |
| Overview queries | 54% | 91% |
| Factual queries | 79% | 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
- ▸Try it: Run the notebook
- ▸Start simple: Implement two levels (L0 + L1) before adding L2 — most gains come from the first split
- ▸Automate summaries: Integrate RAPTOR to generate L0 summaries automatically at index time
Part of: 37 RAG Patterns Collection