Как я делал базу знаний для ИИ‑агента и тестировал её на собственных медицинских анализах

от автора

Результаты годового чекапа пришли в пятницу вечером. Семь документов в личном кабинете лаборатории, в половине строк звёздочки «выше нормы» и «ниже нормы», запись к эндокринологу через две недели. Кто сдавал, тот знает этот вечер: гуглишь каждый показатель по отдельности, находишь ужасы, и нигде не написано, что все эти цифры значат вместе.

Работаю бэкендером, делаю базу знаний в Искре. Поэтому вместо поисковика открыл свой же продукт: загрузил туда все семь файлов и начал спрашивать.

Сразу про конфликт интересов: база знаний, о которой пойдёт речь, — часть продукта, который делаю я сам. Поэтому дальше будет не про то, какая она хорошая, а про устройство и про места, где она ломается.

Почему для теста понадобились именно свои анализы

На тестовых наборах такое проверять бесполезно. Когда система отвечает по чужому договору или выдуманному отчёту, у меня нет способа заметить ошибку: я этот документ не читал и читать не собираюсь, поэтому любой правдоподобный ответ выглядит правильным.

С анализами иначе. Свои цифры я к субботе знал наизусть, и если бы система написала, что ферритин в норме, я бы это увидел сразу.

Семь файлов в очереди на обработку. Часть система уже переименовала по содержимому, про это ниже.

Семь файлов в очереди на обработку. Часть система уже переименовала по содержимому, про это ниже.

Заодно эти данные хорошо ложились на проверку самой механики. Бланки разнородные: таблицы показателей с референсами, отдельно заключение УЗИ сплошным текстом, отдельно комментарии лаборатории. Термины при этом перекрывались между документами: ТТГ, ТПО, трансферрин и ферритин лезли то в один бланк, то в другой. И почти на любой осмысленный вопрос ответ приходится собирать из нескольких документов сразу. Найти похожий кусок текста мало, надо понять, что из чего следует.

Пара слов про механику, иначе дальше будет непонятно. Обычно «ответь по моим документам» работает так: файлы режут на чанки, считают эмбеддинги, под вопрос ищут близкие куски гибридным поиском (вектор плюс лексическая часть, в классическом варианте BM25), сверху иногда ставят реранкер. Это RAG, так устроено большинство баз знаний на рынке, и мы начинали оттуда же.

Сейчас, когда чат привязан к базе знаний, у нас работает другое. Документы заранее разложены так, чтобы по ним можно было ходить: агент открывает оглавление, грепает, читает файлы целиком, идёт по ссылкам между ними. Ближайшая понятная аналогия: примерно так разработчик разбирается в незнакомом репозитории. Все ответы дальше собраны этим режимом.

Что получилось спросить

Начал с простого вопроса, где надо посмотреть один показатель и ничего ни с чем не связывать.

Разбор одного показателя: зона, таблица факторов из разных бланков, дисклеймер.

Разбор одного показателя: зона, таблица факторов из разных бланков, дисклеймер.

Дальше вопрос, который в одном бланке не решается: может ли дефицит железа мешать щитовидке.

Три механизма и вывод, что это не два независимых процесса.

Три механизма и вывод, что это не два независимых процесса.

Чтобы такое собрать, надо прочитать бланк про железо, прочитать бланк про щитовидку и понять, что между ними есть связь. Ни в одном из файлов этой связи не написано.

Потом я попросил то, ради чего вообще всё это грузил: свести бланки вместе — какие показатели вышли за референсные интервалы и в каких файлах они лежат.

Сводка по файлам: показатель, результат, норма, направление отклонения.

Сводка по файлам: показатель, результат, норма, направление отклонения.

Тут сшиты все семь документов сразу. Такой таблицы нет ни в одном бланке, лаборатория выдаёт каждый по отдельности — её собирает агент, прочитав каждый файл.

А под ответом — список источников.

Семь источников с заголовками, которые система сочинила при загрузке. Один почему-то вышел латиницей.

Семь источников с заголовками, которые система сочинила при загрузке. Один почему‑то вышел латиницей.

Дальше про то, как это устроено внутри.

Как это разложено внутри

Если сжимать до одной фразы, база знаний у нас ближе к подготовленной файловой системе, чем к поисковому индексу. По ней ходят теми же ls, grep и read, что и по коду.

Начну с зеркал. Любой загруженный файл получает соседа, текстовый .md. Сверху у него YAML‑шапка с заголовком, типом, датой, коротким summary, тегами и указателем на оригинал, а дальше идёт тело, то есть извлечённый из документа текст. Для PDF, DOCX и картинок его добывает парсер или разбор vision‑моделью.

