Как я собрал семантический поиск по 17 миллионам картинок, не разорившись на AWS
Зачем это всё
Я занимаюсь визуальными искусствами и фронтендом более 10 лет. Для креативной работы мне постоянно нужны референсы, причём основанные на ощущении, визуальном стиле, том, что на английском (да и у нас зачастую) называют vibe.
Был замечательный проект same.energy, который решал именно эту задачу. Когда он перестал работать, я почувствовал себя без рук. И, как инженер, решил не ждать, а собрать свой инструмент.
Задача звучала амбициозно: проиндексировать датасет на 17 миллионов изображений (~4 ТБ) и сделать по нему семантический поиск. С бюджетом инди-разработчика, а не корпорации. Спойлер: индексация заняла два месяца на двух домашних машинах, а скорость поиска, которой удалось добиться — единицы миллисекунд, на пару порядков меньше сетевого оверхеда.

Почему именно этот стек: OpenCLIP, Qdrant и философия «бедности»
OpenCLIP: почему не оригинальный CLIP от OpenAI
Когда речь заходит о мультимодальных эмбеддингах, первое, что приходит в голову — оригинальный CLIP от OpenAI. Но я выбрал OpenCLIP:
Полный open-source. OpenCLIP — это полноценная реимплементация CLIP с открытым кодом обучения. LAION не только воспроизвели архитектуру, но и обучили модели на публичных датасетах, что важно для воспроизводимости.
ViT-H-14 + laion2b_s32b_b79k — эта конкретная комбинация:
986 миллионов параметров — серьёзная модель
78% zero-shot accuracy на ImageNet-1k — лучший показатель среди публично доступных CLIP-моделей
Обучение на LAION-2B (2 миллиарда пар изображение-текст) — на порядок больше, чем у оригинального OpenAI CLIP
В бенчмарках CLIP-as-service эта модель показывает лучшие результаты на всех задачах zero-shot retrieval. Для поисковика по визуальному стилю это критично — нам нужно максимальное качество понимания «вайба», а не только распознавание объектов.
Мультимодальность из коробки. Одна модель умеет эмбеддить и картинки, и текст в одно пространство. Это значит, что можно искать по запросу «cyberpunk city in fog» без единого тега на картинках — то, чего не было в оригинальном same.energy.
Qdrant: Rust, скорость и self-hosting
Выбор векторной базы данных. Какие конкуренты отсеялись:
База | Проблема |
|---|---|
Pinecone | Vendor lock-in + цена |
Milvus | Избыточно, сложный деплой |
pgvector | Не вывезет 17М векторов |
Chroma | Для прототипов, не для продакшена |
Qdrant попал в sweet spot:
Написан на Rust. Rust даёт memory safety без GC, что критично для систем с высокой конкуренцией.
Self-hosting без страданий. Один Docker-контейнер, минимум конфигурации. Для пет-проекта с бюджетом ~$0 — идеально. Это, пожалуй, было решающим фактором.
Payload support. Метаданные хранятся прямо в Qdrant и можно фильтровать по ним при поиске:
points.append({
"id": sha1_to_uuid(sha1),
"vector": emb.tolist(),
"payload": metadata # источник, размеры, теги — всё здесь
})
Архитектура: 260 строк, которые индексируют 4 ТБ
Весь пайплайн эмбеддинга уместился в 260 строк Python. Это не магия, а следствие правильного выбора инструментов.
Общая схема

