
Привет, Хабр! Меня зовут Илья Парамошин, я ведущий инженер в МТС Web Services. В первой части мой коллега Владимир Дробот рассказал, зачем нам понадобился ИИ‑помощник для техподдержки и почему мы выбрали архитектуру на RAG. В этой статье я раскрою техническую сторону и разберу, как мы собирали данные, выбирали эмбеддинги, настраивали поиск и какие подводные камни встретили на пути. Спойлер: без костылей не обошлось, но система работает в проде и ежедневно помогает нашим инженерам.
Для начала напомню, мы собрали рабочую RAG-систему, которая объединяет данные из Confluence и Jira и умеет искать по ним с учетом смысла. В основе — вполне классический стек, но с парой инженерных нюансов:
гибридный поиск — pgvector для семантических совпадений и BM25 для точных текстовых;
локальные эмбеддинги — модель BGE-m3, развернутая внутри контура МТС.
Streamlit UI — легкий веб-интерфейс, чтобы пользоваться системой без лишних инструментов.
все это работает в закрытом контуре, без внешних API-вызовов.
Главный вывод, к которому мы пришли: такую систему действительно можно собрать своими силами — но придется потратить немало времени на тонкую настройку данных и операционную поддержку. Перейдем к архитектуре.
Архитектура системы
Общий взгляд: два контура
Чтобы не запутаться в потоках данных, мы разделили систему на два основных контура — индексацию (Indexing) и поиск (Search). Они работают независимо, но синхронно обновляют и используют одну базу знаний.

