A B C D E F G H I J K L M N O P Q R S T V W Y Z А Б В Г Е И К М О П С Т Ц

PageIndex

PageIndex

 

PageIndex это движок поиска по документам для LLM-приложений, который обходится без векторной базы и без нарезки текста на чанки. Вместо эмбеддингов он строит из документа иерархическое дерево (document tree), похожее на оглавление, и заставляет модель ходить по этому дереву рассуждением. Разработку выпустила компания Vectify AI в сентябре 2025 года, авторы фреймворка Mingtian Zhang (Минтянь Чжан) и Yu Tang (Юй Тан). Идея простая — эксперт, которому дали годовой отчёт на четыреста страниц, не ищет в нём похожие абзацы, а открывает содержание и идёт в нужный раздел. Пакет открыт под лицензией MIT.

 

Что такое PageIndex и какую задачу он закрывает

Классический RAG (Retrieval-Augmented Generation) работает на сходстве. Корпус режется на фрагменты, каждый превращается в вектор, вопрос тоже превращается в вектор, дальше из базы достаются ближайшие соседи. Схема отлично закрывает вопросы вида «что написано про X», где ответ лежит в одном абзаце. Работает это быстро и дёшево. Про то, как устроен этот контур целиком, есть подробный разбор в статье RAG-приложения и Neo4j: поддержка векторного индекса для LLM.

Проблема начинается на профессиональных документах: отчётности, регуляторике, технических регламентах, медицинских протоколах. Там похожий текст и нужный текст расходятся. Расходятся сильно. Формулировка «риски ликвидности» встречается в документе двенадцать раз, а ответ на вопрос лежит ровно в одном разделе, и отличает его не лексика, а место в структуре. Авторы PageIndex формулируют это так: сходство не равно релевантности, а релевантность требует рассуждения.

Отсюда и решение. PageIndex убирает из контура векторную базу, приближённый поиск ближайших соседей и саму нарезку на чанки. Документ остаётся документом со своей иерархией разделов, а поиск превращается в обход дерева, где решение на каждом шаге принимает языковая модель. Такой поиск называют reasoning-based retrieval, а весь подход vectorless RAG. Векторов в нём нет вообще.

 

Архитектура и дерево документа вместо векторного индекса

Единственная структура данных в PageIndex это дерево. Корень соответствует документу, узлы разделам и подразделам, вложенность повторяет реальную иерархию исходника. Никакого отдельного хранилища под это не нужно: дерево целиком помещается в JSON и живёт рядом с документом. Отдельный сервис поднимать не надо.

PageIndex, иерархическое дерево документа с узлами, заголовками и диапазонами страниц

 

Из чего состоит узел дерева

Узел это не кусок текста, а ссылка на диапазон страниц плюс метаданные для навигации. Состав узла разберём по полям.

  • title. Заголовок раздела ровно в том виде, в каком он стоит в документе.
  • node_id. Идентификатор узла, по которому модель называет свой выбор.
  • start_index и end_index. Границы раздела в страницах, нумерация с единицы. Именно они делают ответ проверяемым: систему всегда можно спросить, откуда она это взяла.
  • summary. Краткая выжимка раздела, её пишет модель на этапе индексации. Поле необязательное.
  • nodes. Вложенные узлы, то есть подразделы.

Из этого набора видно главное отличие от чанка: узел знает своё место в документе, а чанк нет. Текст узла в дереве не дублируется. Он достаётся из исходника по номерам страниц.

 

Два режима индексации, flash и standard

Дерево строится двумя разными способами, и разница между ними принципиальная. Режим standard отдаёт документ модели: она читает страницы, находит оглавление, определяет границы разделов и собирает иерархию. Режим flash работает эвристикой по типографике страниц и встроенным закладкам PDF, модель тут нужна только для необязательных выжимок. В локальном клиенте пакета 0.2.10 режимом по умолчанию идёт именно flash. Ключи для него не нужны.

