ИИ-сервис анализа цен конкурентов по фото ценников: vision-модель, YOLO, MinIO

ИИ-сервис анализа цен конкурентов по фото ценников: vision-модель, YOLO, MinIO

На вебинаре я вживую собираю ИИ-сервис, который анализирует цены конкурентов по фото ценников: фото прилетает в Telegram-бота, YOLO-сервис вырезает отдельные ценники, локальная vision-модель mistral-small3.2 через Ollama достаёт название и цену, а векторный поиск по номенклатуре сопоставляет распознанный товар с базой компании и собирает отчёт. По ходу занятия разбираю всю архитектуру проекта: MinIO как локальное облако между сервисами, FastAPI с lifespan-инициализацией и версионированным API, Telegram-бот на aiogram, эмбеддинги и векторная база FAISS. Отдельно затрагиваю практические проблемы: слабые места similarity search без реранжера, локальные и облачные vision-модели для приватных данных и отладку сбоев Ollama-сервера. Видео пригодится 1С- и Python-разработчикам, которые хотят научиться собирать прикладные ИИ-сервисы поверх привычных бизнес-задач.

Вступление и настройка проекта

В начале вебинара я с нуля создаю проект в PyCharm и настраиваю окружение под будущий сервис анализа цен конкурентов по фото ценников. Заодно показываю готовую инфраструктуру курса: локальное облако MinIO и отдельный большой сервис на YOLO, и объясняю, почему в этот раз соберём упрощённую версию с vision-моделью вместо полноценного YOLO-пайплайна.

Создание проекта в PyCharm с Poetry

Новый проект стартует с чистого окружения PyCharm на Poetry, с ограничением версии Python и минимальным набором зависимостей.

Проект создаю стандартным для себя способом: новый пустой проект в PyCharm, Custom Environment, менеджер зависимостей Poetry и нужная версия Python. Сразу после создания появляется файл зависимостей, в котором нужно ограничить диапазон версий Python (указать значение меньше 3.13), иначе дальше нормально не встанут библиотеки. Отдельно завожу файл readme, куда позже попадут все настройки проекта, обещаю прислать его слушателям. Библиотеки ставлю в терминале при обязательно активированном виртуальном окружении, они понадобятся для будущего FastAPI-приложения.

  • Новый проект в PyCharm: Custom Environment, Poetry, указание версии Python
  • В файле зависимостей ограничивается версия Python (меньше 3.13) для совместимости библиотек
  • Создаётся файл readme с настройками проекта, который автор пришлёт отдельно
  • Библиотеки ставятся через терминал с активным виртуальным окружением для FastAPI-приложения
давайте, как всегда, создаем новый пустой проект в PyCharm

MinIO: локальное облако для обмена файлами между сервисами

MinIO поднят в Docker и служит хранилищем-посредником, через которое обмениваются файлами приложения и сервисы проекта.

В курсе разворачиваю локальное облако на базе MinIO, оно у меня уже запущено в Docker. Внутри создаются бакеты, например, уже есть бакет OCR, и именно через этот сервис идёт обмен файлами между разными приложениями и сервисами проекта: Telegram-ботом, сервисом обработки изображений и другими компонентами. Для работы с MinIO из кода понадобится отдельная библиотека, её тоже нужно поставить в проект. MinIO тут не просто хранилище, а связующее звено всей инфраструктуры: через него передаются изображения между этапами обработки.

  • MinIO запущен локально в Docker
  • Внутри MinIO создаются бакеты (например, бакет OCR)
  • Через MinIO идёт обмен файлами между приложениями и сервисами проекта
  • Для работы с MinIO нужно отдельно поставить соответствующую библиотеку
Вот оно у меня в докере работает.

Готовый сервис на YOLO для распознавания ценников

Отдельный уже обученный и запущенный сервис на нейросети YOLO вырезает ценники из целого фото и отдаёт их кусочками.

У меня уже есть отдельный большой сервис обработки изображений на нейросети YOLO. Изначально его обучали распознавать паспорта, но потом довольно быстро дообучили под ценники на фотографиях и добавили в этот проект. Сервис запущен, принимает на вход целое изображение, а на выходе отдаёт уже нарезанные кусочки: отдельные распознанные ценники. Разработать такой сервис целиком в рамках вебинара нереально, он слишком объёмный, поэтому показываю его как готовый компонент, а вместо этого разбираю более простой способ работы с изображениями через vision-модель.

  • Сервис на YOLO уже обучен и запущен отдельно: изначально распознавал паспорта, затем ценники
  • Принимает целое изображение и возвращает уже нарезанные кусочки с ценниками
  • Сервис достаточно большой, разработать его целиком в формате вебинара нереально
  • Вместо полного сервиса на вебинаре показывается упрощённая работа с изображениями через vision-модель

Демонстрация уже готового сервиса распознавания ценников на YOLO

# YOLO-сервис — отдельное приложение, в этом репозитории его нет.
# Наш сервис лишь обращается к нему по HTTP и получает имена вырезанных
# ценников, которые YOLO кладёт обратно в MinIO:
detection_service_url = (
    f"http://YOLO_HOST:8010/api/v1/yolo/price_label/{destination_file}"
)
result = httpx.Client(timeout=120).get(detection_service_url).json()
price_labels = result.get("price").get("price", [])  # список файлов-кропов
Он принимает целое изображение и обратно отдает уже нарезанные кусочки.

Постановка задачи: упрощённая работа с ценниками через vision-модель

Вместо полноценного YOLO-сервиса на вебинаре соберут упрощённый пример работы с фото ценников через большую языковую vision-модель.

Раз полноценный сервис на YOLO слишком велик, чтобы разрабатывать его в прямом эфире, задачу вебинара ставлю иначе: показать вообще, как можно работать с изображениями с помощью больших языковых моделей, причём в максимально упрощённом виде. Решение делаю компактным, чтобы уложиться по времени и остаться понятным без глубокого предварительного контекста. По ходу занятия будут паузы для вопросов между этапами. Ближе к концу добавлю ещё вводную часть по RAG, тоже максимально простую, без лишних усложнений: подробно эта тема разбирается в полном курсе.

  • Задача вебинара: показать работу с изображениями через большие языковые (vision) модели в упрощённом виде
  • Решение специально максимально простое, без лишних усложнений, из-за ограничений по времени
  • Между этапами будут паузы для вопросов слушателей
  • В конце занятия: упрощённая вводная часть по RAG (подробно тема разбирается в полном курсе)
Здесь он будет максимально простой и максимально, скажем так, без всяких наворотов.

Структура исходников проекта: source root и пакеты

Проект организован единой папкой source root с пакетами ai, bot и demo для vision-обработки, Telegram-бота и тестовых фото ценников.

Как и во всех своих проектах, создаю единую папку с исходниками и помечаю её как source root, а внутри выстраиваю структуру пакетов. Пакет ai отвечает за работу с большой языковой моделью, то есть за обработку изображений через vision-модель. Пакет bot содержит разных ботов проекта, включая Telegram-бота, который подключим в ходе занятия. Отдельно добавляю папку demo с тестовыми фотографиями ценников: одну снял сам на телефон в торговом зале, другие прислали слушатели через WhatsApp, качество там не очень, зато можно проверить, как распознаются ценники на изображениях разного качества.

  • Единая папка с исходниками помечается как source root
  • Пакет ai: работа с большой языковой (vision) моделью
  • Пакет bot: разные боты проекта, включая Telegram-бота
  • Папка demo с тестовыми фото ценников разного качества, в том числе присланными через WhatsApp
Помечаем ее как папку source root.

LLM-клиент и вспомогательные утилиты

В этой части пишу файл lm_client.py и подключаю локальную vision-модель mistral-small3.2 через ChatOllama, а затем собираю набор вспомогательных утилит для обмена файлами с MinIO и разбора JSON-ответов модели. Заодно ввожу глобальный модуль-состояние, по аналогии с глобальной переменной в 1С, и включаю автоформатирование кода через Black.

ChatOllama-клиент с mistral-small3.2

