Создание гибридного RAG-пайплайна с Reciprocal Rank Fusion
Как векторная схожесть и полнотекстовый поиск PostgreSQL работают параллельно и сливаются через Reciprocal Rank Fusion в ответы с цитатами и оценками релевантности.
Почему одной стратегии поиска никогда не достаточно
Retrieval-augmented generation живёт или умирает тем, что ретривер передаёт модели. Задайте вопрос базе знаний — и ответ хорош настолько, насколько хороши предшествующие ему чанки, а корпоративные вопросы приходят в двух формах, которые ни одна отдельная стратегия поиска не обрабатывает хорошо.
Семантические вопросы — «как биллинг обрабатывает пропорцию?», «что мы решили насчёт vendor lock-in?» — касаются смысла. Векторный поиск здесь блестит: он встраивает запрос и чанки документов в одно пространство и находит ближайших соседей, так что чанк, написанный совершенно другими словами, всё равно может всплыть. Но чистый векторный поиск ненадёжен для другой формы вопросов: точных идентификаторов. Коды продуктов, строки ошибок, номера регламентов, имена людей — эмбеддинги размывают их, и ближайший сосед малоизвестного кода ошибки часто оказывается другим, не связанным кодом.
Ключевые вопросы — зеркальное отражение. Полнотекстовый поиск PostgreSQL отлично находит точные токены и редкие термины, но полностью пропускает перефразировки: спросите «расширение штата», и он не найдёт документ со словами «привлечение подрядчиков».
Реальные корпоративные документы привлекают оба вида вопросов, часто в рамках одной сессии. Поэтому Simmetric Chat отказывается выбирать. Каждый поиск запускает два плеча параллельно — векторную схожесть и полнотекстовый поиск PostgreSQL — и сливает результаты с помощью Reciprocal Rank Fusion (RRF), прежде чем что-либо достигнет модели. Эта статья проводит по пайплайну: два плеча, математика слияния, путь ингестии, который их питает, и слой цитирования, делающий ответы проверяемыми.
Два плеча, один запрос
Когда чату нужны знания, агент ReAct вызывает свой навык rag_search, и гибридный поиск запускает два независимых поиска одновременно:
- Векторное плечо покидает сервер и обращается к коллектору — сервису, владеющему векторным хранилищем. Коллектор встраивает запрос той же моделью эмбеддингов, что и при ингестии, и возвращает ближайшие чанки из коллекции рабочего пространства. Само векторное хранилище подключаемо — LanceDB по умолчанию (файловое, локальное, совместимое с air-gap), или Qdrant, pgvector, Chroma для развёртываний, предпочитающих серверный индекс.
- Полнотекстовое плечо никогда не покидает сервер. Оно запрашивает PostgreSQL напрямую, сопоставляя запрос с конкатенированной полнотекстовой векторной колонкой на таблице чанков, поддерживаемой GIN-индексом и ранжируемой по лексической релевантности. Токенизация учитывает язык на семи языках, поэтому итальянские и немецкие документы ищутся по своим правилам слов, а не по английским приближениям.
Коллекции привязаны к рабочему пространству — именно это держит знания изолированными: чат в пространстве A ищет по чанкам пространства A и ничему больше. Запуск обоих плеч на каждый запрос стоит одного лишнего раунд-трипа и покупает покрытие, которого нет ни у одного плеча по отдельности.
Режим отказа тоже намеренно избыточен. Страж сравнивает модель эмбеддингов, настроенную сегодня, с той, которой документы пространства были встроены. При несовпадении — например, после смены модели — векторное плечо пропускается целиком, и поиск деградирует до полнотекстового, вместо того чтобы выдавать оценки схожести из несопоставимых пространств эмбеддингов. Чат продолжает работать, а оператор получает явный путь переиндексации в админ-панели вместо тихой регрессии качества.
Reciprocal Rank Fusion, конкретно
Два плеча возвращают ранжированные списки, измеренные в несопоставимых шкалах: косинусная схожесть с одной стороны, лексическая релевантность с другой. Усреднение сырых оценок означало бы калибровку этих шкал друг против друга — хрупко и по-разному для каждой модели эмбеддингов и каждого корпуса. Simmetric Chat сливает ранги, а не оценки, используя Reciprocal Rank Fusion:
fused(chunk) = sum over legs of 1 / (k + rank), with k = 60Каждый чанк накапливает один вклад за каждое плечо, в котором он встречается, поэтому чанк, занявший первое место на обоих плечах, набирает примерно вдвое больше, чем чанк, первый только на одном. Константа k гасит влияние самых верхних рангов — при k = 60 разница между первым и пятым местом на одном плече — небольшой дельт, и именно консенсус между плечами реально двигает чанк вверх по объединённому списку.
Две инженерные детали делают слияние производственного уровня, а не учебникового:
- Набор источников на чанк фиксирует, найден ли он векторным плечом, полнотекстовым или обоими. Чанк, найденный обоими плечами, — самое сильное подтверждение, которое пайплайн может произвести, и нижестоящее форматирование сохраняет этот сигнал, а не сплющивает его.
- Детерминированный тайбрейкер — оценка слияния по убыванию, затем идентификатор документа, затем позиция чанка — гарантирует, что один и тот же запрос вернёт один и тот же порядок на каждой реплике. Когда кто-то спрашивает «почему он процитировал этот абзац?», ответ должен быть воспроизводим; недетерминированное слияние сделало бы вопрос безответным.
От документов к поисковым чанкам
Слияние — лишь половина пайплайна; вторая происходит при ингестии, когда загруженный документ превращается в чанки, которые могут искать оба плеча. Коллектор разбирает PDF, Word, Excel, PowerPoint, Markdown, CSV-файлы и транскрипты YouTube, режет текст рекурсивным символьным сплиттером (1000 символов на чанк, 200 символов перекрытия, чтобы предложения, пересекающие границу, не терялись), встраивает каждый чанк и записывает векторы в коллекцию рабочего пространства.
Ключевой момент: текст чанков также попадает в PostgreSQL, где сервер ведёт полнотекстовый поисковый вектор, который запрашивает FTS-плечо. Коллектор никогда не трогает реляционную базу — жёсткая граница сервисов, которая держит пайплайн «разбор-и-встраивание» заменяемым и оставляет поисковый индекс во владении сервера, который его запрашивает.
Версия 0.22 сделала поиск точным во втором измерении: фильтрация по метаданным. Агент теперь может ограничивать вызов rag_search типами документов (PDF, Markdown, TXT, CSV, DOCX, XLSX) и окнами дат ингестии, что исправляет классический сбой «документы 2023 года отвечают на вопросы 2025 года». Фильтры проходят через оба плеча — как настоящие пре-фильтры по штампованным метаданным ингестии в векторных хранилищах и как параметризованные SQL-предикаты на полнотекстовой стороне. Серверный метаданный предохранитель затем перепроверяет каждый слитый результат против авторитетной таблицы документов, поэтому корректность фильтров не зависит от того, насколько идеально каждый векторный провайдер реализует предикаты. Легаси-векторы, загруженные до штамповки метаданных, просто выпадают, пока активен фильтр, и возвращают фильтруемость через повторное встраивание, запускаемое админом.
Ответы, которые показывают свою работу
Качество поиска — лишь половина истории; вторая — честность о том, на чём основан ответ. Цитаты идут тем же стриминговым каналом, что и токены: бейджи цитат отображаются в строке ответа, каждый несёт исходный документ, чанк и оценку релевантности, и каждый ссылается на источник, чтобы читатель мог проверить утверждение в один клик.
Дисциплина актуальности живёт и здесь. После дедупликации слитых результатов фильтр обоснованности ограничивает цитаты на документ двумя лучшими по оценке релевантности и отбрасывает цитаты чанков, чей текст не имеет значимого лексического пересечения с итоговым ответом. Порог намеренно консервативен — wiki-страницы, архивные результаты и очень короткие ответы всегда сохраняются — но он устраняет режим отказа, когда ответ говорит «дополнительной информации нет», показывая при этом «Источники (5)», взятые из одного едва связанного документа. Дисклеймер «нет подтверждающих доказательств» оценивается по тому же отфильтрованному набору, поэтому дисклеймер и список источников всегда описывают одну и ту же реальность.
Выбор и настройка стека
Пайплайн намеренно конфигурационно-управляемый, и значения по умолчанию — приоритет приватности:
- Векторное хранилище: LanceDB по умолчанию — файловое, хранится локально, без внешнего сервиса, полностью совместимо с air-gap. Qdrant, pgvector и Chroma — в одном изменении окружения, когда развёртывание перерастает его; провайдер выбирается при загрузке.
- Эмбеддинги: локальные трансформеры (384 измерения, полностью офлайн) по умолчанию, или эмбеддинги OpenAI, когда развёртывание принимает исходящий вызов. Смена модели эмбеддингов требует переиндексации, которую ведёт админ-панель; страж несовпадения держит систему в безопасности в промежутке.
- Реранкинг: опционально. Реранкер может пере-выбрать и переупорядочить слитые результаты, когда качество ответа важнее задержки.
- Константа слияния: k = 60 поставляется по умолчанию; это одна константа в сервисе гибридного поиска, если ваши распределения рангов требуют другого гашения.
Полный обход потока данных — граница сервер-коллектор, раскладка хранилища за каждым векторным провайдером и стриминговые события цитат — задокументирован в руководстве по архитектуре.