Практическая разница измеряется секундами против минут. На усечённом годовом отчёте Федеральной резервной системы в пятьдесят страниц flash собрал дерево из семи разделов верхнего уровня за 2.39 секунды и не сделал ни одного обращения к модели. Плата за скорость честная: там, где текстового слоя нет вообще, брать структуру неоткуда, и остаётся только standard.

ИИ-агенты для оптимизации бизнес-процессов

Код курса
AGENT
Ближайшая дата курса
26 октября, 2026
Продолжительность
24 ак.часов
Стоимость обучения
66 000

 

Принцип работы двухшагового поиска через tree search

Поиск в PageIndex распадается на два обращения к модели. В каждом она видит принципиально разные вещи.

На первом шаге модель получает только дерево: заголовки, пути от корня, диапазоны страниц и выжимки, если они есть. Текста документа в промпте нет. Только карта. Задача формулируется как выбор: в каком узле, скорее всего, лежит ответ на вопрос. Модель отвечает идентификатором узла и обоснованием выбора, и это обоснование остаётся в логах, что и делает поиск объяснимым.

На втором шаге по границам выбранного узла достаётся текст соответствующих страниц, и уже он вместе с вопросом уходит в модель за ответом. В контекст попадает раздел, а не весь документ и не набор разрозненных фрагментов из разных мест. Границы раздела при этом остаются в ответе.

 

PageIndex, два шага reasoning-поиска - выбор узла по оглавлению и ответ по тексту узла

В агентском варианте шагов больше: модель спускается по дереву постепенно, при необходимости возвращается на уровень выше и берёт соседний узел. Отсюда и название tree search, и прямая отсылка авторов к AlphaGo, где перебор вариантов тоже шёл по дереву.

 

Чем reasoning-поиск отличается от векторного RAG

Различия удобнее свести в таблицу. Расходятся два подхода почти по всем осям сразу.

Свойство Векторный RAG PageIndex
Что хранится Чанки и их эмбеддинги в векторной базе Дерево разделов в JSON рядом с документом
Как выбирается контекст Ближайшие соседи по метрике сходства Решение модели на каждом узле дерева
Стоимость индексации Векторизация всего корпуса Разбор структуры, в режиме flash без модели
Стоимость запроса Один поиск по индексу, дёшево и быстро Несколько вызовов LLM, дороже и медленнее
Объяснимость Ранг и расстояние, интерпретируются плохо Путь по дереву, номера страниц, текст обоснования
Слабое место Похожее не значит нужное Документ без внятной структуры

Резюме простое: векторный поиск выигрывает на объёме и скорости, PageIndex на точности и проверяемости внутри одного сложного документа. Другой способ уйти от чистого сходства разбирается в статье Не только векторные БД: графовый RAG для LLM и агентского ИИ, и подходы эти друг друга не отменяют.

 

Ограничения, о которых стоит знать заранее

Заявленные 98.7% точности на бенчмарке FinanceBench получены в облачном контуре Vectify AI с сильной моделью и собственным OCR. Открытый пакет использует обычный разбор PDF, и результат у него скромнее. Цифру из README на свой стенд переносить не стоит. Дальше идут ограничения, которые всплывают на первом же прогоне.

  • Каждый запрос стоит вызовов модели. Векторный поиск после индексации почти бесплатен, tree search платит за каждый вопрос. На потоке запросов это заметная статья расходов.
  • Качество упирается в структуру документа. Договор с нумерованными разделами разбирается прекрасно, слитая презентация или скан без текстового слоя не разбирается никак.
  • Мелкие модели путаются в формате. Шаг выбора узла возвращает JSON, и семимиллиардная модель периодически оборачивает его пояснениями. Разбор ответа приходится делать устойчивым к мусору вокруг.
  • Локальный сервер не любит параллель. Режим standard шлёт запросы по страницам пачкой, а Ollama выполняет их по одному. На прогоне двенадцатистраничного PDF очередь упёрлась в таймаут LiteLLM на 600 секунд, и индексация не завершилась.

Ни одно из этих ограничений не является приговором, но все они означают, что PageIndex это инструмент под конкретный класс задач, а не замена векторной базе вообще.

 

