Створюємо «пошуковий рушій» для інтернет-магазину за допомогою Embeddings

Всім привіт! Для тих, хто колись працював над розробкою блогів, інтернет-магазинів і будь-яких інших сайтів, де може бути текстовий пошук по контенту, не є секретом, що це не сама проста задача. Якщо у вашому магазині неможливо знайти товари, то в вас нічого не куплять :) Я теж зіткнувся з цією проблемою при розробці свого магазину.

Спочатку я вирішив спробувати повнотекстовий пошук в PostgreSQL. Завантажив словник української мови, створив відповідний індекс в таблиці з товарами. Воно чудово працює з різними словоформами, не враховує прийменники та інші неважливі частини мови. Проблеми починаються, коли в пошуковому запиті присутні слова, яких немає в назві товарів, або назви брендів написані з помилками чи транслітом. В таких випадках постгрес не знаходить нічого, що не є добре.

Я знаю про Elasticsearch, але ніколи з ним не працював і, чесно кажучи, мені це не здається простим рішенням. В цьому випадку треба створювати ще одне окреме сховище, яке треба якось синхронізувати з основою базою даних. До того ж, наскільки мені відомо, Elasticsearch сам по собі не вирішує проблему транслітерації та не буде просто так шукати «Xiaomi» по запиту «сяомі», для цього теж потрібно створювати відповідні словники. До того ж, деякі користувачі все ще використовують для пошуку російську мову, що теж погано відображається на результатах пошуку.

В той час, коли існують LLM, хотілося б знайти якесь «розумне» рішення, яке вміло б робити все це само по собі. І ігнорувати словоформи, і бути багатомовним, і враховувати транслітерацію та помилки.

На той момент я як раз займався вивченням питанням створення RAG-застосунку. Основною частиною цієї концепції є семантичний пошук в документах по запиту від користувача за допомогою embedding-моделей. І тоді я подумав — якщо ми можемо зробити такий пошук по текстовим документам, то теж саме можна зробити і для товарів в інтернет-магазині. В даній статті хочу поділитися своїм досвідом і показати, що в мене вийшло.

Отже, для реалізації «пошукового рушія» нам знадобляться дві речі — embedding model і векторне сховище.

Embedding model

Спершу розберемося, що таке embeddings в ML. Якщо казати дуже спрощено, то embeddings — це представлення різних обʼєктів, таких як зображення, текст або аудіо у вигляді масиву чисел. Це дозволяє порівнювати обєʼкти між собою і знаходити схожість або «відстань» між ними. А Embedding model, своєю чергою, це модель яка перетворює ці обʼєкти на векторне представлення.

Де нам взяти таку модель? Найпростіший варіант — це використати, наприклад, моделі від OpenAI. Також можна обрати опенсорсну модель з Hugging Face. При пошуку важливо враховувати підтримувані мови. Також треба визначити розмірність векторів, яку повертає модель. Я зупинився на моделі від Alibaba.

Використання опенсорсних моделей дозволяє розгортати модель «локально», в середині вашої інфраструктури, економити час на API-запити до сторонніх сервісів типу OpenAI. Також моделей просто багато і ви можете експериментувати та підібрати ту, яка краще справляється саме з вашими юзкейсами. З мінусів — вам потрібно додати окремий сервіс, який буде обслуговувати запити до локальної моделі.

Vector store

Для того, щоб не просто зберігати векторне представлення тексту, але і мати змогу робити семантичний пошук, потрібна спеціалізована база даних, яка це підтримує. Найбільш вичерпний список можливих варіантів мені трапився в документації до фреймворку LangChain. Оскільки в моєму проєкті вже використовується PostgreSQL, то найбільш очевидним вибором став pgvector — розширення для PostgreSQL. Це позбавило мене необхідності розгортати додаткове сховище і постійно синхронізувати дані між ними.

Реалізація

Перейдемо до реалізації. Створимо простий API-сервер для доступу до моделі. Встановимо потрібні залежності:

pip install fastapi sentence-transformers uvicorn

Створимо файл main.py з кодом серверу:

import os
from fastapi import FastAPI
from sentence_transformers import SentenceTransformer
from pydantic import BaseModel
# Load model from ./model directory or download it if it does not exist
model_name = "Alibaba-NLP/gte-multilingual-base"
model_path = os.path.join("model", model_name)

if os.path.exists(model_path):
   model = SentenceTransformer(model_path, trust_remote_code=True)
   print(f"Model loaded from {model_path}")
else:
   model = SentenceTransformer(model_name, trust_remote_code=True)
   model.save(model_path)
   print(f"Model downloaded and saved to {model_path}")

