Gemini Embedding 2 и мультимодальный RAG: эмбеддим видео и картинки — разбор и туториал

Gemini Embedding 2

10 марта Google выпустил Gemini Embedding 2 — эмбеддинг-модель (превращает данные в числовые векторы для поиска по смыслу). Она работает не только с текстом, но и с картинками, видео, аудио и PDF. Всё попадает в единое векторное пространство.

Почему это важно: раньше поиск по видеобиблиотеке через RAG требовал длинной цепочки. Нужно было транскрибировать аудио, описывать кадры через Vision LLM, склеивать в текст и только потом делать эмбеддинг. Каждый шаг — потеря информации. Тон голоса, визуальный контекст, динамика в кадре — всё терялось при конвертации в текст.

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

Разберём, что нового в модели, а потом построим мультимодальный RAG с нуля на Python, Supabase и Gemini API.

RAG за две минуты

Для тех, кто подзабыл: RAG (Retrieval-Augmented Generation) — подход, при котором LLM отвечает не из галлюцинаций, а сначала ищет релевантную информацию в вашей базе.

  1. Вопрос пользователя → embedding-модель → вектор
  2. Векторная БД → ближайшие по смыслу документы (косинусное расстояние)
  3. Найденные документы → промпт как контекст
  4. LLM генерирует ответ, опираясь на контекст

Зачем: LLM не знает ваших внутренних документов и видеоинструкций. RAG даёт модели актуальный контекст в момент запроса и экономит лимиты — не нужно грузить всё в контекст.

Векторная БД — ключевой компонент. PostgreSQL с pgvector (расширение для работы с векторами), Pinecone, Weaviate, Qdrant — вариантов много. Мы используем Supabase: это hosted Postgres с pgvector из коробки, отлично подходит для прототипа.

Что нового в Gemini Embedding 2

Раньше эмбеддинг-модели от Google (и большинства других вендоров) работали только с текстом. Хотите эмбеддить картинку? Опишите текстом. Видео? Транскрибируйте. Это как искать музыку по словесному описанию мелодии — сработать может, но так себе.

Gemini Embedding 2 нативно принимает разные типы контента:

  • Текст — до 8192 входных токенов (в 4 раза больше предыдущей модели)
  • Картинки — до 6 штук за запрос, PNG и JPEG
  • Видео — до 120 секунд, MP4 и MOV
  • Аудио — нативно, без промежуточной транскрипции
  • PDF — до 6 страниц

Всё проецируется в единое пространство размерностью 3072. Вектор от текстового запроса и вектор от видеоклипа — точки в одном координатном пространстве. Их можно сравнивать напрямую.

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

Бенчмарки

По данным Google, Embedding 2 обходит Amazon Nova 2 и Voyage Multimodal 3.5. Особенно сильно по видеозадачам (68.8 vs 60.3 vs 55.2). Это внутренние бенчмарки, независимых пока нет. Но отрыв заявлен существенный, так что есть шансы, что независимые тесты подтвердят картину.

Туториал: строим мультимодальный RAG

Проблема, которую решаем

Нельзя просто заэмбеддить видео и радоваться. Когда система находит релевантный видеофрагмент по вектору — отлично. Но дальше этот фрагмент нужно передать LLM для формирования ответа. Проблема: LLM не умеет «смотреть» MP4. Она работает с текстом. Система находит видео, но отвечает в духе «вот вам двухминутный клип, ответ где-то там».

Решение — два параллельных вызова при загрузке каждого медиафайла:

  1. Gemini Embedding 2 создаёт нативный вектор из сырого медиа — для поиска
  2. Gemini Flash (или другая генеративная модель) смотрит/слушает медиа и пишет текстовое описание — для столбца content в БД

Вектор находит совпадение. Текстовое описание даёт LLM материал для ответа. Без описания совпадения бесполезны для генерации.

Стек

  • Python 3.10+
  • google-genai — SDK для Gemini API
  • Supabase — hosted PostgreSQL с pgvector
  • ffmpeg — для нарезки видео
  • opencv-python — для извлечения кадров