Кстати, про «умные» имена файлов со скрина загрузки. Это отдельный маленький проход модели: она смотрит начало извлечённого текста и придумывает документу заголовок в несколько слов. В заголовок попадают и детали из анамнеза, отсюда «донор» в названиях. Исходное имя файла уезжает в описание и на скрине загрузки видно строкой ниже: Результаты_260731-00847.pdf превратился в «Анализ крови донора железодефицитная анемия», а Результаты_260731-00848.pdf — в «Результаты биохимического анализа крови гемотест». На одном файле проход сломался и выдал заголовок латиницей — Analiz zheleza s zhelezodefitsitom u donora. Мелочь, но напоминает, что это обычная генерация со всеми её сюрпризами.

Смысл всей затеи с зеркалом простой: у каждого документа появляется человекочитаемое представление, которое агент открывает целиком, а не выдёргивает из него кусок в середине.

Когда зеркало готово, запускается ещё один проход модели, уже за связями. На вход она получает свежее зеркало и оглавление коллекции, на выход отдаёт summary, теги и список связанных документов инлайн‑ссылками [[docID]], как в вики.

Связи идут только вперёд: новый документ ссылается на уже лежащие в базе, старые не переписываются. Пришёл новый бланк — трогаем один его, а не всю коллекцию.

Дёшево тут, правда, только писать. Проход‑линковщик всё равно читает оглавление, а оно растёт вместе с базой, так что бесплатной вставки не получается. Обратные ссылки отдельно нигде не лежат, при необходимости они находятся обычным грепом по содержимому. На большой коллекции это тоже не подарок, скан идёт по всему дереву.

Дело тут не в том, что «вектор слепой». Иногда связь есть прямо текстом: комментарий к общему анализу крови отсылает к панели железа, и это находится обычным грепом. Но это везение формата. Между УЗИ щитовидки и дефицитом витамина D перекрёстных ссылок не пишет никто, а связь есть. Граф закрывает второй случай: он посчитан заранее и лежит явно, поэтому агент идёт по готовому ребру вместо того, чтобы надеяться, что два бланка с разными цифрами и терминами случайно окажутся рядом в выдаче.

Точка входа для агента — _index.md коллекции, и он двухуровневый. Сверху короткий список помесячных под‑оглавлений со счётчиками документов. Внутри каждого под‑оглавления строки документов: ссылка [[docID]], заголовок, теги, summary.

Агент первым делом открывает верхний уровень и видит, что вообще есть в базе, вместо того чтобы гадать вслепую. Побочная выгода: новый документ дописывает одну строку в оглавление своего месяца, общий список переписывать не нужно.

Ходит по всему этому агент так. Бэкенд монтирует ему дерево .md‑зеркал. Только те коллекции, к которым у профиля есть доступ (права проверяются на каждом запросе), и только на чтение. Дальше в ход идут нативные инструменты:

запрос пользователя     │     ▼  ls  ─► открыть оглавление коллекции (_index.md): что вообще есть     │  grep ─► найти документы по словам, кодам, аббревиатурам («ТТГ», «ферритин»)     │  read ─► прочитать целые зеркала найденных файлов     │  follow [[..]] ─► пройти по связям на смежные документы     │  (по нужде) оригинал ─► если нужен точный формат: таблица из Excel как есть     │     ▼  ответ со ссылками на файлы, которые агент прочитал

Вот как это выглядит в «ходе выполнения», когда просишь перечитать базу целиком:

Семь вызовов read подряд: путь до зеркала и счётчик прочитанных строк у каждого.

Семь вызовов read подряд: путь до зеркала и счётчик прочитанных строк у каждого.

Никакой выдачи поиска, файлы читаются целиком — семьдесят четыре строки, шестьдесят четыре, шестьдесят.

grep — построчный поиск регуляркой по содержимому. Точные токены вроде кодов, аббревиатур или «ТТГ 5.8» он находит без промаха. Только ровно этим в классическом гибридном RAG занимается лексическая половина, так что конкурируем мы тут с BM25, а вектор остаётся вообще ни при чём.

Морфологии у грепа при этом никакой. Он ловит только ту форму записи, которую ищет: «ферритин» и «ферритина» для него разные строки, «5.8» и «5,8» тоже. Расширять запрос словоформами и синонимами приходится самой модели по ходу навигации.

Настоящая разница с гибридным RAG в другом: агент читает документы целиком вместо чанков, ходит по заранее посчитанному графу и делает всё это за несколько шагов вместо одного запроса.

