TL;DR: Сделал поиск по личному архиву фотографий с применением трех нейросетей, векторного расширения к PostgreSQL и Django. Красивое.

Начиная с перехода на цифру у меня накопилось больше 20 тысяч фотографий. Каждый раз, когда я смотрю на эти папки, я понимаю, что никогда не разберу его вручную. А ведь где-то там, несомненно, есть, должны быть жемчужины высокого искусства и фиксация событий эпохального масштаба.

Когда из каждого утюга стали кричать про искусственный интеллект, появилась мысль поставить нейросеть на службу обществу (мне), то есть использовать открытые нейросети для классификации и сортировки семейного фотоархива.

Разумеется, нельзя просто так взять облачный ChatGPT и натравить на архив, чтобы он посмотрел на всё это богатство и разложил по полочкам и коробочкам. Во-первых, это будет дорого, во-вторых - страшно дорого, а в третьих будет долго и непонятно, что нужно сделать, чтобы прийти к нужному результату.

То есть надо рассматривать локальные модели.

Сначала я рассматривал такой вариант - прошерстить архив мультимодальной моделью, чтобы она создала описания для каждой фотографии. Полученные описания, вместе с распарсенным EXIF, сложить в полнотекстовый поиск - что-то на основе Apache Lucene, то есть Elasticsearch или Solr. Скорее второй, потому что я с ним имел дело когда-то. И уже над этим делать интерфейс поиска.

Перебрал несколько моделей, но не нашел такой, чтобы сделала внятное описание на русском языке и, одновременно, чтобы я бы ее смог запустить локально. Причем те многоязычные модели, что качественно описывают фотографию на английском, на русском творят несусветную дичь.

Фото счастливой семьи. Взято из открытых источников. Но что, по мнению ИИ, здесь изображено?
Фото счастливой семьи. Взято из открытых источников. Но что, по мнению ИИ, здесь изображено?
Ответы llava-hf/llava-v1.6-mistral-7b-hf

"Describe the image"

The image is a color photograph featuring three individuals, likely a family, outdoors. In the foreground, there is a woman with blonde hair, wearing a light pink top and blue jeans, smiling broadly and looking towards the camera. She appears to be holding a young girl with her left arm wrapped around her. The girl has light brown hair, is wearing a red top, and is also smiling.\n\nBehind the woman and the girl, there is a man with short dark hair, wearing a light blue t-shirt and khaki shorts. He is also smiling and looking towards the camera. His right arm is wrapped around the woman's waist, and his left arm is wrapped around the girl's shoulders, suggesting a close and affectionate relationship among the three.\n\nThe background is blurred, but it appears to be a natural setting with greenery, indicating they might be in a park or a garden. The lighting suggests it is a sunny day. There are no visible texts or logos in the image. The overall mood of the photograph is cheerful and familial.

"three individuals"?

"Что изображено на картинке? Отвечай на русском языке"

На картинке изображено четыре люди, которые se смеют и сближением с камерой. Они se смеют и сближением с камерой, показывая себя с улыбкой и радостью. Наиболее вероятно, что это семейство, т.е. мама и три дети, поскольку они se сближением с камерой и смеются.

Мама и три дети, ага.

Ответы Qwen/Qwen2-VL-7B-Instruct

"Describe the image"

The image shows a family of four enjoying a moment together outdoors. The family consists of two adults and two children. The adults are smiling and appear to be having a fun time, with one adult giving the other a piggyback ride. The children are also smiling and seem to be enjoying the moment. The background suggests they are in a park or a similar outdoor setting with trees and greenery.

"Что изображено на картинке?"

На картинке изображена семья, состоящая из четырех человек. Мужчина и женщина держат на руках двух детей. Все они улыбаются и выглядят счастливыми.

А иногда модель на твердом русском языке убеждала, что на этом фото только два человека.

Ок, умный в гору не пойдет, настоящие герои всегда ищут другое решение. Если есть модель, которая умеет делать описания на английском языке, не будем пытаться переучивать её на русский - долго, дорого, непонятно. Лучше наймем ей переводчика, то есть возьмем модель, обученную делать только одну функцию - перевод с английского на русский, зато делает это отлично достаточно хорошо.

Примерно определился с моделями:

Где-то в этот момент Сбер выкатил на HF модель русскоязычного эмбеддинга ai-sage/Giga-Embeddings-instruct, об этом были статьи на Хабре, И тут Штирлица осенило - а на куа фа городить полнотекстовый поиск, если можно описания превратить в векторы, сложить в векторную же базу и искать уже в ней?