Шаг 1. Настройка проекта

mkdir multimodal-rag && cd multimodal-rag

pip install google-genai supabase python-dotenv opencv-python

Проверяем ffmpeg:

ffmpeg -version

Если нет — на macOS brew install ffmpeg, на Ubuntu sudo apt install ffmpeg, на Windows через choco install ffmpeg.

Структура проекта:

multimodal-rag/
├── .env
├── config.py
├── migration.sql
├── video_chunker.py
├── ingest.py
├── query.py
└── assets/
    ├── docs/      # текстовые документы (.md, .txt)
    ├── images/    # картинки (.png, .jpg)
    └── video/     # видеофайлы и чанки

Шаг 2. Ключи и конфигурация

Нужно три вещи:

  1. Gemini API Key — на aistudio.google.com/apikey (есть бесплатный тир)
  2. Supabase Project URL — создаём проект на supabase.com → Settings → API
  3. Supabase Anon Key — там же

Создаём .env:

GEMINI_API_KEY=ваш_ключ
SUPABASE_URL=ваш_url
SUPABASE_KEY=ваш_key

И config.py:

import os
from dotenv import load_dotenv

load_dotenv()

GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
SUPABASE_URL = os.getenv("SUPABASE_URL")
SUPABASE_KEY = os.getenv("SUPABASE_KEY")

EMBEDDING_MODEL = "gemini-embedding-2-preview"
EMBEDDING_DIM = 1536
LLM_MODEL = "gemini-2.0-flash"

Пара замечаний. gemini-embedding-2-preview — мультимодальная модель. Размерность берём 1536, а не полные 3072. Причина: HNSW-индекс (тип индекса для быстрого поиска ближайших соседей) в pgvector ограничен ~2000 измерениями. Потеря качества при обрезке минимальна.

gemini-2.0-flash — быстрая и дешёвая модель для генерации описаний при загрузке и финальных ответов. Можно заменить на любую другую — на архитектуру это не повлияет.

ID моделей могут меняться. Сверяйтесь с актуальной документацией Google AI Studio.

Шаг 3. База данных

Supabase — PostgreSQL в облаке, но пустой. Нужно включить pgvector, создать таблицу и функцию поиска.

migration.sql:

-- Включаем pgvector
CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA public;