Локальная мультимодальная модель mistral-small3.2 подключается через ChatOllama к отдельному серверу на 3090.

Для работы с vision-моделью создаю экземпляр класса ChatOllama из LangChain, где указываю модель mistral-small3.2:latest, температуру генерации и адрес сервера, где физически запущена Ollama. У меня это отдельный компьютер с видеокартой 3090 под Ubuntu, потому что часть нужных библиотек работает только на Linux. Бэкендов для локальных моделей вообще-то много (llama.cpp, vLLM, Ollama), но Ollama и llama.cpp проще развернуть. Если Ollama запущена на том же компьютере, где идёт разработка, адрес сервера просто убирается: клиент обратится к локальному сервису.

  • ChatOllama создаётся с моделью mistral-small3.2:latest: она мультимодальная и умеет работать с изображениями
  • Указываются температура и адрес удалённого сервера Ollama (локальная сеть, отдельная машина с 3090 на Ubuntu)
  • Часть библиотек требует Linux, поэтому сервер поднят на Ubuntu, а не на рабочем компьютере
  • При локальном запуске Ollama адрес сервера в коде просто не указывается
Mistral Small 3.2 Latest, это та модель, которая умеет работать. Она мультимодальная

Глобальный модуль bot_manager

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

Создаю отдельный модуль bots, он играет роль глобального хранилища состояния, по аналогии с глобальной переменной в 1С, к которой можно обратиться из любого места. Внутри объявляю словарь с ключами, изначально проинициализированными как неопределённые значения. В этом словаре потом будут храниться экземпляр клиента MinIO, эмбеддинги и подключённое векторное хранилище. В отличие от 1С, в Python модуль нужно явно импортировать в каждом файле, где он используется.

  • Модуль bots играет роль глобального модуля/переменной, как в 1С
  • Внутри: словарь с ключами, изначально None ('не определено')
  • В словаре будут храниться клиент MinIO, эмбеддинги и векторное хранилище
  • В Python модуль нужно явно импортировать в каждом файле, где он нужен, в отличие от 1С
Здесь у нас будет сервис MinIO, который будет обмениваться с нашим сервисом MinIO. Эмбединги будут, и векторное хранилище подключено

Утилиты обмена файлами с MinIO

Две функции, minio_load_file и её обратная, читают и записывают бинарные данные в MinIO через методы get_object и put_object.

Первая утилита minio_load_file принимает имя файла, обращается к клиенту MinIO из глобального модуля и вызывает метод get_object, указывая бакет и имя файла. Результат, считанные бинарные данные изображения, кладу в переменную image_data; по умолчанию функция возвращает неопределённое значение на случай непредвиденной ошибки. Обратная функция minio_save_file принимает имя файла и бинарные данные, оборачивает их через io.BytesIO и передаёт в метод put_object. Обе функции обёрнуты в обработку исключений, чтобы сетевые сбои или проблемы с закрытием соединения не роняли сервис, а возвращали понятную ошибку.

  • minio_load_file вызывает get_object(bucket, filename), результат читается в переменную image_data
  • minio_save_file оборачивает бинарные данные через io.BytesIO и передаёт в put_object
  • По документации библиотеки MinIO в конце нужно закрывать соединение
  • Обе функции обёрнуты в try/except на случай сетевых сбоев

Функция minio_load_file: чтение файла из хранилища MinIO

import io
import json
import re
import uuid

from src.bot.service import bot_manager


def minio_load_file(file_name: str):
    response = None
    try:
        response = bot_manager["minio"].get_object("ocr", file_name)
        image_data = response.read()
        return image_data
    except Exception as e:
        print(f"Ошибка при загрузке файла '{file_name}'", e)
        return None
    finally:
        if response:
            try:
                response.close()          # Закрываем соединение
                response.release_conn()   # Возвращаем соединение в пул
            except Exception as close_error:
                print(f"Ошибка при закрытии соединения: {close_error}")

Функция minio_save_file: запись двоичных данных в MinIO

def minio_save_file(file_name, image_data):
    try:
        bot_manager["minio"].put_object(
            bucket_name="ocr",
            object_name=file_name,
            data=io.BytesIO(image_data),
            length=len(image_data),
            content_type="image/jpeg",
        )
    except Exception as close_error:
        print(f"Ошибка при сохранении файла '{file_name}'", close_error)
        return None
У нее есть метод getObject, где мы указываем пакет и название файла

Очистка и извлечение JSON из ответа модели

Регулярное выражение вырезает служебные теги вокруг JSON в тексте ответа модели, после чего json.loads превращает очищенную строку в структуру Python.

Модель возвращает не готовый JSON, а обычный текст, внутри которого JSON обрамлён специальным тегом (условно тройные точки, слово JSON, сам JSON, снова тройные точки). Чтобы получить данные, написал утилиту: она с помощью регулярного выражения убирает всё лишнее вокруг JSON, оставляя только его содержимое. Очищенная строка передаётся в json.loads, на выходе получается обычная структура (словарь). Если декодирование не удалось, ошибка перехватывается и выводится в консоль, в реальном проекте вместо print стоял бы логгер.

  • Ответ модели: текст с JSON, обрамлённым служебным тегом (тройные точки + слово JSON + сам JSON + тройные точки)
  • Регулярное выражение убирает обрамление, оставляя чистый JSON
  • json.loads() превращает очищенную строку в структуру Python
  • Ошибка декодирования перехватывается и выводится в консоль (в реальном проекте, через логгер)

Функция saveJSONToDisk и включение автоформатирования black

def convert_json(raw_response: str) -> dict | None:
    cleaned_content = re.sub(
        r"^\s*```(?:json)?\s*|\s*```\s*$", "", raw_response.strip()
    )
    try:
        return json.loads(cleaned_content)
    except json.JSONDecodeError as e:
        print("Не удалось распарсить JSON:", e)
        return None
И там будет, грубо говоря, будут вот там три точки, потом написано JSON, потом сам JSON, который сформирует нам модель, и потом опять будет вот эти вот три точки

saveJSONToDisk и автоформатирование Black

Функция сохраняет структуру в JSON-файл на диске с именем из f-строки, а расширение Black автоматически форматирует код при сохранении.

Утилита save_json_to_disk принимает структуру данных и префикс имени файла, формирует итоговое имя через f-строку (например, с префиксом вроде price_label и уникальным идентификатором), открывает файл в кодировке UTF-8 и записывает данные методом json.dump с отступами и отключённым экранированием не-ASCII символов. Ошибки записи оборачиваю в try/except с выводом в консоль. Отдельно включаю расширение Black, оно автоматически переформатирует код (расставляет пробелы и так далее) при каждом сохранении файла по Ctrl+S.

  • Имя файла формируется f-строкой с префиксом и идентификатором (аналог str-шаблона в 1С)
  • Запись идёт через json.dump в кодировке UTF-8, с отступами и отключённым экранированием
  • Ошибки записи оборачиваются в try/except с выводом в консоль
  • Включено расширение Black: автоформатирование кода при сохранении файла

Функция saveJSONToDisk и включение автоформатирования black

def save_json_to_disk(data: dict, file_prefix="price_label"):
    filename = f"{file_prefix}_{uuid.uuid4().hex[:8]}.json"
    try:
        with open(filename, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=4)
        print(f"Данные сохранены в файл: {filename}")
    except Exception as e:
        print(f"Ошибка при сохранении файла {filename}: {e}")
Он позволяет форматировать весь код автоматически при сохранении

Telegram-бот и точка входа FastAPI

В этой части ставлю библиотеку aiogram, делаю примитивный конфиг проекта и подключаю базу товаров в формате JSON (наименование, артикул, код, цена), она станет основой будущего RAG по номенклатуре. Пишу первые обработчики Telegram-бота, а затем собираю главную точку входа main.py на FastAPI с lifespan-инициализацией MinIO и embedding-модели, после чего запускаю всё через uvicorn.

Конфиг проекта вместо .env

Ради скорости настройки (токен бота, путь к базе, имя embedding-модели) захардкожены в конфиг-файле, а не вынесены в .env.

