Назад к блогу

Как я реализовал «нулевую галлюцинацию» в Q&A читалки

Инженерные заметки о Q&A без галлюцинаций в AI-читалке — ответы строго на основе текста открытой книги, с переходом к точным фрагментам в один клик.

Обложка: Q&A без галлюцинаций

В этой статье рассказываю об инженерной реализации Q&A без галлюцинаций в AI-читалке: ответы строго опираются на текст открытой книги, а ключевые утверждения можно проследить в один клик до конкретного фрагмента. Если вы делаете AI-чтение, document QA или RAG-приложения, надеюсь, три итерации опыта и финальная архитектура будут полезны.


I. Практический путь: эволюция в три этапа

Q&A без галлюцинаций не был спроектирован идеально с первого дня — он эволюционировал под давлением стоимости, задержки и точности. Ниже — хронологический обзор трёх этапов, чтобы понять, почему текущая архитектура выглядит именно так.

mermaid
flowchart LR
    P1[Этап 1: Полный текст в контекст] --> P2[Этап 2: LLM извлекает ключевые предложения]
    P2 --> P3[Этап 3: Индекс сегментов + Tool-поиск]
    P1 -.->|Медленно, дорого, неточно на длинных книгах| X1[Отказ]
    P2 -.->|Потеря деталей, всё ещё медленно| X2[Отказ]
    P3 -->|Текущая схема| OK[Нулевая галлюцинация + трассируемость]

Этап 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 три ключевых изменения:

  1. Результат предобработки — структурированный индекс (краткие описания на уровне оглавления + точные символьные span), а не выдержки, используемые напрямую как Context для Q&A;
  2. При каждом вопросе модель через Tool Calling ищет по запросу, затем подтягивает исходный текст с метками позиций для ответа;
  3. 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 модели;
  • Одно правдоподобное «мнение из книги» может ввести в заблуждение заметки, цитаты и повторные публикации;
  • Без указания источника пользователь не может проверить — доверие к продукту сложно построить.

Поэтому «нулевая галлюцинация» в инженерии сводится к трём исполнимым правилам:

  1. Вопросы о книге сначала ищут в книге: Всё, что может относиться к открытой книге, модель должна сначала прогнать через поиск (Tool), затем формировать ответ;
  2. Ответы должны быть трассируемы: Ключевые выводы сопровождаются метками позиций в исходном тексте, которые фронтенд может разобрать и по которым можно перейти с подсветкой;
  3. Если не нашли — так и сказать: Если в книге этого нет, нужно явно сообщить, а не выдавать общие знания за «мнение из книги».

Далее — по потоку данных этапа 3, как эти правила реализуются.


III. Общая архитектура: предобработка → Tool-поиск → ограниченная генерация → кликабельная трассировка

mermaid
flowchart TB
    subgraph prep [Офлайн / первая предобработка]
        A[Разбить книгу по оглавлению или длине] --> B[LLM генерирует краткие описания сегментов]
        B --> C[Локальное сохранение кэша Segment]
    end

    subgraph ask [Вопрос пользователя]
        D[Ввод пользователя] --> E{Есть кэш Segment?}
        E -->|Нет| F[Извлечь полный текст / предложить предобработку]
        F --> prep
        E -->|Да| G[Зарегистрировать Tool Calling]
    end

    subgraph retrieve [Tool-поиск]
        G --> H{Тип вопроса}
        H -->|Обзор / рецензия| I[get_full_book_segment_summaries]
        H -->|Факты / персонажи / глава| J[get_related_segment_summaries]
        J --> K[LLM выбирает ID сегментов из каталога кратких описаний]
        K --> L[Получить исходный текст по span + метки позиций]
        I --> M[Склеить краткие описания всех сегментов]
    end

    subgraph answer [Генерация и отображение]
        L --> N[Результаты Tool обратно в модель]
        M --> N
        N --> O[System Prompt: правила цитирования]
        O --> P[Потоковый ответ + сноски позиций]
        P --> Q[Отрисовка кликабельных сносок]
        Q --> R[Клик → предпросмотр → переход и подсветка]
    end

Ключевая идея: не давать модели «отвечать по памяти» — заставить её «сначала собрать доказательства, потом ответить и указать источники».


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 — пять основных пунктов:

  1. Стандартный формат: Обязательно [f_fileIndex-startChar-endChar]; все три числовые части обязательны;
  2. Только из текущих источников: Сноски должны быть дословно скопированы из System/User-сообщений или возвратов Tool текущего хода;
  3. Запрет подделки: Не вычислять, не изменять и не выдумывать позиции;
  4. Лучше опустить: Если в контексте нет допустимой метки — отвечать нормально, не выводить метки позиций;
  5. Сразу после утверждения: Метки следуют за соответствующим предложением; запрещены списки цитат в конце текста.

Перед отображением фронтенд также фильтрует случайные двухчастные недопустимые метки (например, [f1-293]), чтобы в UI не попадали битые сноски.

Всплывающее окно трассировки цитаты


VI. Tool Calling: сначала поиск, потом ответ

Когда диалог привязан к книге (есть resourceId, chatType === 'chat'), перед каждой генерацией регистрируются два Tool с соответствующими executor — стандартный цикл function calling в стиле OpenAI.

Для: концепций, персонажей, сюжета, деталей глав — явное намерение поиска.

Краткий поток:

  1. Модель перефразирует разговорную формулировку пользователя в термины, вероятно встречающиеся в книге («Optimize Search Queries» в System Prompt);
  2. Вызов Tool с параметром question;
  3. Все краткие описания сегментов батчами по бюджету токенов (~30k токенов на батч, максимум 5 батчей);
  4. Каждый батч — отдельный LLM-запрос: из списка { id, title, summary } выбираются релевантные ID сегментов (максимум 5), возвращается JSON вида {"Thinking":"...","answer":["1","3"]};
  5. По 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 Клик

  1. Первый клик: Разбор [f…] → fileIndex и смещения символов → извлечение текста из Spine → всплывающий предпросмотр (опционально с заголовком из оглавления);
  2. Повторный клик по той же сноске: Закрыть предпросмотр;
  3. Подтверждение перехода: Открыть вид чтения, подсветить символьный интервал.

От скопированной моделью метки до текста, который видит пользователь, цепочка не проходит через повторный вызов 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.

Пользовательские AI-провайдеры


Причина 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 — только для справки. Попробовать читалку можно на странице загрузки.