RAG для ассистента портфолио: 43 КБ промпта свернулись в 5 КБ
Ассистент, который отвечает на этой странице, до конца июля работал так: вся справка о мне, 43 КБ текста, уезжала в промпт на каждый вопрос гостя. Работало, но с двумя изъянами. Кейсы сайта в промпт не попадали вообще, поэтому про свои же проекты бот отвечал общими словами. А один раз он собрал ответ из чужого раздела: на вопрос про тесты портфолио выдал цифру проекта под NDA, потому что нужного числа рядом не нашлось, а похожее лежало абзацем выше.
Починил это через RAG (retrieval-augmented generation): перед ответом ищу подходящие куски своих текстов и подкладываю в промпт только их. Ниже про то, как этот RAG устроен, что показали замеры и на какие грабли я успел наступить.
Как это устроено
Корпус собирается из того, что реально опубликовано, а не из файлов репозитория. Сервис забирает
llms-full.txt с боевого адреса, разбирает на документы и режет по секциям: страницы сайта плюс
разделы справки дают пару сотен кусков, и число меняется при каждой публикации. Индекс не может разойтись с тем, что видит гость, потому что
источник у них один.
Поиск гибридный. Векторный ищет по смыслу, полнотекстовый по словам, результаты сливаются
через reciprocal rank fusion. Одного мало: на вопрос «дай адрес сайта для тренеров» вектор
уверенно тащит рассуждения про офлайн-синхронизацию, а точную строку trainerpad.ru находит
именно полнотекстовый.
Векторной базы здесь нет, и это осознанно. На 216 кусках косинус по матрице в памяти считается быстрее, чем занял бы сетевой вызов к Qdrant, а память на сервере считана: там живут четыре чужих прода. Так что всё лежит в SQLite, вектора в BLOB, полнотекст на FTS5. Слой хранилища спрятан за двумя методами, поэтому если корпус вырастет, pgvector подставляется без переписывания поиска.
Эмбеддинги считает внешний сервис. Локальная модель на этом железе не влезает: torch с мультиязычной моделью просит около двух гигабайт при 880 МБ свободных.
Что показали замеры
Сделал набор из 45 вопросов, сформулированных так, как их задаёт живой человек, а не заголовком документа. Четыре группы: базовые вопросы про меня, вопросы по каждому кейсу, провокации на NDA и ловушки, на которых бот раньше путал проекты. Для каждого записано, какой документ обязан найтись.
Итог на бою: recall@5 равен единице, поиск отвечает за 327 миллисекунд по девяносто пятому процентилю, полный ответ бота приходит за 1,7 секунды. Промпт вместо 43 КБ содержит ядро справки на 1,6 КБ плюс найденное, около 5 КБ. Ловушка с тестами теперь даёт «180 на фронте и 46 на бэкенде ассистента» вместо прежней суммы, куда приплюсовывались 758 тестов чужого проекта.
Отдельно проверяется, что в собранном контексте нет строк, которые разглашать нельзя. Это гейт: не метрика для отчёта, а условие выкатки. Пока он красный, поиск не включается.
Чему это научило
Первый же прогон по живым данным нашёл дырку, которую я закрывал двумя днями раньше. Отрасль работодателя закрыта под NDA, из справки ассистента я её вычистил, а на страницах сайта она осталась открытым текстом: в заголовке раздела опыта, в резюме, в тексте одного кейса, плюс в PDF и в llms-файлах. Поиск честно нашёл опубликованное и положил в контекст. Запрет в промпте при открытой странице ничего не защищает, он только создаёт ощущение защиты. Вычистил из пяти файлов, пересобрал PDF, отправил страницы на переобход.
Вторая находка неприятнее, потому что ошибка была в моей же метрике. Проверку приватности я сначала написал так: у верхнего результата должна стоять метка «под NDA». Метрика показала двадцать процентов и держала гейт закрытым, хотя утечки не было ни одной. На вопрос про работодателя поиск отдавал страницы «Опыт» и «Резюме», где после вычистки написано «продуктовая IT-компания, под NDA». Защищает не метка найденного куска, а два других факта: правила приватности всегда в промпте независимо от поиска, и в контексте нет запретных строк. Переписал проверку на сканирование собранного контекста, метка ушла в отдельную некритичную метрику.
Третье касается самого слияния результатов. Reciprocal rank fusion складывает обратные позиции, и документ, найденный только одним поиском, систематически проигрывает тем, что нашлись двумя, даже если у своего поиска он первый. Вылезло это на английском вопросе про работодателя: справка написана по-русски, вектор ставил нужный раздел первым с косинусом 0,39, полнотекстовый не находил его вовсе, потому что общих слов нет, и в итоговую пятёрку он не доезжал. Добавил правило: лидер каждого ранжирования занимает место в выдаче. Метрика по этому вопросу поднялась с 0,2 до единицы.
И бытовое, но важное. Индекс строится из опубликованного файла, поэтому выкладка сайта без переиндексации означает, что бот отвечает по старому контенту. Первую версию я так и оставил, ручным шагом, и это ровно тот шаг, который забывается. Вшил переиндексацию в скрипт выкладки, с явным предупреждением, если она не прошла.
Всё это лежит за рубильником: пустая переменная с адресом сервиса возвращает поведение до поиска, досье целиком. Гасил сервис руками и проверял, что бот продолжает отвечать. Отвечает, просто дороже по токенам.