Обычно в проектах завожу .env-файл с секретами: клиенту передают только пустой шаблон, а он сам вписывает свои токены и ключи. Здесь ради экономии времени на вебинаре от .env отказался, значения (токен Telegram-бота, название embedding-модели, путь к каталогу с базой товаров) прописаны прямо в конфиг-файле. Путь строится через parent от файла конфига до каталога source/embeddings/base, куда кладётся файл базы товаров base1s.json с полями наименование, артикул, код и цена, она станет основой для эмбеддингов и RAG-поиска по номенклатуре.

  • .env обычно хранит секреты и передаётся клиенту как незаполненный шаблон
  • здесь .env не используется: значения захардкожены в конфиг-файле ради скорости
  • в конфиге также путь к базе товаров (source/embeddings/base/base1s.json) и имя embedding-модели
Во всех проектах я обязательно делаю конфиг файл

Обработчики aiogram: Command Start и Handle Photo

На роутере aiogram сделаны обработчик команды /start с ответом пользователю и заглушка для приёма фото или документа с ценником.

Код бота собираю по документации aiogram (QuickStart). На роутере через фильтр CommandStart делаю обработчик: при команде /start бот вызывает message.answer и отвечает 'Я готов к работе', используя данные из объекта message (например, имя пользователя). Отдельно завожу функцию Handle Photo с фильтром, принимающим и фото, и документ, потому что Telegram присылает картинку по-разному: со включённой опцией 'сжать фото' она приходит как photo, а без сжатия, как document. Логику анализа фото пока не пишу, оставляю заглушку pass, допишу её в конце.

  • CommandStart-фильтр вызывает message.answer с ответом 'Я готов к работе'
  • Handle Photo принимает и photo, и document, так как Telegram присылает сжатое и несжатое изображение по-разному
  • обработка фото оставлена заглушкой (pass): допишу позже

Обработчики бота: команда /start и заглушка обработки фото/документа

from aiogram import Bot, Dispatcher, Router, F
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.filters import CommandStart
from aiogram.types import Message

from src.config import tg_token
from src.ai.service import process_image

