Course
Представьте, что перед вами Отчёт по финансовой стабильности Федеральной резервной системы. Вас спрашивают: Какой процент респондентов назвали устойчивую инфляцию главным краткосрочным риском? Вы перелистываете к разделу 5, находите блок 5.1 и за две секунды видите число. Это 72%.
Передайте этот вопрос стандартному конвейеру RAG, и то, найдёт ли он ответ, будет зависеть от того, как чанкёр разрезал страницу. Если блок 5.1 оказался фрагментирован по чанкам, поиск по схожести вернёт фрагменты, где где-то упоминается инфляция, но саму цифру пропустит. В этом и состоит ключевое ограничение поиска по схожести: текст, похожий на ваш запрос, — это не то же самое, что раздел, в котором содержится ответ.
Для длинных, структурированных документов это критично — и именно это призван исправить PageIndex. В этом руководстве я построю систему вопросов-ответов по документам с PageIndex и сравню её с базовой векторной RAG на том же отчёте ФРС, чтобы вы поняли, когда каждый подход оправдывает свои затраты.
Кратко
- PageIndex — это безвекторный фреймворк RAG, который извлекает ответы, рассуждая по структуре документа.
- Он устраняет слабые места векторного RAG: разрезанные таблицы, повторяющиеся термины и перекрёстные ссылки.
- Мы сравним оба подхода на отчёте ФРС за 2023 год: PageIndex и базовый стек FAISS.
- Это компромисс, а не чистая победа: преимущество PageIndex растёт с длиной документа.
Что такое PageIndex?
PageIndex — это фреймворк извлечения, который рассуждает по структуре документа вместо поиска текста, похожего на ваш запрос. Он был выпущен VectifyAI в сентябре 2025 года, разработан Минтяном Чжаном и Ю Таном. Это open source под лицензией MIT, и по состоянию на июль 2026 года репозиторий GitHub накаплил более 34 000 звёзд.
PageIndex vs стандартный RAG
Лучший способ объяснить работу PageIndex — сопоставить его со стандартным RAG. Если вы новичок в RAG, то основная идея — дать LLM доступ к документу, извлекая релевантные части во время запроса и передавая их как контекст.
Именно шаг извлечения PageIndex переосмысливает. Вместо сравнения запроса с каждым чанком в документе PageIndex сначала строит иерархический древовидный индекс из структуры документа. Это ведёт к иным результатам:
- Векторный поиск находит текст, который выглядит похожим на ваш запрос.
- Древовидный поиск находит разделы, которые с высокой вероятностью содержат ответ.
Иными словами, разница между стандартным RAG и PageIndex — это разница между просмотром каждой страницы книги в поисках ключевых слов и сперва чтением оглавления, чтобы найти нужную главу.
Эти цели совпадают для простых фактологических запросов на коротких документах, но резко расходятся, как только документы становятся длинными, структурированными и насыщенными внутренними перекрёстными ссылками.
|
Измерение |
PageIndex |
Векторный RAG |
|
Механизм извлечения |
Рассуждения LLM по древовидному индексу |
Косинусная схожесть по эмбеддингам |
|
Стоимость настройки |
Обработка документа (однократно) |
Чанкинг + эмбеддинг (однократно) |
|
Задержка на запрос |
3–8 секунд (несколько вызовов LLM) |
< 1 секунды (поиск в индексе) |
|
Стоимость на запрос |
Выше (2+ вызова LLM) |
Ниже (поиск эмбеддингов + один вызов LLM) |
|
Точность на длинных структурированных документах |
98,7% на FinanceBench (Mafin 2.5) |
30–50% на FinanceBench |
|
Обрабатывает перекрёстные ссылки |
Да |
Нет |
|
Обрабатывает разрезанные таблицы |
Да |
Нет |
|
Трассируемость |
Полный трейс рассуждений + ссылки на страницы |
Оценки top-k чанков |
|
Лучше всего подходит для |
Финансовые отчётности, контракты, руководства |
FAQ, продуктовая документация, тикеты поддержки |
Как работает древовидный индекс
Процесс проходит в два шага.
- Вы загружаете документ, и PageIndex генерирует дерево. Каждый узел имеет заголовок, краткое описание и индекс страницы. Дерево отражает естественную иерархию документа (главы, разделы, подразделы, приложения — всё, что в документе есть).
- Когда поступает запрос, LLM получает структуру дерева (без полного текста, который бы переполнил окно контекста) и рассуждает, в каких узлах с наибольшей вероятностью находится ответ. Он возвращает набор ID узлов вместе с трейсом рассуждений, объясняющим выбор, после чего текст из этих узлов извлекается и передаётся в этап генерации.

