Семантический поиск против полнотекстового: тестим три embedding-модели на 10 000 категорий Ozon

1. Семантический поиск: поиск по смыслу

Идея семантического поиска — представить и документы, и запрос в виде числовых векторов. Такие векторы называют эмбеддингами (embeddings). Близкие по смыслу тексты получают близкие векторы. Для измерения похожести используется косинусное расстояние — метрика от 0 до 1, где 1 означает полную похожесть.

Как это работает

Текст → Embedding-модель → Вектор [0.012, -0.034, 0.071, ...]
                                    (сотни/тысячи измерений)

При индексации каждый документ превращается в вектор и сохраняется в базу. При поиске запрос тоже векторизуется. Затем pgvector находит ближайшие документы по косинусному расстоянию:

SELECT d.id, d.path, d.title,
       1 - (v.embedding <=> $1::vector) AS score
FROM documents d
JOIN document_vectors v ON v.document_id = d.id
ORDER BY v.embedding <=> $1::vector
LIMIT $2

Оператор <=> в pgvector — косинусное расстояние. 1 - distance даёт similarity score от 0 до 1.

Особенности pgvector

Расширение pgvector позволяет хранить векторы прямо в PostgreSQL. Для ускорения поиска создаётся IVFFlat-индекс — тип индекса, который разбивает векторы на кластеры:

CREATE INDEX ON document_vectors
  USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);

Параметр lists задаёт число кластеров при построении индекса. Для 10K документов 100 кластеров — разумный выбор. В production с миллионами записей стоит рассмотреть HNSW-индекс (CREATE INDEX ... USING hnsw). Он даёт лучшую recall-точность (долю найденных релевантных результатов), но потребляет больше памяти.

Три модели: кого сравниваем

Модель Провайдер Размерность Развертывание Особенности
Qwen3-Embedding-0.6B Alibaba / Qwen 1024 Локально, через TEI на GPU Мультиязычная, компактная, быстрая
GigaChat (EmbeddingsGigaR) Сбер 2560 API gigachat.devices.sberbank.ru Специально обучена на русском языке
OpenAI (text-embedding-3-small) OpenAI 1536 API api.openai.com Мультиязычная, широко используется

Каждая модель генерирует вектор своей размерности, поэтому в базе три отдельные таблицы:

-- Для Qwen (1024 измерения)
embedding vector(1024)
-- Для GigaChat (2560 измерений)
embedding vector(2560)
-- Для OpenAI (1536 измерения)
embedding vector(1536)

2. Полнотекстовый поиск: как работает и где упирается

PostgreSQL предлагает зрелый полнотекстовый поиск из коробки. Его ядро — два типа данных:

  • tsvector — нормализованное представление документа: слова приводятся к начальной форме (лемматизация), удаляются стоп-слова.
  • tsquery — нормализованное представление запроса в том же формате.

Оператор @@ проверяет совпадение, ts_rank ранжирует результаты по частотности совпавших лексем.

Как это выглядит в коде

Миграция, добавляющая полнотекстовый поиск к существующей таблице documents:

ALTER TABLE documents
ADD COLUMN IF NOT EXISTS search_vector tsvector;

UPDATE documents
SET search_vector = to_tsvector('russian', COALESCE(path, ''))
WHERE search_vector IS NULL;

CREATE INDEX IF NOT EXISTS documents_search_vector_gin_idx
  ON documents USING gin (search_vector);

CREATE OR REPLACE FUNCTION documents_search_vector_update()
RETURNS trigger AS $$
BEGIN
  NEW.search_vector := to_tsvector('russian', COALESCE(NEW.path, ''));
  RETURN NEW;
END
$$ LANGUAGE plpgsql;

CREATE TRIGGER documents_search_vector_update_trigger
BEFORE INSERT OR UPDATE OF path
ON documents
FOR EACH ROW
EXECUTE FUNCTION documents_search_vector_update();

Поисковый запрос:

SELECT id, path, title, LEFT(path, 220) AS snippet,
       ts_rank(search_vector, plainto_tsquery('russian', $1)) AS score
