
В этой статье рассказываю об инженерной реализации Q&A без галлюцинаций в AI-читалке: ответы строго опираются на текст открытой книги, а ключевые утверждения можно проследить в один клик до конкретного фрагмента. Если вы делаете AI-чтение, document QA или RAG-приложения, надеюсь, три итерации опыта и финальная архитектура будут полезны.
I. Практический путь: эволюция в три этапа
Q&A без галлюцинаций не был спроектирован идеально с первого дня — он эволюционировал под давлением стоимости, задержки и точности. Ниже — хронологический обзор трёх этапов, чтобы понять, почему текущая архитектура выглядит именно так.
Этап 1: Весь текст в Context (самый простой — и первый, кто сломался)
Подход: Когда пользователь открывает книгу и задаёт вопрос, весь извлечённый основной текст помещается в System Prompt или User-сообщение, и диалоговая модель отвечает. Если книга превышает примерно 400 000 символов, выполняется жёсткое обрезание — остаётся только начало, последующие главы для модели невидимы.
Плюсы:
- Очень низкая стоимость реализации, почти без предобработки;
- На коротких книгах и простых документах работает неплохо — модель действительно «видела всю книгу»;
- Простой UX: спросил — получил ответ, без состояния «подождите, пока мы анализируем».
Минусы (быстро становятся неприемлемыми):
- Медленные ответы: Каждый вопрос заново отправляет огромный объём текста; задержка до первого токена и общее время растут с длиной книги;
- Высокая стоимость токенов: За каждый вопрос вы платите за полный ввод текста книги;
- Длинные книги сильно искажаются: После 400 000 символов вторая половина, приложения и заключения для модели как бы не существуют — и UI часто не сообщает явно, что произошло обрезание;
- Нулевая гранулярность поиска: Модель должна «найти иголку в стоге сена» среди сотен тысяч символов — легко упустить детали и проще получить правдоподобные обобщения без оснований — именно то, чего сценарий чтения должен избегать.
Этап 1 подходит для MVP, но не для продуктового решения.
Этап 2: Лёгкий LLM извлекает ключевые предложения (сжатие Context — но слишком агрессивное)
Подход: Перед Q&A (или при первом открытии книги) более дешёвая модель обрабатывает основной текст: разбивает по главам Spine (или сегментирует всю книгу), извлекает ключевые предложения, сохраняет метки позиций вида [fфайл-начало-конец], затем склеивает выдержки в более короткий Context для последующего Q&A.
Типичный пайплайн: Extract → Cache → Chat. Сначала один раз (офлайн или по запросу) выполняется извлечение и сохранение «набора ключевых предложений», затем он переиспользуется при каждом вопросе — та же идея, что во многих прототипах document QA: сначала сжать документ, потом отвечать.
Плюсы:
- При каждом вопросе в модель уходит значительно меньше текста; расход токенов на запрос заметно ниже, чем на этапе 1;
- Результат предобработки можно кэшировать — для одной книги не нужно извлекать заново при каждом вопросе;
- Уже введены метки позиций — основа для последующей трассировки.
Минусы (на длинных книгах всё ещё не выдерживает):
- Массовая потеря деталей: «Ключевые предложения» отбирает модель субъективно — ограничения, контрпримеры и звенья аргументации легко теряются, ответы становятся «верными, но односторонними»;
- Context на длинных книгах всё ещё велик: Даже только ключевые предложения для крупных произведений дают заметный объём — задержка и стоимость снижаются, но не решаются;
- Двойная ошибка LLM: На этапе извлечения что-то может быть пропущено, на этапе Q&A выдержки могут быть неверно прочитаны — ошибки накапливаются;
- Статический Context: Независимо от того, спрашивает ли пользователь о деталях одной главы или о структуре всей книги, модель всегда получает один и тот же предварительно извлечённый текст — без динамического сужения по вопросу.
Урок этого этапа ясен: дело не в «сжимать или нет», а в том, сжимается ли контент по запросу и можно ли вернуться к исходному тексту.
Этап 3: Индекс сегментов + Tool-поиск по запросу + возврат исходного текста (текущая схема)
Подход: Основная идея заимствована из PageIndex. По сравнению с этапом 2 три ключевых изменения:
- Результат предобработки — структурированный индекс (краткие описания на уровне оглавления + точные символьные span), а не выдержки, используемые напрямую как Context для Q&A;
- При каждом вопросе модель через Tool Calling ищет по запросу, затем подтягивает исходный текст с метками позиций для ответа;
- System Prompt и фронтенд совместно задают формат цитирования и поддерживают переход по сноскам с подсветкой исходного текста.
Сравнение трёх этапов:
| Измерение | Этап 1 (весь текст) | Этап 2 (ключевые предложения) | Этап 3 (текущий) |
|---|---|---|---|
| Context на один вопрос | Вся книга (или обрезанная первая половина) | Предварительно извлечённые ключевые предложения | Только исходные фрагменты, релевантные вопросу |
| Точность на длинных книгах | Резко падает после ~400k символов | Зависит от качества извлечения, теряются детали | Поиск по оглавлению/span, без жёсткого обрезания всей книги |
| Скорость ответа | Медленно | Немного лучше; длинные книги всё ещё медленно | Поиск + короткий Context — заметно быстрее |
| Стоимость токенов | Очень высокая | Средне-высокая | Амортизированная предобработка + оплата по запросу |
| Трассируемость | Слабая (сложно указать источник) | Метки есть, но контент уже вторично отфильтрован | Сноски соответствуют реальным исходным span |
| Инженерная сложность | Низкая | Средняя | Высокая |
Почему остановились на этапе 3: Для сценария чтения «нулевая галлюцинация» — это не «показать модели как можно больше текста», а «перед ответом получить исходные доказательства, релевантные вопросу». Этапы 1–2 боролись с объёмом Context; этап 3 разбивает цепочку на «индекс (предобработка) → поиск (Tool) → доказательства (исходный текст) → ответ (ограниченная генерация)» — и одновременно балансирует точность, стоимость и трассируемость.
Ниже — детали реализации этапа 3.
II. Постановка задачи: в Q&A по книге галлюцинации опаснее, чем в обычном Chat
В обычном ChatBot пользователи часто терпят случайные ошибки. В Q&A по книге цена выше:
- Пользователь спрашивает, что говорит эта книга, а не что хранится в parametric memory модели;
- Одно правдоподобное «мнение из книги» может ввести в заблуждение заметки, цитаты и повторные публикации;
- Без указания источника пользователь не может проверить — доверие к продукту сложно построить.
Поэтому «нулевая галлюцинация» в инженерии сводится к трём исполнимым правилам:
- Вопросы о книге сначала ищут в книге: Всё, что может относиться к открытой книге, модель должна сначала прогнать через поиск (Tool), затем формировать ответ;
- Ответы должны быть трассируемы: Ключевые выводы сопровождаются метками позиций в исходном тексте, которые фронтенд может разобрать и по которым можно перейти с подсветкой;
- Если не нашли — так и сказать: Если в книге этого нет, нужно явно сообщить, а не выдавать общие знания за «мнение из книги».
Далее — по потоку данных этапа 3, как эти правила реализуются.
III. Общая архитектура: предобработка → Tool-поиск → ограниченная генерация → кликабельная трассировка
Ключевая идея: не давать модели «отвечать по памяти» — заставить её «сначала собрать доказательства, потом ответить и указать источники».
IV. Предобработка: превращаем всю книгу в «индекс сегментов» для поиска
Если при каждом вопросе использовать Context всей книги как на этапе 1, длинные книги неизбежно переполнят бюджет токенов, а гранулярность поиска будет слишком грубой. Решение этапа 3: при первом AI-диалоге с книгой в фоне запускается задача кратких описаний сегментов — книга делится по структуре оглавления или длине текста на несколько Segment, для каждого генерируется краткое описание, результат сохраняется локально в IndexedDB.
Каждый Segment в структуре данных содержит краткое описание и физическую позицию в основном тексте:
| Поле | Значение |
|---|---|
startFileIndex / endFileIndex | Индекс файла Spine (для PDF — один файл на страницу) |
startOffset / endOffset | Символьные смещения начала и конца |
sequence | Линейный порядок чтения |
title | Заголовок из оглавления |
Стратегия разбиения балансирует точность и стоимость: если основной текст одного узла оглавления не превышает ~20 KB, суммируется только этот узел; узлы одного уровня могут объединяться в пакеты (15–20 KB) перед вызовом LLM; крупные блоки без оглавления режутся интервалами ~30–40k символов.
System Prompt при генерации кратких описаний требует сохранять встроенные метки позиций (формат [fчисло-число-число]), чтобы при возврате исходного текста через Tool информация о позиции совпадала со смещениями символов Spine. Основное ограничение:
Если содержание краткого описания связано с фрагментом исходного текста, сохраните метку позиции в конце фрагмента в формате [fчисло-число-число] (например, [f1-90-109]).
Метка позиции — единое целое; запрещено изменять, объединять или опускать любой символ или цифру.
После предобработки Q&A опирается не на «Context всей книги», а на структурированный индекс сегментов — инженерная предпосылка «нулевой галлюцинации» для длинных книг.
V. Система меток позиций: кодируем «источник» в текст
«Нулевая галлюцинация» требует не только контент из исходного текста, но и машинно разбираемый, переходимый в UI источник. Используем встроенные метки:
[f{fileIndex}-{startChar}-{endChar}]
Например, [f5-123-165] означает: в 5-м файле Spine (с нуля) символьный интервал 123–165.
5.1 Как метки записываются в основной текст
Слой извлечения текста при выводе фрагментов дописывает в конец каждого небольшого сегмента [f{fileIndex}-{start}-{end}]. Схема:
const position = `[f${fileIndex}-${absOffset}-${absOffset + segment.length}]`;
fileLines.push(segment.text.trim() + position);
И в кратких описаниях предобработки, и в выдержках исходного текста, возвращаемых Tool, позиции выровнены по символьным смещениям Spine, а не по «оценочным номерам страниц» модели.
5.2 Ограничения на вывод модели
При сборке System Prompt отдельно задаются Position Citation Rules — пять основных пунктов:
- Стандартный формат: Обязательно
[f_fileIndex-startChar-endChar]; все три числовые части обязательны; - Только из текущих источников: Сноски должны быть дословно скопированы из System/User-сообщений или возвратов Tool текущего хода;
- Запрет подделки: Не вычислять, не изменять и не выдумывать позиции;
- Лучше опустить: Если в контексте нет допустимой метки — отвечать нормально, не выводить метки позиций;
- Сразу после утверждения: Метки следуют за соответствующим предложением; запрещены списки цитат в конце текста.
Перед отображением фронтенд также фильтрует случайные двухчастные недопустимые метки (например, [f1-293]), чтобы в UI не попадали битые сноски.