Почему это важно для структурированных документов
Я построил достаточно конвейеров RAG, чтобы знать, что чанкинг — это то место, где большинство продакшн-систем тихо проваливаются. На бумаге стратегия выглядит аккуратно, но стоит документу иметь таблицу, пересекающую границы страниц, или сноску, определяющую термин, использованный тремя разделами ранее, как становятся видны трещины.
У векторного RAG есть три структурные слабости, которые стабильно проявляются на финансовых и юридических документах.
- Разрезанный контент: Баланс, разбитый на два чанка, теряет взаимосвязь строк, и обе половины получают низкую релевантность, потому что по отдельности не имеют смысла. (Поздний чанкинг помогает, но не решает перекрёстные ссылки.)
- Повторяющиеся термины: В годовом отчёте «выручка» упоминается десятки раз, поэтому запрос о росте выручки одного подразделения вытягивает чанки с каждым упоминанием, ранжируются они примерно одинаково, и нет способа выделить релевантный.
- Перекрёстные ссылки: Когда в разделе 4.3 говорится «см. приложение G для полной сверки», поиск по схожести не умеет следовать по этому указателю. Ответ находится в приложении G, и ретриверу это никак не узнать.
PageIndex справляется со всеми тремя, потому что извлекает по структуре, а не по фрагментам. Дерево сохраняет документ целостным, этап рассуждения может следовать перекрёстной ссылке или сравнивать разделы, и поскольку каждый узел несёт индекс страницы и краткое описание, ретривер ориентируется по «географии» документа, а не по поверхностной текстовой схожести.
Честно скажу: когда я впервые увидел заявленные 98,7% точности для Mafin 2.5 (системы VectifyAI на базе PageIndex) на FinanceBench, моя первая реакция была скепсис, ведь почти 50-процентный отрыв от стандартного RAG выглядит как бенчмарк, подобранный «в плюс». Но FinanceBench как раз и тестирует эти режимы отказа: многошаговые рассуждения по отчётности SEC с точными числовыми ответами.
Начало работы с PageIndex
В этом руководстве понадобятся несколько пакетов и два API-ключа. Вот всё, что нужно подготовить до первой строки кода.
Весь код, использованный в руководстве, доступен в моём сопутствующем репозитории GitHub.
Чтобы повторить шаги из руководства, вам потребуется:
- Установленный Python 3.10+
- Базовое понимание концепций RAG. Если вы новичок в retrieval-augmented generation, прочитайте Что такое Retrieval Augmented Generation (RAG)? перед продолжением
- Ключ OpenAI API с platform.openai.com/api-keys
- Ключ PageIndex API с dash.pageindex.ai/api-keys (бесплатного тарифа достаточно для этого руководства)

