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

Идея семантического поиска: представить и документы, и запрос в виде числовых векторов (embeddings) в едином пространстве. Близкие по смыслу тексты будут иметь близкие векторы. Для измерения близости используется косинусное расстояние.

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

Текст → 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 с 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

Профиль для светодиодной ленты (один результат, score 0.22)

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» как строительные инструменты. Qwen3 и GigaChat показали сопоставимые результаты. 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, что объяснимо: локальный инференс vs сетевой вызов 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 лидирует?

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 или локальная модель) для понимания намерений пользователя.