bot = Bot(
    token=tg_token,
    default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
dp = Dispatcher()
router = Router()
dp.include_router(router)


@router.message(CommandStart())
async def command_start_handler(message: Message) -> None:
    """Обработчик команды /start"""
    await message.answer(
        f"Привет, {message.from_user.full_name}! Я бот, готов к работе."
    )


@router.message(F.photo | F.document)
async def photo_handler(message: Message) -> None:
    try:
        if message.photo:
            file_id = message.photo[-1].file_id
        elif message.document:
            if not message.document.file_name.lower().endswith(
                (".png", ".jpg", ".jpeg", ".webp")
            ):
                await message.answer("Пожалуйста, отправьте изображение.")
                return
            file_id = message.document.file_id
        else:
            return

        file = await bot.get_file(file_id)
        image_bytes = await bot.download_file(file.file_path)
        await message.answer("Обработка изображения...")
        await process_image(image_data=image_bytes.read(), message=message)
    except Exception as e:
        await message.answer(f"Произошла ошибка: {str(e)}")
Здесь мы принимаем либо фото, либо документ

Эмбеддинги: текст как вектор

Эмбеддинги превращают текст в числовые векторы, а близость этих векторов показывает смысловую близость слов.

Эмбеддинги, это способ преобразовать текст в вектор, массив чисел определённой размерности. Запрос пользователя тоже переводится в вектор, а дальше математические функции считают близость векторов: например, 'кошка' и 'серая кошка' окажутся близкими словами. Так компьютер, который не понимает текст как человек, получает возможность сравнивать смысл фраз. По сути большинство нейросетей под капотом, это умножение матриц и работа с числами, никакой магии тут нет. В проекте для базы товаров используется модель qwen3-embedding:8b, она и формирует основу RAG-поиска по номенклатуре.

  • текст (документ или запрос) превращается в вектор фиксированной размерности
  • близость векторов считается математическими функциями и отражает смысловую близость слов
  • под капотом большинства нейросетей: умножение матриц, а не 'магия'
Если вкратце, мы преобразуем текст в специальные вектора

Lifespan-инициализация в FastAPI

В lifespan-функции при старте приложения разом поднимаются клиент MinIO, embedding-модель и Telegram-бот, а при остановке всё корректно гасится.

В FastAPI-приложениях тяжёлую инициализацию (загрузку моделей, подключения) принято собирать в одной функции lifespan с декоратором, который помечает код, выполняемый при старте и остановке. Всё, что написано до yield, отрабатывает при запуске приложения: создаётся клиент MinIO (локальный докер на порту 19000, access/secret key, secured=False), сразу загружается в память embedding-модель, чтобы быть доступной глобально, и отдельной задачей через asyncio.create_task запускается Telegram-бот. Всё, что после yield, выполняется при остановке: бот и связанные ресурсы корректно завершаются.

  • код до yield выполняется при старте приложения, код после yield: при остановке
  • в lifespan создаётся клиент MinIO (докер, порт 19000, secured=False) и загружается embedding-модель в память
  • Telegram-бот запускается отдельной asyncio-задачей через asyncio.create_task

Функция lifespan: инициализация MinIO и модели эмбеддингов при старте приложения (реальные MinIO-креды из кадра скрыты редактором)

@asynccontextmanager
async def lifespan(app: FastAPI):
    print("Старт работы приложения")
    bot_manager["minio"] = Minio(
        endpoint="MINIO_HOST:9000",       # адрес MinIO — вынести в .env
        access_key="MINIO_ACCESS_KEY",    # ключ доступа — вынести в .env
        secret_key="MINIO_SECRET_KEY",    # секрет — вынести в .env
        secure=False,
    )
    bot_manager["embeddings"] = OllamaEmbeddings(
        model=embeddings_model,
        base_url="http://OLLAMA_HOST:11434",
    )
    bot_manager["vector_store"] = init_vector_store()
    bot_task = asyncio.create_task(start_bot())

    yield

    await stop_bot()
    bot_task.cancel()
    print("Завершение работы приложения")
Все, что выполняется до yield, это у нас при старте приложения

main.py и запуск через uvicorn

main.py собирает FastAPI-приложение с OrjsonResponse и lifespan и запускает его через uvicorn.run с отключённым reload.

Точка входа main.py импортирует asyncio, функцию lifespan, APIRouter (для будущих маршрутов) и OrjsonResponse, это более быстрый, чем стандартный, класс-сериализатор ответов. Приложение FastAPI создаётся с указанием default response class OrjsonResponse и функции lifespan, которая должна запуститься при старте. Роутеры пока не подключаю, обработчиков ещё нет. В конце скрипта вызываю uvicorn.run с указанием модуля main:app, IP-адреса и порта, при этом reload выставлен в false, чтобы приложение не перезапускалось автоматически при каждом изменении кода.

  • FastAPI-приложение создаётся с OrjsonResponse (быстрый сериализатор) и функцией lifespan
  • роутеры пока не подключены: обработчиков ещё нет
  • запуск через uvicorn.run("main:app", host, port, reload=False), чтобы избежать автоперезагрузки

Первый запуск приложения и проверка команды /start в Telegram

main_app = FastAPI(
    default_response_class=ORJSONResponse,
    lifespan=lifespan,
)

# Роутеры вкладываются: app -> /api -> /v1 -> /ocr -> /upload/
router = APIRouter(prefix="/v1")
router.include_router(ocr_router)
main_app.include_router(router, prefix="/api")

if __name__ == "__main__":
    uvicorn.run("main:main_app", host="0.0.0.0", port=8000, reload=False)
Reload false для того, чтобы при изменении кода не было автоматической перезагрузки приложения

Embeddings и векторный поиск по номенклатуре

После короткого демо запущенного бота и ответов на вопросы зрителей перехожу к самой сути поиска по номенклатуре: embeddings и векторной базе FAISS. В отдельном скрипте с нуля пишу функции инициализации и генерации векторного хранилища из JSON с товарами, а затем вживую тестирую semantic search на реальных запросах, попутно объясняя роль метаданных и место реранкинга в более сложных проектах.

Векторная база FAISS

FAISS выбран как база для хранения embeddings и поиска по ним, инициализация обёрнута в try/except: сначала попытка загрузить с диска, иначе создаётся пустой индекс.

FAISS, библиотека для хранения и поиска эмбеддингов, использую её скорее по привычке ("исторически сложилось"), хотя признаю, есть и более мощные альтернативы. Инициализация базы обёрнута в try/except: сначала пытаюсь загрузить существующий индекс с диска через FAISS с указанием пути и функции embeddings; если файла ещё нет, ловится исключение и создаётся пустой индекс вместе с InMemoryDocstore, а функция возвращает пустую, но готовую к заполнению базу. После первой генерации база сохраняется на диск как файлы products.faiss.

  • Embeddings преобразуют текст в вектор, а такие базы, как FAISS, хранят вектора и выполняют по ним поиск
  • Инициализация через try/except: сначала попытка load с диска, при неудаче, создание пустого индекса и InMemoryDocstore
  • Готовая база сохраняется на диск как products.faiss и переиспользуется без повторной генерации
Я использую faiz. Как-то так исторически сложилось.

Document и метаданные

Каждый товар оборачивается в Document: наименование идёт в page_content, а цена и артикул, в metadata, чтобы не дёргать базу данных повторно и уметь фильтровать поиск.

Каждая позиция номенклатуры оборачивается в объект Document: в page_content попадает наименование товара, текст, который будет векторизован, а в metadata, произвольные дополнительные данные, например цена или артикул. Это решает практическую проблему: после того как нужный товар найден по смыслу, не нужно повторно лезть в базу данных за ценой, она уже лежит в metadata найденного документа. Кроме того, по metadata можно дополнительно фильтровать результаты, например сузить поиск по конкретному артикулу или группе товаров, если пользователь их указал, в дополнение к поиску по смысловому сходству текста.

  • page_content: наименование товара для векторизации, metadata: произвольные дополнительные поля (цена, артикул)
  • Metadata избавляет от лишнего похода в базу данных за уже известной информацией о найденном товаре
  • По metadata можно фильтровать результаты дополнительно к поиску по сходству, например, по артикулу или группе
оказалось, что метаданные такая удобная штука, туда можно, допустим, запихнуть цену

Генерация векторного хранилища из JSON

Скрипт читает JSON с номенклатурой, оборачивает каждую позицию в Document и через FAISS.from_documents строит и сохраняет векторную базу.

Функция generateVectorStore читает файл base1s.json с номенклатурой (наименование, артикул, цена), создаёт пустой список документов и в цикле добавляет туда новый Document для каждой позиции. Готовый список передаётся в метод fromDocuments класса FAISS вместе с функцией embeddings, под капотом FAISS обращается к Ollama и модели эмбеддингов, преобразует тексты в вектора и строит индекс. Результат сохраняется на диск как база product, дальше её можно переиспользовать без повторной генерации. Сам скрипт запускаю отдельно от основного приложения через конструкцию if __name__ == '__main__', чтобы генерация не срабатывала при каждом старте бота.

  • Чтение base1s.json (180 элементов: наименование, артикул, цена) и создание списка Document в цикле
  • FAISS.fromDocuments(documents, embeddings) под капотом обращается к Ollama и строит векторный индекс
  • Готовая база сохраняется на диск как products.faiss и переиспользуется без повторной генерации
  • Генерация запускается отдельным скриптом, а не при каждом старте основного приложения

Функция generate_vector_store: преобразование base1s.json в документы FAISS

def init_vector_store():
    try:
        db = FAISS.load_local(
            folder_path=str(base_path),
            index_name="products",
            embeddings=bot_manager["embeddings"],
            allow_dangerous_deserialization=True,
        )
        print("База FAISS загружена")
        return db
    except Exception as e:
        print("Ошибка загрузки базы FAISS", e.__str__())

    try:
        index = faiss.IndexFlatL2(
            len(bot_manager["embeddings"].embed_query("hello world"))
        )
        db = FAISS(
            embedding_function=bot_manager["embeddings"],
            index=index,
            docstore=InMemoryDocstore(),
            index_to_docstore_id={},
        )
        print("Пустая база FAISS инициализирована")
        return db
    except Exception as e:
        print("Ошибка инициализации базы FAISS", e.__str__())

Запуск генерации векторной базы с точкой останова в отладчике

def generate_vector_store():
    with open(base_path / "base1s.json", "r", encoding="utf-8") as f:
        data = json.load(f)

    documents = []
    for item in data:
        documents.append(
            Document(
                page_content=item["Наименование"],
                metadata={"price": item["Цена"]},
            )
        )

    faiss_db = FAISS.from_documents(
        documents=documents, embedding=bot_manager["embeddings"]
    )
    faiss_db.save_local(folder_path=str(base_path), index_name="products")
мы вызываем метод fromDocuments из базы Faiz

Тест semantic search по номенклатуре

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

После загрузки готовой базы метод similarity_search принимает текстовый запрос и число k, сколько наиболее релевантных документов вернуть. На запрос «корм пурина 750 грамм» модель нашла нужный корм, хотя название не совпадало дословно: эмбеддинги улавливают смысл, а не точное совпадение символов, это как с примером про BMW, где на русском найдёт BMW на английском. При k=5 остальные найденные товары оказались релевантны скорее по формальным признакам вроде похожих цифр в названии (700, 702 грамма), а не по сути запроса. На запросе «Леон кастрюля с крышкой» система нашла кастрюлю «Мрамор» 6,3 литра, семантически близкий, хотя не идентичный товар.

  • similarity_search(query, k) возвращает k наиболее похожих Document с их page_content и metadata
  • Поиск работает по смыслу: находит товар даже без точного текстового совпадения (аналогия с BMW на русском/английском)
  • При увеличении k остальные кандидаты могут подбираться по формальным совпадениям (например, похожие цифры в названии), а не по сути запроса

Демонстрация similarity_search по запросу «корм Пурина 750 грамм»

if __name__ == "__main__":
    from langchain_ollama import OllamaEmbeddings

    bot_manager["embeddings"] = OllamaEmbeddings(
        model=embeddings_model,
        base_url="http://OLLAMA_HOST:11434",
    )

    # generate_vector_store()  # первый прогон — построить базу из base1s.json
    vector_store = init_vector_store()

    results = vector_store.similarity_search("корм пурина 750", k=1)
    for res in results:
        print(f"* {res.page_content} [{res.metadata}]")
У этой базы PHAIS есть метод similarity search, в котором мы передаем наш запрос

Многоуровневый поиск и реранкинг

Простой поиск по векторной базе, самый лёгкий вариант, но не идеальный: в рабочих проектах добавляют SQL-поиск и реранкинг через языковую модель.

Простой similarity_search по векторной базе, самый лёгкий, но не самый точный способ искать номенклатуру: чем больше кандидатов возвращается, тем больше среди них случайных совпадений вместо смысловых. В рабочих проектах использую многоуровневую схему: поиск по SQL, затем по векторной базе, а на последнем этапе, реранкинг, когда языковой модели передают уже небольшой отобранный список кандидатов и просят выбрать самый релевантный под запрос вариант. Поиск по номенклатуре вообще обманчиво сложная задача: человеку она кажется тривиальной, а машине даётся тяжело.

  • Поиск по векторной базе: самый простой подход, но он не всегда самый идеальный
  • В рабочих проектах: многоуровневая система (SQL-поиск, векторный поиск, реранкинг)
  • Реранкер (например, языковая модель) отсеивает лишних кандидатов и возвращает 1-2 максимально релевантных варианта
  • Поиск по номенклатуре сложен для машины при кажущейся простоте для человека, на этом, например, зарабатывает Яндекс
Это самый простой подход, но он не всегда самый идеальный.

AI-сервис распознавания ценников через vision-модель

В этом окне с нуля пишу ai_service, сердце всего решения по распознаванию ценников. Показываю весь путь фото: кодирование в base64, формирование системного промпта с JSON-схемой, загрузка в MinIO, нарезка ценников YOLO-моделью, распознавание vision-моделью mistral-small3.2 и поиск товара в векторной базе FAISS. По ходу разбираю практические нюансы: от ограничения размера фото до слабых мест векторного поиска без реранжера.

Кодирование фото в base64 (getEncodedImageUrl)

Функция getEncodedImageUrl превращает двоичные данные фото в data-URL с base64, который понимает vision-модель.

В ai_service изображение считывается как двоичные данные, а функция getEncodedImageUrl кодирует их в строку формата data:<content-type>;base64,<строка>, пригодную для передачи в vision-модель. Такой же подход к base64-кодированию я уже использовал в других своих курсах для отображения картинок в HTML. Отдельно подчёркиваю практический момент: файлы по 5-10 мегабайт отправлять в модель не стоит, обработка будет долгой, а на облачных моделях это ещё и лишние деньги. Поэтому перед подачей в модель большие изображения советую сжимать или обрезать по качеству.

  • Функция принимает двоичные данные и content type, возвращает data-URL вида data:image/jpeg;base64,...
  • Тот же подход к base64 применялся в предыдущих курсах Ильи
  • Файлы по 5-10 МБ нельзя слать напрямую: долгая обработка и лишние расходы на облачных моделях
  • Перед отправкой в модель большие изображения рекомендуется сжимать/обрезать
Не советую вам посылать огромные файлы по 5 мегабайт, по 10 мегабайт в языковую модель.

Промпт с жёсткой JSON-схемой (formatImagePrompt)

SystemPrompt задаёт роль модели-экстрактора и требует вернуть только JSON по строгой схеме name/price.

Функция formatImagePrompt строит промпт через ChatPromptTemplate: системное сообщение идёт первым и описывает роль модели как инструмента извлечения структурированных данных из документов, а затем задаёт точную JSON-схему ответа. Под разные типы документов (ценники, паспорта, водительские удостоверения, накладные) предусмотрены разные схемы, для ценников нужны поля name и price. Промпт несколько раз повторяет требование вернуть только корректный JSON с отступом и ничего лишнего. HumanMessage передаёт текст запроса и закодированное base64-изображение, после чего format_messages собирает всё в готовый набор сообщений для вызова модели.

  • SystemPrompt описывает роль модели и жёстко требует ответ строго в виде JSON с заданной схемой
  • Под разные документы (ценники, паспорта, права, накладные): разные JSON-схемы полей
  • Для ценников схема: name (наименование) и price (цена товара)
  • ChatPromptTemplate объединяет системное и человеческое сообщение (текст + изображение) в единый промпт

Функция format_image_prompt: системный промпт с JSON-схемой name/price

def get_encoded_image_url(contents: bytes, content_type: str) -> str:
    encoded_data = base64.b64encode(contents).decode("utf-8")
    image_data_url = f"data:{content_type};base64,{encoded_data}"
    return image_data_url


def format_img_prompt(encoded_image_url: str):
    system_prompt = """
    Ты продвинутый инструмент извлечения данных из документов.
    Верни результат ТОЛЬКО в виде JSON со следующей структурой:
    {{
        "name": "наименование товара",
        "price": "цена товара"
    }}
    Ничего, кроме корректного JSON, в ответе быть не должно. Отформатируй JSON с отступами.
    """
    user_prompt = "Проанализируй этот документ и извлеки всю важную информацию."

    prompt_template = ChatPromptTemplate.from_messages(
        [
            SystemMessagePromptTemplate.from_template(system_prompt),
            HumanMessagePromptTemplate.from_template(
                [
                    {"type": "text", "text": user_prompt},
                    {
                        "type": "image_url",
                        "image_url": {"url": "{encoded_image_url}"},
                    },
                ]
            ),
        ]
    )
    return prompt_template.format_messages(encoded_image_url=encoded_image_url)
Верни результат только в виде JSON со следующей структуры.

Полный pipeline: MinIO -> YOLO -> vision-модель -> векторный поиск -> отчёт

Фото ценника проходит цепочку: загрузка в MinIO, нарезка YOLO, распознавание vision-моделью, поиск товара в векторной базе и сборка JSON-отчёта.

Асинхронная тестовая функция test эмулирует вызов из Telegram-бота: читает jpeg с диска, инициализирует embeddings и векторную базу, затем вызывает process_image. Внутри двоичные данные фото загружаются в MinIO под сгенерированным именем, сервис YOLO (эндпоинт price_label) по HTTP GET вырезает отдельные ценники и возвращает имена файлов-кропов. Для каждого кропа: файл скачивается из MinIO, кодируется в base64, формируется промпт и отправляется в локальную vision-модель методом invoke; ответ парсится в JSON (name, price); по name делается similarity_search в векторной базе, откуда берутся product name и price; всё собирается в отчёт.

  • MinIO хранит оригинал по сгенерированному имени, YOLO-сервис вырезает отдельные ценники за секунды на локальной 3090
  • Каждый кроп кодируется в base64 и уходит в локальную vision-модель методом invoke, ответ чистится и парсится в JSON
  • По распознанному name делается similarity_search в векторной базе, извлекаются ближайшие product_name и product_price
  • Итоговый отчёт по каждому ценнику собирается в массив reports с полями label_name/price/product_name/product_price

Тестовая асинхронная функция test() и отладка process_image

async def process_image(image_data: bytes, message: Message | None = None):
    report = []

    destination_file = f"{uuid.uuid4()}.png"
    minio_save_file(file_name=destination_file, image_data=image_data)

    # YOLO вырезает отдельные ценники и возвращает их имена в MinIO
    detection_service_url = (
        f"http://YOLO_HOST:8010/api/v1/yolo/price_label/{destination_file}"
    )
    with httpx.Client(timeout=120) as client:
        response = client.get(detection_service_url)
        response.raise_for_status()
        result = response.json()

    price_labels = result.get("price").get("price", [])
    if not price_labels:
        if message:
            await message.answer("На изображении не найдено ценников.")
        return

Вызов vision-модели методом invoke() и разбор content ответа

    for price_label in price_labels:
        try:
            price_label_img = minio_load_file(file_name=price_label)
            encoded_image_url = get_encoded_image_url(
                contents=price_label_img, content_type="image/jpeg"
            )
            messages = format_img_prompt(encoded_image_url=encoded_image_url)

            response = llm.invoke(messages)
            price_label_data = convert_json(response.content)

            name = price_label_data.get("name")
            price = price_label_data.get("price")

            # Поиск товара в номенклатуре по распознанному названию
            vector_result = bot_manager["vector_store"].similarity_search(name, k=1)
            product_name = vector_result[0].page_content
            product_price = vector_result[0].metadata.get("price")

            report.append(
                {
                    "label_name": name,
                    "label_price": price,
                    "product_name": product_name,
                    "product_price": product_price,
                }
            )
        except Exception as e:
            # Ошибка на одном ценнике не роняет обработку остальных
            print(f"Ошибка при обработке одного из ценников: {str(e)}")
            continue

    return report
Двоичные данные отправляем в нашу функцию ProcessImage.

Слабое место векторного поиска без реранжера

similarity_search всегда возвращает ближайший элемент, даже если реального совпадения товара в базе нет.

После получения name из ответа vision-модели вызывается similarity_search векторной базы с запросом на один релевантный элемент; из документа берутся page_content и метаданные, откуда достаются product name и price. На первом ценнике совпадение оказалось точным, но на втором распознанный товар (корм) в базе отсутствовал, и поиск всё равно вернул ближайший по смыслу элемент вместо отказа. Чтобы отсекать такие ложные совпадения, стоит добавить этап реранжирования: либо специализированной обученной моделью-реранжером, либо самой языковой моделью, которая отдельно проверит, действительно ли найденный товар соответствует исходному запросу.

  • similarity_search всегда возвращает как минимум один элемент, даже без реального совпадения в базе
  • На втором ценнике товар не совпал с базой, но поиск вернул ближайший по смыслу результат
  • Решение: добавить реранжер, отдельную обученную модель или LLM-проверку релевантности найденного элемента
Но векторная база нам все равно вернула максимально близкий по смыслу вот к этому запросу.

Асинхронные функции под будущий вызов из Telegram-бота

Все функции ai_service делаются асинхронными, потому что библиотека Telegram-бота вызывает их асинхронно.

Тестовая функция test и process_image сделаны асинхронными, потому что дальше они будут вызываться из библиотеки для Telegram-ботов, а она работает асинхронно. Поэтому весь ai_service сразу проектирую под асинхронный вызов. Тестовая функция test, по сути, аналог запуска main.py: она инициализирует embeddings и векторную базу, читает тестовый jpeg с диска и передаёт его в process_image так же, как в будущем это сделает обработчик сообщений бота. В конце кода уже заложена ветка if message для реального вызова из бота с формированием HTML-сообщения и фото пользователю.

  • Функции ai_service асинхронные, так как библиотека Telegram-бота вызывает их асинхронно
  • Тестовая функция test эмулирует будущий вызов из обработчика сообщений бота
  • В коде уже заложена ветка if message: для реального вызова из бота с HTML-сообщением и фото
Соответственно, нам нужны асинхронные функции.

Архитектура YOLO-сервиса и эксперименты с промптом

В этой части разбираю, как устроен отдельный OSR/YOLO-сервис, который вырезает ценники из фото перед тем, как отдать их vision-модели. Показываю датасет и обучение нейронки, прямо на вебинаре добавляю в промпт извлечение веса товара, а в ответах на вопросы объясняю, зачем для приватных данных нужны именно локальные модели.

Пайплайн нарезки ценников через YOLO-сервис перед vision-моделью

Фото сначала режется YOLO-сервисом на отдельные ценники, и только потом каждый кусочек отдаётся vision-модели.

Вместо того чтобы сразу отправлять целое фото витрины в языковую модель, картинка сначала уходит в отдельный сервис OSR/YOLO. Это дообученная модель детекции: она находит на изображении bounding-box'ы вокруг ценников, вырезает их и возвращает обратно через MinIO. Дальше каждый маленький кусочек по отдельности подаю в локальную vision-модель с просьбой извлечь наименование и цену. Такой подход нужен, потому что целиком большое изображение локальная модель (Mistral на 3090) обработать не может, для этого пришлось бы использовать облачную модель, а нарезанные мини-изображения обрабатывать проще, особенно в потоке.

  • YOLO: предобученная модель, дообученная именно на распознавание ценников (а не людей и т.п.)
  • Сервис определяет bounding-box'ы ценников, вырезает их и возвращает через MinIO
  • Каждый вырезанный кусочек подаётся в vision-модель отдельно, что проще для потоковой обработки
  • Без такой нарезки для целого изображения пришлось бы использовать облачную модель
Мы получили картинку с ценниками, разрезали на мини-изображения, проще обрабатывать

Разметка датасета и обучение YOLO под новый тип документа

Разметка датасета на ценники заняла около получаса, а добавление нового класса распознавания: порядка 50 строк кода.

Показываю сам сервис обучения: датасет с изображениями размечается вручную, на фото просто обводятся прямоугольники вокруг нужных объектов, как в фотошопе). После этого датасет загружается в сервис, который автоматически распаковывает его, приводит к нужному формату и запускает обучение нейронки; итоговая модель сохраняется в файл (например price-best). Раньше по такой же схеме обучал модель для нарезки частей паспорта: имени и серии с номером. Для добавления нового класса (ценников) потребовалось около 50 строк кода, а сама разметка датасета заняла примерно полчаса.

  • Разметка датасета на ценники: вручную обведённые прямоугольники, заняла около получаса
  • Сервис автоматически распаковывает датасет и запускает обучение нейронки одной кнопкой
  • Для нового класса распознавания понадобилось около 50 строк кода
  • Ранее такой же подход применялся для нарезки частей паспорта: имени, серии и номера