Backblaze B2 — холодное хранилище картинок, доступ по обычному HTTPS API
GPU-ноды — две домашние машины для bulk-эмбеддинга. Работали параллельно, уложились в ~2 месяца (не круглосуточно)
CPU-сервер — выделенный сервер на хостинге. Держит Qdrant, Postgres для метаданных, API и фронтенд. Для поисковых запросов GPU не нужен — эмбеддинг одного текстового промпта на CPU занимает миллисекунды
ZeroTier — связывает GPU-ноды и сервер в одну приватную сеть. Пробивает NAT любой сложности — не пришлось покупать белый IP у провайдера. К B2 доступ просто через интернет
Сервер, кстати, классический «рабочая лошадка»: Xeon E3-1246 v3 (4 ядра / 8 потоков), 32 ГБ RAM, два WD2000FYYZ в зеркале.
Ключевые решения в коде
1. Батчинг с ранней фильтрацией. Прежде чем качать картинку, проверяем — может, она уже обработана? Это критично для возобновления после сбоев:
def filter_already_processed(sha1_list):
"""Batch check which SHA1s are already processed"""
ids = [sha1_to_uuid(s) for s in sha1_list]
existing = {p.id for p in qdrant_client.retrieve(COLLECTION_NAME, ids=ids)}
return [s for s in sha1_list if sha1_to_uuid(s) not in existing]
2. Детерминистичные UUID. SHA1 файла превращается в UUID через namespace. Индексацию можно прервать и возобновить с любого места:
def sha1_to_uuid(sha1):
namespace = uuid.UUID('6ba7b810-9dad-11d1-80b4-00c04fd430c8')
return str(uuid.uuid5(namespace, sha1))
3. Graceful shutdown. Индексация 17 миллионов картинок — это дни работы. SIGINT ловится и корректно завершает текущий батч:
def signal_handler(signum, frame):
global stop_processing
print("\nReceived stop signal. Finishing current batch...")
stop_processing = True
4. Параллелизм там, где нужно. Скачивание — в 10 потоков (сеть любит конкуренцию):
with ThreadPoolExecutor(max_workers=10) as pool:
futures = [pool.submit(download_image, path) for path in batch]
# ...
5. Батч-эмбеддинг на GPU. А вот инференс — с другим видом параллелизма: мы складываем целую пачку картинок в один тензор и получаем все эмбеддинги за один проход:
def embed_batch(image_batch):
"""Generate embeddings for a batch of images"""
images = []
metadata = []
for sha1, img, path in image_batch:
try:
images.append(preprocess(img))
metadata.append((sha1, img, path))
except Exception:
continue
tensor = torch.stack(images).to(device)
with torch.no_grad():
embeddings = model.encode_image(tensor).cpu().numpy()
return [(sha1, emb, path) for (sha1, _, path), emb in zip(metadata, embeddings)]
Ключевой момент тут — torch.stack() собирает все препроцессированные картинки в один тензор, и encode_image() обрабатывает их параллельно на GPU. Размер батча подбирается под VRAM видеокарты.
6. Сохранение в Qdrant. API Qdrant приятно лаконичен — upsert принимает список точек с векторами и метаданными:
def upload_batch(batch):
"""Upload embeddings to Qdrant"""
points = []
for sha1, emb, file_path in batch:
metadata = get_metadata(sha1)
points.append({
"id": sha1_to_uuid(sha1),
"vector": emb.tolist(),
"payload": metadata
})
qdrant_client.upsert(collection_name="images", points=points)
Тюнинг Qdrant: 17 миллионов векторов на 32 ГБ RAM
Наивный подход «закинуть всё в память» не работает: 17М векторов × 1024 измерения × 4 байта = ~70 ГБ. У нас 32 ГБ RAM. Нужно думать.
Вот конфигурация коллекции, к которой я пришёл:
{
"vectors": {
"size": 1024,
"distance": "Cosine",
"on_disk": true
},
"hnsw_config": {
"m": 16,
"ef_construct": 100,
"on_disk": false
},
"quantization_config": {
"scalar": {
"type": "int8",
"quantile": 0.99,
"always_ram": true
}
}
}
Ключевая идея: разделить «что держим в памяти» и «что на диске».
Как работает поиск в HNSW
HNSW (Hierarchical Navigable Small World) — это граф, по которому мы «прыгаем» от вершины к вершине, приближаясь к искомому вектору. На каждом шаге нужно:
Посмотреть в индекс — куда прыгать дальше
Сравнить вектор кандидата с запросом
Если индекс на диске — каждый прыжок это random read. При тысячах прыжков на запрос это убийство производительности.
Наше решение
Компонент | Где хранится | Почему |
|---|---|---|
HNSW-индекс | RAM | Нужен быстрый random access при навигации |
Квантизованные векторы (int8) | RAM | Для грубого сравнения на каждом шаге |
Полные векторы (float32) | Диск | Нужны только для финального reranking топ-N |
Реальные цифры после индексации (~17 миллионов векторов):
Компонент | Размер | Где хранится |
|---|---|---|
Полные векторы (float32) | 80 ГБ | Диск |
Квантизованные (int8) | ~17 ГБ | RAM |
HNSW-индекс | 780 МБ | RAM |
Payload (метаданные) | 9.3 ГБ | Диск |
Итого в RAM: ~18 ГБ из 32 ГБ доступных. Остаётся запас на систему и файловые кэши.
HNSW-индекс оказался компактнее, чем можно было ожидать: ~45 байт на вектор. Это by design — иерархическая структура графа, где на верхних уровнях точек экспоненциально меньше.
Запускаем бенчмарк...
Ботлнек
Замеры на рандомизированных запросах выявили неожиданную картину:
Операция | Время |
|---|---|
Чистый векторный поиск | 4.8 мс |
Поиск + загрузка payload | 3124 мс |
Разница — в 650 раз.
Сам по себе поиск по 17 миллионам векторов занимает меньше 5 миллисекунд. HNSW + скалярная квантизация в RAM превзошли мои ожидания — это в десятки раз меньше, чем сетевой round-trip до сервера.
Но payload (~9 ГБ метаданных) лежит на диске с on_disk_payload: true. И когда Qdrant начинает собирать метаданные для топ-N результатов — это сотни random reads с HDD. Три секунды на запрос — совершенно неприемлемо в интерактивном UI.
Переместить payload в RAM не можем — уже используем ~18 ГБ под индекс и квантизованные векторы. Добавит�� ещё 9 ГБ значит упереться в потолок 32 ГБ и рисковать свопом, который убьёт производительность ещё сильнее.
Решение: гибридная архитектура
Для отображения результатов поиска нужны всего пара полей: ID картинки и путь к thumbnail. Но Qdrant не умеет держать в RAM только часть payload — либо всё, либо ничего.
Решение: вынести горячие поля в PostgreSQL. Отдельная таблица с маппингом point_uuid → image_id. Postgres отлично кэширует такие мелкие lookup-таблицы.
Qdrant (4.8ms) → [uuid, uuid, uuid...] → PostgreSQL → thumbnails
векторный top-N IDs быстрый
поиск lookup
Это классический паттерн: векторная база делает то, что умеет лучше всего (ANN-поиск), реляционная — быстрые точечные запросы по индексу.
Итоговая производительность
После оптимизации — полный цикл поиска:
Этап | Среднее | Медиана | Макс |
|---|---|---|---|
Эмбеддинг текста (CPU) | 272 мс | — | 422 мс |
Векторный поиск (сервер) | 7.8 мс | 5.1 мс | 19 мс |
+ передача по сети | 204 мс | 180 мс | 330 мс |
Итого end-to-end | 475 мс | — | 669 мс |
Меньше полсекунды на полный цикл: ввёл запрос → получил релевантные картинки.
Что тут видно:
Векторный поиск — не ботлнек. 5–8 мс на 17 миллионов векторов. Qdrant с правильной конфигурацией летает.
Эмбеддинг на CPU — ожидаемо медленнее. ViT-H-14 — серьёзная модель. Но для интерактивного поиска 270 мс приемлемо, а для batch-операций можно вернуться на GPU.
Сетевой overhead доминирует. ZeroTier через интернет добавляет ~200 мс. Для домашнего проекта — неплохо. В продакшене с co-located серверами можно было бы заметно снизить, но и так интерфейс ощущается отзывчивым.
Экономика: как не разориться
Статья | Облако | Мой вариант |
|---|---|---|
Хранение 4 ТБ/мес | ~$90 (S3) | ~$20 (B2) |
GPU для индексации | ~$100+ (A10G × 100ч) | $0 (свой RTX 4090 + RTX 3090 друга) |
Сеть между нодами | VPN/статический IP | $0 (ZeroTier) |
Векторная база | Pinecone ~$70/мес | $0 (self-hosted Qdrant) |
Сервер для API | ~$50/мес (минимум) | ~$30/мес (выделенный Xeon) |
Итого: ~$50/мес на поддержку (B2 + сервер) вместо ~$200+ в облачном варианте. Единоразовые затраты на GPU — $0, потому что железо уже было.
Заключение
Этот проект начинался как попытка вернуть утраченный инструмент, а превратился в полноценное исследование возможностей современного AI-стека.
Главные выводы:
Векторный поиск — не ботлнек. 5–8 мс на 17 миллионов векторов. HNSW + скалярная квантизация творят чудеса.
Ботлнеки там, где не ждёшь. Payload на диске превратил 5 мс в 3 секунды. Профилируйте всё.
Гибридная архитектура работает. Qdrant для ANN-поиска, PostgreSQL для метаданных, ZeroTier для связи — каждый инструмент делает то, что умеет лучше всего.
Один инженер может многое. Open-source модели, современные базы данных и правильная архитектура позволяют строить production-ready системы с минимальным бюджетом.
Что дальше
Это первая статья цикла. В следующих частях:
Поиск по «усреднённому вайбу» — как искать картинки, похожие на целую коллекцию референсов: от среднего эмбеддинга до покрытия коллекции
PCA, SLiCS и concept-filtered retrieval — разложение эмбеддингов на семантические компоненты для более точного поиска
Стек: Python, FastAPI, OpenCLIP, Qdrant, PostgreSQL, ZeroTier, Backblaze B2
Если вам интересны подобные проекты на стыке AI, сложного UI и нестандартных архитектурных решений — пишите: https://t.me/dkurmyshov, обсудим.