VI. Tool Calling: сначала поиск, потом ответ
Когда диалог привязан к книге (есть resourceId, chatType === 'chat'), перед каждой генерацией регистрируются два Tool с соответствующими executor — стандартный цикл function calling в стиле OpenAI.
6.1 get_related_segment_summaries — целевой поиск по сегментам
Для: концепций, персонажей, сюжета, деталей глав — явное намерение поиска.
Краткий поток:
- Модель перефразирует разговорную формулировку пользователя в термины, вероятно встречающиеся в книге («Optimize Search Queries» в System Prompt);
- Вызов Tool с параметром
question; - Все краткие описания сегментов батчами по бюджету токенов (~30k токенов на батч, максимум 5 батчей);
- Каждый батч — отдельный LLM-запрос: из списка
{ id, title, summary }выбираются релевантные ID сегментов (максимум 5), возвращается JSON вида{"Thinking":"...","answer":["1","3"]}; - По span выбранных Segment из Spine подтягивается исходный текст с метками позиций (не краткое описание) как результат Tool.
Ключевое проектное решение: Tool возвращает исходный текст, а не краткие описания. Модель отвечает по реальным абзацам со встроенными [f…], избегая дрейфа «краткое описание → повторное обобщение».
6.2 get_full_book_segment_summaries — вопросы об обзоре всей книги
Для: «кратко перескажи книгу», «оцени эту книгу», «общая структура / темы» — глобальный обзор.
Склеиваются поля summary всех сегментов в порядке чтения — чтобы не пропустить ключевые главы из-за отбора только по релевантности отдельных фрагментов.
6.3 System Prompt: книга в приоритете, инструменты в приоритете
При привязанной книге применяется Core Principles for Reading Assistant:
1. Book First, Tool First
- Любой вопрос, который может относиться к книге, сначала требует вызова Tool;
- Ответ должен опираться в основном на результаты поиска — запрещено выдумывать «содержание книги» без поиска.
2. General Knowledge as Fallback Only
- Только для: свободной беседы / явного отказа пользователя от книги / пустого результата Tool;
- Если в книге этого нет — сначала заявить «в этой книге это не упоминается», затем при необходимости добавить общие знания.
3. Direct Style
- Сразу к сути — без «на основе предоставленных материалов…», «в заключение…» и подобных шаблонов.
Слой генерации реализует стандартный цикл Tool: tool_calls → выполнение executor → добавление role: tool → продолжение запроса до финального текста. При включённых tools канал thinking отключён, чтобы избежать конфликта с протоколом function call.
VII. Трассировка на фронтенде: от сноски к подсветке исходного текста
Вывод модели [f5-123-165] не показывается как есть — слой рендеринга превращает его в кликабельные цитаты.
7.1 Отрисовка сносок
Перед показом метки позиций нормализуются в Markdown-ссылки, например [1]([f5-123-165]), затем отображаются как нумерованные сноски; при повторении одной позиции — дедупликация, чтобы не загромождать UI.
7.2 Клик
- Первый клик: Разбор
[f…]→ fileIndex и смещения символов → извлечение текста из Spine → всплывающий предпросмотр (опционально с заголовком из оглавления); - Повторный клик по той же сноске: Закрыть предпросмотр;
- Подтверждение перехода: Открыть вид чтения, подсветить символьный интервал.
От скопированной моделью метки до текста, который видит пользователь, цепочка не проходит через повторный вызов LLM — полностью детерминирована и воспроизводима.
VIII. Граничные случаи и честная деградация
«Нулевая галлюцинация» ≠ «всегда есть ответ» — это нет доказательств, нет выдумки:
| Сценарий | Поведение |
|---|---|
| Краткие описания сегментов ещё не готовы | Сначала извлечь полный текст и выполнить суммирование |
| Tool ничего не нашёл | Вернуть (No relevant segment excerpts found…); модель должна заявить, что в книге не упоминается |
| Модель вывела недопустимую двухчастную метку | Фронтенд фильтрует; битые сноски не показываются |
| Свободная беседа пользователя | System Prompt разрешает общие знания вне книги |
| Экспорт диалога | Сноски можно превратить в deep link читалки для sharing или архива |