FROM documents
WHERE search_vector @@ plainto_tsquery('russian', $1)
ORDER BY score DESC
LIMIT $2

Работает быстро — медиана 1.3 мс на 10K документов. Но у полнотекстового поиска есть фундаментальные ограничения:

  1. Только совпадение лексем. Запрос лекарства найдёт документы со словом «лекарств*» в тексте. Но не найдёт «Аптека» или «БАДы».
  2. Нет понимания синонимов. велик — это велосипед, но для tsquery просто неизвестное слово.
  3. Нет кросс-языковости. gaming mouse не найдёт «Игровая мышь».
  4. Нет понимания намерения. у меня протекает кран — ноль результатов. Слова «протекает» и «кран» не встречаются в названиях категорий сантехники.

3. Архитектура проекта

Проект собран на Next.js + PostgreSQL + pgvector. Docker Compose поднимает pgvector/pgvector:pg18 и фронтенд на node:20-alpine. Qwen3 запускается отдельно через Hugging Face Text Embeddings Inference (TEI) с GPU.

Процесс индексации:

  1. Импорт: скрипт читает CSV с категориями Ozon и записывает path (иерархический путь вида Электроника / Компьютеры / Ноутбук) и title в таблицу documents.
  2. Векторизация: для каждого документа три embedding-провайдера параллельно генерируют векторы. Текст, уходящий в модель — это path, та же строка, которая индексируется в tsvector.
  3. Поиск: при запросе текст одновременно отправляется во все три модели, получает три вектора, по каждому ищет top-K ближайших документов.

4. Эксперимент

Датасет: 10 019 категорий товаров Ozon с иерархическими путями. Примеры:

  • Электроника / Компьютеры / Ноутбук
  • Строительство и ремонт / Сантехника / Смеситель
  • Спорт и отдых / Велосипед / Электровелосипед
  • Товары для животных / Корма и лакомства для кошек и собак

Подготовлено 18 запросов в 5 категориях. Они специально подобраны, чтобы показать разницу между подходами. Каждый запрос прогонялся через все 4 метода: full-text + 3 embedding-модели, top-5 результатов.


5. Результаты

5.1. Синонимы и разговорная лексика

Запросы, где слово из запроса отсутствует в данных, но смысл совпадает.

Запрос: «лекарства»

В данных нет слова «лекарства» в корневых категориях — есть «Аптека».

Метод Top-1 результат Score
Full-text (пусто)
Qwen3 Канцелярские товары 0.66
GigaChat Аптека / Лекарственные средства 0.92
OpenAI Товары для взрослых / БДСМ / Плетка 0.31

GigaChat безошибочно связал «лекарства» с аптечными категориями (score 0.92). Qwen3 промахнулся, уведя в канцелярию. OpenAI выдал абсолютно нерелевантный результат.

Запрос: «велик»

Разговорное слово для «велосипед».

Метод Top-1 результат Score
Full-text (пусто)
Qwen3 Красота и гигиена / Щетка для сухого массажа 0.55
GigaChat Спорт и отдых / Велосипед 0.89
OpenAI Товары для взрослых / Секс игрушки / Расширитель 0.24

GigaChat — единственная модель, которая «знает», что «велик» = «велосипед». Это прямое следствие обучения на русскоязычных данных, включая разговорную речь.

Запрос: «косметичка»

Слово-омоним: может означать сумку для косметики или специалиста-косметолога.

Метод Top-1 результат Score
Full-text Галантерея / Аксессуары / Косметичка 0.06
Qwen3 Красота и гигиена / Декоративная косметика 0.81
GigaChat Галантерея / Аксессуары / Косметичка 0.94
OpenAI Аптека / Эстетическая косметология 0.63

Full-text нашёл точное совпадение, но с низким рангом (0.06). GigaChat нашёл то же самое с score 0.94, плюс подтянул смежные категории (сумки, кошельки, декоративная косметика). Семантический поиск не только находит точное совпадение, но и понимает контекст.


5.2. Ситуационные запросы (intent)