Датасет я сделал где-то за полчаса

Эксперимент: извлечение веса товара через доработку промпта

Просто дописав в промпт «вес товара», модель начала извлекать вес прямо с ценника, даже плохого качества.

Прямо на вебинаре решил проверить, сможет ли модель извлечь вес товара, если просто добавить это требование в текст промпта, без изменения кода. Дописал в промпт фразу про вес товара, для красивого вывода отчёта заменил print на pprint и перезапустил скрипт. Модель успешно вернула «750 грамм» (в другом варианте «750g») даже с ценников не самого хорошего качества, включая второй ценник, где цифры были почти не видны. Показывает, как легко расширять схему извлекаемых данных через промпт-инжиниринг, а не через переобучение или доработку кода.

  • Новая характеристика (вес) добавлена только правкой текста промпта, без изменения кода
  • Модель извлекла «750 грамм» даже с некачественных изображений ценников
  • Для красивого вывода отчёта использовали pprint вместо обычного print
  • Дальнейшая постобработка JSON-ответа модели делается через регулярное выражение
Вес, вес товара, в prompt, просто только в prompt добавил и все.

Локальные vs облачные vision-модели для приватных данных

Локальная модель на 3090 не тянет целый скан целиком, зато не отправляет приватные данные наружу, в отличие от облачных моделей.

