Винни-Пух в 768 измерениях: семантический поиск Codex Pets на YDB. codex.. codex. Codex Pets.. codex. Codex Pets. Yandex AI Studio.. codex. Codex Pets. Yandex AI Studio. ydb.. codex. Codex Pets. Yandex AI Studio. ydb. векторный поиск.. codex. Codex Pets. Yandex AI Studio. ydb. векторный поиск. рекомендательные системы.. codex. Codex Pets. Yandex AI Studio. ydb. векторный поиск. рекомендательные системы. семантический поиск.. codex. Codex Pets. Yandex AI Studio. ydb. векторный поиск. рекомендательные системы. семантический поиск. эмбеддинги.

В Codex можно поселить анимированного питомца. Пока агент работает, персонаж бегает, машет рукой, ждёт или печалится после неудачной команды. По его состоянию видно, что происходит с задачей. Сам пакет состоит из pet.json и атласа с кадрами анимации.

Самих питомцев я собрал в каталоге Codex Pets. Сейчас в нем 153 одобренных питомца: от красной панды и аксолотля до героев игр и старых мультфильмов. Любого можно посмотреть в браузере, скачать в ZIP или установить одной командой.

Главная страница Codex Pets

Главная страница Codex Pets

Главная страница реестра. Справа работает живая анимация выбранного питомца.

Параллельные задачи с питомцем в Codex

Параллельные задачи с питомцем в Codex

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

Пока каталог был небольшим, имени и тегов хватало. После сотого питомца поиск по тегам стал практически неприменим и неудобен, потому что человек может помнить «тревожного коричневого медведя из старого мультфильма», но не Winnie и тем более не round-bear.

Обычный поиск проверил имя, slug, описание и теги и не нашёл ничего. Карточка Winnie заполнена по-английски, а коричневый медведь виден только на картинке. В тесте функция rankPetsLexically вернула пустой список.

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

Я решил решить проблему с помощью семантического поиска.

Имя, описание и теги стали текстовым представлением питомца. Модель эмбеддингов кодирует этот документ с ролью document, а поисковую строку с ролью query. Оба вызова возвращают вектор из 768 чисел. Визуальное представление строится в два шага: мультимодальная модель описывает четыре выбранных кадра атласа, затем подпись кодируется как document. Близкие по смыслу тексты оказываются рядом, даже если написаны на разных языках и не содержат общих слов. Позже я добавил каждому питомцу ещё один текстовый вектор с ролью query для фонового расчёта похожих карточек для отображения похожих питомцев.

Публичный поиск на тот же запрос поставил Winnie первым, а вторым показал Foggy Hedgehog. Этот результат и дал статье заголовок.

Результат поиска по описанию Винни-Пуха

Результат поиска по описанию Винни-Пуха

Запрос «тревожный коричневый медведь из старого мультфильма» поставил Winnie первым.

Карточки, ZIP-файлы и атласы уже лежали в YDB. Векторы и готовые рекомендации я сохранил там же. В онлайн-поиске косинусную близость считает YDB через Knn::CosineSimilarity. Фоновый пересчёт похожих питомцев загружает векторы из YDB и сравнивает их попарно в приложении.

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

Из чего складывается смысл питомца

Вот карточка Winnie. Под описанием работает анимированный предпросмотр, рядом перечислены состояния и метаданные пакета.

Карточка Winnie

Карточка Winnie

Карточка Winnie: описание, установка, метаданные и все состояния анимации.

Из pet.json поиск берёт имя, тип, описание и отсортированные теги. Визуальные признаки есть только в атласе. В v1 версии питомцев кодекса это сетка 8×9, в v2 версии размер вырос до 8×11 – добавилось направление взгляда. Каждая строка хранит отдельное состояние анимации, а столбцы содержат его последовательные кадры.

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

Точное имя и редкий тег по-прежнему лучше находит обычный поиск. Галерея объединяет три ранжированных списка: лексический, текстовый семантический и визуальный семантический. Блок «Похожие питомцы» (Related Pets) использует те же источники данных, но рассчитывается заранее.

Почему я оставил векторы в YDB

Приложение уже работало с YDB напрямую через ydb-sdk и local-ydb-toolkit (небольшой mcp сервер для разворачивания и управления локальной базой ydb)

