- BrainTools - https://www.braintools.ru -

Винни-Пух в 768 измерениях: семантический поиск Codex Pets на YDB

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

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

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

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

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

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

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

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

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

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

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

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

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

Публичный поиск [2] на тот же запрос поставил 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 [3] (небольшой 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 сначала работают обычные фильтры по типу, тегам и автору. К модели запрос попадает позже. Так семантический ранг не может вернуть карточку, которую пользователь отсеял явно. Пустую строку и запрос короче трёх нормализованных символов я тоже оставил обычному поиску.

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

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

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

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

Первая ветка ранжирует имя, машинное имя, теги и слова описания. В двух других модель строит вектор запроса, а 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

Источник [20]


Сайт-источник BrainTools: https://www.braintools.ru

Путь до страницы источника: https://www.braintools.ru/article/34064

URLs in this post:

[1] Codex Pets: https://pets.ydb-qdrant.tech/

[2] Публичный поиск: https://pets.ydb-qdrant.tech/api/pets?q=%D1%82%D1%80%D0%B5%D0%B2%D0%BE%D0%B6%D0%BD%D1%8B%D0%B9%20%D0%BA%D0%BE%D1%80%D0%B8%D1%87%D0%BD%D0%B5%D0%B2%D1%8B%D0%B9%20%D0%BC%D0%B5%D0%B4%D0%B2%D0%B5%D0%B4%D1%8C%20%D0%B8%D0%B7%20%D1%81%D1%82%D0%B0%D1%80%D0%BE%D0%B3%D0%BE%20%D0%BC%D1%83%D0%BB%D1%8C%D1%82%D1%84%D0%B8%D0%BB%D1%8C%D0%BC%D0%B0&pageSize=10

[3] local-ydb-toolkit: https://github.com/astandrik/local-ydb-toolkit

[4] ошибке: http://www.braintools.ru/article/4192

[5] Репозиторий: https://github.com/astandrik/codex-pets/

[6] Онлайн-поиск, три ветки и lexical fallback: https://github.com/astandrik/codex-pets/blob/d7eb6cc6675bfbcba4dc8cc7fb5599844e407ec9/src/lib/pets/search-service.ts

[7] Weighted RRF и правила итогового ранга: https://github.com/astandrik/codex-pets/blob/d7eb6cc6675bfbcba4dc8cc7fb5599844e407ec9/src/lib/pets/search-ranking.ts

[8] Выбор четырёх кадров из атласа: https://github.com/astandrik/codex-pets/blob/d7eb6cc6675bfbcba4dc8cc7fb5599844e407ec9/src/lib/pets/search-vision-frames.ts

[9] Фоновый пересчёт related pets: https://github.com/astandrik/codex-pets/blob/d7eb6cc6675bfbcba4dc8cc7fb5599844e407ec9/src/lib/pets/related-pets-rebuild.ts

[10] Хранение и переключение поколений рекомендаций: https://github.com/astandrik/codex-pets/blob/d7eb6cc6675bfbcba4dc8cc7fb5599844e407ec9/src/lib/pets/related-pets-repository.ts

[11] 14 запросов для проверки поиска: https://github.com/astandrik/codex-pets/blob/d7eb6cc6675bfbcba4dc8cc7fb5599844e407ec9/src/lib/pets/search-eval-fixtures.json

[12] Калибровочная и контрольная выборки related pets: https://github.com/astandrik/codex-pets/blob/d7eb6cc6675bfbcba4dc8cc7fb5599844e407ec9/src/lib/pets/related-pets-eval-fixtures.json

[13] KNN-функции YQL: https://ydb.tech/docs/en/yql/reference/udf/list/knn

[14] Векторные индексы YDB: https://ydb.tech/docs/en/yql/reference/syntax/create_table/vector_index

[15] Режимы транзакций YDB: https://ydb.tech/docs/en/concepts/transactions

[16] Текстовые эмбеддинги Yandex AI Studio: https://aistudio.yandex.ru/docs/ru/ai-studio/concepts/embeddings.html

[17] Запросы к мультимодальным моделям Yandex AI Studio: https://aistudio.yandex.ru/docs/ru/ai-studio/operations/generation/multimodels-request.html

[18] Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods: https://cormack.uwaterloo.ca/cormacksigir09-rrf.pdf

[19] Cumulated Gain-Based Evaluation of IR Techniques: https://faculty.cc.gatech.edu/~zha/CS8803WST/dcg.pdf

[20] Источник: https://habr.com/ru/articles/1067576/?utm_campaign=1067576&utm_source=habrahabr&utm_medium=rss

www.BrainTools.ru

Rambler's Top100