Если отправлять vision-модели сразу целое изображение с несколькими ценниками, локальная модель (mistral на видеокарте 3090) с этим не справится, придётся использовать облачную. Локальная модель нормально обрабатывает только небольшие изображения хорошего качества, например водительское удостоверение или паспорт. Для приватных документов, которые нельзя отправлять в облако, применяется другой подход: YOLO сначала вырезает нужный фрагмент, а локальная модель работает уже с маленьким кусочком. Локальная модель крутится в контуре предприятия за фаерволом и никуда данные не отправляет, а данные, отправленные в облачную модель, потенциально могут стать достоянием общественности.

  • Целое крупное изображение с ценниками локальная модель на 3090 обработать не может: нужна облачная
  • Локальная модель справляется с небольшими изображениями хорошего качества (паспорт, права)
  • Для приватных документов YOLO вырезает нужный фрагмент, и в локальную модель подаётся уже маленький кусочек
  • Данные, отправленные в облачную модель, могут стать достоянием общественности, локальная модель работает только внутри контура предприятия
мы должны понимать, что, скорее всего, эти данные могут стать достоянием общественности

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

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

Открытые веса и архитектуры нейросетей сильно ускорили развитие проектов на их основе: ещё несколько лет назад применение нейронок было уделом узкого круга и стоило дорого, особенно с большими языковыми моделями. Сейчас большие LLM, на мой взгляд, вышли на своеобразное плато, потому что уже обучены на огромных объёмах данных, и тренд сместился в сторону маленьких, но сильно оптимизированных моделей: четырёхмиллиардная модель уже решает большую часть задач и запускается на видеокарте всего с 8 ГБ памяти, а мощная карта вроде H100 позволяет развернуть LLM-проект полностью внутри контура предприятия.

  • Открытый доступ к весам и архитектурам дал большой толчок развитию нейросетевых проектов
  • Большие языковые модели, по наблюдению Ильи, вышли на плато по качеству
  • Четырёхмиллиардные модели уже решают большую часть задач и запускаются на видеокарте с 8 ГБ памяти
  • Мощная видеокарта (например H100) позволяет развернуть LLM-проект целиком внутри контура предприятия
Веса в открытом доступе. Архитектура в открытом доступе. Просто берите и используйте.

HTTP API и финальная интеграция с ботом

Сервис распознавания ценников оборачиваю в HTTP-эндпоинт: создаю router.py с APIRouter и функцией upload_image, подключаю его к главному приложению через версионированный префикс v1 и проверяю результат в Swagger UI. Затем тот же process_image подключаю к обработчику фото в Telegram-боте на aiogram и запускаю первый end-to-end тест, который сразу вскрывает пару багов.

Эндпоинт /api/v1/osr/upload через APIRouter