Для 153 питомцев отдельная векторная база добавила бы синхронизацию, резервное копирование и ещё один сервис в мониторинге, в то время как YDB и так может считать близость через Knn::CosineSimilarity и векторный поиск.

К исходным таблицам добавились четыре:

Таблица

Что в ней лежит

codex_pet_search_embeddings

текстовые и визуальные векторы, а также отдельные query-role векторы для рекомендаций

codex_pet_search_captions

внутренние подписи к выбранным кадрам атласа

codex_pet_related_snapshots

готовые списки похожих машинных имён по поколениям

codex_pet_related_state

указатели на активное и предыдущее поколение

Строка с эмбеддингом выглядит так:

CREATE TABLE codex_pet_search_embeddings (
  model_revision Utf8 NOT NULL,
  pet_slug Utf8 NOT NULL,
  source_hash Utf8 NOT NULL,
  dimensions Uint32 NOT NULL,
  embedding String NOT NULL,
  updated_at Utf8 NOT NULL,
  PRIMARY KEY (model_revision, pet_slug)
);

Поле embedding имеет тип String, но хранит бинарный FloatVector. Вектор из 768 значений занимает меньше места, чем JSON-массив, и его можно передать напрямую в KNN-функции YQL.

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

SELECT pet_slug,
       source_hash,
       Knn::CosineSimilarity(embedding, $query_embedding) AS score
FROM codex_pet_search_embeddings
WHERE model_revision = $model_revision
  AND dimensions = $dimensions
ORDER BY score DESC;

ANN-индекса здесь пока нет (на такой небольшой выборке он не нужен, хотя, конечно, на миллиардах векторов он значительно ускоряет поиск). Чтобы измерить именно работу YDB, без вызова реальной эмбеддинг-модели из AI Studio, я взял уже сохранённый вектор запроса. Два раза прогрел запрос, затем выполнил ещё 20 запусков. В каждом из них YDB читала 153 строки с векторами карточек и ещё одну строку с вектором запроса: всего 154 строки, 476 098 байт на одном шарде. По total_duration_us медиана составила 190 мс, а p95 — 196 мс.

В эти 196 мс входит выполнение YQL вместе с компиляцией. Вызов модели и внешний HTTP в замер не входят. В этом замере YDB перебирала все 153 вектора текущего каталога.

Как не перепутать старый вектор с новым

В первом варианте таблицы я хотел оставить только pet_slug и массив чисел. Такая строка понятна ровно до первой смены модели. Потом передо мной лежат те же 768 чисел, но я уже не знаю, какой эмбеддер их построил, в каком режиме он работал и можно ли сравнивать этот вектор с соседними.

Тут было просто решение – добавить поле model_revision. В него входят модель, роль и правила сборки документа. Роль здесь играет роль. Yandex AI Studio по-разному кодирует document и query, хотя обе мои текущие конфигурации возвращают 768 измерений. Например имевшиеся до перехода на 768 измерений 256-мерные данные получили другие ревизии. Один YQL-запрос читает только выбранную ревизию и заодно проверяет dimensions.

Карточка может измениться раньше, чем для неё пересчитается эмбеддинг. Поэтому вместе с вектором я сохраняю source_hash — хеш текста, из которого этот вектор был построен. Во время поиска я заново собираю текст актуальной карточки и сравниваю хеши. Если они не совпали, значит вектор устарел, и семантический поиск временно его пропускает (до тех пор пока не произойдет пересчет).

До появления свежей строки работают остальные варианты поиска для карточки которая находится в режиме пересчета эмбеддингов.

Что происходит после Enter

После Enter сначала работают обычные фильтры по типу, тегам и автору. К модели запрос попадает позже. Так семантический ранг не может вернуть карточку, которую пользователь отсеял явно. Пустую строку и запрос короче трёх нормализованных символов я тоже оставил обычному поиску.

Остальные запросы идут по трём веткам:

Как собирается поисковая выдача

Как собирается поисковая выдача

Три независимых списка объединяются по позициям. При ошибке векторного контура остаётся обычный поиск.

Первая ветка ранжирует имя, машинное имя, теги и слова описания. В двух других модель строит вектор запроса, а YDB сравнивает его отдельно с текстовыми документами и сгенерированными визуальной моделью подписями.