Запросы, описывающие ситуацию, а не товар. Full-text бессилен во всех случаях.

Запрос: «у меня протекает кран»

Метод Top-3 результата Score
Full-text (пусто)
Qwen3 Запчасть для кулера, Тепловая обработка, Модуль доступа 0.50, 0.48, 0.45
GigaChat Сантехника / Смеситель, Сантехника / Сифон сливной, Сантехника / Слив-перелив 0.79, 0.77, 0.77
OpenAI Стержень для ручки, Инструмент для развод… 0.25, 0.24

GigaChat понял, что протекающий кран — задача для категории «Сантехника». Все 5 результатов — сантехнические товары. Qwen3 ушёл в бытовую технику. OpenAI выдал канцелярию.

Запрос: «собираюсь в поход»

Метод Top-3 результата Score
Full-text (пусто)
Qwen3 Охота и стрельба (разные позиции) ~0.49
GigaChat Спорт и отдых, Походная аптечка, Набор походной посуды 0.78, 0.78, 0.77
OpenAI Тренажеры / Силовая скамья 0.28

GigaChat правильно определил туристическую тематику. Qwen3 уловил направление (спорт и отдых), но ушёл в «охоту и стрельбу».

Запрос: «первый раз завожу кота»

Метод Top-3 результата Score
Full-text (пусто)
Qwen3 Профиль для светодиодной ленты (один результат) 0.22
GigaChat Товары для животных, Когтеточка, Антицарапки 0.71, 0.71, 0.69
OpenAI Корма для кошек и собак, Лакомство 0.31, 0.29

GigaChat точно понял: человек заводит кота, и ему нужны когтеточка, наполнитель, сетка-фиксатор для мытья. OpenAI двинулся в правильном направлении (корма), но score низкий. Qwen3 полностью промахнулся.

Запрос: «хочу научиться рисовать»

Метод Top-3 результата Score
Full-text (пусто)
Qwen3 Набор для рисования, Набор для создания гравюры, Картина по контурам 0.56, 0.55, 0.50
GigaChat Набор для рисования, Раскраска, Бумага для рисования 0.80, 0.80, 0.80
OpenAI Обучающий плакат, Декоративный элемент 0.28, 0.26

Здесь и Qwen3, и GigaChat показали хорошие результаты. GigaChat точнее — раскраски и бумага для рисования ближе к запросу начинающего, чем гравюра.


5.3. Подарки и события

Запрос: «подарок маме на 8 марта»

Метод Top-3 результата Score
Full-text (пусто)
Qwen3 Крем для загара, Игрушка-тренажер для дыхания 0.36, 0.35
GigaChat Открытка, Букет из игрушек, Пасхальный декор 0.78, 0.76, 0.75
OpenAI Брошь ювелирная, Сувенир ювелирный 0.25, 0.25

GigaChat ассоциировал запрос с подарочной тематикой: открытки, букеты, подарочные коробки. OpenAI зацепился за ювелирные украшения — направление не совсем верное, но логичное. Qwen3 выдал случайный шум.

Запрос: «что купить первокласснику»

Метод Top-3 результата Score
Full-text (пусто)
Qwen3 Детские товары, Неокуб, Пупс 0.60, 0.58, 0.58
GigaChat Детские рюкзаки и ранцы, Сумка для сменной обуви, Дневник школьный 0.84, 0.81, 0.80
OpenAI Запчасть для р/у моделей, Кубики 0.31, 0.29

GigaChat не просто понял «детские товары», а выбрал именно школьные: ранцы, сменка, дневник, пенал. Впечатляющий уровень семантического понимания.


5.4. Кросс-языковые запросы

Запросы на английском при полностью русскоязычных данных.

Запрос: «gaming mouse»

Метод Top-1 результат Score
Full-text (пусто)
Qwen3 Электроника / Устройства ручного ввода / Игровая мышь 0.73
GigaChat Электроника / Устройства ручного ввода / Игровая мышь 0.90
OpenAI Товары для взрослых / Секс игрушки / … 0.28