Загрузку фото ценников оборачивают в HTTP-эндпоинт на APIRouter, чтобы сервис можно было вызывать откуда угодно, а не только из Telegram-бота.

В source создаю router.py, куда импортирую APIRouter и UploadFile из FastAPI, последний нужен, чтобы принимать файлы в формате multipart/form-data (так же, как картинки можно отправлять из 1С, с сайта или мобильного приложения). Туда же импортирую уже готовую функцию process_image. Создаю APIRouter с префиксом, и функция upload_image принимает файл, считывает его и передаёт в process_image. Так вызов сервиса перестаёт быть привязан только к Telegram и превращается в обычный HTTP-вызов, который можно дёргать из любого клиента.

  • В router.py импортируются APIRouter и UploadFile из FastAPI для приёма файлов через multipart/form-data.
  • Функция upload_image считывает переданный файл и передаёт его в process_image.
  • Роутер получает свой префикс и позже подключается к главному API-роутеру приложения.
Создаем API router с префиксом

Многоступенчатая архитектура роутеров с версионированием API

Роутеры вкладываются друг в друга (app → /api/v1 → /osr → /upload), чтобы заранее заложить версионирование и упростить масштабирование.

В main.py создаю главный APIRouter с префиксом v1, к которому подключается osr_router, а сам главный роутер уже подключается к main.app. В итоге маршрут получается многоступенчатым: /api/v1/osr/upload. Такое версионирование (v1, v2 и так далее), обычная практика для API. Для тестового приложения можно было бы обойтись одной функцией upload прямо в main.py, но здесь сознательно закладываю архитектуру с самого начала, чтобы приложение было легче масштабировать в будущем.

  • Главный APIRouter создаётся с префиксом v1 и подключает под-роутер osr_router.
  • Итоговый маршрут получается многоступенчатым: /api/v1/osr/upload.
  • Архитектура и версионирование закладываются даже в тестовом приложении ради удобства будущего масштабирования.
Закладываем архитектуру, чтобы потом легче было масштабировать.

Проверка эндпоинта в Swagger UI (/docs)

Перед подключением к боту новый эндпоинт проверяют вручную через встроенную Swagger-документацию FastAPI.

У FastAPI есть встроенная документация на /docs. После первого запуска main.py маршрут не появляется, потому что не был указан префикс v1, как только структуру роутеров исправил, в Swagger UI появился /api/v1/osr/upload. Нажимая Try it out, выбираю файл ценника и отправляю его на обработку через кнопку execute, получаю отчёт в ответ. Это показывает, что тот же эндпоинт можно вызывать откуда угодно: из 1С, с мобильного приложения, с сайта, присылая фото и получая готовый отчёт.

  • FastAPI автоматически предоставляет интерактивную документацию на /docs.
  • Отсутствие маршрута в Swagger UI объясняется забытым префиксом v1 у главного роутера.
  • Через Try it out можно отправить файл на эндпоинт без написания отдельного клиента и сразу получить отчёт.
У fastapi есть встроенная документация, то есть мы /docs пишем.

Интеграция обработки фото в Telegram-боте (aiogram)

Хендлер фото в aiogram скачивает файл по file_id и передаёт его в process_image вместе с объектом сообщения, чтобы отправить отчёт обратно в чат.

Дорабатываю функцию PhotoHandler бота: если в сообщении пришло фото, беру его file_id; если пришёл документ, проверяю расширение (png, jpeg), иначе пользователю пишем просьбу прислать изображение; если нет ни фото, ни подходящего документа, обработчик просто завершается без ответа. Через bot.get_file(file_id) Telegram отдаёт бинарные данные фото, они считываются и вместе с объектом сообщения (нужен для отправки ответов в чат) передаются в process_image, ранее не импортированную в файл бота. В цикле по каждому распознанному ценнику формируется и отправляется в чат вырезанное фото с отчётом в HTML-формате.

  • Проверяется тип вложения: фото, документ с расширением png/jpeg, либо ничего из этого (тогда обработчик просто завершается).
  • Через bot.get_file(file_id) скачиваются бинарные данные изображения из Telegram.
  • Бинарные данные и объект сообщения передаются в process_image, что позволяет отправить отчёт по каждому ценнику обратно в тот же чат.
  • process_image импортируется из ie_service в начало файла бота, так как раньше не была подключена.
Телеграм как? Ты получаешь файл ID, а потом уже по этому ID ты скачиваешь фотографию.

Отладка первого end-to-end теста: пропущенная инициализация и необработанное исключение

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

При отправке первого фото в бота вылезает ошибка на этапе similarity search: при старте приложения инициализировал MinIO и эмбеддинги, но забыл инициализировать сам векторный поиск. В main.py импортирую init_vector_store из source embedding_service и вызываю при старте, чтобы заранее собранная база загружалась в память. После перезапуска первый тест проходит успешно. На втором фото с двумя ценниками модель падает при попытке извлечь вес: на одном ценнике веса нет, и по строгой схеме модель не может вернуть значение, а необработанное исключение обрывает всю обработку цикла, ничего не добавляя в отчёт.

  • При старте приложения инициализировали MinIO и эмбеддинги, но забыли вызвать init_vector_store, из-за этого падал similarity search.
  • Добавление init_vector_store в main.py чинит запуск: векторная база загружается в память при старте.
  • При обработке двух ценников модель падает на извлечении веса (на одном ценнике его нет), а необработанное исключение обрывает весь цикл обработки.
  • Решение: обернуть обработку каждого изображения в try/except; на курсе эта проблема снимается нестрогой схемой, возвращающей 'неопределено' для отсутствующих полей.
А векторный поиск при старте приложения мы не инициализировали.

Отладка ошибок и итоги вебинара

В финальной части вебинара живая демонстрация ломается прямо на глазах: сервис обработки падает посреди анализа фото ценника, и я на ходу разбираюсь, что случилось с Ollama-сервером. Параллельно отвечаю на вопросы зрителей про галлюцинации LLM и делюсь планами по развитию курса: от гибридного поиска номенклатуры до детекции товаров на полке.

Падение сервиса при обработке фото ценника

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

В ходе живого показа отправляю боту очередное фото документа, но обработка обрывается: в консоли видно, что ценники успели распознаться, а дальше процесс встаёт. Первая гипотеза, проблема на стороне Ollama («Аламо» в расшифровке), которая могла упасть на сервере. Времени разбираться в логах на месте не было, так что решил не искать точную причину, а просто перезапустить сервис и вернуться к вопросам зрителей, пока идёт перезагрузка.

  • Обработка фото ценника прерывается уже после этапа распознавания текста
  • Подозрение падает на сервис Ollama, упавший на сервере
  • Вместо детального дебага на вебинаре принято решение просто перезапустить сервис
Да, по ходу дела упал сервис.

Перезапуск упавшего Ollama-сервера

Илья подключается к серверу и перезапускает Ollama, после чего проверяет её статус перед повторной попыткой.

Чтобы восстановить работу vision-модели, подключаюсь к удалённому серверу на Ubuntu и перезагружаю его, предполагая, что именно LLM-сервис завис. После перезагрузки проверяю статус командой и вижу, что LLM запущен, но при повторной отправке фото выясняется, что сам сервис ещё не поднят, ошибка на его стороне. После явного запуска сервиса скачивание модели проходит, и обработка снова начинает работать, хотя первопричину сбоя за отведённое время так и не выяснил).

  • Сервер на Ubuntu я запустил только в день вебинара, в отличие от привычной связки на Windows
  • После перезагрузки сервера отдельно проверяется статус LLM-сервиса командой
  • Причина падения не установлена: решено не тратить время на расследование в эфире
Возможно, там у LLM сервис что-то случилось.

Галлюцинации LLM и почему это не «интеллект»

LLM: это математика и архитектурные приёмы поверх неё, а не разум, поэтому галлюцинации неизбежны и проекты строятся так, чтобы свести их к минимуму.

