Proxy-Pointer RAG: мультимодальный поиск без дорогих эмбеддингов | 2026 | AiManual
AiManual Logo Ai / Manual.
30 Апр 2026 Гайд

Мультимодальный RAG без мультимодальных эмбеддингов: метод Proxy-Pointer и открытый код

Архитектура Proxy-Pointer: текстовые эмбеддинги + иерархическое дерево блоков для поиска по изображениям. Полный open-source пайплайн. Пошаговый гайд.

Почему мультимодальные эмбеддинги - это дорогое шаманство?

Вы когда-нибудь пробовали запихнуть в свою 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 к вопросу).

Что пошло не так? (Ошибки, которые я совершил за вас)

  1. Отсутствие кэширования captioning - при каждом переиндексировании модель переописывает картинки. Это жрёт часы. Кладём caption в SQLite или Redis.
  2. Chunking без учёта иерархии - если просто резать PDF по N токенов, теряются связи между текстом и иллюстрациями. Только HierarchicalNodeParser с сохранением метаданных.
  3. Игнорирование OCR - многие картинки содержат текст. Без распознавания (PaddleOCR или TrOCR) caption будет "a diagram with some text". Не годится. Добавляйте OCR в пайплайн.
  4. 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.

Подписаться на канал