IX. Компромисс проектирования: почему не «векторный RAG»?
Коллеги, делающие document QA, часто спрашивают: если вы делаете retrieval-augmented generation, почему не Embedding + векторная БД Top-K?
На самом деле мы тоже делаем RAG — перед каждым ответом сначала ищем в книге, потом генерируем. Разница в том, что в сообществе «RAG» часто подразумевает векторизацию и поиск по сходству; текущая схема — «индекс сегментов + Tool с подтягиванием исходного текста по запросу» (этап 3), намеренно без векторного слоя. Ниже — архитектурные причины выбора, а не отрицание ценности векторного RAG.
Определение границ: не «без поиска», а «без векторного поиска»
- Широкий RAG: найти материалы → сгенерировать → мы это делаем.
- Векторный RAG: recall через сходство Embedding → в текущей версии не делаем.
Предобработка всей книги даёт индекс кратких описаний сегментов; при вопросе модель через Tool выбирает сегменты, затем получает исходный текст. Retrieval-augmented generation есть, но без отдельной embedding-модели и поддержки векторного индекса.
Причина 1: поддержка пользовательских LLM Provider — минимальная цепочка конфигурации
Продукт позволяет подключать собственный API Key, custom Base URL или локальный Ollama — диалоговая модель на выбор пользователя, контроль стоимости и пути данных. Для многих self-hosted сценариев и сравнения моделей это жёсткое требование.
Типичный векторный RAG заметно расширяет поверхность интеграции:
- Помимо Chat-модели обычно нужна Embedding-модель (другое имя model, иногда другой endpoint);
- Локальный Ollama требует отдельно подтянуть embedding-модель и решать вопросы размерности и совместимости API;
- Расширяется область отказов: Chat работает, но поиск пуст — возможны проблемы embedding, индекса или несовпадения размерности; отладка сложнее, чем «один Provider на всю цепочку».
В текущей схеме выбор сегментов и ответ используют одну конфигурацию Provider — без «Chat на A, индекс на B». Для приложений с подключаемым LLM это часто важнее нескольких процентов recall.

