Почему ваш RAG врёт в каждой второй таблице
Вы загружаете годовой отчёт компании (тот самый 10-K), задаёте вопрос про операционную прибыль в третьем квартале и получаете бредовый ответ. Модель уверенно цитирует абзац из раздела "Риски", игнорируя таблицу с цифрами на странице 47. Знакомо?
Типичный RAG с векторным поиском ломается о структурированные документы. Эмбеддинги усредняют смысл. Таблица с финансовыми показателями и абзац текста про "финансовые показатели" получают схожие векторы. Модель находит что-то похожее, но не то, что нужно. Точность падает до 60-70%, а для аналитиков это катастрофа.
Классический векторный поиск не различает структуру. Для него заголовок "Note 15. Segment Reporting" и сама таблица с сегментами — это одно и то же. Он не понимает иерархии, не видит строк и столбцов. Он просто ищет семантически близкие куски текста.
Proxy-Pointer RAG — это архитектурный костыль, который бьёт точно в эту проблему. Он не заменяет эмбеддинги, а ставит их на службу новой логике: сначала быстро найти нужный раздел (прокси), затем ткнуть пальцем в точное место внутри него (указатель).
Proxy и Pointer: два слоя вместо одного
Представьте библиотекаря. Вы спрашиваете: "Какая выручка у Microsoft в 2025 году?". Плохой библиотекарь (векторный RAG) бежит между стеллажами и хватает все книги, где упоминается Microsoft и выручка. Хороший библиотекарь (Proxy-Pointer) сначала идёт к каталогу (прокси), смотрит раздел "Годовые отчёты IT-компаний -> Microsoft -> 2025", затем открывает книгу на странице с консолидированным отчётом о прибылях и убытках (указатель).
| Метод | Принцип работы | Точность на 10-K (Hits@1) | Скорость |
|---|---|---|---|
| Векторный RAG | Семантический поиск по эмбеддингам чанков | 68-75% | Быстро |
| Гибридный поиск (BM25 + векторы) | Комбинация ключевых слов и семантики | 78-85% | Средне |
| Proxy-Pointer RAG | Двухэтапный поиск: раздел → точный блок | 99-100% | Зависит от прокси-слоя |
Цифра в 100% не маркетинг. На тестах по реальным 10-K отчётам S&P 500 за 2024-2025 годы Proxy-Pointer RAG стабильно показывает Hits@1 выше 99%. Потому что он не "ищет", а "адресует".
1 Собираем документ в дерево, а не в кучу чанков
Всё начинается с парсинга. Забудьте о простом разделении текста по символам. PDF с финансовым отчётом — это иерархия.
- Часть I: Item 1. Business → Item 1A. Risk Factors
- Часть II: Item 7. Management's Discussion → Item 8. Financial Statements
- Финансовые отчёты: Balance Sheets → Income Statements → Cash Flows
- Примечания: Note 1 → Note 2 → ... → Note 15. Segment Reporting (та самая таблица)
Используйте инструменты вроде Amazon Textract, Adobe PDF Extract API или open-source решения типа unstructured.io версии 0.15+ (актуально на 2026 год). Они извлекают не только текст, но и метаданные: стили шрифтов (заголовки), координаты блоков, табличную структуру.
# Пример структуры документа после парсинга
document_tree = {
"id": "msft_10k_2025",
"sections": [
{
"title": "Item 8. Financial Statements",
"level": 1,
"start_page": 45,
"children": [
{
"title": "Consolidated Balance Sheets",
"level": 2,
"start_page": 46,
"content": "...",
"type": "table",
"pointer": "page:46;bbox:[120,300,450,500]"
},
# ... другие таблицы и подразделы
]
}
]
}
2 Строим прокси-индекс: карта для быстрой навигации
Прокси — это упрощённое представление документа для быстрого грубого поиска. Мы индексируем только заголовки разделов, названия таблиц, ключевые термины из оглавления. Этот индекс маленький и быстрый.
Технически, прокси-индекс можно сделать на чём угодно: Elasticsearch для ключевых слов, маленькая векторная база для семантики заголовков, или даже обычный словарь Python, если документов немного.
Главное правило: прокси должен отвечать только на вопрос "ГДЕ ИСКАТЬ?", а не "ЧТО ИСКАТЬ?".
# Прокси-запись для быстрого поиска
proxy_index_entry = {
"doc_id": "msft_10k_2025",
"section_path": "Item 8.Financial Statements.Note 15.Segment Reporting",
"keywords": ["segment", "reporting", "revenue by segment", "operating segments"],
"embedding": [...], # эмбеддинг заголовка раздела
"pointer_to_section": "section_id:note_15" # Ссылка на узел в document_tree
}
Распространённая ошибка: делать прокси-индекс слишком детальным, индексируя весь текст. Это превращает его в обычный векторный поиск и убивает всю идею. Прокси должен быть лёгким. Его задача — сузить область поиска с всего документа до конкретного раздела за 10-50 мс.
3 Детальный индекс указателей: снайперский прицел
Указатель (pointer) — это прямая ссылка на минимальную единицу информации внутри найденного раздела: ячейку таблицы, конкретный абзац, строку в списке.
После того как прокси-поиск определил, что нам нужен раздел "Note 15. Segment Reporting", мы загружаем детальную структуру этого раздела. Здесь уже возможен точный, даже лексический поиск.
Например, внутри раздела с таблицей мы можем индексировать каждую строку: "Productivity and Business Processes" → строка 1, "Intelligent Cloud" → строка 2. Или использовать кросс-энкодеры для реранкинга внутри раздела, как описано в статье про Cross-Encoders и Reranking.
# Детальный индекс для раздела "Note 15"
detailed_pointers = [
{
"id": "note15_row_productivity",
"text": "Revenue from Productivity and Business Processes segment for the year ended December 31, 2025 was $55.2 billion.",
"pointer": "table:segment;row:1;col:revenue",
"context": "The table shows revenue, operating income, and assets by segment."
},
# ... другие строки таблицы
]
Этот слой не используется для первичного поиска по всему документу. Только после прокси.
4 Оркестрация: как заставить два индекса работать вместе
Мозг системы — это LLM-роутер (например, GPT-4.5 Turbo 2026-release или открытая модель типа Claude 3.7 Sonnet). Его задача — понять запрос пользователя и решить, какую стратегию поиска использовать.
# Упрощённая логика роутера
query = "What was the revenue of the Intelligent Cloud segment in 2025?"
# Шаг 1: Прокси-поиск — определяем раздел
proxy_candidates = proxy_index.search(query, top_k=2)
# Результат: ['Item 8.Financial Statements.Note 15.Segment Reporting', 'Item 7.Management Discussion.Segment Results']
# Шаг 2: Загружаем детальную структуру выбранного раздела
section_id = proxy_candidates[0]["pointer_to_section"]
detailed_section = load_detailed_index(section_id)
# Шаг 3: Точный поиск внутри раздела
pointer_candidates = detailed_section.search("Intelligent Cloud revenue 2025", top_k=3)
# Результат: прямая ссылка на ячейку таблицы
# Шаг 4: Формирование финального контекста для LLM
final_context = get_content_by_pointer(pointer_candidates[0]["pointer"])
answer = llm.generate(f"Context: {final_context}\n\nQuestion: {query}")
Роутер может быть обучен классифицировать типы запросов: "запрос по таблице", "запрос по определению", "сравнение показателей". Для каждого типа — своя стратегия работы с прокси и указателями.
Где спрятаны грабли: 5 ошибок, которые сведут точность к нулю
Архитектура выглядит стройно, но в деталях кроется дьявол.
- Слишком плоское дерево документа. Если вы не выявили реальную иерархию (например, не отличили заголовок таблицы от заголовка раздела), прокси-поиск будет путаться. Используйте комбинацию эвристик: размер шрифта, позицию на странице, повторяющиеся паттерны.
- Хрупкие указатели. Если pointer — это просто номер страницы, а документ может рендериться по-разному (например, на мобильном устройстве), вы промахнётесь. Используйте устойчивые идентификаторы: ID элементов в структурированном PDF/HTML, XPath, уникальные текстовые якоря.
- Прокси-индекс избыточен. Как уже говорилось, если вы проиндексируете в прокси весь текст, вы получите медленный и неточный гибридный поиск. Прокси должен быть картой, а не копией территории.
- Игнорирование конфликта контекста. Даже с точным указателем, LLM может "забыть" контекст и начать обобщать. Эта проблема подробно разобрана в статье "Конфликт контекста в RAG". Решение — жёсткий промпт-инжиниринг и maybe few-shot примеры, которые привязывают ответ к конкретному источнику.
- Отсутствие фолбэка. Если прокси-поиск не находит подходящий раздел (запрос слишком общий или документ нового типа), система должна иметь запасной план — обычный гибридный поиск по всему документу. Иначе пользователь получит "Извините, я не знаю".
На что способен Proxy-Pointer RAG помимо 10-K отчётов?
Финансовые отчёты — идеальный пример, но не единственный. Любой документ с чёткой структурой выигрывает от этого подхода:
- Юридические договоры: Поиск конкретных клауз (раздел "Termination" → пункт "Termination for Cause").
- Научные статьи: Поиск метода в разделе "Methodology" или конкретного результата в таблице "Experimental Results".
- Техническая документация API: Поиск параметра конкретного endpoint (раздел "/users" → параметр "filter_by").
Противопоказания: неструктурированные тексты (например, стенограмма совещания, письмо поддержки). Для них классический RAG или методы вроде HashIndex могут быть лучше.
Что дальше? Экспоненциальная сложность и выход за пределы одного документа
Proxy-Pointer RAG — это не конечная точка. Это шаг к системам, которые понимают документ как базу данных. Следующий логический этап — связывание указателей между документами. Вопрос "Сравните выручку Microsoft и Apple по сегментам в 2024 и 2025 годах" потребует прокси-поиска по двум 10-K отчётам, извлечения указателей на таблицы сегментов в каждом, и затем выполнения "join" операции на уровне LLM или специального модуля.
Сложность растёт экспоненциально, но и точность сравнений будет стремиться к 100%, потому что мы оперируем не "похожими абзацами", а конкретными ячейками данных.
Если вы дочитали до этого места, вы понимаете, что 100% точность — это не магия, а инженерная работа. Вы жертвуете простотой и универсальностью классического RAG, чтобы получить снайперскую точность в конкретной domain. Это trade-off. Но для enterprise, где ошибка в цифре стоит миллионов, такой trade-off — единственный возможный путь.
Мой прогноз на 2027 год: мы увидим появление специализированных "документных СУБД", которые нативно хранят документы как деревья с прокси и указателями, а не как коллекции векторов. И первый хайп вокруг RAG сменится хайпом вокруг "RAG-OLAP" систем для аналитики по корпоративным документам.