app = FastAPI()

class EmbeddingRequest(BaseModel):
   texts: list[str]

@app.post("/get-embeddings")
def get_embedding(payload: EmbeddingRequest):
   texts = payload.texts
   embeddings = model.encode(texts, normalize_embeddings=True)
   return {"embeddings": embeddings.tolist()}

if __name__ == "__main__":
   import uvicorn
   service_port = int(os.getenv("EMBEDDING_PORT", 9010))
   uvicorn.run(app, host="0.0.0.0", port=service_port)

Застосунок має один ендпоінт — «/get-embeddings», який приймає масив рядків з текстом для формування векторів і повертає масив векторів у відповідному порядку. Для того, щоб не завантажувати модель після кожного перезапуску застосунку, ми зберігаємо її в ./model.

Запускаємо сервер:

python main.py

І перевіряємо, що все працює:

Тепер можна перейти до підготовки нашої БД. В документації до pgvector детально описані всі способи встановлення розширення, а також в них є Docker Image. Я просто замінив образ postgres:14 на pgvector/pgvector:pg14 в своєму docker-compose файлі. Все запустилося без проблем. Далі треба увімкнути розширення для потрібної бази даних (це робиться один раз для кожної потрібної бази):

CREATE EXTENSION vector;

Після цього додаємо колонку в потрібну таблицю:

ALTER TABLE public.product ADD COLUMN embedding VECTOR(768);

Тут 768 — це розмірність векторів, яку генерує наша модель. Ви не зможете записати в цю колонку вектор іншої розмірності.

Подальша реалізація бізнес-логіки буде дуже залежати від вашого конкретного проєкту і мої фрагменти коду будуть нерелевантні, тому далі я буду використовувати псевдокод і спрощені SQL запити, щоб передати суть.

Проєкт написаний на TypeScript/Node.js, тому я скористався бібліотекою pgvector-node для формування запитів в БД повʼязаних з векторами. Повний список бібліотек для різних мов програмування тут.

Далі, нам треба згенерувати емебеднігі для всіх товарів і заповнити нову колонку даними. Для цього робимо наступне:

  1. Дістаємо з бази всі товари, де колонка embedding порожня.
  2. Збираємо назви товарів.
  3. Відправляємо їх нашій моделі.
  4. Отримуємо векторні представлення.
  5. Зберігаємо значення в БД.

Приблизний код виглядає так:

let productChunk: Array<{ id: number, title: string }>;
const limit = 50;
do {
  productChunk = await ProductRepository.getProductInfoForEmbeddingGeneration(limit);
  if (!productChunk.length) {
    break;
  }
  
  const dataToUpdate: Array<{ id: number, embedding: Array<number> }> = [];
  // Send product titles to the model to get embeddings
  const embeddings = await EmbeddingModelAPI.getEmbeddingsByText(
    productChunk.map(product => product.title)
  );
  // Combine product id with embeddings
  for (let i = 0; i < productChunk.length; i++) {
    const product = productChunk[i];
    const embedding = embeddings.embeddings[i];
    dataToUpdate.push({ id: product.id, embedding });
  }
  
  // Update product embeddings
  await ProductRepository.updateProductEmbeddings(dataToUpdate);
} while (productChunk.length === limit);

Запит на оновлення товару з використанням TypeORM і pgvector-node:

dbConnection.query(
 `UPDATE public.product SET embedding = $1 WHERE id = $2`,
 [pgvector.toSql(product.embedding), product.id]
);

Після цього можемо побачити результат в БД:

Тепер ми можемо робити семантичний пошук товарів. Але спочатку треба зазначити що, коли ми шукаємо записи за схожістю, то насправді ми не фільтруємо результати, а просто упорядковуємо від більш схожих до менш схожих. Тобто ми робимо запити не через WHERE, а додаємо ORDER BY. Це означає, що наш SELECT буде завжди повертати всі товари, просто вони будуть ранжовані за релевантністю. Це важливо для побудови UI вашого застосунку. По-перше, можливо, ви захочете обмежити кількість результатів пошуку. Я зробив просто 5 сторінок. По-друге, щоб не написали користувачі в пошуку, вони ніколи не побачать порожні результати, просто вони будуть не дуже релевантні.

Базова логіка пошуку виглядає так:

  1. Відправляємо пошуковий запит в нашу модель для формування ембедінгу.
  2. Використовуємо цей ембедінг для пошуку схожих товарів в БД.
  3. Для ранжування я використовую оператор <-> (L2 Distance). Детальніше про всі оператори тут.

