Запустили полнотекстовый и гибридный поиск в YDB: рассказываем, что под капотом


Привет, Хабр! Меня зовут Александр Зевайкин, и мы с командой делаем YDB (СУБД Яндекса). Год назад я рассказывал на Хабре, как мы запустили векторный поиск, а весной — как он используется в Нейроюристе для поиска по миллионам юридических документов.
В релизе 26.3 к векторному поиску добавились полнотекстовый поиск с ранжированием и гибридный поиск, который объединяет оба вида поиска в одном SQL-запросе. Кроме того, мы доработали фильтруемый векторный индекс.
Зачем нужны оба вида поиска, хорошо видно на примере. Страховой юрист ищет «выплаты по полису 7702-345678 при переносе рейса». Номер полиса нужно найти слово в слово, а описание случая — понять по смыслу. Полнотекстовый поиск справляется с первой половиной задачи и не справляется со второй, векторный — наоборот.
Прежде чем перейти к статье, я хочу пригласить вас на вебинар «Быстро находите нужное с YDB», который проведу в прямом эфире 15 октября. На нём я покажу рецепт, как за час собрать приложение для поиска сразу по документам, тикетам, карточкам и диалогам. Готовый поиск я подключу к ИИ-ассистенту с помощью RAG, и мы вместе со зрителями посмотрим, как меняется качество ответа. Регистрируйтесь и добавляйте в календарь — всё, что я покажу на вебинаре, доступно в рамках бесплатного тарифа и можно будет сразу попробовать самим.
А под катом в этой статье я отвечу на шесть вопросов о гибридном поиске:
Почему одного вида поиска недостаточно и чем два вида дополняют друг друга.
Что теряет бизнес, когда поиск работает отдельно от СУБД, и как оба индекса стали таблицами YDB.
Как сделать инвертированный индекс в виде распределённой таблицы и что добавляет к нему ранжирование по BM25.
Что происходит с каждым индексом при записи и как индекс объединяется с фильтрацией.
Как две выдачи объединяются внутри одного плана запроса.
Чем гибридный поиск помогает рекомендательным системам и чем измеряют результат.
Почему одного вида поиска недостаточно
ИИ-агент использует архитектуру Retrieval-Augmented Generation (RAG): на вопрос пользователя он сначала находит в хранилище несколько десятков подходящих фрагментов текста, потом добавляет их в промпт модели, и модель формирует ответ на их основе. Подробно я разбирал архитектуру RAG в статье про векторный поиск. Качество ответа определяется тем, какие документы попали в контекст, и у двух видов поиска здесь разные слабые места.
Первый из них, векторный поиск, работает с эмбеддингами. Эмбеддинг кодирует смысл текста: модель превращает текст в последовательность чисел, которую называют вектором, и близкие по смыслу тексты получают близкие векторы. Поэтому векторный поиск находит документ, где нужная мысль сформулирована другими словами: «соглашение о найме коммерческой недвижимости» и «договор аренды нежилого помещения» окажутся похожими.
Зато точную последовательность символов эмбеддинг не хранит: редкая фамилия или уникальный номер могут не попасть в топ-50 «похожих по смыслу» документов. Также в топе могут оказаться слова или названия, отличающиеся от нужного на один-два символа. Например, «152-ФЗ» и «153-ФЗ» отличаются одной цифрой и стоят в векторном пространстве рядом.
Второй вид, полнотекстовый поиск, устроен противоположно: он находит только документы, в которых есть слово из запроса. По запросу «договор аренды» он не найдёт документ, где написано «соглашение о найме».
Полнотекстовый поиск приводит словоформы к основе с помощью алгоритма стемминга (от английского stem — «основа»), поэтому «суд», «суда» и «судом» считаются одним словом. Синонимы при этом остаются разными словами.
Зато фамилию, город, код ошибки, артикул и номер полиса полнотекстовый поиск находит точно. В запросе страхового юриста он найдёт документы с номером 7702-345678, но ничего не знает о том, что «перенос рейса» и «задержка вылета» — один и тот же страховой случай.
Запросы в техподдержке и в интернет-магазине используют оба поиска. В базе знаний техподдержки лежат инструкции, описания инцидентов и карточки сервисов, то есть текст, коды и структурированные атрибуты рядом. Инженер ищет «сервис отвечает с задержкой, повторные запросы создают дубликаты»: имя сервиса и код ошибки нужно найти точно, описание симптома — по смыслу. Покупатель в каталоге интернет-магазина пишет в одной строке артикул и «что-нибудь тёплое на осень в офис».
Итак, два вида поиска ошибаются по-разному, и их результаты различаются по составу. Реальному запросу необходимы и точное совпадение, и близость по смыслу, а результат нужен один.
Почему оба индекса хранятся внутри СУБД
Чтобы получить из двух видов поиска один результат, сначала нужно решить, где хранить два индекса. Долгое время ответ был один: рядом с реляционной СУБД ставили отдельную поисковую систему, а в последние годы ещё и векторное хранилище. СУБД хранила исходные данные, а в поисковые системы их переносили механизмами переноса данных (ETL).
В такой архитектуре данные хранятся в двух (а иногда и в трёх) экземплярах, и между ними всегда есть окно рассинхронизации, так что строка, записанная секунду назад, в поисковом индексе ещё не видна. Каждый поиск идёт сначала в индекс за идентификаторами, потом в СУБД за строками.
Фильтрацию результатов приходится реализовывать повторно в слое приложения, а две выдачи, лексическую и векторную, приложение объединяет своим кодом, и каждая команда пишет этот код заново. А ещё две или три системы, которые нужно масштабировать, обновлять и оплачивать.
Для бизнеса это оборачивается ограничениями на то, что можно обещать пользователю: данные доходят до поискового индекса с задержкой, поэтому оператор поддержки не находит тикет, созданный минуту назад.
Права доступа приходится копировать в индекс или проверять уже после поиска, и ИИ-агент либо показывает сотруднику документ из чужого проекта, либо после фильтрации возвращает пустой ответ.
Каталог меняется каждую минуту, а рекомендации строятся по вчерашней копии и предлагают товар, которого уже нет в наличии.
Проблемы с согласованностью, проверкой прав и масштабированием под нагрузкой возникают потому, что поиск живёт отдельно от данных. Если индекс — часть той же СУБД, он получает всё это сразу, без синхронизации, отдельного кластера и дополнительного кода в приложении.
Поэтому в YDB и полнотекстовый, и векторный индекс устроены как служебные таблицы той же СУБД. Индексные таблицы хранятся с той же избыточностью, что и таблицы с данными, так же делятся на партиции и перемещаются между узлами, а планировщик запросов обращается к индексным таблицам внутри того же запроса, что и к таблице с данными. Для индексных таблиц можно включить автопартиционирование по нагрузке и объёму, а также реплики чтения.
Оба индекса обновляются в той же транзакции, в которой меняются данные. Различие состоит в том, что полнотекстовый индекс после обновления остаётся точным, а векторный индекс распределяет новые строки таблицы по уже построенным кластерам, поэтому его поиск остаётся приближённым; об этом я расскажу отдельно. Так данные всегда транзакционно согласованы, а полноту векторного поиска настраивают отдельно.
Поисковая библиотека или отдельная поисковая система отвечает на вопрос «кто ближе всего» по индексу, который целиком лежит в памяти одного сервера. От СУБД требуется ответ на тот же вопрос по строкам, которые распределены по многим серверам и постоянно меняются, с предсказуемой стоимостью запроса.
Инвертированный индекс в распределённой таблице
Чтобы найти документы со словом «суд», без индекса пришлось бы прочитать текст каждой строки таблицы. Стоимость такого запроса растёт вместе с таблицей, и обычный индекс по колонке не помогает: он ищет по значению целиком, а не по словам внутри текста. Инвертированный индекс хранит для каждого слова список документов, где оно встречается, поэтому поиск сводится к одному обращению к словарю, а запрос из нескольких слов — к пересечению таких списков. Название отражает направление: документ состоит из слов, а индекс «переворачивает» эту связь и ведёт от слова к документам.
Чтобы построить такой индекс, текст документа при записи обрабатывают в два этапа. Токенизация разбивает текст на слова по пробелам и знакам препинания. Нормализация приводит слова к общей форме: фильтр нижнего регистра убирает разницу между «Суд» и «суд», а стемминг отрезает окончание и оставляет основу, так что «суд», «суда» и «судом» становятся одним термом, единицей словаря индекса. В опенсорс-версии YDB стемминг выполняет библиотека Snowball.
Клиентам Yandex Cloud и в Enterprise-версии YDB доступна морфология Яндекса, та же, что работает в поиске Яндекса.
Дальше YDB пополняет словарь (отсортированный список всех известных термов) и для каждого терма ведёт список документов, в которых он встречается, и такой список называется posting list. В релизе 26.3 мы также добавили для этих списков сжатие, что делает индексы более компактными.
Физически всё это хранится в служебной индексной таблице YDB: строка таблицы содержит терм и сжатый список документов, в которых этот терм встречается (а также частоту терма в документах для ранжирования). Индексная таблица делится на партиции по объёму и нагрузке, как любая другая таблица YDB.