Сказано - сделано. То есть попробовано. Только взгляд на модель Сбера несколько расстроил - файлов на 13 гигов. Влезет ли на видеокарту, в которой 16 гигов? На HF нашлась еще одна моделька эмбеддинга, angelitasr/jina-embeddings-v3_eeid, её файлы занимают около 1,1 гига.

Впоследствии оказалось, что я зря опасался, Giga-Embeddings-instruct занимает на GPU примерно 7,5 гиг, а вторая - 2,5. то есть влезет и та, и другая, и обе вместе. Но модель от Сбера создает вектор на 2048 элементов, а вторая - на 1024. Учитывая заявленную русскоязычность Giga-Embeddings-instruct, выбор очевиден.

С выбором векторной базы вопрос не стоял - разумеется, PostgreSQL с расширением pgvector. Не потому, что постгри весь такой крутой и сильный, а потому что дополнительных приключений на свой филей не хотелось. Раз финтех на нём работает, то и я как-нибудь разберусь.

Когда я запускал модели для пробы, то брал примеры скриптов прямо из их Readme. Эти же скрипты, чуть обмазав их http.server и изобразив некое подобие OpenAI API, теперь запускают на сервере модели. Да, то есть нет, я не использую ни vllm, ни llama.cpp, ни ollama, мой велосипед, где хочу, там и запускаю.

Итого у нас здесь банкет на две персоны - сервер, который не знает, что за фотки ему подсовывают, и клиент, который ходит по папкам, собирает фотографии, переводит их в векторы и складывает в базу.

"Но и это ещё не всё!", как говорят в рекламе. Теперь нужен какой-то интерфейс к этой, несомненно, очень нужной и важной штуке, который позволит, собственно, искать фото.

Мне нужен доступ в домашней сети, поэтому нужен веб-сервер, в который будет достаточно просто встроить Python-код поиска в базе. Следовательно, надо брать Django, хоть я с ним и не знаком от слова "вообще".

Нашел в Сети самый простой пример показа галереи картинок, переделал под свои нужды - одно простое поле ввода, ниже открываются найденные картинки. Как тебе такое, Google Photo?

Строка поиска отправляется на ту же самую (и это важно) модель эмбеддинга Giga-Embeddings-instruct, которой создавались вектора по картинкам. По полученному от модели вектору делаем поиск в векторной базе, фильтруя дистанцию между векторами какой-то наспех выбранной константой, сортируем по дистанции, и ограничиваем выборку, скажем, 50-ю записями.

Далее немного кода в Django

# models.py
from django.db import models
from pgvector.django import VectorField
import base64
import os


class Image(models.Model):
    image_path = models.CharField()  # путь к оригинальному фото на диске
    embedding = VectorField(dimensions=2048)  # вектор для описания фото
    description = models.CharField()  # Сгенерированное описание
    thumb = models.ImageField()  # Миниатюра, PNG

    def __str__(self):
        return os.path.basename(self.image_path)

    def thumb_url(self) -> str:
        """
        Формирует строку в base64 миниатюры фотографии. Если миниатюры нет, возвращает url на заглушку.
        Полученную строку можно использовать в <img src="url">  
        :return: строка url  
        """
        if self.thumb:
            return 'data:image/png;base64, ' + base64.b64encode(bytes(self.thumb)).decode('utf-8')
        return 'static/noimage.png'

    class Meta:
        db_table = 'description_vectors'
        managed = False

# views.py
from django.shortcuts import render
from .models import Image
from django.http import HttpResponse, HttpRequest
from .extern import ExternModel
from pgvector.django import L2Distance


def image_list(request: HttpRequest):
    """
    По строке поиска из параметра 'q' получает вектор от модели-эмбеддера.
    По этому вектору ищет в БД подходящие записи и рендерит страницу.
    :param request: GET-запрос с query-параметром 'q'
    :return: HTML-страница
    """
    model = ExternModel("ai-sage/Giga-Embeddings-instruct")  # подключаем модель эмбеддинга
    prev_sample = ""  # для заполнения строки поиска
    sample = request.GET.get("q")
    if sample:
        vector = model.embedding(sample)  # получили вектор
        images = Image.objects.using("cloud_db").alias(  # подключлись к БД
            distance=L2Distance('embedding', vector)  # берем дистанцию от вектора
        ).filter(  
            distance__lt=0.93  # и ограничиваем ее неким числом, выбранным по вкусу
        ).order_by(  
            L2Distance('embedding', vector)  # сортируем, чтобы самые похожие были в начале
        )[:50]  # оставляем только 50 записей
        prev_sample = sample
    else:
        images = []
    return render(request, "search.html", {"images": images, "sample": prev_sample})