Контур индексации: от данных до векторов
Все начинается с выгрузки данных. Мы подключили загрузчики Confluence API и Jira API, чтобы регулярно собирать документацию, статьи, тикеты и комментарии.
После этого данные проходят обработку (Processors/Cleaning). HTML конвертируется в Markdown с сохранением структуры: заголовки (#), списки (-), code blocks (```), таблицы, ссылки ([]()), выделения (**bold**, italic). Это позволяет сохранить семантические границы документа для качественного чанкинга и последующего семантического поиска.
На первый взгляд задача кажется рутинной, но стратегия нормализации играет ключевую роль для качества поиска. В наших запросах мы применяем строгую нормализацию — lowercase, удаление лишних символов и опционально транслитерацию и лемматизацию — чтобы обеспечить совместимость с семантическим и полнотекстовым поиском.
При этом документы сохраняются полностью: Markdown-структура, заголовки, списки, code blocks, таблицы и спецсимволы остаются нетронутыми. Такой подход минимизирует потерю важной технической информации, одновременно обеспечивая согласованность данных и высокое качество индексации.
Например, строка «Привет, мир!!! Как дела??..» превращается в «привет мир как дела».
Эта унификация позволяет корректно токенизировать текст, улучшает качество векторных представлений за счет согласованности данных и снижает размерность пространства. Такой подход создает надежную основу для последующих этапов обработки — фрагментации текста и построения эмбеддингов.
Затем документы разбиваются на фрагменты длиной около 3000 символов с перекрытием в 10%. Оптимальный размер чанка - не фиксированная величина: он зависит от выбранной модели эмбеддингов (например, BGE-m3 работает в токенах, а не в символах), плотности знаний в тексте, структуры документа и типа downstream-задачи. В нашем случае 3000 символов соответствуют примерно 1000–1500 токенам — это диапазон, при котором мы получили оптимальный баланс между сохранением контекста и стабильностью векторизации. На таких размерах улучшились метрики семантического поиска: меньше «обрывов» смысла на границах частей и более точные совпадения при гибридном ранжировании.
Это также помогает корректно обрабатывать случаи, когда описание инцидента или техническая инструкция переходят из одной секции в другую, не теряя ключевой связности.
Для векторизации мы используем BGE-m3, развернутую внутри контура МТС. Преобразование выполняется асинхронно, с микробатчингом и кешированием, чтобы не перегружать ресурсы. Полученные векторы попадают в pgvector, где формируются индексы для семантического поиска.
Параллельно создается BM25-индекс в SQLite — полнотекстовый поиск по ключевым словам — для случаев, когда нужно найти точное текстовое совпадение. Метаданные и связи между фрагментами хранятся там же, в небольшой базе.
Индексация работает как фоновый процесс. Например, когда в Confluence появляется новая статья или в Jira закрывается тикет, данные автоматически обновляются в векторной базе.
Контур поиска: от запроса к ответу
Когда пользователь вводит вопрос, система проходит несколько шагов:
Сначала QueryProcessor приводит запрос к стандартному виду — добавляет нижний регистр, аккуратную нормализацию и удаляет лишние символы. Далее запрос обогащается: подмешиваются синонимы, транслитерации и доменные акронимы, которые живут в нашей документации и тикетах. Это повышает шанс, что и семантический поиск, и классический полнотекстовый попадут в нужные места.
Дальше в дело вступает основной движок SearchEngine. Он параллельно дергает два индекса: pgvectorдля семантики и BM25 для точных совпадений.
Результаты сходятся в координаторе SearchManager, где происходит объединение и финальное ранжирование с учетом релевантности источника и качества матчинга.
Чтобы интерфейс оставался отзывчивым, тяжелые операции (например, векторный поиск по большому индексу) выносятся в отдельный background-поток. Это позволяет пользователю видеть промежуточные результаты и продолжать работу, пока система дотягивает оставшиеся совпадения и уточняет ранжирование.
На основании лучших найденных фрагментов LLM Manager формирует компактный контекст для модели. Он собирает промпт, следит за лимитами и правилами компоновки, а затем отправляет запрос во внутренний LLM HTTP API.
Вся генерация проходит внутри периметра, в контуре МТС. Ответ возвращается вместе со списком источников (страницы Confluence и задачи Jira) и их оценкой релевантности, чтобы инженер мог быстро перейти к первоисточнику.
Пользовательский интерфейс
Чтобы инженеры могли пользоваться системой без лишних инструментов, мы сделали для нее легкий веб-интерфейс Streamlit UI. Он открывается прямо из браузера и сразу готов к работе — никаких установок, логинов или зависимостей.

Для тех, кому важен контроль, добавили расширенные настройки. В них можно выбрать используемую модель, переключить режим поиска (Hybrid/BM25/Vector), задать порог релевантности и при желании включить оптимизацию запроса через LLM.
Все параметры спрятаны под выпадающим меню, чтобы не мешать повседневной работе.
Взаимодействие компонентов и алгоритмы работы
Вся система работает по классическому конвейерному принципу. Данные из Confluence и Jira проходят через контур индексации, где очищаются, нормализуются и превращаются в поисковые индексы. Эти индексы сохраняются в хранилище и регулярно обновляются.
Когда пользователь отправляет запрос, SearchManager координирует параллельный поиск сразу по двум направлениям — pgvector(семантический) и BM25 (текстовый), объединяет результаты и передает их языковой модели. LLM формирует итоговый ответ, который отображается в интерфейсе Streamlit.
Пайплайн индексации
Процесс индексации построен последовательно, как цепочка из пяти этапов.
Этап 1. Загрузчики по API Confluence и Jira собирают статьи, тикеты и комментарии.
Этап 2. Данные проходят очистку и конвертацию в Markdown-формат с сохранением структурных элементов. Мы используем библиотеку markdownify (open-source) для преобразования HTML в Markdown, что позволяет сохранить семантические границы документа.
В Confluence и Jira критически важны inline-структуры: заголовки (h1-h6), списки (ul/ol) и блоки кода (pre/code). Полное «сплющивание» HTML разрушило бы семантические границы документа, что критично для качества RAG-системы. Поэтому мы сохраняем структурные маркеры:
- Заголовки конвертируются в # ## ### и т.д.
- Списки сохраняются как - или 1. 2. 3.
- Code blocks остаются как ``code
- Таблицы конвертируются в Markdown-таблицы
- Выделения сохраняются как bold и italic
Это позволяет удерживать семантические границы документа и значительно улучшает качество последующего чанкинга и поиска.
Для Confluence дополнительно обрабатываем специфичные макросы (ac:structured-macro, ac:link) — заменяем их на текстовые метки (например, [Макрос имя: параметры]), что сохраняет контекст без потери структуры.
Для Jira обрабатываем панели (info/warning/error/note), упоминания пользователей ([~username]) и ссылки на тикеты — всё это сохраняется в структурированном виде.
Документы сохраняются в базе данных в Markdown с полной структурой и всеми спецсимволами Markdown. Нормализация (приведение к нижнему регистру, удаление лишних символов) применяется только к пользовательским запросам при поиске. Такой подход обеспечивает совместимость между структурированными документами и нормализованными запросами: векторный поиск работает на уровне семантики, а BM25 учитывает точные совпадения.
Этап 3. Происходит разбиение на чанки. Большие документы делятся на фрагменты примерно по 3000 символов с перекрытием 10%. Это перекрытие сохраняет контекст между частями и позволяет не терять смысл на стыках. Например:

Этап 4. После чанкинга система создает векторные представления (эмбеддинги) с помощью модели BGE-m3, развернутой внутри периметра МТС. Эти векторы записываются в PostgreSQL, где формируются индексы для семантического поиска.
Этап 5. Параллельно тот же текст токенизируется и индексируется по BM25, чтобы можно было искать точные совпадения.
В результате формируется устойчивая база, где каждая статья или тикет представлен в обеих формах — семантической и текстовой.
Пайплайн поиска
Когда пользователь вводит запрос, система проходит несколько этапов обработки.
Сначала QueryProcessor приводит запрос к единому виду и дополняет его синонимами и акронимами. Это важно, потому что внутренняя документация часто смешивает русские и латинские термины — например, postmortem и «постмортем». Для таких случаев мы добавили отдельную логику, которая позволяет правильно распознавать оба варианта.
Далее запускается параллельный поиск:
pgvector ищет документы, близкие по смыслу;
BM25 — точные текстовые совпадения.
Результаты сводятся в общий список и проходят ранжирование. Используется комбинированная формула, которая учитывает не только оценки pgvectorи BM25, но и дополнительные параметры — наличие решения в тикете, дату обновления и вес заголовка:
final_score = combine(vector_score, bm25_score, title_boost, date_weight, has_solution_boost)
После ранжирования система отсекает нерелевантные документы и передает оставшиеся в LLM. Модель на их основе формирует связный ответ, дополняя его ссылками на источники.
Такой пайплайн позволяет сочетать точность классического поиска с гибкостью семантического, что особенно ценно в технической поддержке, где одна и та же проблема может быть описана десятком разных формулировок.
Ключевые компоненты и почему мы их выбрали
Векторная база: PostgreSQL + pgvector
На старте проекта мы экспериментировали с хранением векторных индексов в FAISS, но в итоге остановились на связке PostgreSQL с расширением pgvector. Это решение устранило ключевую операционную сложность - поддержку нескольких разнородных систем хранения (FAISS, SQLite, файлы).
Мы рассматривали и альтернативы:
Chroma показалась удобной по API, но при больших индексах (миллионы векторов) оказалась заметно медленнее и требовательнее к оперативной памяти. В высоконагруженной среде она давала нестабильные задержки.
Milvus и Weaviate предлагали расширенные функции, но требовали поддержки отдельной базы данных — для наших задач это выглядело избыточно.
Облачные решения вроде Pinecone или Vectara мы исключили сразу. Внешние вызовы создают зависимость и регулярные расходы, что противоречит внутренним требованиям по безопасности.
Почему PostgreSQL + pgvector:
Единая система: Векторы, метаданные и тексты хранятся вместе, что гарантирует целостность данных и упрощает резервное копирование.
Мощный гибридный поиск: Сложные запросы, объединяющие векторное сходство (<=>), полнотекстовый поиск (to_tsvector) и фильтрацию по метаданным, выполняются эффективно через двухэтапную архитектуру с объединением результатов на уровне приложения.
Транзакционность и надежность: Инкрементальное обновление индексов стало стандартной и безопасной операцией INSERT / UPDATE
-
Простота эксплуатации: Не нужно управлять отдельными процессами для векторной БД и синхронизировать их с хранилищем метаданных.

В итоге мы реализовали следующую схему:
Для каждого пространства по-прежнему создается изолированный набор данных, но теперь это отдельные схемы (schemas) в рамках одной БД.
Связь "фрагмент(чанк) -> документ" хранится на уровне внешних ключей между таблицами chunks и documents.
Используем индекс типа IVFFlat для скорости поиска на больших объемах
Простота, атомарность операций и отсутствие внешних зависимостей сделали такой стек идеальным для наших задач.
Эмбеддинги: BGE-m3
Для построения векторных представлений мы используем модель BGE-m3, доступную через внутренний API MWS GPT. Модель работает внутри корпоративного периметра, что исключает утечки данных. Каждый вызов логируется — это помогает воспроизводить ошибки и отслеживать задержки.
Размерность эмбеддингов в BGE-m3 — 1024. Этого достаточно, чтобы надежно кодировать смысловые связи между фрагментами документации и при этом не перегружать систему по объему хранения и скорости поиска.
Чтобы снизить нагрузку на систему при индексации, мы реализовали адаптивный асинхронный микробатчинг. При запуске индексатора размер батча определяется динамически, исходя из текущего использования памяти. Если процесс приближается к лимиту, размер следующего батча уменьшается (например, с 64 до 32 документов). Если ресурсов достаточно — увеличивается обратно.
Дополнительно мы внедрили несколько оптимизаций:
LRU-кеш для эмбеддингов — повторяющиеся фрагменты (типовые заголовки, предупреждения, блоки кода) не пересчитываются повторно;
retry с backoff — повтор запросов при временных сбоях;
мониторинг zero-embedding rate — система шлет алерты, если доля пустых эмбеддингов превышает порог.
Таким образом система сама регулирует нагрузку и предотвращает Out of Memory без ручного вмешательства.
Гибридный поиск: двухэтапная архитектура
Мы внедрили гибридный поиск, объединяющий семантический (pgvector) и классический (BM25) подходы:
BM25 — это статистический алгоритм, улучшенный вариант TF-IDF, который хорошо работает на коротких запросах и точных совпадениях, вроде кодов ошибок или идентификаторов.
pgvector, напротив, ищет по смыслу и устойчив к перефразированию. Вместе они покрывают оба сценария — от «404 на проде» до «падает доставка сообщений с задержкой».

В гибридном подходе итоговая оценка релевантности вычисляется как комбинация оценок векторного и полнотекстового поиска. Мы используем оба метода параллельно, объединяя результаты через комбинирование оценок (score fusion). Процесс выглядит следующим образом:
Этап 1. Параллельный поиск. Запускаем векторный поиск по индексу pgvector и BM25-поиск по текстовому индексу асинхронно. BM25 работает с исходным текстом чанков, который хранится в PostgreSQL, и ищет точные совпадения терминов.
Этап 2. Нормализация оценок. Чтобы можно было корректно сравнивать результаты из разных систем, мы приводим их к единому диапазону [0, 1] с помощью min-max нормализации:
нормализованная_оценка = (исходная_оценка - min_текущего_запроса) / (max_текущего_запроса - min_текущего_запроса)
Это позволяет оценивать документы в контексте одного запроса, а не абсолютных значений score.
Этап 3. Взвешенное объединение. Финальный рейтинг вычисляется по формуле:
final_score = α × vector_score + β × bm25_score
По умолчанию α = 0.7, β = 0.3.Для коротких или технических запросов (до трех слов) β увеличивается до 0.5–0.7, чтобы текстовая точность имела больший вес.
Этап 4. Повышающие коэффициенты. Для учета контекста мы добавили дополнительные множители Их значения - не теоретические, а эмпирические: мы подбирали их на тестовом сете и проверяли валидацию на реальных запросах инженеров. После нескольких итераций остановились на следующих параметрах::
×1.2 — если совпадение найдено в заголовке,
×1.1 — если документ свежий (моложе 30 дней),
×1.5 — если тикет в Jira содержит пометку о решении.
Это не строгий перебор по сетке значений, а прагматичный инженерный подбор: попробовали несколько вариантов, посмотрели метрики поиска и отклик пользователей - эти коэффициенты предварительно показали наилучший баланс и стабильно улучшали топ-результаты.
Этап 5. Фильтрация. Если документ встречается в обеих выборках, система оставляет вариант с максимальной оценкой.
На практике гибридный поиск покрывает почти все пользовательские сценарии. BM25 работает как «страховка» для коротких запросов, а pgvectorпомогает в случаях, когда запрос сформулирован свободно и не содержит ключевых слов из документации. Благодаря такому сочетанию инженеры поддержки получают корректные ответы даже при неточных формулировках.
LLM для генерации ответов
Мы используем внутреннюю модель Cotype Pro 2, которая показала лучший баланс качества и стабильности (подробнее о ней писали в блоге МТС AI). Модель работает детерминированно, температура выставлена в 0, чтобы при одинаковых запросах выдавались одинаковые результаты — это важно для саппорта и воспроизводимости ответов.
Для отказоустойчивости реализована стандартная логика повторов: три попытки с экспоненциальной задержкой при сбоях. Таймаут на вызов установлен в 300 секунд — этого достаточно даже для длинных контекстов.
В итоге гибридный поиск и LLM-генерация образуют связный цикл. Поиск быстро находит нужный контекст, а модель превращает его в связный, читаемый ответ.
Стек и средства разработки
Наша система строилась на проверенном и понятном стеке, который удобно поддерживать и развивать внутри корпоративного контура.
В качестве основного языка мы выбрали Python 3.12+ (asyncio, aiohttp, typing). Он позволяет быстро собирать прототипы и хорошо подходит под асинхронную архитектуру. Асинхронность реализована на asyncio, сетевые операции — через aiohttp, а параллелизм регулируется семафором, чтобы не допускать перегрузки при массовой индексации.
Интерфейс для пользователей написан на Streamlit — это библиотека, которая идеально подходит для случаев, когда критична скорость разработки, а нагрузка остается умеренной. Такой подход позволил без привлечения фронтенд-разработчиков быстро собрать понятный веб-UI, доступный прямо из браузера.
Мониторинг и эксплуатационные метрики мы вывели в связку VictoriaMetrics + Grafana. Там отслеживаются задержки, процент ошибок от API и общая статистика использования помощника. Все сервисы развернуты в контейнерах, а пайплайн поставки построен на GitLab CI — с автоматической сборкой Docker-образов и деплоем в тестовый и продовый контуры.
Отдельно стоит упомянуть про инструменты разработки. Мы активно использовали внутренние решения DevX Platform — в частности, IDE-Copilot и AI-агент. Они брали на себя рутинные задачи: генерировали шаблонный код (классы, методы, тесты), помогали быстро экспериментировать с новыми библиотеками и находить простые ошибки. На первых этапах разработки это значительно ускорило работу и позволило сосредоточиться на логике, а не на инфраструктурной обвязке.
Однако без оговорок не обошлось Любой сгенерированный код должен проходить обязательную ручную проверку — на безопасность, корректность и соответствие внутренним стандартам.

В итоге использование AI-копайлота дало реальную экономию времени на типовых задачах и ускорило создание прототипов, но ответственность за качество все равно оставалась за инженером.
Промпты: от простого к сложному
Одним из самых трудоемких этапов стала настройка системных промптов — тех, что задаются в конфигурации и не видны пользователю. От их формулировки напрямую зависело качество ответов модели: иногда одно слово в инструкции могло изменить поведение ИИ кардинально.
Чтобы показать, насколько чувствительной оказалась модель, приведем пару показательных примеров.
Пример 1: нежелательная «творчество-ориентированность»
До: «Сформулируй ответ на основе доступных документов».
После: «Сформулируй строго фактологический ответ только на основе предоставленного контекста».
Эффект: модель перестала «креативить» и придумывать детали, которых нет в документации.
Пример 2: слишком широкая интерпретация контекста
До: «Используй контекст, чтобы помочь пользователю».
После: «Используй только наиболее релевантные фрагменты из контекста; игнорируй нерелевантные и устаревшие сведения».
Эффект: исчезли ответы, в которых модель объединяла инструкции из разных версий документа в одну «солянку».
Такие мелочи легко пропустить, но на практике одно неточное слово в системном промпте может полностью поменять стиль, структуру и точность ответа. Поэтому настройка промптов превратилась в отдельную инженерную задачу - с циклом тестирования, сравнением вариантов и регулярными итерациями.
Мы начали с максимально простого сценария — базового промпта для прямых фактологических запросов, где достаточно одного источника информации.
Базовый промпт
Этот вариант мы разрабатывали методом проб и ошибок, постепенно избавляясь от «нагромождений». Оказалось, что краткость и четкие ограничения работают надежнее сложных инструкций.
Главное правило, к которому мы пришли: модель должна использовать только предоставленный контекст и не «додумывать» факты. Финальная версия выглядела так:

Такой промпт оказался особенно устойчив к «галлюцинациям» модели и давал предсказуемые ответы в большинстве повседневных случаев.
Промпт для генерации ответа по нескольким источникам
Следующий шаг — обработка сложных вопросов, где контекст состоит из десятков документов. Здесь базового промпта стало не хватать, так как LLM нередко смешивала противоречивые данные или игнорировала актуальные версии инструкций.
Мы расширили шаблон, добавив приоритизацию источников, правила разрешения конфликтов и классификацию типов запросов. Это позволило модели лучше понимать контекст, а главное — структурировать ответ в зависимости от задачи.

Эта версия уже ближе к полноценной системе правил. Модель не только отвечает по контексту, но и умеет определять тип задачи, выбирать соответствующий стиль и даже указывать степень уверенности в ответе.
Практический эффект оказался ощутим:
снизилось количество «смешанных» ответов из разных версий инструкций;
улучшилась структурированность и читаемость вывода;
система стала понятнее инженерам поддержки — ответы выглядят как подготовленные коллегой, а не абстрактным ИИ.
Главный урок тут — усложнение промпта оправдано только тогда, когда оно добавляет управляемости. Простые задачи лучше решаются простыми правилами, а сложные — требуют четкой логики и приоритизации.
Проблемы и их решения
По мере разработки мы столкнулись с несколькими типичными инженерными ловушками, которые пришлось решать экспериментально.
Чанкинг
Первой задачей стал выбор оптимального размера чанков. Маленькие фрагменты теряли контекст — модель видела только обрывки текста и не понимала, о чем речь. Слишком большие, наоборот, создавали «шум» и в эмбеддинги попадали лишние детали, не относящиеся к запросу.
Решение: мы пришли к формуле ≈3000 символов с перекрытием 10%. Так сохраняется контекст на границах, а поисковая система продолжает работать быстро. Каждый чанк связан с исходной страницей, поэтому при показе результатов можно восстановить весь документ целиком.
Качество эмбеддингов
Следующая боль — аббревиатуры и внутренняя терминология. Модель не всегда правильно интерпретировала жаргонизмы и сокращения вроде postmortem, SLO, RTM или их русские аналоги.
Решение: мы добавили словарь синонимов и транслитераций, который используется при расширении запросов. Теперь поисковая система автоматически понимает, что «постмортем» и postmortem — одно и то же, а «время реакции» и response time — эквивалентные выражения. Этот прием значительно повысил полноту поиска без вмешательства пользователя.
LLM-ранжирование
Идея использовать LLM для дополнительного ранжирования документов выглядела красиво на бумаге, но в реальности увеличивала время отклика на 200–300% без заметного улучшения качества ответов. Модель часто «пересортировывала» результаты, руководствуясь субъективными формулировками в тексте.
Решение: мы оставили этот функционал опциональным — его можно включить для исследовательских задач, но в продакшене он по умолчанию отключен. Так система остается быстрой и предсказуемой.
Инкрементальные обновления
Следующая проблема - обновление индексов без простоя. Полная перестройка занимала время и блокировала поиск, что для поддержки недопустимо. Чтобы избежать «дрейфа» между текстовым слоем и векторным индексом pgvector, мы реализовали атомарную схему инкрементальных обновлений на базе возможностей PostgreSQL.
Мы используем комбинацию нескольких механизмов:
1. Atomic UPSERT.
Все обновления проходят через INSERT … ON CONFLICT DO UPDATE в одной транзакции. Документ либо создаётся, либо полностью обновляется — без состояния, когда текст уже новый, а эмбеддинг ещё старый.
2. Soft-delete вместо физического удаления.
Старые версии помечаются is_deleted = true. Поисковые запросы фильтруют WHERE is_deleted = false, поэтому устаревшие фрагменты исчезают из результатов мгновенно. Физическая очистка выполняется отдельно, как maintenance-задача.
3. Content hash для пропуска неизменённых документов.
Перед обновлением проверяем content_hash. Если документ не изменился, мы не пересчитываем эмбеддинг и не затрагиваем индекс. Это снижает нагрузку и предотвращает «шум» в pgvector.
4. Маркер устаревших данных.
Scheduler записывает в БД, какие документы нужно обновить (outdated = true). Поисковые движки проверяют этот маркер при запросе и автоматически подхватывают новую версию.
5. Scheduled Maintenance.
Раз в сутки запускается обслуживание индексов:
REINDEX CONCURRENTLY для pgvector (без блокировки чтения),
VACUUM для очистки удалённых кортежей,
ANALYZE для обновления статистики.
Это предотвращает накопление «мусора» и деградацию качества поиска.
Индексы pgvector (IVFFlat) не пересоздаются при каждом обновлении - новые векторы распределяются по существующим спискам. Чтобы поддерживать качество поиска и предотвращать деградацию IVFFlat при накоплении обновлений, мы выполняем периодический REINDEX CONCURRENTLY в тихие часы. Этого достаточно для нашего объёма изменений, поэтому отдельные схемы или альтернативные индексы (например, HNSW) не требуются.
Такой подход - комбинация soft-delete, upsert, content-hash и регламентного обслуживания - обеспечивает атомарность обновлений, стабильность векторного поиска и отсутствие простоя без необходимости в двойной индексации или репликации схем.
Безопасность
Поскольку мы работаем с внутренней документацией, безопасность для нас — не просто пункт в чек-листе, а архитектурный принцип.
Решение: все компоненты, включая векторные индексы, API и LLM-сервисы, находятся внутри периметра МТС, без внешних вызовов. В индексацию не попадают персональные данные или конфиденциальные поля, а контроль доступа осуществляется на уровне исходных систем — Confluence и Jira. Эта изоляция позволила разрабатывать и тестировать систему без компромиссов между скоростью и безопасностью.
Тестирование и выводы
Для тестирования системы мы используем комбинированный подход. Автотесты крутятся в окружении, максимально близком к продакшену. Для каждого тест-кейса мы фиксируем численные метрики (нормализованное расстояние / similarity, passed/failed), сохраняем артефакты и логи — их удобно разбирать постфактум.
Параллельно инженеры саппорта помечают проблемные пространства и темы. Например, где ответы расплывчаты, где не хватает контекста или где нужно подкрутить пайплайн. Мы следим за метриками:
distance — нормализованное расстояние между ожидаемым ответом и ответом модели (0 = идентично, 1 = максимально удалено).
similarity — косинусная схожесть между ожидаемым и полученным ответом (0..1).
duration_s — время выполнения прогона / генерации (в секундах).
Precision@K — доля релевантных документов среди первых K результатов поиска. Например, Precision@5 = 0.8 означает, что в топ-5 результатах 4 документа релевантны запросу.
По результатам последнего прогона, гибридный поиск стабильно дает прирост качества, микробатчинг эмбеддингов экономит память, а Streamlit отлично подошел для UI. Однако требуется доработка, так как качество данных важнее сложных алгоритмов — лучше вложиться в очистку.
В свою очередь, LLM-ранжирование ухудшает латентность на 200–300% и не дает гарантированного выигрыша по качеству — поэтому пока держим опцией по умолчанию. Цифры можно посмотреть в таблице:
test_id |
mode |
distance |
similarity |
duration_s |
Precision@5 |
reset_profile_001 |
default |
0.033 |
0.967 |
63.935 |
0.92 |
reset_profile_001 |
opt_query |
0.028 |
0.972 |
50.064 |
0.92 |
reset_profile_001 |
rerank |
0.139 |
0.861 |
72.261 |
0.90 |
reset_profile_001 |
opt_query+rerank |
0.123 |
0.877 |
69.415 |
0.90 |
city_missing_001 |
default |
0.283 |
0.717 |
59.002 |
0.72 |
city_missing_001 |
opt_query |
0.291 |
0.709 |
50.943 |
0.72 |
latency_001 |
default |
0.244 |
0.756 |
46.464 |
0.83 |
latency_001 |
rerank |
0.291 |
0.709 |
64.456 |
0.83 |
Наш опыт показывает, что собрать работающую RAG‑систему своими силами реально. Современные инструменты вроде PostgreSQL+pgvector, Streamlit и готовых эмбеддинг-моделей сильно снижают порог входа. Но сама система — это только половина истории. RAG — это не «кнопка ответить», а про инфраструктуру: данные, индексы, мониторинг и непрерывное улучшение.
На текущем этапе мы покрываем базовые потребности production-RAG:
Гибридный поиск - комбинация Vector Search (pgvector) и BM25 помогает находить больше релевантных документов;
LLM-based reranking - LLM оценивает семантическую релевантность и переранжирует результаты;
Оптимизация запросов - пользовательские запросы проходят легкую LLM-подготовку перед поиском.
Но мы не останавливаемся на этом. В планах есть несколько интересных направлений для улучшения качества поиска:
Re-chunking - динамическое переразбиение документов в зависимости от контекста запроса вместо статических чанков;
Synthetic queries - генерация синтетических запросов, чтобы расширить покрытие и обучить систему на редких кейсах;
Multi-vector retrieval - использование разных типов эмбеддингов для оценки различных аспектов документа (семантика, структура, метаданные);
Cross-encoder reranking - специализированные модели для более точного и быстрого переранжирования результатов.
Сейчас мы фокусируемся на стабильности и масштабируемости базовой инфраструктуры: инкрементальные обновления, мониторинг и поддержка системы в рабочем состоянии. А после этого начнем внедрять продвинутые техники, чтобы RAG отвечала ещё точнее и умнее.
Советую построить минимально работающий pipeline, начать замерять результатами, и тогда итерациями достигнете стабильного бизнес-эффекта.
Задавайте вопросы в комментариях, а также рассказывайте, используете ли вы готовые решения или также, как мы, разрабатываете собственных помощников под процессы?