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 | Специально обучена на русском языке |
OpenAI (text-embedding-3-small) | OpenAI | 1536 | API | Мультиязычная, широко используется |
Каждая модель генерирует вектор своей размерности, поэтому в базе три отдельные таблицы с векторами:
-- Для 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 документов. Но у полнотекстового поиска есть фундаментальные ограничения:
Только совпадение лексем. Запрос
лекарстванайдет документы со словом «лекарств*» в тексте. Но не найдет «Аптека» или «БАДы».Нет понимания синонимов.
велик-- это велосипед, но для tsquery это просто неизвестное слово.Нет кросс-языковости.
gaming mouseне найдет «Игровая мышь».Нет понимания намерения.
у меня протекает кран-- ноль результатов, потому что слова «протекает» и «кран» не встречаются в названиях категорий сантехники.
3. Архитектура проекта
Проект собран на Next.js + PostgreSQL + pgvector. Docker Compose поднимает pgvector/pgvector:pg18 и фронтенд на node:20-alpine. Qwen3 запускается отдельно через Hugging Face Text Embeddings Inference с GPU.
Процесс индексации:
Импорт: скрипт читает CSV с категориями Ozon и записывает
path(иерархический путь видаЭлектроника / Компьютеры / Ноутбук) иtitleв таблицуdocuments.Индексация: для каждого документа три embedding-провайдера параллельно генерируют векторы. Текст, который уходит в модель -- это
path, та же строка, которая индексируется вtsvector.Поиск: при запросе текст одновременно отправляется во все три модели, получает три вектора, по каждому ищет 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 -- это общепризнанно качественная модель. Вероятная причина низких результатов в нашем эксперименте:
Короткие тексты на русском. Модель обучена преимущественно на английском корпусе. Короткие иерархические пути (
Дом и сад / Свечи и подсвечники) -- не тот формат, на котором она максимально эффективна.Отсутствие контекста. В отличие от полноценных описаний товаров, у нас только пути категорий -- минимум текста для извлечения семантики.
Высокая латентность из-за прокси. Запросы шли через 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)
Лучший вариант -- комбинация обоих методов:
Запустить полнотекстовый и семантический поиск параллельно.
Если полнотекстовый дал точные совпадения с высоким рангом -- поднять их в выдаче.
Дополнить семантическими результатами для расширения охвата.
Примерная формула: 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 или локальная модель) для понимания намерений пользователя.
