Семантичний пошук на 300 000 товарів: як ми готували pgvector + HNSW
У світі eCommerce пошук — це не просто поле «введіть назву». Це інструмент конверсії. Коли покупець пише «мені потрібен тихий ноутбук для дизайну до 25 тисяч з матовим екраном», класичний SELECT ... LIKE або навіть ElasticSearch часто видають порожній результат або купу сміття.
Працюючи над своїм проєктом Shop AI, ми поставили собі ціль: бот має розуміти клієнта з пів слова, незалежно від того, чи в базі 50 товарів, чи 300 000. Ось як ми це реалізували технічно.
Чому не спеціалізовані векторні БД?
На старті ми розглядали Pinecone та Qdrant. Проте для нативного українського SaaS важливим був фактор ціни та цілісності даних. Ми вже використовували PostgreSQL 16, тож розширення pgvector виглядало логічним кроком. Це дозволяє тримати метадані товарів (ціни, залишки, посилання) в одній базі з векторами, уникаючи зайвих мережевих затримок та проблем із синхронізацією.
Архітектура індексу
Для генерації ембедінгів ми обрали text-embedding-3-small від OpenAI (1536 вимірів). Це золота середина між вартістю та якістю розуміння української мови.
Основний виклик — швидкість при великій кількості SKU. Для малих магазинів (до 1000 позицій) вистачає звичайного IVFFlat, але для тарифу Pro на 300k товарів ми перейшли на HNSW (Hierarchical Navigable Small World). Створення індексу виглядає приблизно так:
SQL
CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
Ми експериментували з параметром m (кількість зв’язків для кожного вузла). Збільшення m покращує точність, але суттєво роздуває розмір індексу в RAM. Для наших задач m=16 виявилося оптимальним.
Проблема ізоляції даних (Multi-tenancy)
Оскільки проєкт — це SaaS, ми не можемо дозволити змішування векторів різних магазинів. Ми реалізували це через фільтрацію за store_id ще до виконання векторного пошуку. Це критично для безпеки, особливо в B2B-сегменті.
Що не спрацювало
Спочатку ми намагалися запихнути весь опис товару в один ембедінг. Це була помилка. Для великих каталогів краще працює схема «заголовок + ключові характеристики». Довгі описи розмивають вектор, і точність пошуку падає. Тепер наш парсер на базі Claude Sonnet 4.6 витягує лише суть, що дозволяє боту чітко відповідати на складні запити.
Результати та tradeoffs
Зараз система на Hetzner-серверах видає відповідь на пошуковий запит в межах
Плюс: Дешево в обслуговуванні, зрозумілий стек (FastAPI + pgvector).
Мінус: HNSW-індекси довго будуються. Якщо клієнт завантажує одразу 300k нових товарів, системі потрібен час на перерахунок графів.
3 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарівRetrieval набагато складніша задача ніж ви до нього підійшли(треба брати декілька степів додаткових в пайплані вилачі результатів, відповідні моделі для асиметричного пошуку для ембедінгів, частіше самим навчати/донавчати, міксувати лексичні степи , векторний пошук завжди сфокусований на приблизному пошуку без розуміння логіки запиту, вміння ранжувати якісно і тд.. ) вибор індексу це абсолютно другорядна проблема для вашого обʼєму данних і навантаження скоріш за все, на якість впливає невірний підхід до проблеми куди більше.
Мені складно уявити користувача який шукає -мені потрібен тихий ноутбук. Мабуть для вашого прикладу більш релевантний кейс , коли дуже відомий ритейлер взуття на запит ’якісні кросівки’ пише — нічого не знайдено
Дякую, по суті — згоден. «Тихий ноутбук» справді ближче до showroom-кейсу, ніж до реального запиту. Ваш приклад точніший: прикметник-якість («якісні», «надійні», «зручні») не мапиться на жодне поле у БД магазину, тому keyword-search повертає 0. З того, що ми бачимо у клієнтських магазинах, такі ж «тихі вбивці» конверсії:
— «телефон до 20 000» — бюджет у тексті, а не у фільтрі
— «навушники з активним шумозаглушенням» — фіча у описі, не в параметрах
— «недорогий принтер для офісу» — ціновий + use-case одночасно
— «що краще, X чи Y» — порівняння двох SKU без окремої сторінки compare У наступних публікаціях візьму ваш приклад з кросівками — він добре ілюструє ширшу проблему.