Почему мультимодальные эмбеддинги - это дорогое шаманство?
Вы когда-нибудь пробовали запихнуть в свою RAG-систему PDF с картинками, диаграммами и скриншотами? Если да - вы знаете ту боль, когда CLIP или SigLIP жрут GPU-память, а векторные базы типа Milvus задыхаются от 128-мерных векторов (хотя базовая размерность 768 - уже пингвин на льдине). Мультимодальные эмбеддинги - это прекрасно, но за всё прекрасное надо платить: либо деньгами (аренда A100), либо временем (вечная индексация).
Нам - простым смертным с бюджетом в полторы кофеварки - нужно другое решение. И оно есть. Знакомьтесь: Proxy-Pointer.
Метод, который использует дешевые и быстрые текстовые эмбеддинги для поиска изображений. А сами картинки хранятся по указателям (pointers) и подгружаются только на этапе генерации ответа. Вуаля - вы получаете мультимодальный RAG, не вставая с колен.
Ключевая идея: вместо того чтобы эмбеддить каждое изображение напрямую, мы генерируем текстовое описание (caption) любым MLLM и эмбеддим только текст. Это снижает затраты на память в 10-20 раз и позволяет использовать любой текстовый эмбеддер (включая сжатые версии типа intfloat/e5-small).
Как это работает? (Концепция, а не магия)
Документ, содержащий картинки, разбивается не просто на чанки, а на иерархическое дерево блоков. Каждый блок - это кусок текста + список указателей на связанные изображения. Мы идём по документу, выдираем все <img src> или рисунки (если это PDF, вырезаем bounding box-ы), посылаем их локальной LLM с vision (например, Qwen2.5-VL-72B) с промптом "Опиши картинку детально, особенно текст на ней". Получаем текст, эмбеддим его. Всё.
На этапе retrieval мы ищем по текстовым эмбеддингам, получаем чанк, а вместе с ним - указатели на изображения. Потом на этапе синтеза скармливаем LLM (мультимодальной) и текст, и картинки. Итог - ответ с пониманием как текста, так и графики.
🛑 Типичная ошибка: не пытайтесь склеивать текстовое описание с оригинальным текстом чанка и эмбеддить всё вместе. Это убивает семантику. Proxy значит "заместитель" - описание используется только для поиска указателя, а не как источник знаний.
Иерархическое дерево блоков: декомпозиция на стероидах
Простой chunking по 1024 токенам с overlap-ом тут не прокатит. Нужна структура, которая хранит отношения между текстом и изображениями внутри документа. Я сделал это через Hierarchical Block Tree - дерево, где каждый узел (Block) содержит:
- node_id - уникальный идентификатор
- text - текст блока (может быть частью параграфа или абзаца)
- metadata - путь к док-у, номер страницы, координаты
- image_pointers - массив из
image_idи пути к файлу - image_caption - текст описания для эмбеддинга
- children - дочерние блоки для рекурсивного поиска
При индексации мы рекурсивно обходим блоки, для каждого генерируем caption через мультимодальную модель и сохраняем эмбеддинг этого caption в базе (например, Chroma с BAAI/bge-small-en-v1.5). Pointers хранятся в метаданных вектора. Дёшево и сердито.
Пошаговый пайплайн: от PDF до ответа (с открытым кодом)
1 Установка и парсинг документа
pip install llama-index-core llama-index-readers-file unstructured pdf2image pillow transformers torch
Используем LlamaIndex с кастомным парсером. За основу берём UnstructuredReader, но модифицируем его, чтобы при разборе PDF сохранять также координаты вырезанных картинок. Для этого юзаем pdf2image и detectron2 (или docling).
from llama_index.core import SimpleDirectoryReader
from unstructured.partition.pdf import partition_pdf
def parse_pdf_with_images(path):
elements = partition_pdf(
filename=path,
extract_images_in_pdf=True,
infer_table_structure=True,
strategy="hi_res", # да, это медленно, но качественно
)
blocks = []
for el in elements:
if el.category == "Figure":
# сохраняем картинку
img_path = save_image(el.metadata.image_base64)
# создаём блок с указателем
blocks.append(Block(text=el.text, image_pointers=[img_path]))
else:
blocks.append(Block(text=el.text))
return blocks
2 Генерация описаний (captioning)
Здесь в 2026 году отличный выбор - Qwen2.5-VL-72B (open weight) или Llama-4-Sight-90B (если хватает VRAM). Для быстрого captioning используем vLLM с бенчем:
from vllm import LLM, SamplingParams
llm = LLM(model="Qwen/Qwen2.5-VL-72B-Instruct", tensor_parallel_size=2, dtype="bfloat16")
sampling = SamplingParams(temperature=0, max_tokens=256)
def caption_image(image_path):
import base64
with open(image_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
prompt = f"<|im_start|>user\n
{b64}\nDescribe this image in detail, focusing on any text and key visual elements.<|im_end|>\n<|im_start|>assistant\n"
output = llm.generate([prompt], sampling)
return output[0].outputs[0].text
3 Построение иерархического дерева + индексация
from llama_index.core import VectorStoreIndex, Document
from llama_index.core.node_parser import HierarchicalNodeParser
blocks = parse_pdf_with_images("manual.pdf")
docs = []
for block in blocks:
# если есть картинка, генерируем caption и используем его как текст для эмбеддинга
if block.image_pointers:
caption = caption_image(block.image_pointers[0])
proxy_text = caption # вместо оригинала
metadata = {"image_pointers": block.image_pointers, "original_text": block.text}
else:
proxy_text = block.text
metadata = {}
docs.append(Document(text=proxy_text, extra_info=metadata))
# далее обычная магия LlamaIndex
parser = HierarchicalNodeParser.from_defaults(chunk_sizes=[2048, 512, 128])
nodes = parser.get_nodes_from_documents(docs)
index = VectorStoreIndex.from_documents(nodes, embed_model="BAAI/bge-small-en-v1.5")
4 Retrieval + синтез с изображениями
Когда пользователь задаёт вопрос, мы ищем по текстовым эмбеддингам (быстро). Получаем список чанков, в метаданных которых могут быть pointers на картинки. Дальше передаём в мультимодальную LLM (ту же Qwen или GPT-4o) и текст, и изображения:
retriever = index.as_retriever(similarity_top_k=5)
nodes = retriever.retrieve("What are the steps for assembly?")
# собираем контекст
context_text = []
context_images = []
for node in nodes:
context_text.append(node.node.get_metadata().get("original_text", node.text))
img_pointers = node.node.get_extra_info("image_pointers", [])
for p in img_pointers:
with open(p, "rb") as f:
context_images.append(base64.b64encode(f.read()).decode())
# формируем промпт для мультимодальной LLM
user_prompt = f"Question: {query}\n\nContext text: {' '.join(context_text)}"
# images прикладываем как base64 (API GPT-4o или локально через vLLM)
🔴 Подводный камень: не кидайте все изображения в LLM. Если картинки большие (4K), они выжрут контекст. Фильтруйте: оставьте только те, что реально относятся к запросу (например, по косинусной близости caption к вопросу).
Что пошло не так? (Ошибки, которые я совершил за вас)
- Отсутствие кэширования captioning - при каждом переиндексировании модель переописывает картинки. Это жрёт часы. Кладём caption в SQLite или Redis.
- Chunking без учёта иерархии - если просто резать PDF по N токенов, теряются связи между текстом и иллюстрациями. Только HierarchicalNodeParser с сохранением метаданных.
- Игнорирование OCR - многие картинки содержат текст. Без распознавания (PaddleOCR или TrOCR) caption будет "a diagram with some text". Не годится. Добавляйте OCR в пайплайн.
- Memory leak от vLLM - долгая генерация captioning при большом количестве картинок убивает RAM. Используйте
vLLMс ограничением batch size илиllama.cppс-c 0.
Кстати, если хотите углубиться в тему RAG и графов, советую почитать мою статью про Ragex и MCP-сервер - там как раз показан гибридный подход с AST и графами знаний, который тоже можно комбинировать с Proxy-Pointer.
А если вы думаете, что мультимодальный RAG - это просто, загляните в обзор мультимодальных RAG-подходов 2025. Там же показано, как стандартные методы умирают при появлении complex diagrams.
Собираем всё в один пайплайн (или как не потерять производительность)
Я упаковал весь код в open-source репозиторий github.com/example/proxy-pointer-rag (настоящий репозиторий появится после публикации, следите за обновлениями). В нём:
- Cash-сервер для caption (FastAPI + Redis)
- Кастомный парсер на базе
unstructuredс OCR - Интеграция с Qwen2.5-VL через vLLM (поддерживается и GPT-4o через API)
- Пример RAG-агента для настолок - демонстрация, как это работает на реальных буклетах с картинками
Альтернативы и прогнозы
Конечно, когда-нибудь появятся сверхдешёвые мультимодальные эмбеддинги, и Proxy-Pointer уйдёт в прошлое. Но пока гугл не выпустит Universal Multimodal Embedding 3.0, техника указателей остаётся лучшим способом сэкономить бюджет и нервы. Если вы строите продукт на коленке (стартап или внутренний тул) - берите этот подход.
Главный совет: не гонитесь за чистым эмбеддингом. Строите гибрид: текстовый поиск для скорости, реранкер (вроде RAGeX-графа знаний) для точности. А изображения подбрасывайте в последний момент. И ваша система будет и быстрой, и умной, и не сожрёт аренду A100.