Приблизний код:

const embeddings = await EmbeddingModelAPI.getEmbeddingsByText([searchQuery]);
const products = await ProductRepository.getClientProductListByEmbeddingSearch(
  embeddings.embeddings[0],
  limit,
  offset
);

Запит на пошук товарів з використанням TypeORM і pgvector-node:

dbConncection.query(
  `
    SELECT id, title, price,
      (embedding <-> $1) AS "embeddingDistance"
    FROM public.product
    WHERE embedding IS NOT NULL
    ORDER BY "embeddingDistance"
    LIMIT $2 OFFSET $3
  `,
  [pgvector.toSql(embedding), limit, offset]
);

Після всього цього мені вдалося значно покращити пошук в магазині. Ось приклади результатів:

Як можна побачити, модель добре справляється з помилками, транслітом, а також підтримує різні мови.

Наприкінці ще хочу додати можливі шляхи покращення перфомансу пошуку:

  1. Кешувати ембедінги для пошукового запиту на короткий час, щоб не звертатися до моделі при переході між сторінками результатів пошуку.
  2. Формувати ембедінги для товарів на основі не тільки назви, але й, наприклад, опису і атрибутів. В моєму випадку це складно через відсутність цього у 90% товарів.
  3. Спробувати додати індекс на колонку з вектором. Але він впливає не тільки на швидкість, а також і на результати пошуку.

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось11
До обраногоВ обраному10
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Завантажив словник української мови, створив відповідний індекс в таблиці з товарами

Можна детальніше про це

Ви коротко згадали про RAG. Які проблеми інтернет-магазину ви хотіли вирішити за допомогою RAG? Побудувати клієнтську підтримку? Зробити систему рекомендацій? Може щось інше?

Вітаю. RAG вивчав для інших цілей в контексті іншого проєкту.

Дякую за цікаву технічну статтю!

повний час запиту на пошук з фронтенду, то виходить 1,5-2c

А що з цього getEmbeddingsByText, що getClientProductListByEmbeddingSearch? Та яка апаратна конфігурація вебсервера та СУБД?

Зараз в мене все в докері на одному інстансі cax31 в хетзнері. Це 8 ядер arm, та 16гб ОЗП.

Запит до моделі локально:

# curl --location 'local.model.host:9010/get-embeddings' --header 'Content-Type: application/json' --data '{"texts": ["Сяомі редмі нот 14"]}' -s -o /dev/null -w  "%{time_starttransfer}\n"
0.159880

Виконання SQL:

SELECT id, title,
(embedding <-> '[-0.07437053322792053, ... ,0.02803190052509308]') AS "embeddingDistance"
FROM public.product
WHERE embedding IS NOT NULL
ORDER BY "embeddingDistance"
LIMIT 20
[2025-02-27 19:19:15] 20 rows retrieved starting from 1 in 746 ms (execution: 358 ms, fetching: 388 ms)

Дякую за подробиці.

CAX31 — це shared. Тобто на dedicated повинно бути швидше.

Цікава стаття! Чи заміряли Ви швидкість запитів, скільки в базі товарів? Можливо робили якісь оптимізації для швидкодії?

Товарів на разі 53 тис. Якщо брати повний час запиту на пошук з фронтенду, то виходить 1,5-2c. Що не дуже швидко. Для порівняння, запит, який обробляється звичайним пошуком, коли в нас є точне співпадіння, типу «iphone 15», виконується за 100-200мс. Я пробував додавати індекс, але в моєму кейсі це значно погіршує релевантність пошуку. В документаціі так і написано

You can add an index to use approximate nearest neighbor search, which trades some recall for speed.

Дякую за статтю!
Доречі еластік давно має векторний пошук, а зараз так само на ізі інтегрується із ЛЛМ. Ну це так, для спарвки ))

Ще 2 роки тому розглядали їх vector store для себе і я робив PoC, але sales чуваки навіть на корпоративну пошту вирішили не відповідати щоб дати trial enterprise ліцензію) ( нам треба було це все на aks розгорнути)

А хіба там ліцуха потрібна? На роботі не векторний пошук але в акс все парцює і наче без ліцензії.

Дякую за популяризацію використання АІ!

Єдине, не зовсім зрозуміло, навіщо вам саме ембедінги та семантичний пошук для цієї задачі, пошук товарів в інтернет-магазині. Класичний пошук по словам «телефон», «Iphone», «16 pro max» і т.д. видавав би також релевантні результати, ще й суттєво швидше та дешевше, так як ви пропускаєте етап звертання до LLM для перетворення запитів у ембединги.
Семантичний пошук це все ж про дещо інше.