Раз агент открывал конкретные файлы, на них он и ссылается: «согласно анализу железа…». Пользователь видит, откуда взят факт, а нам это даёт место, где враньё становится заметным: если факта нет в прочитанных документах, цитировать его нечем.

Список источников под ответом собирает не модель, а сервер, из фактических read‑вызовов. Каждое прочитанное зеркало попадает в источники само, даже если модель забыла на него сослаться. Подделать этот список текстом ответа нельзя, он выводится из вызовов инструментов.

Цитирование не блокирует галлюцинации. Модель может честно сослаться на файл и написать то, чего в нём нет. От этого защищает промпт, запрещающий выходить за пределы прочитанного, и человек, который может кликнуть по ссылке. Серверный список показывает, куда агент смотрел, но не то, что он оттуда взял.

Старый RAG, кстати, никуда из кода не делся: чанки, эмбеддинги, гибрид вектора с полнотекстовым поиском Postgres (строго говоря, не BM25, а ts_rank_cd), опциональный реранкер. Но для чатов с подключённой базой знаний мы его выключили: агент в этом режиме даже не получает инструмент поиска по чанкам, только навигацию. Между собой мы зовём это «разворотом RAG», и поиск по чанкам сейчас висит кандидатом на удаление.

Выключили его не из‑за качества, дело в классе задач: там, где ответ собирается из нескольких файлов и важно показать источник, вырванные чанки проигрывают чтению целых документов. Медицинские анализы как раз такой класс.

Как агент забыл про собственную базу

Получилось всё случайно, специально я ничего не ломал.

В одном из прогонов я задал вопрос про сводку в свежем чате, открытом кнопкой «Обсудить» прямо из папки с анализами. И получил ответ, в котором не было ни одного обращения к базе.

«База знаний сейчас не подключена к этому чату — у меня нет доступа к /kb-files». Дальше вежливое предложение подключить базу или приложить файлы в чат.

«База знаний сейчас не подключена к этому чату — у меня нет доступа к /kb‑files». Дальше вежливое предложение подключить базу или приложить файлы в чат.

База при этом была подключена. Внизу окна висела плашка с названием папки, семь зеркал лежали готовые, а система объясняла мне, как её подключить. Ни одного ls, grep или read при этом не случилось.

Спросил в лоб: «ты не видишь прикреплённую бз с файлами?» Система спохватилась, пошла по дереву и прочитала документы: «База знаний подключена, вижу 7 документов».

Дальше пошёл нормальный ответ, та самая сводка по семи бланкам, с которой начиналась предыдущая глава. Данные, которые в ней появились, в переписке не звучали вообще: гемоглобин 118, витамин D 18, объём щитовидки 11.8 мл, чистый анализ мочи. Я ничего из этого не называл, взяться этому неоткуда, кроме зеркал документов.

На один и тот же вопрос получилось два ответа: в первом система сообщила, что данных у неё нет, во втором прочитала семь файлов. Разница между «отвечает из контекста чата» и «открыл документы» видна прямо на этих двух скриншотах.

Где оно сломалось

Главной проблемой оказалось само решение, идти ли в базу. Это отдельная задача, и агент на ней ошибается. Дерево смонтировано ему всегда, но лезть туда на каждую реплику дорого по вызовам и токенам, поэтому агент решает сам, идти по /kb-files или ответить из контекста диалога. На первом вопросе он решил, что идти некуда, и сообщил, что базы нет. Ошибка не в устройстве навигации, а в моменте её запуска. На игрушечных данных такое не поймать, на своих вылезло за минуты.

Мандата на момент этого прогона не было — агент решал сам. После случая в системный промпт для чатов с подключённой базой уехало требование заглянуть в /kb-files перед ответом, включая самый первый вопрос. Это снижает вероятность, а не убирает её: решение всё равно принимает модель.

Цитаты воспроизводятся не буква в букву. На скрине с трансферрином агент цитирует строку из бланка как «насышение трансферрина», хотя в файле написано «насыщение». Модель переписывает найденное своими токенами, и мелкие искажения проскакивают. Поэтому ссылка на исходный файл под ответом важнее самой цитаты.

Абстрактный запрос плохо превращается в grep. «Собери общую картину по всему» — это не термин и не код, тут нечего искать. Агенту приходится самому раскладывать абстракцию на зацепки: какие показатели грепать, по каким связям идти. В лоб он это делает не всегда, и лишние шаги такой запрос стоит.