И Qwen3, и GigaChat точно перевели «gaming mouse» в «Игровая мышь». Qwen3 показал отличную мультиязычность (score 0.73). OpenAI полностью провалился.

Запрос: «DIY tools»

Метод Top-3 результата Score
Full-text (пусто)
Qwen3 Электропилы, Садовый электроинструмент, Расходники для инструмента 0.78, 0.76, 0.76
GigaChat Инструменты для ремонта, Оснастка для инструмента, Набор инструментов 0.79, 0.79, 0.78
OpenAI Мелок разметочный, Нож для садового инструмента 0.38, 0.37

Обе модели уверенно определили «DIY tools» как строительные инструменты с сопоставимыми результатами. OpenAI хотя бы зацепился за правильную область (score ~0.37).

Запрос: «smartphone accessories»

Метод Top-1 результат Score
Full-text (пусто)
Qwen3 Запчасти и инструменты для ремонта смартфонов 0.76
GigaChat Смартфоны, планшеты, мобильные телефоны 0.85
OpenAI Гаджеты и аксессуары / Умная визитка 0.42

Все три embedding-модели уловили тематику электроники. GigaChat точнее: в его top-5 есть «Чехол для смартфона» и «Шнурок для телефона».


5.5. Абстрактные формулировки

Запрос: «здоровое питание»

Метод Top-1 результат Score
Full-text Продукты питания / Программа здорового питания 0.18
Qwen3 Продукты питания 0.79
GigaChat Продукты питания + Программа здорового питания + Мюсли, Овес 0.88, 0.87, 0.84
OpenAI Детское питание 0.48

Full-text нашёл точное совпадение (есть категория «Программа здорового питания»), но с низким рангом. GigaChat дал тот же результат плюс мюсли, овес, суперфуды — контекстуально релевантные категории.

Запрос: «уютный вечер дома»

Метод Top-3 результата Score
Full-text (пусто)
Qwen3 Дом и сад, Печи, Одноразовая посуда 0.74, 0.72, 0.71
GigaChat Дом и сад, Декор и интерьер, Пледы и покрывала, Свечи и подсвечники 0.76, 0.75, 0.74, 0.74
OpenAI Товары для взрослых / БДСМ / … 0.26

GigaChat ассоциировал «уютный вечер» с пледами, свечами, декором — именно то, что ожидаешь. Qwen3 пошёл в правильном направлении, но менее точно.


6. Сводная таблица по латентности

Метод Медиана (мс) Среднее (мс) Мин (мс) Макс (мс)
Full-text (PostgreSQL) 1.3 3.3 1.0 37.2
Qwen3-Embedding-0.6B (локально) 22.8 21.1 9.0 55.9
GigaChat API 168.3 201.3 150.4 645.4
OpenAI API 274.9 360.0 250.1 1182.3

Full-text вне конкуренции по скорости. Среди embedding-моделей Qwen3 на локальном GPU в 8x быстрее GigaChat и в 12x быстрее OpenAI. Это закономерно: локальный инференс (выполнение модели на своих серверах) против сетевого вызова API.


7. Итоговое сравнение моделей

Критерий Qwen3-0.6B GigaChat OpenAI
Русский язык (синонимы) Слабо Отлично Слабо
Разговорная лексика (велик, косметичка) Не понимает Понимает Не понимает
Intent-запросы (ситуации) Частично Отлично Слабо
Кросс-язык (EN→RU) Хорошо Отлично Слабо
Абстрактные запросы Средне Хорошо Слабо
Латентность ~21 мс ~200 мс ~360 мс
Стоимость Бесплатно (свой GPU) По тарифу API По тарифу API
Конфиденциальность Данные не покидают сервер Данные уходят в Сбер Данные уходят в OpenAI

Почему OpenAI показал слабые результаты?

