Всем привет! В этой статье хочу поделиться с вами моим кейсом создания быстрой, а главное — рабочей системы рекомендаций для картин.
Первые шаги
Все началось с того, что мне написал мой друг с просьбой проконсультировать его в области создания веб-приложений. Мы долго обсуждали его идею, и в какой-то момент я сказал:
«Давай я займусь сайтом. Я всё-таки программист».
Мы договорились начать.
Идея сайта была не новой, но довольно интересной — что-то вроде Pinterest, но для картин и предметов искусства. Главная фича нашего продукта должна была быть в:
подборе похожих картин
персонализированных рекомендациях для пользователя
Сначала мы занимались привычными вещами:
проектировали UI
продумывали UX
делали базовый каркас приложения
Но когда дело дошло до системы рекомендаций — я внезапно понял, что не знаю, как её реализовать.
Я начал искать статьи, читать документацию, спрашивать у ChatGPT. Самым очевидным решением оказалась система рекомендаций по тегам.
Например:
картина: теги: импрессионизм, природа, вода картина: теги: портрет, природа, вода
Алгоритм был простой:
Берем картину
Смотрим ее теги
Находим картины с такими же тегами
Сортируем по количеству совпадений
query = """ SELECT p.id, p.title, COUNT(*) AS score FROM painting_tags pt1 JOIN painting_tags pt2 ON pt1.tag_id = pt2.tag_id JOIN paintings p ON p.id = pt2.painting_id WHERE pt1.painting_id = :painting_id AND pt2.painting_id != :painting_id GROUP BY p.id ORDER BY score DESC LIMIT 10 """ result = session.execute(query, {"painting_id": 1})
Но очень быстро всплыла проблема.
Каждую картину приходилось размечать вручную:
писать теги
проверять их
следить за качеством
подбор был не точный
Когда картин стало несколько сотен — это стало медленно, дорого и неудобно.
Переход к эмбеддингам
Я решил использовать эмбеддинги изображений.
Идея проста:
изображение → нейросеть → вектор признаков
Дальше можно искать похожие вектора, а значит — похожие картины.
Модель для эмбеддингов CLIP / OpenCLIP
Для генерации эмбеддингов существует много моделей, но я остановился на CLIP.
CLIP — это модель, обученная связывать изображения и текст в одном пространстве признаков.
Это дает несколько важных преимуществ:
1. Понимание содержимого изображения
CLIP обучен на огромном количестве изображений и подписей, поэтому он может улавливать:
стиль
композицию
объекты
настроение изображения
Например, две картины с похожей атмосферой будут иметь близкие вектора, даже если у них нет одинаковых тегов.
2. Единое пространство для текста и изображений
CLIP умеет кодировать:
изображение → embedding текст → embedding
Это открывает возмо��ность:
искать картины по тексту
делать мультимодальные рекомендации
Например:
"impressionist landscape" → похожие картины
Сначала создадим сервис, который будет отвечать за генерацию эмбеддингов.
from transformers import CLIPModel, CLIPProcessor import torch
Загрузка модели:
class CLIPEmbeddingService: def __init__(self, model_name="openai/clip-vit-large-patch14-336"): self.device = self._setup_device() self.model = CLIPModel.from_pretrained(model_name).to(self.device) self.processor = CLIPProcessor.from_pretrained(model_name) self.model.eval()
Модель загружается один раз и используется для inference.
Для ускорения вычислений сервис автоматически выбирает устройство.
def _setup_device(self): if torch.cuda.is_available(): return torch.device("cuda") elif torch.backends.mps.is_available(): return torch.device("mps") else: return torch.device("cpu")
Это позволяет использовать:
GPU
Apple Silicon
CPU
в зависимости от доступного оборудования.
Перед обработкой нужно привести изображение к формату PIL.
def _load_image(self, image_input): if image_input.startswith(("http://", "https://")): with urllib.request.urlopen(image_input) as response: image_bytes = response.read() elif os.path.isfile(image_input): with open(image_input, "rb") as file: image_bytes = file.read() image = Image.open(BytesIO(image_bytes)).convert("RGB") return image
Сервис умеет работать с:
URL изображений
файлами
base64 строками
Генерация embedding
Теперь можно получить вектор признаков изображения.
def _get_image_embedding(self, image_input): image = self._load_image(image_input) inputs = self.processor( images=image, return_tensors="pt" ).to(self.device) with torch.no_grad(): embedding = self.model.get_image_features(**inputs) embedding = embedding / embedding.norm(p=2, dim=-1, keepdim=True) return embedding
Мы также нормализуем вектор, чтобы корректно использовать cosine similarity.
Получение embedding для картины
Теперь можно создать метод, который возвращает embedding для конкретного артворка.
def get_embedding(self, art_id, image_input): embedding = self._get_image_embedding(image_input) return { "id": art_id, "embedding": embedding }
На выходе получаем структуру:
{ "id": 123, "embedding": [0.12, -0.44, ...] }
Почему мы выбрали Elasticsearch
После генерации эмбеддингов возникает следующая задача:
где хранить вектора и как быстро искать похожие?
Для этого существует несколько специализированных векторных баз данных, но мы решили использовать Elasticsearch.
Причина довольно простая.
Нашему сервису нужно было не только искать похожие изображения, но и поддерживать обычный текстовый поиск.
Например:
поиск по названию картины поиск по автору поиск по описанию
Elasticsearch уже отлично решает такие задачи.
Гибридный поиск
Использование Elasticsearch позволило объединить два типа поиска:
Текстовый поиск
title: "water lilies"
Векторный поиск
embedding → похожие картины
В результате система рекомендаций стала выглядеть так:
изображение ↓ CLIP ↓ embedding ↓ Elasticsearch ↓ поиск похожих картин
Такой подход дал несколько преимуществ:
система не требует ручной разметки тегов
алгоритм анализирует само изображение
можно делать текстовый и векторный поиск в одной системе
Персонализированные рекомендации
Поиск похожих картин — это полезная функция, но нам хотелось сделать следующий шаг — персонализированные рекомендации.
Идея проста: если мы знаем, какие картины нравятся пользователю, можно попытаться понять его вкус и рекомендовать новые работы, которые могут ему понравиться.
Какие данные мы использовали
На первом этапе мы решили не строить сложную систему аналитики, а использовать простые пользовательские сигналы.
Мы учитывали:
лайки картин
посещения страниц картин
Эти события уже фиксировались в базе данных, поэтому их можно было использовать для построения профиля.
Где мы храним события
Пока что все события хранятся в PostgreSQL.
Причина довольно простая — нагрузки на систему пока небольшие:
пользователей не так много
поток событий относительно маленький
сложная аналитическая инфраструктура пока не требуется
Поэтому добавлять отдельную систему для аналитики было бы преждевременной оптимизацией.
Построение профиля пользователя
Каждая картина в системе уже имеет embedding, который хранится в Elasticsearch.
профиль пользователя = средний embedding картин, с которыми он взаимодействовал
Схематично это выглядит так:
картины пользователя ↓ получаем embeddings ↓ усредняем ↓ получаем embedding пользователя
Пример кода построения профиля
Сначала получаем embeddings картин, с которыми взаимодействовал пользователь.
like view
Теперь получаем embeddings этих картин из Elasticsearch.
def get_embeddings(art_ids, es): embeddings = [] for art_id in art_ids: doc = es.get( index="painting_embeddings", id=art_id ) embeddings.append(doc["_source"]["embedding"]) return embeddings
После этого можно построить embedding пользователя.
import numpy as np def build_user_profile(embeddings): if not embeddings: return None return np.mean(embeddings, axis=0).tolist()
Получается вектор, который отражает предпочтения пользователя.
Теперь можно искать картины, похожие на профиль пользователя.
def recommend_for_user(user_embedding, es): query = { "knn": { "field": "embedding", "query_vector": user_embedding, "k": 10, "num_candidates": 100 } } res = es.search( index="painting_embeddings", knn=query ) return res["hits"]["hits"]
Таким образом пользователь получает персональную ленту картин.
Почему пока хватает PostgreSQL
Пока система работает на PostgreSQL, потому что объем данных относительно небольшой.
Мы не храним миллионы событий и не строим сложные аналитические модели, поэтому обычная реляционная база данных полностью справляется с задачей.
Но по мере роста продукта ситуация может измениться.
Планируемый переход на ClickHouse
Если количество пользователей и событий вырастет, мы планируем перенести хранение событий в ClickHouse.
ClickHouse хорошо подходит для:
хранения больших объемов событий
аналитических запросов
построения пользовательских профилей
Это позволит:
хранить значительно больше событий
учитывать временные факторы
строить более точные профили пользователей
Например, можно будет учитывать:
вес лайков
частоту просмотров
недавние интересы пользователя
Итог
В результате система рекомендаций стала состоять из нескольких частей:
изображение ↓ CLIP ↓ embedding ↓ Elasticsearch ↓ поиск похожих картин + события пользователя ↓ профиль пользователя ↓ персональные рекомендации
Даже относительно простая модель профиля позволяет значительно улучшить качество рекомендаций.
А по мере роста системы можно постепенно усложнять алгоритмы и инфраструктуру.
Надеюсь, что этот опыт и примеры кода будут полезны кому-то, кто планирует построить свою собственную систему рекомендаций или просто хочет понять, как реализовать такой кейс на практике.