Дальше главный минус подхода: по токенам он дороже RAG. Сложный вопрос в режиме навигации стоит, по нашим прикидкам, порядка 10–15 вызовов инструментов: агент лезет в оглавление, грепает, читает, идёт по графу, снова читает. Причём дело даже не в числе шагов, а в том, что стоит за ними: агент читает документы целиком, и каждый шаг — отдельный вызов модели по растущему контексту, куда накапливается вся история навигации. Разрыв с RAG (один поисковый запрос плюс одна генерация) больше, чем кажется по счётчику шагов.

Это оценка порядка величины, а не измеренный бенчмарк. Латентность и стоимость мы ещё будем мерить на реальном объёме.

Граф неполон и врёт. Модель пропускает связи и выдумывает идентификаторы документов, которых нет; выдуманные отсекаются сверкой со списком существующих, а от пропусков спасает только то, что связи — подсказка, а не единственный путь: базовым слоем всегда лежит греп.

Остаётся масштаб, и самое очевидное возражение я назову сам. Семь бланков по четыре килобайта влезают в контекст целиком: тут не нужен ни RAG, ни навигация, склей всё и отвечай. Так и есть. На этом объёме статья показывает не выигрыш подхода, а его механику и места, где он ломается. Выигрыш надо мерить на сотнях документов, и мы этого пока не сделали.

Как поведёт себя навигация на такой базе, я не знаю: ls по плоскому дереву зеркал станет большим, греп пойдёт по всей коллекции, число шагов вырастет. Помесячные под‑оглавления придуманы под этот рост, но на масштабе не проверены.

Есть ещё одна граница, уже не техническая, и ведёт она себя непредсказуемо. На части прогонов система отказывается разбирать показатель вообще.

«Я не интерпретирую медицинские показатели и не оцениваю, опасно ли значение. Обратитесь к врачу». Ниже — цитата из файла с тем же значением ТТГ.

«Я не интерпретирую медицинские показатели и не оцениваю, опасно ли значение. Обратитесь к врачу». Ниже — цитата из файла с тем же значением ТТГ.

Модель знает, что такое ТТГ, и, как видно по скринам выше, в других прогонах разбирает его абзацами. Разница в политике: у нас есть встроенный скилл grounding с правилами осторожного ответа в рискованных доменах, и внутри него три инструмента — legal_guidance, medical_guidance, business_guidance. Распознав медицинский вопрос, агент вызывает medical_guidance и получает правила: структурируй только факты из источников этого чата, без источника не интерпретируй показатели, диагноз и риск, не назначай лечение и обследования, при экстренных симптомах отправляй в 112.

Политика тут не текст, вшитый в промпт навсегда, а инструмент, который агент вызывает по ситуации, как read или grep. И вот это «по ситуации» — честная дыра: решение вызвать medical_guidance принимает та же модель, поэтому на одном и том же вопросе можно получить и подробный разбор, и короткий отказ. У меня на семи файлах вышло и то и другое, разница была только в том, что я спрашивал перед этим.

Правильным я считаю строгий режим: цена ошибки в медицинской интерпретации несопоставима с ценой скучного демо. Но пока это вероятность, а не гарантия, и делать вид, что граница жёсткая, нечестно.

Куда уходят данные

Здесь легко спутать две вещи. Хранилище (файлы, зеркала, метаданные графа) лежит в Postgres и объектном хранилище, его размещение контролируемо. Инференс — отдельная ось: модель, читающая эти файлы, в облачном сценарии может вызываться через внешний API, и «всё в одном контуре» перестаёт быть данностью.

Поэтому для чувствительных документов размещение инференса выбирают осознанно, а не по умолчанию. Мои бланки — демонстрационные, и это единственная причина, по которой я спокойно гонял их через облако.

Чем всё кончилось

За ответом по документам со ссылкой на источник стоит конвейер: зеркало каждого документа, граф связей [[...]], оглавление как точка входа и агент, который по всему этому ходит и цитирует прочитанное. Классический RAG с чанками остаётся разумным дефолтом, когда нужен один факт из одного файла. В нашей базе знаний он конкуренции не выдержал: там, где ответ собирается из нескольких документов и важна проверяемость, чтение целых файлов оказалось сильнее.

Самой недооценённой частью всей конструкции оказалось решение, идти ли в базу вообще. На нём я и поймал собственную систему, после чего мандат «сначала загляни в базу» переехал в системный промпт.

Анализы я в итоге всё равно понёс врачу, и диагноз ставил он. ИИ сделал другое: свёл семь бланков в одну таблицу и показал, в каком файле лежит каждая строчка, так что на приём я пришёл со списком вопросов вместо папки непонятных бумаг. Дальше его работа заканчивается

ссылка на оригинал статьи https://habr.com/ru/articles/1066856/