-- Основная таблица
CREATE TABLE documents (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  content TEXT,                          -- текст или описание медиа
  embedding VECTOR(1536),                -- вектор от Embedding 2
  source_type TEXT NOT NULL,             -- 'text', 'image', 'video'
  source_file TEXT,                      -- имя файла
  chunk_index INT,                       -- номер чанка (для видео)
  metadata JSONB DEFAULT '{}',
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- HNSW-индекс для быстрого поиска
CREATE INDEX idx_documents_embedding
  ON documents USING hnsw (embedding vector_cosine_ops);

-- Функция поиска похожих документов
CREATE OR REPLACE FUNCTION match_documents(
  query_embedding VECTOR(1536),
  match_count INT DEFAULT 5,
  filter_source_type TEXT DEFAULT NULL
) RETURNS TABLE(
  id UUID,
  content TEXT,
  source_type TEXT,
  source_file TEXT,
  chunk_index INT,
  metadata JSONB,
  similarity FLOAT
) AS $$
BEGIN
  RETURN QUERY
  SELECT
    d.id, d.content, d.source_type, d.source_file,
    d.chunk_index, d.metadata,
    1 - (d.embedding <=> query_embedding) AS similarity
  FROM documents d
  WHERE (filter_source_type IS NULL OR d.source_type = filter_source_type)
  ORDER BY d.embedding <=> query_embedding
  LIMIT match_count;
END;
$$ LANGUAGE plpgsql;

Два ключевых столбца: embedding хранит нативный вектор (для поиска), content — текст или описание (для LLM). Это те самые два параллельных канала.

Как применить миграцию:

Вариант А — через Supabase CLI:

npm install -g supabase
supabase init
supabase login
supabase link --project-ref ваш_project_ref
mkdir -p supabase/migrations
cp migration.sql supabase/migrations/20250101000000_init.sql
supabase db push

Project ref — поддомен из Supabase URL. Если URL https://abcxyz.supabase.co, то ref — abcxyz.

Вариант Б — скопировать SQL в SQL Editor в дашборде Supabase и выполнить. Для туториала проще.

Шаг 4. Чанкинг видео

Gemini Embedding 2 принимает максимум 120 секунд видео за запрос. Если видео длиннее — нужно нарезать. Но тупая нарезка по 120 секунд — плохая идея: контекст на границах чанков потеряется.

Режем на ~97-секундные сегменты с 15-секундным нахлёстом. 97 секунд дают запас до лимита, 15 секунд overlap гарантируют, что контекст на стыках не пропадёт. Идеально — чанкинг по сменам сцен или семантическим границам (разбиение по смыслу, а не по таймкоду), но для прототипа хватит и этого.

video_chunker.py:

"""Нарезка видео на сегменты для Gemini Embedding 2 (макс 120 сек на чанк)."""

import os
import subprocess
import cv2


def get_duration(video_path: str) -> float:
    """Получаем длительность видео через ffprobe."""
    result = subprocess.run(
        ["ffprobe", "-v", "quiet", "-show_entries", "format=duration",
         "-of", "csv=p=0", video_path],
        capture_output=True, text=True
    )
    return float(result.stdout.strip())


def chunk_video(
    input_path: str,
    output_dir: str = "assets/video",
    segment_duration: int = 97,
    overlap: int = 15,
) -> list[str]:
    """Разбиваем видео на перекрывающиеся сегменты."""
    os.makedirs(output_dir, exist_ok=True)
    duration = get_duration(input_path)
    chunks = []
    start = 0
    index = 0

    while start < duration:
        chunk_path = os.path.join(output_dir, f"chunk_{index:03d}.mp4")
        cmd = [
            "ffmpeg", "-y", "-ss", str(start), "-t", str(segment_duration),
            "-i", input_path, "-c", "copy", chunk_path,
        ]
        subprocess.run(cmd, capture_output=True)
        chunks.append(chunk_path)
        print(f"Создан {chunk_path} (начало={start}с)")
        start += segment_duration - overlap
        index += 1

    return chunks


def extract_thumbnail(video_path: str, output_path: str | None = None) -> str:
    """Извлекаем кадр из середины видео как превью."""
    cap = cv2.VideoCapture(video_path)
    total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    cap.set(cv2.CAP_PROP_POS_FRAMES, total_frames // 2)
    ret, frame = cap.read()
    cap.release()

    if not ret:
        raise RuntimeError(f"Не удалось прочитать кадр из {video_path}")

    if output_path is None:
        base = os.path.splitext(video_path)[0]
        output_path = f"{base}_thumb.jpg"

    cv2.imwrite(output_path, frame)
    return output_path


if __name__ == "__main__":
    import sys
    if len(sys.argv) < 2:
        print("Использование: python video_chunker.py <видеофайл>")
        sys.exit(1)
    paths = chunk_video(sys.argv[1])
    print(f"\nСоздано {len(paths)} чанков.")

Шаг 5. Ingestion-пайплайн

Ядро системы — два параллельных вызова для медиа.

ingest.py:

"""Загрузка текста, картинок и видео в Supabase через Gemini Embedding 2."""

import os
import glob
from google import genai
from google.genai import types
from supabase import create_client

from config import (
    GEMINI_API_KEY, SUPABASE_URL, SUPABASE_KEY,
    EMBEDDING_MODEL, EMBEDDING_DIM, LLM_MODEL,
)

# Инициализация клиентов
gemini = genai.Client(api_key=GEMINI_API_KEY)
supabase = create_client(SUPABASE_URL, SUPABASE_KEY)


# ===== Функции эмбеддинга =====

def embed_text(text: str) -> list[float]:
    """Эмбеддинг текста."""
    result = gemini.models.embed_content(
        model=EMBEDDING_MODEL,
        contents=[text],
        config=types.EmbedContentConfig(
            task_type="RETRIEVAL_DOCUMENT",
            output_dimensionality=EMBEDDING_DIM,
        ),
    )
    return result.embeddings[0].values


def embed_image(image_path: str) -> list[float]:
    """Нативный эмбеддинг картинки - без конвертации в текст."""
    with open(image_path, "rb") as f:
        image_bytes = f.read()

    mime = "image/png" if image_path.endswith(".png") else "image/jpeg"

    result = gemini.models.embed_content(
        model=EMBEDDING_MODEL,
        contents=types.Content(parts=[
            types.Part.from_bytes(data=image_bytes, mime_type=mime),
        ]),
        config=types.EmbedContentConfig(output_dimensionality=EMBEDDING_DIM),
    )
    return result.embeddings[0].values


def embed_video(video_path: str) -> list[float]:
    """Нативный эмбеддинг видео - без транскрипции."""
    with open(video_path, "rb") as f:
        video_bytes = f.read()

    result = gemini.models.embed_content(
        model=EMBEDDING_MODEL,
        contents=types.Content(parts=[
            types.Part.from_bytes(data=video_bytes, mime_type="video/mp4"),
        ]),
        config=types.EmbedContentConfig(output_dimensionality=EMBEDDING_DIM),
    )
    return result.embeddings[0].values


# ===== Генерация описаний (для столбца content) =====

def describe_content(file_path: str, mime_type: str) -> str:
    """
    Просим Gemini посмотреть/послушать медиафайл
    и написать текстовое описание.

    Это описание пойдёт в столбец content,
    чтобы LLM могла сослаться на него при генерации ответа.
    """
    with open(file_path, "rb") as f:
        data = f.read()

    response = gemini.models.generate_content(
        model=LLM_MODEL,
        contents=types.Content(parts=[
            types.Part.from_bytes(data=data, mime_type=mime_type),
            types.Part.from_text(
                text="Подробно опиши содержимое этого файла для базы знаний. "
                     "Включи все ключевые концепции, процессы и связи."
            ),
        ]),
    )
    return response.text


# ===== Вставка в Supabase =====

def insert_document(
    content: str,
    embedding: list[float],
    source_type: str,
    source_file: str,
    chunk_index: int | None = None,
    metadata: dict | None = None,
):
    """Сохраняем документ в базу."""
    supabase.table("documents").insert({
        "content": content,
        "embedding": embedding,
        "source_type": source_type,
        "source_file": source_file,
        "chunk_index": chunk_index,
        "metadata": metadata or {},
    }).execute()


# ===== Пайплайны загрузки =====

def ingest_text_docs(docs_dir: str = "assets/docs"):
    """
    Загрузка текстовых документов.
    Текст идёт и в content, и в эмбеддинг.
    """
    files = glob.glob(os.path.join(docs_dir, "*.md")) + \
            glob.glob(os.path.join(docs_dir, "*.txt"))

    for path in files:
        filename = os.path.basename(path)
        print(f"Загружаем текст: {filename}")

        with open(path, "r", encoding="utf-8") as f:
            text = f.read()

        vector = embed_text(text)
        insert_document(
            content=text,
            embedding=vector,
            source_type="text",
            source_file=filename,
        )
        print(f"  Готово ({len(vector)} измерений)")


def ingest_images(images_dir: str = "assets/images"):
    """
    Загрузка картинок - два вызова:
    1. embed_image() - нативный вектор для поиска
    2. describe_content() - текстовое описание для LLM
    """
    files = glob.glob(os.path.join(images_dir, "*.png")) + \
            glob.glob(os.path.join(images_dir, "*.jpg"))

    for path in files:
        filename = os.path.basename(path)
        print(f"Загружаем картинку: {filename}")

        mime = "image/png" if path.endswith(".png") else "image/jpeg"

        print("  Генерируем описание...")
        description = describe_content(path, mime)

        print("  Создаём эмбеддинг...")
        vector = embed_image(path)

        insert_document(
            content=description,
            embedding=vector,
            source_type="image",
            source_file=filename,
            metadata={"description": description},
        )
        print(f"  Готово ({len(vector)} измерений)")


def ingest_video_chunks(video_dir: str = "assets/video"):
    """
    Загрузка видеочанков - два вызова на каждый чанк.
    Видео уже нарезано через video_chunker.py
    """
    chunk_files = sorted(glob.glob(os.path.join(video_dir, "chunk_*.mp4")))

    for i, path in enumerate(chunk_files):
        filename = os.path.basename(path)
        print(f"Загружаем видеочанк: {filename}")

        print("  Генерируем описание...")
        description = describe_content(path, "video/mp4")

        print("  Создаём эмбеддинг...")
        vector = embed_video(path)

        insert_document(
            content=description,
            embedding=vector,
            source_type="video",
            source_file=filename,
            chunk_index=i,
            metadata={"description": description, "chunk_index": i},
        )
        print(f"  Чанк {i+1} загружен ({len(vector)} измерений)")


if __name__ == "__main__":
    print("=== Загрузка текстовых документов ===")
    ingest_text_docs()

    print("\n=== Загрузка картинок ===")
    ingest_images()

    print("\n=== Загрузка видеочанков ===")
    ingest_video_chunks()

    print("\nЗагрузка завершена.")

Что здесь происходит — ключевое место.

Для текста всё тривиально: сам текст идёт в content, его эмбеддинг — в embedding. Один вызов.

Для картинок и видео — два отдельных вызова к разным моделям:

  • embed_image() / embed_video()embedding (нативный вектор от сырого медиа, через Embedding 2)
  • describe_content()content (текстовое описание, через Gemini Flash)

Вектор и описание обслуживают разные цели. Вектор — для поиска, описание — для генерации ответа. Без описания LLM не сможет ничего сказать о найденном видео. Без нативного эмбеддинга (если эмбеддить описание вместо оригинала) потеряется качество поиска. Описание — это приближённое представление с потерями, оно не передаёт все детали оригинала.

Шаг 6. Query-движок

Эмбеддим вопрос → ищем через RPC (удалённый вызов функции в БД) в Supabase → собираем контекст → генерируем ответ.

query.py:

"""Поисковый движок: эмбеддинг вопроса → поиск → генерация ответа."""

from google import genai
from google.genai import types
from supabase import create_client

from config import (
    GEMINI_API_KEY, SUPABASE_URL, SUPABASE_KEY,
    EMBEDDING_MODEL, EMBEDDING_DIM, LLM_MODEL,
)

gemini = genai.Client(api_key=GEMINI_API_KEY)
supabase = create_client(SUPABASE_URL, SUPABASE_KEY)


def query_rag(
    question: str,
    top_k: int = 5,
    source_type: str | None = None,
) -> tuple[str, list[dict]]:
    """
    RAG-запрос: эмбеддим вопрос → ищем в Supabase → генерируем ответ.

    Возвращает (ответ, список_совпадений).
    """

    # 1. Эмбеддим вопрос
    #    task_type="RETRIEVAL_QUERY" (не DOCUMENT)
    result = gemini.models.embed_content(
        model=EMBEDDING_MODEL,
        contents=[question],
        config=types.EmbedContentConfig(
            task_type="RETRIEVAL_QUERY",
            output_dimensionality=EMBEDDING_DIM,
        ),
    )
    query_vector = result.embeddings[0].values

    # 2. Поиск через RPC-функцию
    rpc_params = {
        "query_embedding": query_vector,
        "match_count": top_k,
    }
    if source_type:
        rpc_params["filter_source_type"] = source_type

    matches = supabase.rpc("match_documents", rpc_params).execute()

    if not matches.data:
        return "Ничего не найдено.", []

    # 3. Собираем контекст из найденных документов
    context_parts = []
    for m in matches.data:
        label = f"[{m['source_type'].upper()}] {m['source_file']}"
        if m.get("chunk_index") is not None:
            label += f" (чанк {m['chunk_index']})"
        label += f" - similarity: {m['similarity']:.3f}"
        context_parts.append(f"{label}\n{m['content']}")

    context = "\n\n---\n\n".join(context_parts)

    # 4. Генерируем ответ
    prompt = (
        "Ты - помощник, отвечающий на вопросы на основе мультимодальной "
        "базы знаний с текстовыми документами, картинками и видео.\n\n"
        f"Контекст:\n{context}\n\n"
        f"Вопрос: {question}\n\n"
        "Дай чёткий, подробный ответ на основе контекста выше. "
        "Ссылайся на конкретные источники где возможно."
    )
    response = gemini.models.generate_content(model=LLM_MODEL, contents=prompt)

    return response.text, matches.data


if __name__ == "__main__":
    import sys

    question = " ".join(sys.argv[1:]) if len(sys.argv) > 1 else \
        "Как настроить авторизацию?"

    print(f"Вопрос: {question}\n")
    answer, sources = query_rag(question)
    print(f"Ответ:\n{answer}\n")
    print(f"Источники ({len(sources)}):")
    for s in sources:
        print(f"  [{s['source_type']}] {s['source_file']} - {s['similarity']:.3f}")

Обратите внимание на task_type. При загрузке использовали RETRIEVAL_DOCUMENT, при поиске — RETRIEVAL_QUERY. Это подсказывает модели оптимизировать вектор под задачу. Мелочь, но влияет на качество.

Как запустить

# 1. Положите файлы в assets/
#    assets/docs/   — .md и .txt
#    assets/images/ — .png и .jpg

# 2. Если есть длинное видео — нарезаем
python video_chunker.py assets/video/my_video.mp4

# 3. Загружаем всё в базу
python ingest.py

# 4. Задаём вопросы
python query.py "Как работает система авторизации?"
python query.py "Что показано на архитектурной диаграмме?"

Результаты приходят из разных модальностей — текстовые документы, описания картинок, видеофрагменты — всё через один векторный поиск.

Ограничения и подводные камни

Preview-статус. Модель в public preview. API может меняться, гарантий стабильности нет. Для боевого продакшена — дважды подумать.

Лимит 120 секунд на видео. Для коротких роликов норма, для часовых записей чанкинг становится отдельной инженерной задачей. Наш подход с фиксированными сегментами — бейзлайн. В реальном проекте стоит смотреть в сторону семантического чанкинга (разбиение по смыслу, а не по таймкоду).

Несовместимость с предыдущими эмбеддингами. Если есть база на gemini-embedding-001 или text-embedding-005 — переиндексация с нуля. Векторные пространства разных моделей несовместимы, смешивать их нельзя.

Стоимость аудио вдвое выше. Текст, картинки и видео — $0.25 за миллион токенов. Аудио — $0.50.

Реальный фидбек. Команда Ragie (RAG-платформа) провела тесты и обнаружила: описания видео, сгенерированные через Vision LLM, иногда работают не хуже нативных мультимодальных эмбеддингов при поиске. И обходятся дешевле и быстрее. Это не значит, что нативные эмбеддинги бесполезны. Они ловят то, что текстовое описание упускает — визуальные детали, динамику. Ключевые слова — «иногда» и «не хуже». Но в каждом конкретном случае стоит тестировать оба подхода.

Чанкинг — нерешённая задача. Для текста есть много подходов к семантическому разбиению. Для видео всё сложнее. Простая нарезка по времени может разрезать объяснение пополам, а нахлёст лишь частично решает проблему.

Заключение

Gemini Embedding 2 — серьёзный шаг для мультимодального поиска. Нативное эмбеддирование видео, картинок и аудио в одно пространство с текстом — то, чего не хватало для полноценных мультимодальных RAG-систем.

Но сама по себе модель проблему не решает. Ключ — в архитектуре данных: нативные эмбеддинги обеспечивают поиск, текстовые описания дают LLM материал для ответов. Их симбиоз позволяет строить по-настоящему мощные RAG-системы.

Если в базе знаний есть видеоинструкции, скриншоты, записи созвонов — теперь всё это можно сделать поисковым нативно, без костылей с транскрипцией.

Полезные ссылки: