PageIndex — Tree-Based RAG for Document Retrieval¶
PageIndex builds a hierarchical semantic tree from PDF and Markdown documents using LLM reasoning, then uses that tree for vectorless, context-aware retrieval. Unlike embedding-based RAG, PageIndex navigates the document structure to find relevant sections — no vector database required.
[!TIP] PageIndex works with any LLM provider supported by ai-parrot: OpenAI, Google Gemini, Claude, Groq, and more.
Table of Contents¶
- Quick Start
- Core Concepts
- LLM Adapter
- Building Trees from PDFs
- Building Trees from Markdown
- Tree Search Retriever
- Integration with Agents
- Configuration Options
- API Reference
Quick Start¶
import asyncio
from parrot.clients.google.client import GoogleGenAIClient
from parrot.knowledge.pageindex import (
PageIndexLLMAdapter,
PageIndexRetriever,
build_page_index,
)
async def main():
# 1. Create an LLM client (any provider works)
client = GoogleGenAIClient()
adapter = PageIndexLLMAdapter(client, model="gemini-3-flash-preview")
# 2. Build a tree from a PDF
tree = await build_page_index("report.pdf", adapter)
# 3. Search the tree
retriever = PageIndexRetriever(tree, adapter)
context = await retriever.retrieve("What are the key findings?")
print(context)
asyncio.run(main())
Core Concepts¶
How It Works¶
┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ PDF │────▶│ TOC detect │────▶│ Tree build │────▶│ Summaries │
│ document │ │ & extract │ │ & structure │ │ & metadata │
└──────────┘ └──────────────┘ └──────────────┘ └──────────────┘
│ │ │
▼ ▼ ▼
LLM calls Hierarchical LLM calls
(any provider) tree (JSON) (any provider)
- Document Parsing — Extract text from each page using PyMuPDF or PyPDF2.
- TOC Detection — LLM examines the first N pages to determine if a Table of Contents exists.
- TOC Extraction — If found, the TOC is extracted and mapped to physical page indices.
- Tree Construction — Sections are organized into a hierarchical tree with
start_index/end_indexpage ranges. - Summarization — Each node gets an LLM-generated summary for retrieval guidance.
- Retrieval — At query time, the LLM reasons over the tree structure to identify relevant nodes.
Tree Structure¶
The output is a JSON tree where each node represents a document section:
{
"doc_name": "report.pdf",
"doc_description": "Annual financial report for fiscal year 2024...",
"structure": [
{
"title": "Introduction",
"node_id": "0000",
"start_index": 1,
"end_index": 3,
"summary": "Overview of the company's performance...",
"nodes": [
{
"title": "Executive Summary",
"node_id": "0001",
"start_index": 1,
"end_index": 2,
"summary": "Key highlights and metrics..."
}
]
}
]
}
LLM Adapter¶
PageIndexLLMAdapter wraps any ai-parrot AbstractClient, providing PageIndex-specific methods with retry logic and structured output support.
Creating an Adapter¶
from parrot.knowledge.pageindex import PageIndexLLMAdapter
# With Google Gemini
from parrot.clients.google.client import GoogleGenAIClient
client = GoogleGenAIClient()
adapter = PageIndexLLMAdapter(client, model="gemini-3-flash-preview")
# With OpenAI
from parrot.clients.openai import OpenAIClient
client = OpenAIClient()
adapter = PageIndexLLMAdapter(client, model="gpt-4o")
# With Claude
from parrot.clients.claude import ClaudeClient
client = ClaudeClient()
adapter = PageIndexLLMAdapter(client, model="claude-sonnet-4-20250514")
# With Groq
from parrot.clients.groq import GroqClient
client = GroqClient()
adapter = PageIndexLLMAdapter(client, model="llama-3.3-70b-versatile")
Constructor Parameters¶
| Parameter | Type | Default | Description |
|---|---|---|---|
client |
AbstractClient |
required | Any ai-parrot LLM client |
model |
str \| None |
Client default | Override the model used for all calls |
max_retries |
int |
3 |
Number of retries on LLM failure |
retry_delay |
float |
1.0 |
Base delay between retries (multiplied by attempt number) |
Methods¶
ask(prompt, structured_output=None, temperature=0.0, system_prompt=None)¶
Send a prompt, return raw text. Optionally pass a Pydantic class for structured output.
ask_structured(prompt, output_type, temperature=0.0, system_prompt=None)¶
Send a prompt, return a validated Pydantic model instance. Tries native structured output first, falls back to JSON extraction.
ask_json(prompt, temperature=0.0, system_prompt=None)¶
Send a prompt, return a parsed dict or list.
ask_with_finish_info(prompt, temperature=0.0, chat_history=None, system_prompt=None)¶
Returns (text, finish_reason) where finish_reason is "finished" or "max_output_reached". Used internally to detect when LLM output was truncated.
Building Trees from PDFs¶
Basic Usage¶
from parrot.knowledge.pageindex import build_page_index, PageIndexLLMAdapter
from parrot.clients.google.client import GoogleGenAIClient
client = GoogleGenAIClient()
adapter = PageIndexLLMAdapter(client, model="gemini-3-flash-preview")
tree = await build_page_index("document.pdf", adapter)
With Options¶
tree = await build_page_index(
doc="document.pdf",
adapter=adapter,
options={
"if_add_node_id": "yes", # Assign unique IDs to nodes
"if_add_node_summary": "yes", # Generate summaries per node
"if_add_doc_description": "yes", # Generate document-level description
"if_add_node_text": "no", # Include raw page text in nodes
"toc_check_page_num": 20, # Pages to check for TOC
"max_page_num_each_node": 10, # Max pages before splitting
"max_token_num_each_node": 20000, # Max tokens before splitting
},
)
From BytesIO¶
from io import BytesIO
with open("document.pdf", "rb") as f:
pdf_bytes = BytesIO(f.read())
tree = await build_page_index(doc=pdf_bytes, adapter=adapter)
Saving and Loading Trees¶
import json
# Save
with open("tree.json", "w") as f:
json.dump(tree, f, indent=2)
# Load
with open("tree.json") as f:
tree = json.load(f)
Building Trees from Markdown¶
For Markdown documents, use md_to_tree which parses headers into a hierarchical structure:
from parrot.knowledge.pageindex import md_to_tree, PageIndexLLMAdapter
from parrot.clients.google.client import GoogleGenAIClient
client = GoogleGenAIClient()
adapter = PageIndexLLMAdapter(client, model="gemini-3-flash-preview")
md_text = open("README.md").read()
tree = await md_to_tree(
md_text=md_text,
adapter=adapter,
doc_name="README.md",
options={
"if_add_node_id": "yes",
"if_add_node_summary": "yes",
},
)
The Markdown builder:
- Parses # through ###### headers into a nested tree
- Assigns each node its text content and token count
- Thins out nodes below a token threshold (default: 50)
- Generates summaries via the LLM adapter
Tree Search Retriever¶
PageIndexRetriever performs LLM-based tree search — the core RAG mechanism. Instead of vector similarity, the LLM reasons over the tree structure to find relevant sections.
Basic Retrieval¶
from parrot.knowledge.pageindex import PageIndexRetriever
retriever = PageIndexRetriever(tree, adapter)
# Search: returns TreeSearchResult with thinking + node_list
result = await retriever.search("What OCR engine is used?")
print(result.thinking) # LLM reasoning about which nodes match
print(result.node_list) # ["0004", "0005"]
# Retrieve: returns concatenated text from matched nodes
context = await retriever.retrieve("What OCR engine is used?")
With Expert Knowledge¶
Guide the search with domain expertise:
retriever = PageIndexRetriever(
tree=tree,
adapter=adapter,
expert_knowledge="OCR configuration is discussed in Section 3.2 AI Models",
)
context = await retriever.retrieve("What OCR options are available?")
With PDF Pages (Full Text Extraction)¶
When you need the full page text from matched nodes, pass the original PDF pages:
from parrot.knowledge.pageindex.utils import get_page_tokens
pdf_pages = get_page_tokens("document.pdf")
context = await retriever.retrieve(
"What are the results?",
pdf_pages=pdf_pages,
)
Loading from Saved JSON¶
retriever = PageIndexRetriever.from_json(
"tree.json",
adapter=adapter,
expert_knowledge="Focus on methodology sections",
)
Retrieval Priority¶
When extracting text from matched nodes, the retriever follows this priority:
- Node
textfield — If the tree was built withif_add_node_text: "yes" - PDF pages — If
pdf_pagesargument is provided, extracts fromstart_indextoend_index - Node summary — Falls back to the
summaryorprefix_summaryfield
Integration with Agents¶
System Prompt Injection¶
The simplest integration: inject the tree structure into the bot's system prompt so the LLM has document awareness.
retriever = PageIndexRetriever(tree, adapter)
tree_context = retriever.get_tree_context()
# Use with any bot's create_system_prompt
system_prompt = await bot.create_system_prompt(
pageindex_context=tree_context,
user_context="...",
)
RAG-Augmented Conversations¶
Combine tree search with the conversational pipeline:
from parrot.bots.base import BaseBot
from parrot.knowledge.pageindex import PageIndexLLMAdapter, PageIndexRetriever
class DocumentQABot(BaseBot):
"""Bot that answers questions using a PageIndex tree."""
def __init__(self, tree_path: str, **kwargs):
super().__init__(**kwargs)
self._tree_path = tree_path
self._retriever = None
async def _get_retriever(self) -> PageIndexRetriever:
if self._retriever is None:
adapter = PageIndexLLMAdapter(self.llm_client, model=self.model)
self._retriever = PageIndexRetriever.from_json(
self._tree_path, adapter
)
return self._retriever
async def conversation(self, question: str, **kwargs):
retriever = await self._get_retriever()
# Get relevant context via tree search
context = await retriever.retrieve(question)
# Get tree overview for system prompt
tree_ctx = retriever.get_tree_context()
# Build system prompt with both contexts
system_prompt = await self.create_system_prompt(
pageindex_context=tree_ctx,
vector_context=context,
)
# Ask the LLM with the enriched context
response = await self.ask(
question=question,
system_prompt=system_prompt,
)
return response
Pre-Built Tree Workflow¶
For production, build the tree once and reuse it:
import json
# === Indexing phase (run once per document) ===
async def index_document(pdf_path: str, output_path: str):
client = GoogleGenAIClient()
adapter = PageIndexLLMAdapter(client, model="gemini-3-flash-preview")
tree = await build_page_index(
doc=pdf_path,
adapter=adapter,
options={"if_add_node_summary": "yes", "if_add_doc_description": "yes"},
)
with open(output_path, "w") as f:
json.dump(tree, f, indent=2)
# === Query phase (run per user question) ===
async def query_document(tree_path: str, question: str):
client = GoogleGenAIClient()
adapter = PageIndexLLMAdapter(client, model="gemini-3-flash-preview")
retriever = PageIndexRetriever.from_json(tree_path, adapter)
return await retriever.retrieve(question)
Multi-Document Search¶
Search across multiple document trees:
async def multi_doc_search(tree_paths: list[str], query: str, adapter):
all_contexts = []
for path in tree_paths:
retriever = PageIndexRetriever.from_json(path, adapter)
result = await retriever.search(query)
if result.node_list:
context = await retriever.retrieve(query)
tree_data = retriever.get_tree_json()
all_contexts.append({
"doc_name": tree_data.get("doc_name", path),
"context": context,
"nodes": result.node_list,
})
return all_contexts
Configuration Options¶
Options passed to build_page_index or md_to_tree:
| Option | Type | Default | Description |
|---|---|---|---|
model |
str |
"gpt-4o" |
Default LLM model (overridden by adapter's model) |
toc_check_page_num |
int |
20 |
Number of pages to scan for TOC |
max_page_num_each_node |
int |
10 |
Max pages per node before recursive splitting |
max_token_num_each_node |
int |
20000 |
Max tokens per node before recursive splitting |
if_add_node_id |
str |
"yes" |
Assign node_id to each node ("yes" / "no") |
if_add_node_summary |
str |
"yes" |
Generate LLM summaries per node |
if_add_doc_description |
str |
"no" |
Generate a document-level description |
if_add_node_text |
str |
"no" |
Include raw page text in each node |
API Reference¶
Public Imports¶
from parrot.knowledge.pageindex import (
build_page_index, # PDF → tree pipeline
md_to_tree, # Markdown → tree pipeline
PageIndexLLMAdapter, # LLM adapter wrapper
PageIndexRetriever, # Tree search retriever
PageIndexNode, # Pydantic model for tree nodes
TreeSearchResult, # Pydantic model for search results
TocItem, # Pydantic model for TOC entries
)
build_page_index(doc, adapter, options=None) → dict¶
Build a PageIndex tree from a PDF.
| Param | Type | Description |
|---|---|---|
doc |
str \| BytesIO |
Path to PDF file or BytesIO stream |
adapter |
PageIndexLLMAdapter |
LLM adapter for all LLM calls |
options |
dict \| SimpleNamespace \| None |
Configuration overrides |
Returns: dict with keys doc_name, structure, and optionally doc_description.
md_to_tree(md_text, adapter, options=None, doc_name="document.md") → dict¶
Build a PageIndex tree from Markdown text.
| Param | Type | Description |
|---|---|---|
md_text |
str |
Full Markdown document text |
adapter |
PageIndexLLMAdapter |
LLM adapter |
options |
dict \| SimpleNamespace \| None |
Configuration overrides |
doc_name |
str |
Document identifier |
PageIndexRetriever(tree, adapter, expert_knowledge=None)¶
| Method | Returns | Description |
|---|---|---|
search(query) |
TreeSearchResult |
LLM reasoning + matched node IDs |
retrieve(query, pdf_pages=None) |
str |
Concatenated text from matched nodes |
get_tree_context(include_summaries=True) |
str |
Formatted tree for system prompts |
get_tree_json() |
dict |
Raw tree data |
from_json(path_or_dict, adapter, expert_knowledge=None) |
PageIndexRetriever |
Class method constructor |
TreeSearchResult¶
class TreeSearchResult(BaseModel):
thinking: str # LLM's reasoning about relevant sections
node_list: list[str] # List of matched node_id values
Utility Functions¶
from parrot.knowledge.pageindex.utils import (
get_page_tokens, # Extract (text, token_count) per PDF page
get_text_of_pages, # Get text from a page range
count_tokens, # Count tokens in a string
find_node_by_id, # Find a node in the tree by node_id
get_nodes, # Flatten tree to list of nodes
get_leaf_nodes, # Get all leaf nodes
write_node_id, # Assign sequential IDs to tree nodes
print_toc, # Print tree as indented text
)