Сценарии использования, когда брать и когда не стоит

Подход хорошо ложится на длинные документы со внятной иерархией, где цена ошибки высока, а объём корпуса умеренный. Это отчётность и раскрытия по ценным бумагам, регуляторные документы, юридические договоры, технические регламенты и руководства по эксплуатации, научные монографии и учебники. Общее у них одно: длинные и с оглавлением. Отдельный сценарий это работа ИИ-агента: дерево отдаётся ему как инструмент, и агент сам решает, в какой раздел заглянуть. Проектированию таких систем посвящён курс ИИ-агенты для оптимизации бизнес-процессов.

Брать PageIndex не стоит там, где корпус состоит из миллионов коротких неструктурированных текстов: переписка, тикеты поддержки, товарные карточки, посты. У таких данных нет иерархии, по которой можно рассуждать, и векторный поиск здесь и быстрее, и уместнее. Не подойдёт он и там, где важна миллисекундная задержка ответа. Несколько вызовов модели в такой бюджет не помещаются.

 

Архитектура ML-систем

Код курса
ARML
Ближайшая дата курса
12 октября, 2026
Продолжительность
24/32 ак.часов
Стоимость обучения
76 800

 

Практика, строим дерево и ищем по нему

По традиции весь код используемый в статье выкладываем на наш GitHub репозиторий
PageIndex from airflow import DAG from airflow.operators.bash import BashOperator from datetime import datetime with DAG( dag_id="spark_submit_demo", start_date=datetime(2025, 1, 1), schedule="@daily", catchup=False ) as dag: run = BashOperator( task_id="run_job", bash_command="spark-submit app.py" ) GitHub code example PageIndex

Прогон сделан на macOS 26.5.2 с Python 3.12.13, пакетом pageindex 0.2.10 и локальной моделью qwen2.5:7b через Ollama 0.32.13. Документом взят усечённый годовой отчёт Федеральной резервной системы на пятьдесят страниц из примеров репозитория. Первый скрипт, build_tree.py, строит дерево вообще без обращений к модели. Ollama на этом шаге не нужна.

# pageindex 0.2.10, прогон на macOS 26.5.2, Python 3.12.13, вывод снят 2026-08-24
"""Строит дерево документа из PDF режимом flash: без LLM, без эмбеддингов."""
import json
import time
from pageindex import page_index_flash

PDF = "annual_report.pdf"


def print_tree(nodes, depth=0):
    """Печатает дерево с отступами: заголовок узла и диапазон страниц."""
    for node in nodes:
        pages = f'{node["start_index"]}-{node["end_index"]}'
        print(f'{"  " * depth}[{pages:>6}] {node["title"][:70]}')
        print_tree(node.get("nodes", []), depth + 1)


if __name__ == "__main__":
    start = time.time()
    # summary=False и optimize=False отключают все обращения к модели:
    # структура извлекается по типографике страницы и закладкам PDF
    result = page_index_flash(PDF, summary=False, optimize=False)
    elapsed = time.time() - start

    structure = result["structure"]
    print(f'Документ: {result["doc_title"]}')
    print(f"Дерево построено за {elapsed:.2f} с, без единого вызова LLM")
    print(f"Узлов верхнего уровня: {len(structure)}")
    print("-" * 60)
    print_tree(structure)

    # дерево сохраняется в JSON, дальше по нему идёт reasoning-поиск
    with open("tree.json", "w", encoding="utf-8") as f:
        json.dump(structure, f, ensure_ascii=False, indent=2)
    print("-" * 60)
    print("Дерево сохранено в tree.json")

Вывод показывает иерархию с диапазонами страниц. Вложенность повторяет структуру отчёта.

Построение индекса по документу с PageIndex

Второй скрипт, tree_search.py, реализует те самые два шага. Дерево разворачивается в плоский список с путями от корня, модель выбирает узел, после чего в контекст уходит текст только выбранных страниц.

# pageindex 0.2.10, litellm 1.98.0, Ollama 0.32.13, модель qwen2.5:7b, прогон 2026-08-24
"""Reasoning-поиск по дереву PageIndex: модель выбирает узел, потом отвечает по нему."""
import json
import re
import time