Установите необходимые пакеты:
pip install pageindex openai requests faiss-cpu pymupdf
Задайте API-ключи в переменных окружения, а не хардкодьте их:
export PAGEINDEX_API_KEY="your_pageindex_key_here"
export OPENAI_API_KEY="your_openai_key_here"
Имея оба ключа, инициализируйте клиенты:
import os
import copy
import time
import json
import asyncio
import requests
from pageindex import PageIndexClient
import pageindex.utils as utils
import openai
PAGEINDEX_API_KEY = os.environ["PAGEINDEX_API_KEY"]
OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]
pi_client = PageIndexClient(api_key=PAGEINDEX_API_KEY)
openai_client = openai.AsyncOpenAI(api_key=OPENAI_API_KEY)
Вся настройка: девять импортов, две переменные окружения, два клиента.
Загрузка документа и построение дерева PageIndex
В этом руководстве мы используем Отчёт по финансовой стабильности ФРС от октября 2023 года. Это общедоступный PDF, структурированный как реальный производственный документ: формальные разделы, пронумерованные главы, приложения и внутренние перекрёстные ссылки.
Загрузка документа
ФРС публикует отчёты как доступные PDF по стабильным ссылкам. Проверка if not os.path.exists() означает, что вы скачаете его только один раз — это важно, когда вы итерируете остальной код и не хотите каждый раз перекачивать 60-страничный PDF.
DOWNLOAD_DIR = "./data"
os.makedirs(DOWNLOAD_DIR, exist_ok=True)
PDF_URL = "https://www.federalreserve.gov/publications/files/financial-stability-report-20231020.pdf"
PDF_PATH = os.path.join(DOWNLOAD_DIR, "fed_financial_stability_report_2023.pdf")
if not os.path.exists(PDF_PATH):
print("Downloading Federal Reserve Financial Stability Report (Oct 2023)...")
response = requests.get(PDF_URL, timeout=60)
response.raise_for_status()
with open(PDF_PATH, "wb") as f:
f.write(response.content)
print(f"Saved to {PDF_PATH}")
else:
print(f"Already present: {PDF_PATH}")
Отправка документа
Отправка — это один вызов API. submit_document() загружает файл и возвращает doc_id, который вы будете использовать для всех дальнейших операций с этим документом. Сохраните его где-нибудь: если перезапустите сессию, можно пропустить шаг отправки и сразу перейти к get_tree() с тем же ID.
print("Submitting document to PageIndex...")
submit_result = pi_client.submit_document(PDF_PATH)
doc_id = submit_result["doc_id"]
print(f"Document ID: {doc_id}")
PageIndex обрабатывает документ асинхронно, поэтому нужно опрашивать статус, пока дерево не будет готово:
print("Waiting for tree generation...")
while True:
status_result = pi_client.get_document(doc_id)
status = status_result.get("status")
print(f" Status: {status}")
if status == "completed":
break
elif status == "failed":
raise RuntimeError(f"Processing failed: {status_result}")
time.sleep(10)
print("Done.")
Обработка 60-страничного PDF обычно занимает от двух до четырёх минут, и эта стоимость платится один раз на документ. Вы увидите смену статуса queued → processing → completed.
Submitting document to PageIndex...
Document ID: pi-cmq2bp4ok00rx01qxmym7tnd1
Waiting for tree generation...
Status: queued
Status: processing
Status: completed
Processing done.
Осмотр дерева
После завершения обработки get_tree() возвращает полную иерархическую структуру в виде вложенного списка узлов. Хелпер utils.create_node_mapping() затем разворачивает её в простой словарь по ID узла, что делает последующее извлечение текста намного быстрее, чем рекурсивный обход дерева при каждом запросе.
tree_result = pi_client.get_tree(doc_id, node_summary=True)
tree = tree_result["result"]
# Build a flat node map for easy access later
node_map = utils.create_node_mapping(tree)
# Print the top-level nodes
print(f"\nTop-level nodes ({len(tree)} sections):\n")
for node in tree:
print(f" [{node['node_id']}] {node['title']}")
print(f" Page {node['page_index']}")
print(f" {node['summary'][:120]}...")
print()
Запуск на Отчёте по финансовой стабильности даёт «скелет» документа одним взглядом:
Top-level nodes (9 sections):
[0000] Financial Stability Report
Page 1
This document is the October 2023 Financial Stability Report from the Federal Reserve...
[0001] Purpose and Framework
Page 5
This report outlines the Federal Reserve's framework for assessing U.S. financial stability...
[0002] Overview
Page 9
This report evaluates the stability of the U.S. financial system by analyzing four key vulnerability areas...
[0003] 1 | Asset Valuations
Page 13
...
[0012] 2 | Borrowing by Businesses and Households
Page 23
...
[0019] 3 | Leverage in the Financial Sector
Page 33
...
[0029] 4 | Funding Risks
Page 45
...
[0036] 5 | Near-Term Risks to the Financial System
Page 53
...
[0041] Appendix | Figure Notes
Page 59
…
Дерево — это обычный JSON: каждый узел содержит node_id, title, summary, page_index и список nodes для вложенных подразделов. В отличие от векторных индексов, нет ни непрозрачного пространства эмбеддингов, ни бинарного индексного файла со специальным ридером. Это просто вложенный список, который можно напечатать, посмотреть и осмыслить напрямую.
Эта прозрачность очень полезна на практике: если ретривер вернул неверный раздел, вы можете взглянуть на дерево и понять, почему — чего вы не сделаете с 768-мерным эмбеддингом.
Запросы к PageIndex с древовидным поиском LLM
Шаг извлечения отправляет структуру дерева (без полного текста, который переполнит окно контекста) в LLM и просит определить, какие узлы релевантны запросу.
Функция древовидного поиска
Промпт передаёт «тонкое» дерево (только заголовки и краткие описания, без полного текста разделов) в LLM и просит вернуть объект JSON с трейсом рассуждений и списком ID узлов. Два решения здесь стоит отметить.
Во-первых, utils.remove_fields() удаляет фактическое содержимое из каждого узла перед сериализацией, что позволяет уложиться в лимиты контекста даже на длинном документе. Вызов copy.deepcopy() необходим, потому что remove_fields мутирует объект на месте, а исходное дерево нужно сохранить для последующего извлечения текста.
Во-вторых, response_format={"type": "json_object"} заставляет модель вернуть структурированный ответ, что делает парсинг надёжным, а не хрупким. Температура — 0, потому что это задача на рассуждение.
TREE_SEARCH_PROMPT = """You are a document retrieval assistant.
Given a document's tree structure and a user query, identify which nodes (sections)
are most likely to contain the answer.
Document tree:
{tree_json}
User query: {query}
Return a JSON object with the following format:
{{
"reasoning": "Your step-by-step reasoning about which sections to retrieve",
"node_ids": ["id1", "id2", ...]
}}
Return ONLY the JSON object, no other text."""
async def tree_search(tree, query: str, model: str = "gpt-4o") -> dict:
"""Use an LLM to reason over the tree and return relevant node IDs."""
# remove_fields mutates in place; deepcopy protects the original tree
slim_tree = utils.remove_fields(copy.deepcopy(tree), fields=["text"])
tree_json = json.dumps(slim_tree, indent=2)
prompt = TREE_SEARCH_PROMPT.format(tree_json=tree_json, query=query)
response = await openai_client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=0,
response_format={"type": "json_object"}
)
result = json.loads(response.choices[0].message.content)
return result
Примечание: ниже используются вызовы await, предполагается среда Jupyter или iPython, где поддерживается верхнеуровневый await. Если вы запускаете как обычный скрипт .py, оберните async-вызовы в функцию async def main() и вызовите её через asyncio.run(main()).
Выполнение запроса
Начнём с вопроса, требующего навигации по нескольким разделам. Запрос об уязвимостях в оценках активов требует материала из обзора, главы об оценках активов и её подразделов по конкретным рынкам.
Это разделы, которые векторный RAG может найти по отдельности, но редко — вместе и с правильной иерархией. Это как раз тот случай, где древовидный поиск окупается.
query_1 = "What are the main vulnerabilities the Fed identified in asset valuations as of October 2023, and which markets were flagged as stretched?"
result = await tree_search(tree, query_1)
print("LLM Reasoning:")
print(result["reasoning"])
print("\nSelected node IDs:", result["node_ids"])
Трейс рассуждений — часть, на которую стоит обратить особое внимание. Вот какой типичный вывод вы увидите:
LLM Reasoning:
To identify the main vulnerabilities in asset valuations as of October 2023,
we should focus on sections that specifically discuss asset valuations and
related market conditions. The '1 | Asset Valuations' section and its
subsections are directly relevant as they provide detailed insights into
asset valuation pressures, equity market conditions, and specific market
sectors flagged as stretched.
Selected node IDs: ['0003', '0004', '0006', '0009', '0010', '0011']
Вы видите ровно, почему выбран каждый раздел — такой степени трассируемости вы просто не получите от ранжирования по косинусной схожести.
Генерация ответов на основе извлечённого контекста
Получив релевантные ID узлов, следующий шаг — извлечь соответствующий текст и передать его модели генерации.
Извлечение контекста и генерация ответов
Две функции отвечают за этап генерации.
-
generate_answer()ищет каждый выбранный узел вnode_map, добавляет заголовок раздела и номер страницы (это и даёт проверяемые цитаты в финальном ответе) и передаёт собранный контекст в модель генерации. -
pageindex_pipeline()объединяет древовидный поиск и генерацию в один вызов, чтобы не связывать их вручную каждый раз.
ANSWER_PROMPT = """You are a financial document analyst. Answer the user's question
using ONLY the provided context. Cite the specific section(s) you are drawing from.
If the context does not contain enough information to answer, say so clearly.
Context:
{context}
Question: {question}
Provide a precise, well-cited answer."""
async def generate_answer(node_ids: list, query: str, node_map: dict,
model: str = "gpt-4o") -> str:
"""Extract text from the selected nodes and generate an answer."""
# Gather the text from each selected node
context_parts = []
for node_id in node_ids:
node = node_map.get(node_id)
if node:
section_header = f"[{node['title']} | Page {node['page_index']}]"
context_parts.append(f"{section_header}\n{node.get('text', '')}")
context = "\n\n---\n\n".join(context_parts)
prompt = ANSWER_PROMPT.format(context=context, question=query)
response = await openai_client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
temperature=0
)
return response.choices[0].message.content
async def pageindex_pipeline(tree, query: str, node_map: dict) -> dict:
"""Full PageIndex pipeline: tree search + answer generation."""
search_result = await tree_search(tree, query)
node_ids = search_result["node_ids"]
reasoning = search_result["reasoning"]
answer = await generate_answer(node_ids, query, node_map)
return {
"query": query,
"reasoning": reasoning,
"retrieved_sections": node_ids,
"answer": answer
}
Запустите полный конвейер:
result = await pageindex_pipeline(tree, query_1, node_map)
print(f"Query: {result['query']}\n")
print(f"Retrieved sections: {result['retrieved_sections']}\n")
print(f"Answer:\n{result['answer']}")
Ответ возвращается с цитатами на уровне разделов и номерами страниц. Это трассируемость, которую PageIndex даёт по умолчанию.
Query: What are the main vulnerabilities the Fed identified in asset valuations
as of October 2023, and which markets were flagged as stretched?
Retrieved sections: ['0003', '0004', '0006', '0009', '0010', '0011']
Answer:
The main vulnerabilities identified by the Fed in asset valuations as of
October 2023 include:
1. Equity Markets: Valuations increased modestly from an already high level,
with the forward price-to-earnings ratio rising further above its historical
median (Page 13, "Equity market valuation pressures remained notable").
2. Residential Real Estate: House prices started increasing again, and the
price-to-rent ratio was close to its previous peak from the mid-2000s
(Page 20, "House prices started increasing again in recent months").
3. Commercial Real Estate: Despite recent price declines, valuations remained
elevated relative to rental income (Page 19, "Commercial real estate
valuations remained elevated").
4. Farmland: Prices near the peak of their historical distribution, driven
by strong agricultural commodity prices (Page 21, "Farmland valuations
remained elevated").
Сравнение PageIndex и векторного RAG
Это раздел, который действительно несёт практическую пользу. Построим базовый векторный RAG на том же документе и запустим оба конвейера на одних и тех же запросах.
Построение базового векторного RAG
База следует стандартному шаблону: извлекаем сырой текст из PDF с PyMuPDF, разбиваем его на перекрывающиеся чанки по 500 слов, встраиваем каждый чанк моделью text-embedding-3-small и загружаем всё в in-memory индекс FAISS для поиска по косинусной схожести.
Если хотите глубже разобраться, как размер чанка и перекрытие влияют на качество извлечения, статья Стратегии чанкинга стоит прочтения параллельно с этим материалом, а Что такое FAISS? также полезно, если шаги построения индекса вам незнакомы.
from openai import OpenAI
import numpy as np
import faiss
sync_openai = OpenAI(api_key=OPENAI_API_KEY)
def extract_text_from_pdf(pdf_path: str) -> str:
"""Extract raw text from a PDF using PyMuPDF."""
import fitz # pip install pymupdf
doc = fitz.open(pdf_path)
pages = []
for page_num, page in enumerate(doc):
text = page.get_text()
if text.strip():
pages.append(f"[Page {page_num + 1}]\n{text}")
return "\n\n".join(pages)
def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
"""Split text into overlapping chunks by word count."""
words = text.split()
chunks = []
start = 0
while start < len(words):
end = min(start + chunk_size, len(words))
chunks.append(" ".join(words[start:end]))
start += chunk_size - overlap
return chunks
def embed_chunks(chunks: list[str], model: str = "text-embedding-3-small") -> np.ndarray:
"""Embed a list of text chunks with the OpenAI embeddings API."""
all_embeddings = []
batch_size = 100
for i in range(0, len(chunks), batch_size):
batch = chunks[i : i + batch_size]
response = sync_openai.embeddings.create(input=batch, model=model)
batch_embeddings = [item.embedding for item in response.data]
all_embeddings.extend(batch_embeddings)
return np.array(all_embeddings, dtype="float32")
def build_faiss_index(embeddings: np.ndarray) -> faiss.IndexFlatIP:
"""Build an in-memory FAISS index for inner-product (cosine) search."""
dim = embeddings.shape[1]
faiss.normalize_L2(embeddings)
index = faiss.IndexFlatIP(dim)
index.add(embeddings)
return index
def vector_rag_pipeline(query: str, chunks: list[str], index: faiss.IndexFlatIP,
k: int = 5, model: str = "gpt-4o") -> str:
"""Full vector RAG pipeline: embed query, retrieve top-k, generate answer."""
# Embed the query
query_embedding = sync_openai.embeddings.create(
input=[query], model="text-embedding-3-small"
).data[0].embedding
query_vec = np.array([query_embedding], dtype="float32")
faiss.normalize_L2(query_vec)
# Retrieve top-k chunks
_, indices = index.search(query_vec, k)
retrieved_chunks = [chunks[i] for i in indices[0] if i < len(chunks)]
context = "\n\n---\n\n".join(retrieved_chunks)
# Generate answer
response = sync_openai.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": "Answer the question using only the provided context. Be precise."},
{"role": "user", "content": f"Context:\n{context}\n\nQuestion: {query}"}
],
temperature=0
)
return response.choices[0].message.content
С определёнными хелперами строим индекс. На 60-страничном документе шаг эмбеддинга — самый медленный: ожидайте минуту–две в зависимости от подключения и пропускной способности OpenAI.
print("Extracting text from PDF...")
raw_text = extract_text_from_pdf(PDF_PATH)
print("Chunking text...")
chunks = chunk_text(raw_text, chunk_size=500, overlap=50)
print(f" {len(chunks)} chunks created")
print("Embedding chunks...")
embeddings = embed_chunks(chunks)
print(f" Embeddings shape: {embeddings.shape}")
print("Building FAISS index...")
faiss_index = build_faiss_index(embeddings)
print("Done.\n")
Запуск сравнения
60-страничный отчёт даёт около 47 чанков при 500 словах и перекрытии в 50 слов.
Extracting text from PDF...
Chunking text...
47 chunks created
Embedding chunks (takes 1-2 min)...
Embeddings shape: (47, 1536)
Building FAISS index...
Done.
Три запроса, каждый — под разные вызовы для извлечения:
queries = [
# Direct factual lookup
"What does the Fed consider the most significant near-term risk to financial stability as of October 2023?",
# Cross-reference question (Box 5.2 is referenced from Section 5, requires following an internal pointer)
"What methodology does the Fed use to assess climate-related financial risks, and where in the report is it described?",
# Multi-section reasoning (requires connecting Section 1 and Section 3)
"How do elevated asset valuations interact with leverage in the financial sector to amplify systemic risk, according to the report?"
]
Когда оба конвейера готовы, цикл ниже отправляет каждый запрос в обе системы и собирает результаты. Срез [:500] на каждом ответе просто держит вывод читаемым; полные ответы сохраняются в results для последующего разбора.
results = []
for query in queries:
print(f"\nQuery: {query}\n")
# PageIndex
pi_result = await pageindex_pipeline(tree, query, node_map)
pi_answer = pi_result["answer"]
# Vector RAG
vec_answer = vector_rag_pipeline(query, chunks, faiss_index)
results.append({
"query": query,
"pageindex_answer": pi_answer,
"vector_rag_answer": vec_answer
})
print("PageIndex answer:")
print(pi_answer[:500])
print("\nVector RAG answer:")
print(vec_answer[:500])
print("\n" + "="*60)
Сравнение результатов
Вот типичные результаты по трём типам запросов:
|
Запрос |
PageIndex |
Векторный RAG |
Правильный ответ |
Победитель |
|
Самый значимый краткосрочный риск |
Широкий ответ по нескольким рискам с цитатами на разделы |
Извлёк конкретную цифру опроса 72% (тут точнее) |
Устойчивая инфляция и ужесточение денежно-кредитной политики, упомянутые 72% респондентов (Блок 5.1) |
Ничья, небольшое преимущество у векторного RAG по конкретной статистике |
|
Методология климатических рисков (перекрёстная ссылка) |
Нашёл раздел, но выбрал родительский узел вместо блока 5.2, из-за чего текст методологии не попал в генерацию |
Разрезал прямо через блок 5.2 и вернул реальные шаги методологии |
В блоке 5.2 описан перевод физических и трансформационных рисков в финансовые экспозиции через сценарный анализ |
Векторный RAG |
|
Взаимодействие оценок активов и левериджа |
Извлёк 18 узлов по разделам 1 и 3 и объяснил механизм усиления |
Соединил два раздела и объяснил механизм усиления |
Завышенные оценки повышают риск резкой коррекции цен; леверидж усиливает убытки, когда коррекция бьёт по кредитно-рычажным институтам (разделы 1 и 3) |
Ничья |
Интерпретация результатов
Результаты тоньше, чем «сухая победа» одной системы, и оттого полезнее.
PageIndex во всех трёх случаях пришёл к верным разделам. На прямом фактологическом запросе обе системы ответили верно, хотя векторный RAG вытащил конкретную цифру 72%, потому что его плоский чанкинг случайно захватил блок 5.1 целиком — скорее удача чанкинга, чем структурное преимущество. На мультиразделовом запросе PageIndex извлёк 18 узлов, охватив разделы 1 и 3, и объяснил взаимодействие, примерно сопоставимо с векторным RAG. Единственным явным проигрышем был запрос с перекрёстной ссылкой, и важно понять, почему.
В запросе о климатической методологии PageIndex дедуцировал раздел 5 и Обзор, но методология находится в блоке 5.2 — отдельном дочернем узле раздела 5. Древовидный поиск выбрал родительский раздел, а его текст лишь ссылается на блок, не воспроизводя его, поэтому этап генерации методологию не увидел. Векторный RAG победил, разрезав прямо через блок 5.2.
Это вопрос гранулярности извлечения, а не провал рассуждения: выбор родительского узла не подтягивает тексты его потомков, если вы явно их не расширяете. Разрыв можно закрыть, попросив древовидный поиск возвращать дочерние узлы релевантных разделов или расширяя каждый выбранный узел его потомками перед генерацией.
Преимущество PageIndex проявилось наиболее отчётливо в самостоятельном запуске конвейера ранее в руководстве (запрос об оценках активов), где он корректно рассуждал о структуре документа и вернул хорошо цитируемые, атрибутированные разделам ответы. Сравнительные запросы выше были специально подобраны для стресс-теста более сложных сценариев извлечения, поэтому на 60-страничном документе они благоволят векторному RAG.
Честный вывод: преимущество PageIndex над векторным RAG усиливается с длиной документа и плотностью внутренних перекрёстных ссылок. На 60 страницах разрыв умеренный. На 200-страничном 10-K с десятками сносок, ссылающихся друг на друга, ожидайте большего.
Если хотите «дожать» базовый векторный RAG прежде чем списывать его, Как повысить эффективность RAG охватывает пять самых действенных приёмов.
Когда использовать PageIndex
Сравнение выше относится к одному типу и длине документа. Подходит ли PageIndex, зависит от трёх факторов: структура документа, тип запросов и объём.
Когда PageIndex выигрывает
PageIndex окупает накладные расходы на длинных, структурированных профессиональных документах, где ошибка дорогая:
- 10-K и прочая финансовая отчётность
- Юридические контракты с определениями и перекрёстными ссылками
- Нормативные руководства
- Технические руководства с нумерованными разделами
Это также случаи, где трейсы рассуждений дают вам что-то конкретное для аудита. Сладкая точка — низкий объём, высокая цена ошибки: несколько десятков документов в день, где каждый ответ должен быть верным, и премия по задержке и стоимости того стоит.
Когда лучше выбрать векторный RAG
Векторный RAG — лучший дефолт для высокопроизводительных, низколатентных сценариев, где несколько вызовов LLM в PageIndex становятся узким местом, а кэшированные эмбеддинги держат нагрузку за долю цены.
Он также подходит для плоских документов с малой иерархией для рассуждений, таких как:
- Новостные статьи
- Описание продуктов
- Тикеты поддержки
Это также правильный выбор там, где достаточно приблизительных ответов. Чат-бот поддержки, который в девяти случаях из десяти находит нужный FAQ, наверное, выполняет задачу.
Честные компромиссы
Цена — это скорость и деньги. Каждый запрос PageIndex — это как минимум два вызова LLM (древовидный поиск и генерация ответа) плюс предварительная обработка, так что ожидайте 3–8 секунд на запрос для 60-страничного документа против менее секунды для векторного RAG с кэшированным индексом FAISS — и счёт за запрос растёт аналогично.
Отчёт по финансовой стабильности — честный тест для PageIndex: структурированный, иерархический, с перекрёстными ссылками. Корпус из тысяч коротких тикетов поддержки — нет, и там векторный RAG обойдёт его за долю стоимости.
Заключение
У векторного RAG есть скрытая предпосылка: что фрагмент, наиболее похожий на ваш запрос, — это и есть фрагмент с ответом. Для коротких, слабо структурированных документов это предположение в целом работает. Для длинных, структурированных профессиональных документов оно предсказуемо ломается: разрезанные таблицы, вводящая в заблуждение частота терминов и «невидимые» перекрёстные ссылки.
PageIndex решает все три, рассматривая извлечение как задачу рассуждения по структуре документа, а не поиск схожести по фрагментам текста.
Практическое правило простое: если ваши документы иерархичны, запросы требуют следования внутренним ссылкам, и правильность ответа важнее скорости, то PageIndex стоит дополнительной задержки и стоимости. Если вы запускаете массовые поиски по коротким или «плоским» документам, векторный RAG остаётся верным дефолтом.
Хотите глубже погрузиться в продакшн-RAG и агентные системы? Наш трек «AI Engineering with LangChain» проведёт от основ приложений через извлечение, оценку и агентов, использующих инструменты.
Частые вопросы по PageIndex
Что такое PageIndex?
PageIndex — это open-source фреймворк RAG от VectifyAI (сентябрь 2025), который заменяет поиск по векторной схожести рассуждениями LLM по иерархическому древовидному индексу: без эмбеддингов, без векторной БД, без чанкинга.
Чем PageIndex отличается от стандартного RAG?
Стандартный RAG режет документ на фрагменты, встраивает их и извлекает наиболее похожие на запрос чанки. PageIndex строит дерево из естественной структуры документа и просит LLM рассуждать, какие разделы с наибольшей вероятностью содержат ответ. Шаг извлечения — это задача на рассуждение, а не поиск схожести.
Требуется ли для PageIndex ключ OpenAI API?
Не обязательно. Самостоятельно разворачиваемый open-source репозиторий работает с любым провайдером, поддерживаемым LiteLLM. В этом руководстве используется OpenAI для рассуждений при извлечении и для генерации ответов, так что если вы следуете дословно, вам понадобится ключ OpenAI плюс ключ PageIndex с dash.pageindex.ai/api-keys.
Подходит ли PageIndex для любых документов?
Нет. PageIndex раскрывает свой потенциал на длинных, структурированных документах с иерархией и перекрёстными ссылками. Для больших корпусов коротких, слабо структурированных текстов, например тикетов поддержки или страниц FAQ, векторный RAG будет быстрее и дешевле.
Какую точность показал PageIndex на FinanceBench?
Mafin 2.5, система VectifyAI на базе PageIndex, достигла 98,7% против примерно 30–50% у традиционного векторного RAG. FinanceBench оценивает вопросы-ответы по финансовой отчётности SEC, требующие многошаговых рассуждений и точного извлечения чисел. Именно этот разрыв и призван закрыть PageIndex.