PageIndex: RAG With a Cost Model Instead of a Vector Index

June 15, 2026

|repo-review

by Florian Narr

PageIndex: RAG With a Cost Model Instead of a Vector Index

PageIndex builds a table-of-contents-style tree for a PDF, then lets an LLM search that tree by reasoning over titles and summaries instead of running a similarity query against embeddings.

What it does

Instead of chunking a document and embedding the chunks, PageIndex parses a PDF into a hierarchical tree of sections — think a table of contents with page ranges and per-node summaries. At query time, an agent walks the tree with tool calls, the way you'd flip to the right chapter in a report, rather than asking a vector store what's "similar" to the question.

Why I starred it

Vector RAG breaks on long professional documents in a specific way: it retrieves by embedding distance, and distance isn't relevance. A question about "the auditor's going-concern note" can be textually distant from the paragraph that answers it. PageIndex's pitch is that retrieval on this kind of document is a search problem, not a similarity problem, and the README backs it with a number: 98.7% on FinanceBench, measured against vector RAG baselines. That's a claim I wanted to check against the code, not the marketing page.

How it works

The repo splits cleanly into two things: building the tree, and searching it.

Building the tree. pageindex/flash/ is a from-scratch PDF text layer — over 4,300 lines across parser_pdfium_charlevel/. It doesn't use pypdfium2's built-in text extraction. Instead pipeline.py pulls raw CMaps, content streams, and font dictionaries via PyPDF2 (parser_pdfium_charlevel/pipeline.py:11-16) because pdfium doesn't expose that layer, and reconstructs character-level spans itself — font size, positioning, bidi reordering, the works. That's the raw material for the heading detector (flash/heading_detection/) and column/gutter splitter (flash/columns/) that eventually produce an outline.

The part I actually stopped to read carefully is pageindex/tree_optimize.py. Once a raw outline exists, PageIndex decides which nodes are worth keeping as separate tree nodes versus collapsing into their parent, and it does this with an explicit cost model instead of heuristics:

# tree_optimize.py
def S(node):
    """Pages to scan linearly if this node were collapsed."""
    return subtree_end(node) - node["start_index"] + 1

def S_residual(node):
    """Pages of the node covered by no child."""
    ...

def tree_cost(node, routing=ROUTING_COST):
    """Worst-case search cost of the subtree as it currently stands."""
    if is_frontier(node):
        return S(node)
    branches = [tree_cost(c, routing) for c in node["nodes"]]
    residual = S_residual(node)
    if residual:
        branches.append(residual)
    return routing + max(branches)

S(v) is the cost of giving up and scanning a node's pages linearly. tree_cost(v) is the worst-case cost of routing through its children instead. merge() (tree_optimize.py:532) walks the tree bottom-up and collapses any node where S(v) <= tree_cost(v) — i.e., where having a subtree there doesn't actually save the agent anything in the worst case:

cost = tree_cost(node, routing)
checked = tree_cost_via_frontier(node, routing)
span = S(node)
if span <= cost:
    ...
    node.pop("nodes", None)

What I like here is checked = tree_cost_via_frontier(node, routing) sitting right next to cost. tree_cost is a recursive formula; tree_cost_via_frontier (tree_optimize.py:277) computes the same number independently by taking a max over every frontier leaf's (routing distance × R) + S. Both get computed and logged on every merge decision — a self-consistency check baked into the hot path, not a one-off unit test. When a subtree does get merged away, the discarded titles aren't discarded — they're kept on the parent as key_items (tree_optimize.py:557-560), so the routing information survives even though the child nodes don't.

Searching the tree. There's no search() tool. pageindex/agent_tools.py exposes get_document_structure() — titles, page ranges, and summaries, no text — and get_page_content() — the actual text for specific pages. The agent decides where to look by reading the tree, the same way you would. The tool contract is deliberately identical to PageIndex's cloud MCP server (agent_tools.py:1-15), down to unsupported params in local mode still returning the same {"error": ...} envelope the cloud API would — so an agent prompt tuned against local mode ports to the hosted MCP connection unchanged.

Using it

pip install -U pageindex
from pageindex import PageIndexClient

client = PageIndexClient(
    index_model="gpt-5.6-luna",   # cheap model, builds the tree
    chat_model="gpt-5.6-sol",     # best model you can afford, searches it
)
doc_id = client.submit_document("report.pdf")["doc_id"]
print(client.chat("What was the 2023 operating margin, and where is it stated?", doc_id=doc_id))

You can also drive it from the CLI, which is closer to how I actually poked at it:

python3 run_pageindex.py --pdf_path report.pdf --mode flash

--mode flash skips the LLM entirely for structure extraction — headings come from the heuristic parser, not a model call — and only spends tokens on node summaries and the merge/expand pass. The README's own benchmark puts that at roughly $0.001/page, so a 1,000-page filing costs about a dollar to index once.

Rough edges

The package still ships Development Status :: 3 - Alpha in pyproject.toml, and the git log backs that up — five feature/fix commits landed in the two days before I wrote this, several touching the chat surface and tool schemas directly. If you pin to a local checkout instead of a release, expect API churn.

Local mode is explicitly text-only: the README's own comparison table lists OCR and image retrieval as Cloud-only, so a scanned PDF or an image-heavy deck won't get useful structure out of the local path — you're pushed to PageIndex Cloud for that, which is a hosted dependency for a project that otherwise sells itself as vectorless and local-first.

What surprised me in the other direction: tests/ runs to 6,251 lines against 5,731 lines of source — more test code than implementation, with test_agent_tools.py alone at 2,530 lines checking cloud-contract parity against a seeded local store. For an alpha-labeled package, that's an unusual amount of discipline.

Bottom line

If you're building retrieval over long structured documents — filings, contracts, technical manuals — where "similar" keeps returning the wrong section, PageIndex is worth reading past the README for the cost model in tree_optimize.py alone. If your documents are short, scanned, or genuinely need semantic fuzziness rather than structural navigation, a vector store is still less code to operate.

VectifyAI/PageIndex on GitHub
VectifyAI/PageIndex