Демо-проект
ИИ-агент по базе знаний: ответ с проверенной цитатой
Ответ собирается только по найденным фрагментам документов и несёт дословную цитату с указанием источника.
разработка · Node.js · SQLite · OpenRouter API · RAG · bge-m3

Задача
Компании хочется «чат по своим документам»: сотрудник или клиент спрашивает обычными словами и получает ответ, а не ссылку на сорокастраничный регламент. Мешает одно: наивная обёртка над языковой моделью отвечает уверенно на любой вопрос, включая тот, ответа на который в документах нет. Придуманный срок гарантии или придуманная цена обходятся дороже, чем честное «в базе этого нет».
Демо собрано на восьми документах вымышленной столярной мастерской «Волокно»: регламент производства, материалы и отделка, прайс, доставка и подъём, гарантия, уход за изделием, замер и оплата, частые вопросы. Компания придумана целиком, контактов и персональных данных в базе нет - это демонстрация механики, а не выгрузка чьей-то настоящей документации.
Решение
Путь от документа до ответа.
Документ режется по разделам, раздел - по абзацам, абзацы упаковываются во фрагменты до 900 знаков. Фрагмент запоминает, из какого он документа, из какого раздела и какой у него номер из скольких: именно из этого потом собирается подпись под цитатой. Восемь файлов дали 53 фрагмента, средний 315 знаков, самый длинный 821.
В модель векторов уходит не голый фрагмент, а строка «документ. раздел. текст»: без заголовков абзац под названием «Сроки» не значит ничего. Векторы нормируются, поэтому близость считается скалярным произведением.
Индекс лежит в sqlite: три таблицы, вектор блобом. Под 53 фрагмента отдельную векторную базу поднимать незачем, а файл переживает перезапуск и открывается любым клиентом.
Вопрос превращается в вектор, сравнивается со всеми фрагментами и отсекается порогом близости; в ответ уходит пятёрка лучших. Сверху лежит ограничение «не больше трёх фрагментов из одного документа» - страховка на случай, когда один файл забирает всю выдачу. Что ограничение работает, показывает самопроверка на отдельном маленьком индексе.
Модель получает найденные фрагменты с указанием документа и раздела и правило: отвечать только по ним, ставить номер фрагмента после каждого утверждения, цитировать дословно. Ответ приходит объектом JSON.
Каждая цитата ищется в своём фрагменте подстрокой. Не сошлась - отбрасывается, и на странице видно, сколько цитат отброшено.
Три рубежа против выдумки
Первый - порог близости. Вопрос мимо базы не набирает ни одного фрагмента, и отвечающая модель не вызывается вообще: сочинять ей просто не из чего.
Второй - признак «ответ найден» в разборе ответа модели. Фрагменты нашлись, но ответа в них нет - агент говорит об этом прямо. В прогоне это видно на вопросе про рассрочку: порог прошли пять фрагментов, включая порядок оплаты, агент прочитал их и отказался придумывать условия рассрочки.
Третий - сверка цитат. Она страхует уже не от «не нашёл», а от красивой выдумки со ссылкой на настоящий документ.
Детали, которые легко не заметить
- Самопроверка гоняет 14 проверок механики без единого обращения к модели и без сети: подделанная цитата не проходит сверку, склейка кусков из разных мест фрагмента не проходит, ссылка на фрагмент вне выдачи не проходит, порог отсекает далёкое, один документ не занимает всю выдачу. 14 из 14.
- Сверка приводит цитату и фрагмент к общему виду по пробелам, тире и кавычкам - иначе проверка спотыкалась бы о типографику, а не о факты.
- Температура ответа выставлена в ноль: в запросе к модели нет ничего, что специально добавляло бы разброс в формулировки.
- Составной вопрос агент собрал из трёх документов: цену взял из прайса, доставку - из доставки, а вес щита - из материалов, чтобы понять, что место тяжелее 30 кг и подъём считается отдельно. Ни один документ в одиночку на такой вопрос не отвечает.
- Запросы к API идут с таймаутом и тремя попытками с нарастающей задержкой; ключ читается из файла окружения, в код не вписан и в вывод не попадает.
- Внешних зависимостей нет: хватает встроенных в Node запросов и sqlite. Модели переключаются переменными окружения, порог близости - тоже.
- Каждый заданный на странице вопрос дописывается в журнал прогонов, поэтому скриншот всегда снимается с настоящего ответа, а не с заготовки.
- Стенд поднимается локально, и каждый вопрос на странице - платный вызов модели. Поэтому открытой живой ссылки у него нет, а в разборе показаны записанные прогоны.
- Замеры сняты на одном прогоне и зависят от машины и сети: вектор вопроса 224-921 мс, поиск по 53 фрагментам 2-8 мс, ответ модели 1,1-1,8 с.
Скриншоты