text-embedding-3-small — общепризнанно качественная модель. Причины низких результатов в этом эксперименте:

  1. Короткие тексты на русском. Модель обучена преимущественно на английском корпусе. Короткие иерархические пути (Дом и сад / Свечи и подсвечники) — не тот формат, на котором она максимально эффективна.
  2. Минимум контекста. В отличие от полноценных описаний товаров, только пути категорий — мало текста для извлечения семантики.
  3. Высокая латентность из-за прокси. Запросы шли через HTTP-прокси. Это добавило задержку и, возможно, повлияло на стабильность.

Для объективной оценки стоит протестировать text-embedding-3-large или другие модели OpenAI на более длинных текстах.

Почему GigaChat лидирует?

Модель EmbeddingsGigaR специально обучена на русскоязычном корпусе. Она «знает»:

  • что «велик» = «велосипед»
  • что «протекает кран» связано с сантехникой
  • что «первоклассник» — про школу
  • что «уютный вечер» — это пледы и свечи

Это подтверждает тезис: для задач на конкретном языке локализованные модели работают лучше универсальных.

Роль Qwen3

Qwen3-Embedding-0.6B при всего 600M параметрах и 1024-мерных векторах показала неровные результаты. С одной стороны — отличный кросс-лингвальный поиск (gaming mouse, DIY tools, smartphone accessories) и хорошая работа на отдельных русских запросах (хочу научиться рисовать, ноутбук). С другой — провалы на разговорной лексике и intent-запросах.

Главное преимущество — скорость и автономность: 21 мс на запрос, данные не покидают инфраструктуру. Для production-сценариев, где важны приватность и латентность, это может перевесить разницу в качестве.


8. Когда что использовать

Только полнотекстовый поиск

  • Пользователи вводят точные названия товаров
  • Критична латентность (< 5 мс)
  • Не нужна обработка синонимов и разговорной речи
  • Минимальная инфраструктура (только PostgreSQL)

Только семантический поиск

  • Запросы в свободной форме («у меня протекает кран»)
  • Мультиязычные пользователи
  • Поиск по коротким или неструктурированным текстам

Гибридный подход (рекомендация для production)

Лучший вариант — комбинация обоих методов:

  1. Запустить полнотекстовый и семантический поиск параллельно.
  2. Если полнотекстовый дал точные совпадения с высоким рангом — поднять их в выдаче.
  3. Дополнить семантическими результатами для расширения охвата.

Примерная формула: hybrid_score = alpha * fts_score + (1 - alpha) * semantic_score, где alpha подбирается экспериментально (обычно 0.3–0.5).

В PostgreSQL это реализуется одним запросом через UNION + COALESCE + нормализацию рангов.


9. Как воспроизвести эксперимент

Весь код проекта открыт: github.com/borodulin/embeddings-demo. Для запуска:

# Поднять PostgreSQL с pgvector
docker compose up -d postgres

# Установить зависимости и применить миграции
npm install
npm run db:migrate

# Импортировать данные
npm run import:data

# Проиндексировать все три модели
npm run index:vectors

# Запустить бенчмарк
npm run benchmark:search -- --limit 5

Для Qwen3 потребуется GPU и запуск TEI:

docker run --gpus all -p 8080:80 -v ./data:/data \
  ghcr.io/huggingface/text-embeddings-inference:cuda-1.9 \
  --model-id Qwen/Qwen3-Embedding-0.6B

Заключение

Семантический поиск — не замена полнотекстовому, а принципиально другой инструмент. Полнотекстовый ищет слова, семантический ищет смысл. На эксперименте с 10K категорий Ozon:

  • GigaChat показал лучшее качество на русскоязычных запросах, особенно на разговорной лексике и intent-запросах.
  • Qwen3-0.6B удивил скоростью (21 мс) и хорошей мультиязычностью, но нестабилен на русском.
  • OpenAI разочаровал на данном датасете, хотя на длинных английских текстах это сильная модель.
  • Full-text незаменим по скорости (1.3 мс) и точности на буквальных совпадениях.

Для production-поиска на русскоязычном маркетплейсе оптимальная стратегия — гибрид: быстрый полнотекстовый поиск для точных попаданий + семантический (GigaChat или локальная модель) для понимания намерений пользователя.