Отвечая на вопрос зрителя, подчёркиваю: никакого «интеллекта» в современных моделях нет, это математика и определённые архитектурные подходы поверх неё, без всякой магии). Галлюцинации есть всегда, и задача разработчика, строить проект так, чтобы свести их к нулю, а не полагаться на модель как на самостоятельного эксперта. Особенно опасно доверять LLM обработку финансовых документов: один и тот же отчёт, отправленный модели повторно с просьбой проанализировать, с высокой вероятностью даст разные ответы даже у самых мощных моделей. Поэтому реальные проекты, это связка алгоритмов, LLM и специально обученных сетей, а не просто промпт к модели.

  • LLM: это математика и архитектура, а не разум; «суперинтеллекта» не существует и в ближайшее время не предвидится
  • Галлюцинации есть всегда, задача проекта: свести их к нулю за счёт архитектуры, а не игнорировать
  • Финансовые документы: зона повышенного риска, модель может дать разный ответ на один и тот же запрос
  • Рабочий проект: это комбинация алгоритмов, LLM и обученных нейросетей, а не голый запрос к модели
Ну, галлюцинация есть, да, но мы учимся как раз делать проекты, чтобы эти галлюцинации свести к нулю.

Гибридный поиск для подбора номенклатуры (эмбеддинги + реранкеры + метаданные)

Будущий блок курса про подбор номенклатуры для чат-бота построен на связке эмбеддингов, реранкеров и сужения поиска по метаданным.

Рассказывая о планах курса, описываю один из самых сложных будущих блоков: автоматический подбор номенклатуры для бота (например, десять тысяч товаров с автозапчастями). Задача решается не одним поиском, а комбинацией методик: эмбеддинги для семантического поиска, реранкеры для уточнения релевантности результатов и сужение поиска по метаданным для отсечения нерелевантных вариантов. Итоговая цель, бот-агент, который подбирает нужный товар клиенту, предлагает его и сам оформляет заказ в 1С, снимая с менеджера до 90% рутинной работы.

  • Задача: подбор нужного товара среди тысяч позиций номенклатуры по запросу клиента
  • Используется связка эмбеддингов, реранкеров и сужения поиска по метаданным
  • Итоговый бот-агент сам предлагает товар и оформляет заказ в 1С после подтверждения клиента
Используем разные методики: эмбеддинги, реранкеры, поиск, сужение поиска по метаданным.

Именно эту связку — эмбеддинги, реранкеры и поиск по номенклатуре 1С, а поверх них ИИ-агент, который подбирает товар и сам оформляет заказ, — я подробно разбираю на флагманском курсе «ИИ-агент для 1С: LangChain, RAG и MCP-серверы». Если вам близка задача из этой статьи, но нужен не упрощённый пример на вебинар, а рабочая схема под свою базу, это ваш следующий уровень.

Двухэтапная детекция для подсчёта товаров на полке

Идея будущего проекта: одна нейросеть считает все товары на фото полки, вторая, дообученная на конкретном бренде, считает только товары клиента.

Отвечая на вопрос про долю полки, занятую продукцией клиента среди конкурентов, описываю возможный подход на базе computer vision: под конкретного клиента собирается датасет с фото его товаров, на котором дообучается нейросеть выделять именно эти товары на полке. Обученная модель выдаёт bounding box'ы с координатами найденных объектов, но изображение не режется, просто считается количество. Первая, общая детекция считает все товары на фото полки, а вторая, дообученная под бренд, считает только товары клиента, так можно получить процент присутствия на полке и даже картинку с разноцветными рамками для своих и чужих товаров. Такой проект, скорее всего, реализуем уже в рамках курса.

  • Под клиента дообучается отдельная модель на датасете именно его товаров
  • Модель выдаёт bounding box'ы с координатами, изображение не режется: только считаются объекты
  • Первая детекция считает все товары на полке, вторая (дообученная): только товары клиента
  • Результат можно визуализировать разноцветными рамками для своих и чужих товаров
И она просто выделяет нам вот эти B-боксы с координатами.

Что дальше

На вебинаре мы собрали компактный, но работающий сервис: фото ценника → нарезка YOLO → распознавание локальной vision-моделью → поиск товара в векторной базе → отчёт. Это фундамент, на котором строятся прикладные ИИ-сервисы поверх привычных бизнес-задач: приватные данные не уходят в облако, а модель крутится в контуре предприятия. Дальше начинается инженерия — гибридный поиск номенклатуры, реранкинг, нестрогие схемы извлечения и превращение сервиса в полноценного бота-агента, который сам оформляет заказ в 1С.

Если хотите пройти этот путь до конца и на своих данных — приходите на курс «ИИ-агент для 1С: LangChain, RAG и MCP-серверы»: там те же кирпичи (Ollama, эмбеддинги, векторный поиск, FastAPI), но собранные в промышленную схему под реальную базу 1С. А исходники и readme из этого вебинара можно забрать в блоге — если что-то не заведётся, задавайте вопросы, разберём.

Глоссарий

MinIO
Локальное S3-совместимое облако, развёрнутое в Docker, через которое разные сервисы проекта (Telegram-бот, сервис обработки изображений и другие) обмениваются файлами, например фото ценников.
YOLO-сервис нарезки ценников
Готовый дообученный сервис детекции на базе YOLO: принимает целое фото витрины, находит на нём bounding-box'ы ценников и возвращает уже нарезанные отдельные кусочки для дальнейшей обработки.
Vision-модель mistral-small3.2 (ChatOllama)
Локальная модель компьютерного зрения, вызываемая через класс ChatOllama из LangChain; работает на отдельной машине с видеокартой 3090 и извлекает структурированные данные (название, цену) из фото нарезанных ценников.
Ollama
Бэкенд для запуска локальных языковых и vision-моделей; в проекте используется вместе с llama.cpp/vLLM как один из самых простых в развёртывании вариантов для локального инференса.
Эмбеддинги (embeddings)
Способ преобразовать текст в вектор чисел так, чтобы близкие по смыслу фразы оказывались рядом в векторном пространстве; в проекте для базы товаров используется модель qwen3-embedding:8b как основа RAG-поиска по номенклатуре.
Векторная база FAISS
Библиотека для хранения и поиска эмбеддингов; индекс либо загружается с диска, либо создаётся пустым при первом запуске, а после генерации из JSON с номенклатурой сохраняется на диск как файлы products.faiss.
Similarity search (семантический поиск)
Метод поиска по векторной базе, который по текстовому запросу возвращает k наиболее близких по смыслу документов, даже если формулировка не совпадает дословно с названием товара в базе.
Реранкинг (реранжер)
Дополнительный этап после векторного поиска, на котором обученная модель-реранжер или сама LLM проверяют небольшой список кандидатов и отбирают действительно релевантный, отсекая случайные смысловые совпадения.
RAG-поиск по номенклатуре
Подход, при котором запрос пользователя или распознанное моделью название товара ищется через эмбеддинги в базе номенклатуры компании, чтобы подтянуть реальную цену и метаданные товара.
FastAPI и lifespan-инициализация
Веб-фреймворк, в котором тяжёлая инициализация (клиент MinIO, embedding-модель, запуск Telegram-бота) собирается в одной функции lifespan: код до yield выполняется при старте приложения, код после, при остановке.
aiogram
Библиотека для асинхронных Telegram-ботов на Python; в проекте на ней собраны обработчики команды /start и приёма фото/документа с ценником.
Document и metadata (LangChain)
Объект, в котором page_content содержит текст для векторизации (наименование товара), а metadata, дополнительные данные вроде цены и артикула, доступные сразу после нахождения нужного документа без повторного похода в базу данных.
Base64-кодирование фото
Способ превратить бинарные данные изображения в строку формата data:<content-type>;base64,<строка>, пригодную для передачи в vision-модель; большие файлы перед этим рекомендуется сжимать.
Галлюцинации LLM
Свойство языковых моделей иногда выдавать неверный, но правдоподобный ответ; задача разработчика: строить архитектуру проекта (алгоритмы + LLM + обученные сети) так, чтобы свести риск галлюцинаций к нулю, а не полагаться на модель как на самостоятельного эксперта.

Частые вопросы