Причина 2: Embedding жёстко привязан к индексу — смена Provider дорога
В векторном RAG часто недооценивают: вектор — не универсальный промежуточный формат, а координаты в пространстве конкретной embedding-модели. Индекс построен моделью A, запрос моделью B — сходство обычно несопоставимо; смена модели часто означает полную повторную векторизацию книги, а размерности (768 / 1024 / 1536 …) фиксируют схему хранения.
На этапе 3 сохраняются структурированные краткие описания + символьные span, не векторы; при смене Chat-модели индекс перестраивать не нужно, цепочка доказательств (позиции в исходном тексте) не меняется — это лучше согласуется с целью «в любой момент сравнивать разные LLM».
Причина 3: для длинных документов с оглавлением структурированная маршрутизация часто достаточна
Электронные книги и PDF обычно имеют главную структуру; предобработка даёт заголовки сегментов + краткие описания. Для вопросов «что в главе X» или «как книга определяет Y» выбор сегментов из каталога и подтягивание исходного текста на практике работает стабильно; Tool возвращает исходный текст с [f…], и «нулевая галлюцинация» по-прежнему привязана к символьным span.
Векторный поиск силён при семантической неоднозначности, кросс-языковых запросах, длинных абзацах без буквального совпадения; для читалки с оглавлением, предобработкой и сильной трассируемостью ROI выше, когда сложность вкладывается в Tool + возврат исходного текста + правила цитирования.
Дальнейшее направление: гибридный recall, а не переписывание с нуля
Не исключаем в будущем грубый векторный recall (например, embedding только для Top-N кандидатов глав), с финалом всё равно в выбор сегмента → исходный текст → кликабельная трассировка — правила «нулевой галлюцинации» не меняются. При внедрении постараемся: Embedding опционален, при смене модели — явное предупреждение о перестройке индекса, без silent wrong retrieval.
До тех пор приоритет: работает любой OpenAI-совместимый Chat API; смена Chat-модели не требует перестройки локального индекса.
X. Итог
| Этап | Средство | Роль |
|---|---|---|
| Предобработка | Разбиение по оглавлению/длине + кэш кратких описаний сегментов | Длинные книги доступны для поиска и локализации |
| Метки позиций | [fфайл-начало-конец] в исходном тексте | Машинно разбираемый источник |
| Tool-поиск | По вопросу — сегменты / краткие описания всей книги, возврат исходного текста | Принудительный сбор доказательств перед ответом |
| System Prompt | Книга в приоритете, запрет поддельных сносок, честность при отсутствии | Ограничение генерации |
| Фронтенд | Сноска → предпросмотр → переход и подсветка | Пользователь проверяет доказательства |
| Без векторного поиска | Один Provider; смена Chat-модели без перестройки индекса | Ниже стоимость интеграции и миграции |
«Нулевая галлюцинация» — не надежда, что модель никогда не ошибается, а инженерная фиксация вывода на цепочке доказательств: нет результата поиска — не выдавать себя за содержание книги; есть результат поиска — дать проверяемые позиции в исходном тексте.
Если вы делаете AI-чтение или document QA, надеюсь, путь полный текст → ключевые предложения → Tool-first поиск по запросу, а также подход встроенные метки позиций + возврат исходного текста будет полезной реализацией для ориентира.
Это наш опыт разработки AI-читалки Foxycape — только для справки. Попробовать читалку можно на странице загрузки.