Исходные оценки этих веток несопоставимы. У лексического поиска своя шкала, у косинусной близости своя. Поэтому я объединяю уже отсортированные списки с помощью Weighted Reciprocal Rank Fusion. Позиция карточки даёт вклад weight / (60 + position). Лексический и текстовый списки имеют вес 1, а вес визуального списка я подбирал на калибровочной выборке.

У такого объединения есть цена. RRF видит позиции, но не разницу между оценками соседей. На каталоге из 153 питомцев я с этим мирюсь: спорный результат можно открыть и разложить на три списка без отдельного обучаемого ранжировщика.

На ответ модели я отвёл 800 мс. Если модель не успела или упал векторный запрос, API отдаёт лексическую выдачу. Визуальную ветку я изолировал отдельно, чтобы её ошибка не выключала текстовый семантический список.

Во время финальной проверки я поймал lexical_fallback прямо в продакшене. После деплоя контейнер запустился без файла с ключом AI Studio. Снаружи всё выглядело нормально: сайт отвечал, ошибок на странице не было. Но все 14 контрольных запросов обслужил лексический поиск. После восстановления файла семантическая выдача снова заработала. Диагностика теперь проверяет HTTP-ошибки и режим ранжирования в ответе.

Четыре кадра из атласа

Число ячеек сначала выглядит внушительно: 72 в атласе v1 и 88 в v2. Но это восемь состояний анимации, разложенных по последовательным фазам. Для поиска внешности соседние фазы добавляют мало. Я выбрал центральные кадры четырёх строк: idle, running-right, waving и review. В мультимодальный запрос уходят четыре изображения вместо 72 или 88.

Выбор кадров пришлось сделать воспроизводимым. Если следующий запуск сдвинется на соседнюю ячейку, одна и та же версия атласа может получить другую подпись. Поэтому точные координаты записаны в политике pet-vision-central-frames-v1. Её имя вместе с caption_revision, моделью, промптами и SHA-256 атласа входит в source_hash подписи.

Дальше идут два вызова модели. Qwen описывает четыре изображения на русском и английском, затем поле caption_text превращается в вектор визуального профиля. Эти промежуточные данные остаются внутри сервиса. Публичные API, MCP и WebMCP не возвращают подписи, промпт, изображения или векторы.

Визуальная ветка помогла на запросах black gothic dress and blindfold, blonde woman with eyepatch и pink hair red bodysuit, где нужных деталей не хватало в тегах. На точном имени Zero Two она, наоборот, опустила правильную карточку. Так появился отдельный вес визуального списка.

Проверка на 14 запросах

Для калибровки я отделил восемь запросов, ещё шесть оставил контрольными. На первой группе перебрал веса визуального списка 0,25, 0,50, 0,75 и 1,00. До контрольной группы дошёл один профиль, уже привязанный к ревизиям моделей.

Сам прогон использует 14 запросов из репозитория. Для 12 заранее записаны подходящие машинные имена. Два странных запроса, quantum banana compiler и tax invoice spreadsheet, должны дать пустой результат. Скрипт ранжирует один и тот же публичный манифест из 153 питомцев через rankPetsLexically, затем забирает семантическую выдачу из /api/pets.

В таблицу я вынес NDCG@5 и Recall@5. Первая метрика учитывает порядок результатов, вторая считает долю эталонных питомцев в первой пятёрке.

Выборка

Запросов / с эталоном

Обычный NDCG@5

Семантический NDCG@5

Обычный Recall@5

Семантический Recall@5

калибровочная

8 / 7

0,496

0,723

0,476

0,702

контрольная

6 / 5

0,600

0,726

0,600

0,800

всего

14 / 12

0,539

0,724

0,528

0,743

Средние числа скрывают несколько полезных деталей. Оба точных имени остались на первом месте, поэтому MRR@5 для них равен 1,0. Два отрицательных запроса действительно вернули пустую выдачу. cute и badass, наоборот, провалились: среди первых пяти не оказалось ни одного питомца из эталона.

На всём наборе NDCG@5 вырос с 0,539 до 0,724, Recall@5 с 0,528 до 0,743. Я считаю это smoke test качества. Четырнадцати запросов мало для вывода о любых формулировках, а публичный API возвращает только объединённый список. Текстовую и визуальную ветки этот прогон по отдельности не измеряет.