import litellm
import pypdfium2 as pdfium

MODEL = "ollama/qwen2.5:7b"
PDF = "annual_report.pdf"
QUESTION = "What did the Federal Reserve do about crypto-asset supervision?"


def flatten(nodes, out=None, path=""):
    """Разворачивает дерево в плоский список узлов с путём от корня."""
    out = [] if out is None else out
    for node in nodes:
        full = f'{path} > {node["title"]}' if path else node["title"]
        out.append({
            "node_id": f'{len(out):04d}',
            "title": full,
            "start": node["start_index"],
            "end": node["end_index"],
        })
        flatten(node.get("nodes", []), out, full)
    return out


def ask(prompt):
    """Один синхронный вызов локальной модели через LiteLLM."""
    response = litellm.completion(
        model=MODEL,
        messages=[{"role": "user", "content": prompt}],
        num_ctx=8192,
        timeout=900,
    )
    return response.choices[0].message.content


def pages_text(start, end):
    """Достаёт текст диапазона страниц PDF, страницы нумеруются с единицы."""
    doc = pdfium.PdfDocument(PDF)
    chunks = []
    for page_no in range(start - 1, min(end, len(doc))):
        chunks.append(doc[page_no].get_textpage().get_text_range())
    return "\n".join(chunks)


if __name__ == "__main__":
    with open("tree.json", encoding="utf-8") as f:
        nodes = flatten(json.load(f))

    # шаг 1: модель видит только оглавление, а не текст документа
    toc = "\n".join(f'{n["node_id"]}: {n["title"]} (pages {n["start"]}-{n["end"]})'
                    for n in nodes)
    select_prompt = (
        f"Here is the table of contents of a document.\n\n{toc}\n\n"
        f'Question: "{QUESTION}"\n'
        "Which single section most likely contains the answer? "
        'Reply with JSON only: {"node_id": "0000", "reason": "..."}'
    )
    start = time.time()
    raw = ask(select_prompt)
    print("Ответ модели на шаге выбора узла:")
    print(raw.strip())

    chosen_id = re.search(r'"node_id"\s*:\s*"?(\d+)"?', raw).group(1).zfill(4)
    node = next(n for n in nodes if n["node_id"] == chosen_id)
    print(f'\nВыбран узел {chosen_id}: {node["title"]} '
          f'(страницы {node["start"]}-{node["end"]})')
    print(f"Шаг выбора занял {time.time() - start:.1f} с")

    # шаг 2: в контекст уходят только страницы выбранного узла
    text = pages_text(node["start"], node["end"])
    print(f"В контекст ушло {len(text)} символов вместо всего документа")

    start = time.time()
    answer = ask(f"Answer the question using only this text.\n\n{text}\n\n"
                 f"Question: {QUESTION}\nAnswer in three sentences.")
    print(f"\nОтвет (получен за {time.time() - start:.1f} с):")
    print(answer.strip())

На вопрос про надзор за криптоактивами модель прошла три уровня иерархии и остановилась на нужном разделе. Промахов не было. В контекст ушло 6442 символа вместо полусотни страниц.

Выбор раздела по модели PageIndex

Обратите внимание на строку с обоснованием выбора. Это и есть та самая объяснимость, которой нет у векторного поиска: система прямо говорит, куда пошла и почему, а номера страниц позволяют проверить ответ руками. Исходники и разбор архитектуры лежат в репозитории PageIndex.

 

Заключение

PageIndex переносит поиск с уровня похожести текста на уровень структуры документа. Дерево разделов заменяет векторный индекс, обход дерева заменяет поиск ближайших соседей, а решение на каждом шаге принимает языковая модель и объясняет его. Взамен приходится платить вызовами модели за каждый запрос и мириться с тем, что документ без внятной иерархии такому поиску не поддаётся. На длинных профессиональных документах обмен выходит выгодным. На потоке коротких неструктурированных текстов нет.

 

Референсные ссылки