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

ababo
14.03.2026 13:02Я правильно понял, что для семантического поиска сложность O(n) вместо O(log n). Есть ли теоретическая возможность построить индекс по косинусному расстоянию?

kotafey Автор
14.03.2026 13:02Без индекса -- да, brute-force scan по всем векторам, O(n). Но индексы для approximate nearest neighbor (ANN) давно существуют и активно используются, в том числе в pgvector.
В проекте используется IVFFlat -- индекс, который разбивает все векторы на кластеры (параметр
lists). При поиске сканируются только ближайшие кластеры (probes), а не все записи. Сложность поиска примерно O(n / lists * probes), что при правильной настройке дает существенное ускорение:CREATE INDEX ON document_vectors USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);Второй вариант -- HNSW (Hierarchical Navigable Small World), граф-based индекс. Сложность поиска O(log n), recall выше чем у IVFFlat, но потребляет больше памяти и дольше строится:
CREATE INDEX ON document_vectors USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);pgvector поддерживает оба. На 10K записей разница незаметна (и так быстро), но на миллионах -- принципиальна. На практике HNSW сейчас стандарт де-факто для production ANN-поиска.
Оба индекса приближенные (approximate) -- теоретически могут не вернуть абсолютно ближайшего соседа, но recall 95-99% достижим при правильной настройке параметров. Точный поиск по косинусному расстоянию за O(log n) невозможен в общем случае из-за «проклятия размерности», но ANN-индексы на практике решают задачу.

AlexanderAnisimov
14.03.2026 13:02На бенчмарках Джина выше чем Квен. Наверно было бы логично с Джиной сравнивать. Чем обусловлен выбор именно Квена?
https://huggingface.co/spaces/mteb/leaderboard



kotafey Автор
14.03.2026 13:02Я особо не выбирал. Что было под рукой то и сравнил... Меня больше всего интересовало сравнение OpenAI vs Gigachat, Qwen до кучи добавил, потому что он у меня запускается на моём ноуте с ноутбучной видюхой. Вообще тема интересная, можно добавлять и сравнивать разные модели.

Pavel_Antipov
14.03.2026 13:02Пробовали Vertex AI Vector Search и Vertex AI Search for commerce ?

kotafey Автор
14.03.2026 13:02Нет, не пробовал. Посмотрел на цены... Для сравнения - я купил 50млн токенов гигачат за 700 рублей. Полностью проиндексировать весь каталог в 10к строк - ушло примерно 220 тысяч токенов. Это примерно 3 рубля я потратил на материал для этой статьи.

buriy
14.03.2026 13:02Я думаю надо попробовать еще расшифровывать строчку классификатора перед взятием эмбеддинга с ЛЛМ. Вдруг индексы начнут гораздо лучше работать. Также и описание товара тоже расшифровывают, бывает.

kotafey Автор
14.03.2026 13:02Всё верно. Но тут не сами товары, а категории. Можно увеличить объем ключевыми словами, какими-то описаниями, тогда ассоциаций будет больше и поиск станет точней. Но это уже тюнинг под конкретные задачи.

Vladislav557
14.03.2026 13:02Было бы интересно сравнить embeddinggemma с гигачатовской моделью, потому как когда делал вектора для 300к товаров из всех моделей именно embeddinggemma показал хоть что-то удовлетворительное. Гигачат потрогать возможности не было

AlexanderAnisimov
14.03.2026 13:02Я как раз недавно пытался играться с похожим функционалом. У меня немного другой подход получился: Сначала с помощью LLM выбираются ключевые слова для поиска, а потом уже набор ключевых слов уходят как входные параметры для семантического поиска. Для intent это вроде бы лучше должно работать.
Вот описание моей реализации: https://github.com/alexplusplus/fin_news_agent/blob/public/fin_news_agent.ipynb
Triton5
Спасибо за статью:)
Видимо, вся проблема в том, что эмбеддинг модель предназначена для точных запросов, т.е. для профессиональной терминологии должно быть приемлемо.
Для варианта как в статье ей отчаянно нужен какой-то предварительный семантический обработчик, ИМХО.