Задержку я проверил отдельно после рестарта. Один последовательный прогон тех же 14 запросов дал p95 полного HTTP-времени 1,18 с. Это примерный ориентир без конкретного разложения на AI Studio, YDB и сеть.

Как Winnie получает похожих друзей

На странице Winnie сейчас видны четыре рекомендации:

Похожие питомцы для Winnie

Похожие питомцы для Winnie

Foggy Hedgehog, Ezhik, Krosh и Cheburashka на боевой странице.

Для одной страницы достаточно сравнить текущего питомца с остальным каталогом, это O(N). После модерации, правки карточки или смены модели рекомендации нужны уже для всех питомцев, и полный пересчёт требует O(N²) попарных сравнений. Поэтому он выполняется в фоне, а страница читает одну готовую строку.

Фоновый расчёт строит для каждого питомца три списка кандидатов. Один из них собирается по типу и тегам. Для текстовой ветки запрос строится из нормализованных тегов, а при их отсутствии из описания. Модель кодирует его с ролью query. Карточки кандидатов заранее закодированы с ролью document. Визуальная ветка сравнивает векторы двуязычных подписей к атласам.

Фоновый процесс загружает из YDB только актуальные векторы с совпадающими хешами, а попарную косинусную близость считает в приложении. Затем RRF с k = 60 объединяет текст с весом 1, визуальный список с весом 0,5 и метаданные с весом 0,15. На 12 калибровочных случаях nDCG@4 вырос с 0,048 у одних метаданных до 0,131 после добавления текста и до 0,208 у полного гибрида. Отложенных случаев было всего четыре. На них гибрид не ухудшил текстовую ветку, но для оценки произвольных карточек этого мало. Зафиксированный профиль сейчас работает в продакшене.

Перед записью пересчёт требует свежие query-, document- и visual-векторы для всех 153 одобренных питомцев, ранжирует пары и готовит по одной строке на питомца. В ней лежат generation_id, ревизия ранжирования и максимум четыре уникальных машинных имени без самого исходного питомца.

Пересчёт и публикация похожих питомцев

Пересчёт и публикация похожих питомцев

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

codex_pet_related_state хранит идентификаторы requested, active и previous поколений. До активации процесс проверяет входные векторы и все записанные строки. Не хватает хотя бы одного свежего вектора, значит поколение остаётся черновиком. После успешной проверки одна serializable read-write транзакция переносит прежний active_generation_id в previous_generation_id и назначает новый активный идентификатор.

Пока состояние равно building, HTTP-обработчик использует эвристику по типу и тегам и не читает недописанное поколение. Если карточки или векторы изменились во время расчёта, активация отменяется, после чего восстанавливается совместимое предыдущее состояние. Отсутствующая или несовместимая строка снимка также приводит к эвристической выдаче. В одном ответе не смешиваются строки из разных поколений.

Винни нашёлся. Что дальше

Запрос про «тревожного коричневого медведя» теперь выводит Winnie первым. Точный запрос Zero Two по-прежнему остаётся на первом месте благодаря обычному поиску. А страница Winnie получает Foggy Hedgehog, Ezhik, Krosh и Cheburashka как похожих pets.

Сейчас любой странный результат можно разложить на три исходных списка и веса RRF. Онлайн-ветка делает один полный проход по активной ревизии в YDB. Пересчёт related pets устроен тяжелее: он сравнивает весь каталог со всем каталогом и растёт как O(N²). При заметном росте числа карточек именно эта часть упрётся в предел первой.

Поиск и related pets удалось построить на одном наборе данных. Они читают одни и те же векторы из YDB, проверяют их ревизии и расходятся только на этапе ранжирования. Поиск отвечает на текущий запрос, рекомендации заранее готовят целое поколение. source_hash, атомарное переключение и lexical_fallback не дают смешать старые данные с новыми и сохраняют рабочую выдачу при сбое модели.

В этом и состоит непростой путь Винни по 768 измерениям.


Код и первоисточники

Codex Pets

YDB и Yandex AI Studio

Ранжирование и оценка качества

Автор: astandrik

Источник