Хоча якщо стояла задача потренуватися у інтеграції LLM в бізнес-задачі, то місія пройдена)

Класичний пошук не знайде ’Айфон’, якщо в назві товару це слово відсутнє. Можна звичайно різні словники додавати, коректор помилок, але через векторний пошук і налаштовану ШІ модель має знайти + релевантність результатів

Так, як я писав на початку статті і Олександр нижче в коменті, звичайний пошук не знайде «Iphone» по запиту «Айфон», «чохол» по «чехол», «скло» по «стекло» та ще купа інших кейсів. А якщо врахувати можливі одруки та граматичні помилки, то тут варіанти написання пошукового запиту прямують до нескінченності. В мене реалізований звичайний повнотекстовий пошук в постгресі і він не вирішує цю проблему. А LLM вирішує і досить успішно. Для економії, я спочтаку роблю пошук звичаним способом і тільки якщо він не поверне нічого, то роблю семантичний пошук.

звичайний пошук не знайде «Iphone» по запиту «Айфон», «чохол» по «чехол», «скло» по «стекло» та ще купа інших кейсів.

Лексичний повнотекстовий пошук також все це знаходить, просто треба час на налаштування правильної індексації і скорингу і запитів.

Векторний(семантичний пошук) на поверхні все це хендлить, але як тільки почати давати більш складні/точні запити , комбінувати умови, використовувати логічні структури(наприклад: захисне скло ... без рамки) — ваша hello word реалізація посиплеться(насправді вже посипалась в ситуаціі з пошуком чохла) ще швидше ніж лексичний пошук який ви не потягнули для цієї задачі.

Не зовсім розумію, чому ви вважаєте, що щось посипалось, коли модель знайшла релевантні товари. Чи ви вважаєте. що в результатах немає чохлів?)) А якщо врахувати сам запит, то результати нормальні. Спробуйте той самий запит на розетці. Там одразу ж в топі видає ZTE blade a35. Також не зрозуміло, чому «посиплеться» модель при запитиі «скло без рамки», якщо відповідне буде вказано в назві товару? Тут як раз може посипатись повнотекстовий пошук через те, що ігнорує прийменники і «без» є в словнику стопслів.

Не зовсім розумію, чому ви вважаєте, що щось посипалось, коли модель знайшла релевантні товари. Чи ви вважаєте. що в результатах немає чохлів?)) А якщо врахувати сам запит, то результати нормальні. Спробуйте той самий запит на розетці. Там одразу ж в топі видає ZTE blade a35.

у вас топ результат рекомендує скло, при пошуку чохла. ви і саме бачете судячи з відповіді бо підлаштовуєте критерії оцінки рішення під вигідні для себе.
res.cloudinary.com/...​/r76andcipniron5vhzql.png

res.cloudinary.com/...​/untsisoqgfjn6v7qroza.png
в будь якому випадку вам ще дуже рано рівнятися на розетку.

Тут як раз може посипатись повнотекстовий пошук через те, що ігнорує прийменники і «без» є в словнику стопслів.

то треба вірно обробляти input query для виявлення такий речей, правильно застосувати токен фільтри взяти, налаштувати правильно токенайзер нє?
повнотекстовий пайплайн пошуку надає апі однознаний яким можна точно захендлити логічні операції і і виконати їх доволі однозначно. навідміну від векторного пошуку де весь корпус кодується в фіксовану розмірність вектора і там це все взагалі ігнорується(але кодується не логіка, а контекст). такі речі в векторних кейсах хендляться prompt engineering або взагалі домен специфічною моделю або дотреновувати існуючий трансформер знімаючи активаційний layer, прокидуючи градієнт у моделі враппері.

взагалі NN пошук у векторному просторі для вашого use case це скоріше як не треба робити пошуковий рушій — семантичний пошук це дуже склада задача (набагто складніша ніж лексичний для вашого use case) передбачає набагато більш складніші рішення на рівні системного дизайну і векторний пошук там початковий степ, що дозволяє зробити пошук по великому корпуску нетабличних, табличних данних, текстових данних зробити початковий candidates retrieval, але ранкінг результатів так само як і логічную складову треба хендлети окремими рішеннями які ідуть далі в пайплайні, nearest neighborhood алгоритм їх не здатен виконати концептуально.

Там аналізатори чи токенайзери потрібні, короч для люцену потрібно мовний пакет ставити. Як це прокинути через еластік — хз. Давно дуже колупауся із люценом котрий під капотом в еластіку крутиться.

Підписатись на коментарі