У такого индекса есть одно расхождение с реляционной таблицей. Posting list выгодно хранить с компактным числовым номером документа: чем короче номер, тем плотнее список и тем дешевле его читать. А первичный ключ таблицы задаёт пользователь, и ключ бывает строковым или составным.
Если ключ состоит из одной целочисленной колонки, её значение становится номером документа напрямую. В остальных случаях YDB добавляет в таблицу системную колонку __ydb_row_id, заполняет её автоматически и строит по ней уникальный индекс __ydb_unique_row_id. После поиска этот индекс превращает номер документа обратно в первичный ключ, и только потом читается строка.
При этом все полнотекстовые индексы одной таблицы используют одну и ту же колонку и один и тот же уникальный индекс. Дополнительное чтение и дополнительная запись входят в стоимость каждого запроса и каждого обновления.
Со стороны пользователя всё это устройство скрыто: полнотекстовый индекс создаётся тем же синтаксисом, что и обычный вторичный:
ALTER TABLE documents
ADD INDEX ft_idx
GLOBAL USING fulltext_relevance
ON (body)
WITH (
tokenizer=standard,
use_filter_lowercase=true,
use_filter_snowball=true,
language=russian
);Токенизатор standard делит текст по пробелам и знакам препинания, а два фильтра в примере приводят слова к нижнему регистру и включают стемминг для русского языка; другие токенизаторы и фильтры перечислены в документации по полнотекстовым индексам. Тип fulltext_relevance нужен для ранжирования, и о нём пойдёт речь в следующем разделе.
Ещё один фильтр, use_filter_ngram, в примере выше не показан. Он разбивает каждое слово на N-граммы, подстроки заданной длины: «search» превращается в «sea», «ear», «arc» и «rch». С таким индексом работают поиск по подстроке и обычные LIKE и ILIKE по индексируемой колонке через VIEW, а краевые N-граммы (фильтр use_filter_edge_ngram) дают автодополнение.
У N-грамм есть недостаток: каждое слово превращается в десяток термов, индекс занимает больше места и медленнее обновляется, поэтому N-граммы включают только там, где нужен поиск по части слова.
Полнотекстовый индекс в YDB — это набор обычных таблиц. Поэтому он сразу получает всё, что умеет табличный слой: партиционирование, реплики чтения, восстановление после отказа оборудования, транзакции. Ничего из этого не пришлось реализовывать заново для индекса.
Пока такой индекс отвечает только на вопрос, в каких документах есть слова запроса. Но пользователю нужны не все такие документы, а первые двадцать самых подходящих. Чтобы их выбрать, индексу нужно хранить больше, чем список документов для каждого терма.
Ранжирование меняет индекс
Полнотекстовый поиск в YDB работает в два шага. Сначала отбор: функция FulltextMatch находит документы, в которых есть слова запроса. Затем ранжирование: функция FulltextScore оценивает, насколько хорошо каждый найденный документ подходит под запрос. Для отбора хватает обычного инвертированного индекса, а для ранжирования его приходится расширять.
Начнём с отбора. Запрос разбивается на термы тем же токенизатором и фильтрами, что и документы при индексации, поэтому «суды» в запросе находит «суд» в документе. Для каждого терма YDB читает его posting list и объединяет списки.
По умолчанию документ должен содержать все слова запроса. Запрос «суд краснодар» найдёт документ, где есть и «суд», и «Краснодар», и пропустит тот, где упомянут только суд. Чтобы хватило любого слова, правило ослабляют аргументом DefaultOperator:
SELECT id, title
FROM documents VIEW ft_idx
WHERE FulltextMatch(body, "суд краснодар", "Or" AS DefaultOperator)
LIMIT 20;Такой запрос вернёт и документы про суд без упоминания Краснодара. Обратно ужесточить его помогает MinimumShouldMatch: он задаёт, сколько слов запроса должно совпасть, числом или долей. Аргумент работает только вместе с Or, потому что при правиле «все слова» ужесточать уже нечего.
Отбор не решает задачу целиком: слова «суд» и «Краснодар» встречаются в сотнях тысяч документов юридического архива. Юристу не нужны сотни тысяч результатов, ему нужны первые двадцать самых подходящих. Поэтому совпадения нужно упорядочить и взять лучшие.
Порядок документов задаёт BM25, классический алгоритм ранжирования. Он учитывает три величины: как часто терм встречается в документе, какой длины документ и насколько терм редок во всём наборе документов. Редкая фамилия получает больший вес, чем «суд», а короткий документ с двумя вхождениями оказывается выше длинного с теми же двумя.
Все три величины — частоту терма, длину документа и редкость терма — нужно где-то хранить, а в обычном инвертированном индексе их нет. Поэтому в YDB два типа полнотекстового индекса: fulltext_plain хранит только posting lists, и его хватает для FulltextMatch, а fulltext_relevance хранит рядом частотную статистику, и с ней работает FulltextScore.
Статистика замедляет запись: при вставке, изменении и удалении документа обновляются не только posting lists, но и величины, от которых зависят оценки других документов. Если нужен только отбор по совпадению, fulltext_plain занимает меньше места и обновляется быстрее.
SELECT id, title, FulltextScore(body, "суд краснодар") AS relevance
FROM documents VIEW ft_idx
WHERE FulltextScore(body, "суд краснодар") > 0
ORDER BY relevance DESC
LIMIT 50;Здесь FulltextScore выполняет оба шага: в WHERE она отбирает документы по тому же правилу, что и FulltextMatch, то есть по умолчанию те, где есть все слова запроса, а в SELECT возвращает оценку для сортировки. Условие больше нуля обязательно: по нему запрос получает доступ к индексу релевантности, и другой порог вместо нуля поставить нельзя. Вызов нужно повторять, потому что в YQL, как и в обычном SQL, в WHERE нельзя сослаться на псевдоним из SELECT.
Токенизатор и фильтры задаются при создании индекса, и запрос обрабатывается теми же правилами, что и документы. Поэтому сменить стеммер или включить N-граммы на работающем индексе нельзя: нужно построить новый.
Разное обновление индексов при записи
Что найдёт запрос сразу после записи, зависит от устройства индекса, и для двух индексов ответ различается.
В полнотекстовом индексе нет структуры, которая зависела бы от всех данных сразу, как дерево кластеров у векторного. Новый документ меняет только posting lists своих слов, а это обычные строки служебных таблиц. Поэтому YDB записывает их в той же транзакции, что и строку таблицы: после INSERT документ находится сразу, после DELETE сразу перестаёт находиться, UPDATE перестраивает списки изменённых термов.
Векторный индекс устроен иначе: он строится один раз по данным, которые были в таблице на момент построения, алгоритм k-средних делит векторы на кластеры, для каждого считает центроид, средний вектор кластера, и так на каждом уровне дерева (мы уже писали про этот механизм на Хабре). При обновлении новая строка попадает в ближайший существующий кластер, удалённая уходит из posting table, служебной таблицы, которая связывает кластер со строками, а центроиды не пересчитываются.
Данные и индекс остаются согласованы, но дерево кластеров со временем перестаёт быть сбалансированным: одни кластеры набирают слишком много векторов и замедляют поиск, другие пустеют, и полнота падает.
Степень деградации векторного индекса зависит от того, как приходят данные. Индекс, построенный на представительной выборке, деградирует медленно. Обратная крайняя ситуация — индекс на пустой таблице состоит из одного кластера, и поиск по нему равен полному перебору, поэтому на пустой таблице индекс не строят. Когда данных накопилось много и полнота заметно упала, индекс перестраивают: рядом строят новый и атомарно заменяют им старый.
В итоге оба индекса согласованы с данными: удалённая строка не вернётся ни из одного. Различаются они точностью. Полнотекстовый поиск находит все документы со словами запроса. Векторный ищет приближённо: на каждом уровне дерева он просматривает только несколько ближайших кластеров, и часть подходящих строк может остаться в соседних.
Фильтр — часть схемы индекса
Поиск почти никогда не идёт по всем данным таблицы. В Нейроюристе пользователь выбирает сферу права и категорию документов. Запрос к базе знаний техподдержки ограничен именем сервиса, окружением и критичностью инцидента. У агента для сотрудников фильтр выражает право доступа: документы чужого проекта в контекст модели не попадают.
Как объединить поиск по индексу и фильтрацию?
Применить фильтр до поиска нельзя: и векторный, и полнотекстовый индекс построены сразу для всех строк таблицы. Векторный индекс делит все строки таблицы на дерево кластеров и позволяет быстро спуститься от корня дерева к одному или нескольким близким по смыслу кластерам. Применение фильтра к такому индексу потребует перестройки всего дерева, что несоизмеримо медленнее, чем поиск по дереву. Полнотекстовый индекс также делит все строки таблицы, но не на дерево кластеров, а на списки документов, в которых встречается то или иное слово. В этих списках также хранится статистика редкости слов BM25: она используется для ранжирования и считается относительно всех строк таблицы. Применение фильтра к такому индексу потребует пересчёта всей статистики, что является очень дорогой операцией.
Применить фильтр после поиска также нельзя, но по другой причине. Архитектура векторного поиска подразумевает, что, спустившись вниз по дереву, поиск найдёт в кластерах нижнего уровня оптимальное количество элементов: не слишком много, чтобы не тратить время на расчёт расстояний, но и не слишком мало, чтобы вернуть не менее запрошенного количества. Если применить к найденным элементам фильтр, то оставшихся элементов может оказаться недостаточно. И исправить такую ситуацию будет нельзя — дерево уже пройдено, кластеры нижнего уровня найдены, больше элементов взять неоткуда.
Для полнотекстового поиска ситуация проще — если результатов недостаточно, то можно продолжать спускаться по ранжированной выдаче. Но популярное слово вроде «Краснодар» может встречаться в сотнях тысяч документов, и объём чтений становится непредсказуемым. Тысячи высоко ранжированных документов могут не подпадать под условие фильтра, но проверять их придётся по одному, так как самих документов в индексе нет.
Повторный поиск с увеличенным лимитом позволяет применить фильтр после поиска, но делает это ценой непредсказуемой задержки. А если фильтр выражает право доступа, отбор кандидатов до фильтра означает, что чужой документ уже прочитан из базы и передан в приложение, что может не соответствовать высоким стандартам безопасности.
Поэтому в YDB фильтр входит в структуру индекса. Колонки фильтрации перечисляются перед текстовой или векторной колонкой при создании индекса, и для каждого значения фильтра индекс хранит отдельную структуру: для полнотекстового — свой инвертированный индекс, для векторного — своё дерево кластеров.

