10 марта Google выкатил Gemini Embedding 2 - embedding-модель, которая умеет превращать в векторы не только текст, но и картинки, видео, аудио и PDF. Причем всё это ложится в одно векторное пространство.
Почему это круто? До сих пор если вы хотели искать по видеобиблиотеке через RAG, приходилось городить огород: сначала транскрибировать аудиодорожку, потом описывать кадры через Vision LLM, склеивать это в текст, и только потом эмбеддить. Каждый шаг - потеря информации. Тон голоса, визуальный контекст, динамика в кадре - все это терялось при "переводе" в текст.
Теперь же, как заявляют Гугл, можно скормить модели MP4-файл напрямую, и она вернёт вектор. Тот же вектор, что живет в том же пространстве, что и векторы текстовых документов. Текстовый запрос "как настроить авторизацию" найдет и статью из вашей базы знаний, и фрагмент видеоинструкции - через один и тот же старый добрый трюк - косинусное расстояние.
В этой статье разберем что нового в модели, а потом построим полноценный мультимодальный RAG с нуля, используя Python, Supabase, Gemini API.
Для тех кто подзабыл или не сталкивался - очень коротко.
RAG (Retrieval-Augmented Generation) - это когда LLM отвечает не из галлюцинаций головы, а сначала ищет релевантную информацию в вашей базе данных. Схема такая:
Берем вопрос пользователя, прогоняем через embedding-модель - получаем вектор
Ищем в векторной БД ближайшие по смыслу документы (косинусное расстояние).
Найденные документы подставляем в промпт как контекст
LLM генерирует ответ, опираясь на этот контекст
Зачем это нужно? LLM не знает ваших внутренних документов, не читала ваши видеоинструкции и понятия не имеет что написано в корпоративной вики. RAG решает эту проблему - модель получает актуальный контекст прямо в момент запроса. А еще это экономит лимиты, так как не надо загружать всю информацию в контекст.
Векторная БД тут ключевой компонент. Она хранит эмбеддинги и умеет быстро находить похожие. PostgreSQL с расширением pgvector, Pinecone, Weaviate, Qdrant - вариантов много. Мы будем использовать Supabase, потому что это по сути hosted Postgres с pgvector из коробки - для новичков и просто попробовать самое то.
Все, база есть. Едем дальше.
Раньше embedding-модели от Google (да и от большинтсва других вендоров) работали только с текстом. Хотите эмбеддить картинку? Опишите её текстом, а потом эмбеддьте описание. Хотите видео? Транскрибируйте, потом эмбеддьте транскрипцию. Это как искать музыку по словесному описанию мелодии (ну там вот так: "тра-та-там-бам-бам") - сработать может конечно, но так себе.
Gemini Embedding 2 позволяет нативно (as is) принимать разные типы контента:
Текст - до 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). Но это их собственные бенчмарки, независимых пока толком пока нет. Впрочем, отрыв заявлен не маленький, так что есть шансы что в независимых тестах картина будет похожей.
Окей, теория - это хорошо, но старая добрая практика позволяет пощупать все тонкости самому. Давайте построим игрушечную рабочую систему.
На практике нельзя просто взять Gemini Embedding 2, заэмбеддить видео и радоваться. Вот почему: когда пользователь задает вопрос, система находит релевантный видеофрагмент по вектору - отлично. Но дальше этот фрагмент часто нужно передать LLM чтобы она сформировала ответ. И тут проблема: LLM не может «прочитать» MP4. Она работает с текстом. В итоге система находит видео, но отвечает в духе «вот вам двухминутный клип, ответ где-то там». Выглядит не как самая полезная вещь - есть пространство что улучшить.
Решение - два параллельных вызова при загрузке каждого медиафайла:
Gemini Embedding 2 создает нативный (то есть самого видео/аудио) вектор из сырого медиа - это для поиска
Gemini Flash (или любая обычная генеративная модель на ваш вкус) смотрит/слушает медиа и пишет текстовое описание - это для столбца content в БД, см. ниже.
Вектор находит совпадение. Текстовое описание дает LLM материал для ответа. Без описания совпадения будут так себе - найдены поиском, но бесполезны для генерации ответа и анализа.
Как итог получаем лучшее из двух миров: улучшенный поиск (ведь теперь находится идеально подходящее видео) и готовый контекст для LLM для будущего анализа.
Python 3.10+
google-genai - SDK для Gemini API
Supabase - hosted PostgreSQL с pgvector
ffmpeg - для нарезки видео
opencv-python - для извлечения кадров
Создаём директорию и ставим зависимости:
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/ # видеофайлы и чанки
Нужно три вещи:
Gemini API Key - берем на aistudio.google.com/apikey (есть бесплатный тир)
Supabase Project URL - создаем проект на supabase.com, идём в Settings → API.
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, потому что pgvector имеет свои ограничения (HNSW-индекс в 2000 измерений). Не переживаем - потеря качества при обрезке до 1536 минимальна.
gemini-2.0-flash - быстрая и дешёвая генеративная модель, которую мы используем для двух задач: генерация описаний медиа при загрузке и генерация финальных ответов. Можете заменить на другую модель если хотите - на суть архитектуры это не влияет.
Supabase - это по сути PostgreSQL в облаке, но пустой. Когда ты создаёшь проект, там нет ни таблиц, ни расширений -чистый лист. Нужно сказать базе: "включи расширение pgvector, создай таблицу documents с такими-то столбцами, создай индекс для быстрого поиска по векторам, создай функцию match_documents которую мы будем вызывать из Python".
Для этого создаем migration.sql. Миграция тут - это просто SQL-скрипт, который создает структуру базы данных в Supabase. Тут одна таблица и одна функция для поиска:
-- Включаем 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:
# Установка 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 и выполняете. Для туториала это проще.
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)} чанков.")
Это ядро системы. Здесь реализуются те самые два параллельных вызова для медиа.
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, meta 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" # Вызов 1: описание для content print(" Генерируем описание...") description = describe_content(path, mime) # Вызов 2: нативный эмбеддинг для поиска 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 не сможет ничего сказать о найденном видео. Если убрать нативный эмбеддинг и вместо него эмбеддить описание (как делают многие фреймворки) - потеряется качество поиска, потому что описание это lossy-представление оригинала.
Теперь поисковый движок. Логика такая: эмбеддим вопрос → ищем через 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. return "Ничего не найдено.", [] # 3. Собираем контекст из найденных документов context_parts = [] for m in matches. 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 секунд на видео. Для коротких роликов нормально, но для часовых записей чанкинг превращается в отдельную инженерную задачу. Наш подход с фиксированными сегментами - это бейзлайн. В реальном проекте стоит смотреть в сторону semantic chunking.
Несовместимость с предыдущими эмбеддингами. Если у вас есть существующая база на gemini-embedding-001 или text-embedding-005 - придётся переиндексировать все с нуля. Векторные пространства разных моделей несовместимы, смешивать их в одном индексе нельзя.
Стоимость аудио вдвое выше. Текст, картинки и видео - $0.25 за миллион токенов. Аудио - $0.50. Поэтому аккуратней с аудио.
Реальный фидбек. Команда Ragie (платформа для RAG) провела тесты и обнаружила интересную вещь: для видео описания сгенерированные через Vision LLM иногда работают не хуже нативных мультимодальных эмбеддингов при поиске, при этом обходятся дешевле и быстрее. Это не значит что нативные эмбеддинги бесполезны - они ловят то, что текстовое описание может упустить (визуальные детали, динамику). Ключевые слова там - "иногда" и "не хуже". Но это не лишнее напоминание, что в каждом конкретном случае стоит тестировать оба подхода.
Чанкинг - нерешенная задача. Для текста есть много подходов к семантическому разбиению. Для видео все гораздо сложнее. Простая нарезка по времени может разрезать объяснение пополам, а нахлест только частчно решает проблему.
Gemini Embedding 2 - это серьезный шаг вперед для мультимодального поиска. Возможность эмбеддить видео, картинки и аудио нативно, в одно пространство с текстом - это то, чего не хватало для полноценных мультимодальных RAG-систем.
Но, как мы показали в туториале сама по себе модель не решает проблему. Ключ - в правильной архитектуре данных: нативные эмбеддинги дают поиск, а текстовые описания дают LLM материал для ответов - их симбиоз позволит создавать по-настоящему мощные RAG системы.
Если у вас есть база знаний с видеоинструкциями, скриншотами, записями созвонов - теперь все это можно сделать поисковым и не через костыли с транскрипцией, а нативно.
Полезные ссылки:
Видео вдохновение этого разбора
Официальный блог Google о Gemini Embedding 2
Документация Gemini API - Embeddings
Vertex AI документация
Источник