# extern.py
import requests


class ExternModel:
    """
    Обращение к внешней модели
    """
    def __init__(self, model):
        """                
        :param model: имя модели, сконфигурированное на сервере 
        """
        super().__init__()
        self.server = server_url  # адрес сервера, где крутится модель
        self.checkpoint = model

    def embedding(self, text) -> tuple or list:
        """
        Обращается к модели и получает веектор
        :param text: строка 
        :return: вектор эмбеддинга
        """
        req = {
            "model": self.checkpoint,
            "text": text
        }
        response = requests.post(self.server + '/embeddings', json=req)
        r = response.json()
        answer = r.get('text', {}).get('embedding', [])
        return answer
search.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Поиск фото</title>
    <style>
        /* Add CSS styles for flex container and items */
        .image-list {
            display: flex;
            flex-wrap: wrap; /* Allow items to wrap to the next row if necessary */
            justify-content: space-between; /* Space items evenly along the main axis */
            list-style: none; /* Remove list styles */
            padding: 0;
        }

        .image-item {
            flex: 1; /* Grow to fill available space evenly */
            max-width: 30%; /* Limit item width to avoid overcrowding */
            margin: 10px; /* Add spacing between items */
            border: 1px solid #ccc; /* Add a border for visual separation */
            padding: 10px;
            text-align: center;
        }

    </style>
</head>
<body>
<div>
    <form method="get">
        <label>
            Поиск фото
            <input type="text" name="q" style="width: 80%" value="{{ sample }}"/>
        </label>
        <button type="submit">поиск</button>
    </form>
</div>
{% load static %}
<div>
    <ul class="image-list">
        {% for image in images %}
            <li class="image-item">
                <a href="{% static image.image_path %}" target="_blank">
                    <img src="{{ image.thumb_url }}" alt="{{ image.image_path }}" title="{{ image.description }}" height="200"/>
                </a>
{#                <p>{{ image.image_path }}</p>#}
            </li>
        {% endfor %}
    </ul>
</div>
</body>
</html>

И вот что вышло.

Поиск по слову "горнолыжники". Сгенерированные и переведенные описания здесь специально не скрыты, чтобы видеть качество поиска.
Поиск по слову "горнолыжники". Сгенерированные и переведенные описания здесь специально не скрыты, чтобы видеть качество поиска.
Еще примеры
Поиск по фразе "птицы или звери"
Поиск по фразе "птицы или звери"
"туман в горах"
"туман в горах"
Без трудностей перевода никуда. Поиск "чайника" приводит к чайкам.
Без трудностей перевода никуда. Поиск "чайника" приводит к чайкам.
Но "пизанская башня" - это всё же Пизанская башня
Но "пизанская башня" - это всё же Пизанская башня

Красота, однако!

По времени такой расклад:

  • Генерация описания на английском: примерно от 9 до 20 секунд

  • Перевод описания на русский язык: около двух секунд

  • Получение вектора по тексту: менее секунды

  • Поиск фото:

    • первый раз, пока модель разворачивается в GPU - около 10 секунд

    • при уже загруженной модели - примерно 2 секунды

Что бы еще такого сделать? Есть мысли:

  1. Найти получше модели генерации описания и перевода. Лучше всего, конечно, использовать одну русскоязычную модель генерации описания, если такая найдется.

  2. Сделать предварительную классификацию фото, хотя бы по EXIF, вынести ее на Web-интерфейс, чтобы искать, например, только среди прошлогодних фотографий, а не во всём архиве.

  3. Прикрутить распознавание знакомых лиц, и тоже вынести их в классификатор.

Комментарии (4)


  1. sevmax
    10.11.2025 05:58

    Рассматривали ли вы готовые решения, например Immich?


    1. sshmakov Автор
      10.11.2025 05:58

      Именно lmmich я не рассматривал. По февральской статье, его основная фишка - хранение фото и шаринг. Есть поиск по описанию и распознавание лиц. Но на английском.

      Для хранения и шаринга у меня есть Nextcloud. А поиск на русском сделать, как оказалось, не сильно сложно.

      И, конечно, не будем забывать про фатальный недостаток всех этих решений, Not Invented Here.


  1. Litemanager_remoteadmin
    10.11.2025 05:58

    Можно конечно и в ручную посидеть поразбирать фото 20к штук , шучу конечно, но думаю там полно повторов обычно делаешь несколько фото чтобы выбрать лучшую а потом просто жалко удалять , да думаю в таком случае AI должен помочь


    1. sshmakov Автор
      10.11.2025 05:58

      Кстати, да, повторы и мало отличающиеся фото можно отследить по близости их векторов.