Запрос к такому индексу содержит условие равенства по каждой колонке фильтрации, и неподходящие документы исключаются до отбора кандидатов. Такой индекс занимает больше места и медленнее обновляется, поэтому колонки фильтрации выбирают как часть схемы данных, вместе с первичным ключом.
Если данные не сбалансированы, то после применения фильтра в кластерах нижнего уровня может остаться недостаточно элементов, и полнота векторного поиска понизится. Для юридических данных обычная ситуация, когда судебная практика по защите прав потребителей содержит 2,5 млн векторов, а законодательство по корпоративному праву — всего 6 тысяч.
Параметры levels и clusters задавались на индекс целиком, и с усреднёнными настройками по корпоративному праву находилось 26 векторов вместо нужных 50. Поэтому разработчики Нейроюриста использовали семейство векторных индексов с разными параметрами, от tiny до xxxlarge, и код приложения выбирал индекс под каждую комбинацию фильтров.
В релизе 26.3 фильтруемый векторный индекс подбирает число кластеров автоматически. При adaptive_clusters=true YDB считает, сколько векторов содержит каждое значение колонки фильтрации, и выбирает для него число кластеров: значение с миллионами векторов получает число, близкое к верхней границе clusters, значение с несколькими десятками векторов — 2.
Если параметры индекса не задавать явно, адаптивный режим включается по умолчанию. Семейство индексов из статьи про Нейроюриста продолжает работать; адаптивный индекс позволяет заменить его одним.
ALTER TABLE legal_embeddings
ADD INDEX law_vec_idx
GLOBAL USING vector_kmeans_tree
ON (law_area, embedding)
WITH (
distance=cosine,
vector_type="float",
vector_dimension=512,
levels=2,
clusters=512,
adaptive_clusters=true
);Здесь clusters=512 задаёт верхнюю границу для одного значения law_area.

