Как я собрал семантический поиск по 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) — это граф, по которому мы «прыгаем» от вершины к вершине, приближаясь к искомому вектору. На каждом шаге нужно:

  1. Посмотреть в индекс — куда прыгать дальше

  2. Сравнить вектор кандидата с запросом

Если индекс на диске — каждый прыжок это 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, обсудим.