HybridRank: два поиска в одном плане запроса
Теперь в таблице есть два индекса, каждый обновляется вместе с данными и фильтрует внутри себя. Остаётся объединить внутри СУБД две выдачи, полнотекстовую и векторную, оценки которых несопоставимы. Дальше я называю каждую из них ветвью гибридного поиска.
Раньше две выдачи сводил тот, кто обращается к СУБД, в нашем примере — бэкенд агента. Он отправлял два независимых запроса, получал два списка документов и объединял их своим кодом: находил документы из обеих ветвей, решал порядок остальных документов, убирал дубликаты. Оба запроса читали данные независимо, фильтры в них легко расходились, соответственно, каждая команда писала это слияние заново.
В релизе 26.3 логика гибридного ранжирования выполняется внутри YQL-запроса. Гибридный поиск в YDB обходится без нового типа индекса: это запрос к таблице, в которой уже есть полнотекстовый индекс fulltext_relevance и векторный индекс vector_kmeans_tree. Функция HybridRank получает по одному оценивающему выражению на ветвь, и YDB сопоставляет каждую ветвь с индексом по колонке автоматически.

Для запроса с гибридным поиском по таблице documents нужен векторный индекс. Поэтому рядом с ft_idx добавляем vec_idx только по колонке embedding.
ALTER TABLE documents
ADD INDEX vec_idx
GLOBAL USING vector_kmeans_tree
ON (embedding)
WITH (
distance=cosine,
vector_type="float",
vector_dimension=512,
levels=2,
clusters=512
);Сам запрос с гибридным поиском выглядит так:
DECLARE $embedding AS List<Float>; -- эмбеддинг запроса считает приложение
$queryText = "суд краснодар";
$queryVector = Knn::ToBinaryStringFloat($embedding);
SELECT id, title
FROM documents
ORDER BY HybridRank(
FulltextScore(body, $queryText), -- полнотекстовая ветвь → ft_idx
Knn::CosineDistance(embedding, $queryVector) -- векторная ветвь → vec_idx
)
LIMIT 50;Указывать индексы через VIEW здесь не нужно: YDB находит их сам по колонкам в аргументах HybridRank. Если на колонке несколько подходящих индексов, нужный задают по имени аргументом Indexes.
Дальше YDB выполняет обе ветви как части одного плана. Каждая ветвь отбирает свой пул кандидатов, по умолчанию в 10 раз больше, чем LIMIT. Размер пула планировщику нужен до выполнения, поэтому в таком запросе LIMIT задаётся числом, а не параметром; аргумент Limits задаёт размеры пулов явно и снимает это требование.
Размер пула и есть главный параметр качества: документ, не попавший в пул своей ветви, при объединении этой ветвью не учитывается, и никакое объединение его уже не поднимет.
Объединить два пула тоже непросто: складывать оценки напрямую нельзя, потому что BM25 не ограничен сверху и зависит от набора документов, а косинусное расстояние лежит между 0 и 2. Поэтому по умолчанию работает Reciprocal Rank Fusion, RRF. Он учитывает только позиции документа в каждой ветви: вклад ветви равен единице, делённой на сумму константы и позиции, и вклады суммируются.
Константа по умолчанию равна 60 и не даёт первым позициям непропорционально большой вес; веса ветвей равны единице, и подбирать их обычно не приходится.
Второй режим, linear, работает не с позициями, а с самими оценками. Он приводит оценки каждой ветви к диапазону от 0 до 1 и складывает их с весами. Режим задаётся аргументом Mode, а веса передаются кортежем Weights, по одному на ветвь. Универсальных весов здесь нет, их нужно подобрать под свои данные, например, на выборке запросов с известными правильными ответами:
SELECT id, title
FROM documents
ORDER BY HybridRank(
FulltextScore(body, $queryText),
Knn::CosineDistance(embedding, $queryVector),
"linear" AS Mode,
(0.3, 0.7) AS Weights)
LIMIT 50;Если встроенных режимов не хватает, объединение можно настроить с помощью анонимной функции. Аргумент RankLambda получает позиции документа в пулах ветвей, а аргумент ScoreLambda — исходные оценки ветвей. Лямбда принимает один список: элементов в нём столько же, сколько ветвей, и в том же порядке (подробности — в документации). Чем большее значение вернула лямбда, тем выше документ в ранжировании. Анонимная функция полностью управляет объединением, поэтому Mode, Weights и K вместе с ней не задают, а размеры пулов по-прежнему настраивает Limits:
$rrf = ($ranks) -> (
COALESCE(1.0 / (60.0 + CAST($ranks[0] AS Double)), 0.0) +
COALESCE(1.0 / (60.0 + CAST($ranks[1] AS Double)), 0.0));
SELECT id, title
FROM documents
ORDER BY HybridRank(
FulltextScore(body, $queryText),
Knn::CosineDistance(embedding, $queryVector),
$rrf AS RankLambda)
LIMIT 50;Объединение внутри плана даёт единый план запроса вместо двух независимых, одни правила фильтрации для обеих ветвей, одну дедупликацию и одну выдачу, которую агент получает без своего кода слияния.
Гибридный поиск в рекомендациях
В рекомендательной системе пользователь ничего не вводит: запрос за него собирает приложение. Текстовая часть — это ключевые слова из недавних просмотров и покупок: бренд, категория, размер. Векторная — эмбеддинг профиля, например, усреднённый эмбеддинг просмотренных карточек. Город, диапазон цен и наличие задаются обычным фильтром в WHERE. Такой фильтр работает вместе с HybridRank, но применяется к уже объединённой выдаче: кандидатов каждая ветвь отбирает без него, поэтому при узком фильтре карточек в ответе может оказаться меньше, чем просит LIMIT, и пул расширяют аргументом Limits.
Такому запросу нужны оба сигнала: полнотекстовая ветвь находит карточки с нужным брендом, артикулом и словами из описания, а векторная — товары, близкие к профилю по смыслу, даже без общих слов с ним. Режим и веса здесь по умолчанию: RRF с равными весами хорошо работает без подбора.
DECLARE $profileVector AS List<Float>;
$profileText = "куртка утеплённая осень офис";
$profileEmbedding = Knn::ToBinaryStringFloat($profileVector);
SELECT sku, title, price
FROM products
WHERE city = "Москва" AND in_stock = true
ORDER BY HybridRank(
FulltextScore(description, $profileText),
Knn::CosineDistance(item_embedding, $profileEmbedding))
LIMIT 20;Каталог меняется постоянно, и здесь важно различие из раздела о записи. Полнотекстовая ветвь видит изменённую карточку в той же транзакции: товар с пометкой «нет в наличии» исчезает из выдачи сразу. Векторная ветвь тоже согласована с данными, и новая карточка попадает в ближайший кластер и находится, но дерево кластеров деградирует, и его перестраивают по расписанию, так что рекомендация опирается на текущий каталог.
Как проверить, что стало лучше
Гибридная выдача точнее каждой ветви только на тех запросах, где ветви ошибаются по-разному, поэтому качество проверяют по контрольным примерам.
Для этого мы собираем тестовый набор документов из семейств вокруг каждого контрольного запроса: точное совпадение, тот же смысл другими словами, похожий симптом с другой причиной, та же причина в другом сервисе и правдоподобный документ, который выглядит подходящим и не подходит. Для каждого запроса записаны документы, которые обязаны попасть в топ, и документы, которым в нём не место.
Затем три режима, полнотекстовый, векторный и гибридный, прогоняются на одних и тех же запросах, и сравниваются позиции одних и тех же документов в топ-5. Доля запросов, в которых нужный документ попал в топ-k, и есть метрика hit@k.
Нагрузку и полноту измеряют утилиты YDB CLI: ydb workload fulltext и ydb workload vector. Они загружают набор данных, строят индекс, прогоняют поисковые запросы, измеряют задержки p50, p95 и p99 и нагрузку на запись, а утилита ydb workload vector с ключом --recall считает полноту приближённого поиска относительно точного перебора.
Если на контрольных запросах нужный документ не попадает в топ, сначала стоит понять, где он потерялся. Если его нет в пуле своей ветви, объединение уже не поможет: нужно увеличить пул аргументом Limits или полноту самой ветви. Для векторной ветви это число кластеров, которые просматриваются на каждом уровне дерева (KMeansTreeSearchTopSize), для полнотекстовой — токенизатор и фильтры индекса, например стемминг (use_filter_snowball). Если документ в пуле есть, но оказывается ниже, чем нужно, — дело в объединении: в режиме RRF его меняет константа K, в режиме linear — веса Weights.
Заключение
Реальный запрос почти всегда смешивает точное и смысловое, и одному виду поиска его не выполнить. В YDB 26.3 оба вида поиска работают в одном SQL-запросе: полнотекстовая ветвь находит номера, имена и коды, векторная — смысл, а HybridRank сводит обе выдачи в одну.
Оба индекса — обычные таблицы YDB. Они обновляются в той же транзакции, что и данные, и подчиняются тем же фильтрам и правам доступа, поэтому отдельная поисковая система не нужна, а выдача не расходится с данными.
Полнотекстовый и гибридный поиск уже есть в опенсорсе. С 23 сентября они доступны в Yandex Managed Service for YDB, в том числе в режиме Serverless со стартовым тарифом, и в коммерческой сборке с открытым ядром для установки в контуре компании. Подробности описаны в документации: полнотекстовые индексы, гибридный поиск, векторные индексы.
Мы общаемся с пользователями в Telegram и на Хабре. Расскажите в комментариях, как вы сейчас ищете по тексту в своих проектах и чего вам в этом поиске не хватает: мне как разработчику СУБД это интересно.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.