Извлечение текста из PDF-картинок без Vision LLM: каскадный подход | AiManual
AiManual Logo Ai / Manual.
20 Июн 2026 Гайд

Оптимизация RAG для PDF: как извлекать текст из изображений без лишних затрат на Vision LLM

Научитесь извлекать текст из изображений PDF с минимальными затратами на Vision API. Каскадная фильтрация: OCR, дешевая типизация и только 10% запросов к VLM.

Реклама
partv2

Зачем платить за GPT-4V, если можно прокачать каскад?

Типичный RAG-пайплайн для PDF сегодня выглядит так: вытащили текст через PyMuPDF, а все картинки отправили прямиком в Vision LLM. GPT-4V, Claude Sonnet 3.5, Qwen2.5-VL-32B — каждая картинка стоит денег. А в PDF их могут быть сотни. Итог: счет за API превращается в чек из ресторана с мишленовской звездой.

Но проблема глубже. 80% изображений в PDF — это не схемы и не сложные графики. Это скриншоты текста, копии таблиц, сканы документов. Вы платите за то, что дешевый OCR вытащил бы за копейки. Или даже за то, что могло быть просто проигнорировано (логотипы, декоративные элементы).

Я покажу вам технику, которая позволит тратить на Vision API в 10-20 раз меньше, не теряя качества извлечения. Называется это каскадная фильтрация изображений.

💡 Идея: не все изображения нуждаются в дорогом понимании. Сначала быстрый фильтр отбрасывает пустышки, потом дешевый классификатор определяет тип (текст/таблица/график), затем OCR/парсер извлекает содержимое, и только если ничего не получилось — подключаем тяжелую артиллерию в виде Vision LLM.

Как выглядит типичный каскад (и почему он работает)

Многие пытались решить проблему извлечения текста из картинок через универсальный one-shot подход: загрузили картинку в VLM, получили транскрипцию. Это работает, но дорого. Альтернатива — построить pipeline, где каждый этап отсеивает ненужное.

  1. Детекция и извлечение изображений — вырезаем все картинки из PDF вместе с метаданными (позиция, размер, наличие текстового слоя рядом).
  2. Фильтр пустых/декоративных — отбрасываем логотипы, иконки, пустые рамки (на основе энтропии и площади).
  3. Типизация изображений — дешевый классификатор (например, простая нейросеть или эвристики) определяет: текст, таблица, график, схема.
  4. OCR для текстовых изображений — используем быстрый открытый OCR (Surya, TrOCR). Для таблиц — специализированный парсер (Camelot, Table Transformer).
  5. Фолбэк на Vision LLM — только для графиков, сложных схем и случаев, когда OCR дал низкий confidence.

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

DataFrame с метаданными — ваш главный козырь

Чтобы управлять каскадом, нужно сначала собрать image_df — датафрейм, где каждая строка — изображение с его характеристиками. Это превращает хаотичную задачу в структурированные данные. С ними легко работать: фильтровать, классифицировать, отслеживать пропуски.

Давайте построим такой датафрейм на Python. Используем PyMuPDF (версия 1.25.0, актуальная на июнь 2026) для извлечения изображений и базовой информации.

import fitz  # PyMuPDF
import pandas as pd
import io
from PIL import Image

def extract_images_to_df(pdf_path):
    doc = fitz.open(pdf_path)
    records = []
    for page_num in range(len(doc)):
        page = doc[page_num]
        images = page.get_images(full=True)
        for img_index, img in enumerate(images):
            xref = img[0]
            base_image = doc.extract_image(xref)
            image_bytes = base_image["image"]
            pil_image = Image.open(io.BytesIO(image_bytes))
            width, height = pil_image.size
            records.append({
                "page": page_num,
                "index": img_index,
                "width": width,
                "height": height,
                "format": base_image["ext"],
                "bytes": image_bytes,
                "pixel_count": width * height,
                "aspect_ratio": round(width / height, 2)
            })
    doc.close()
    return pd.DataFrame(records)

df = extract_images_to_df("annual_report.pdf")
print(f"Найдено {len(df)} изображений")

Теперь у нас есть таблица с метаданными. Дальше добавляем столбцы с результатами фильтрации и обработки.

Фильтр пустышек — энтропия и площадь

Первая линия обороны. Логотипы обычно маленькие (менее 10_000 пикселей), одноцветные с низкой энтропией. Иконки — тоже. Выбрасываем их без сожаления.

import numpy as np

def compute_entropy(pil_image):
    img_gray = pil_image.convert("L")
    hist = img_gray.histogram()
    hist_norm = [h / sum(hist) for h in hist]
    return -sum(p * np.log2(p) for p in hist_norm if p > 0)

def filter_irrelevant(row):
    # извлекаем изображение по bytes
    img = Image.open(io.BytesIO(row["bytes"]))
    entropy = compute_entropy(img)
    # эвристика: низкая энтропия и малый размер -> декоративный элемент
    if entropy < 2.0 and row["pixel_count"] < 15000:
        return "decorative"
    return "relevant"

df["filter_label"] = df.apply(filter_irrelevant, axis=1)
df_relevant = df[df["filter_label"] == "relevant"]
print(f"Осталось релевантных: {len(df_relevant)}")

⚠️ Ловушка: не отбрасывайте все маленькие картинки — иногда это диаграммы с легендой. Используйте энтропию и контекст страницы (наличие текстового слоя вокруг).

Типизация: дешевый классификатор вместо Vision LLM

Вместо того чтобы гнать каждую картинку в VLM для описания, обучим простой классификатор на основе признаков. Можно взять легковесную ONNX-модель (MobileNetV3) или использовать эвристики.

Для демонстрации сделаем эвристический классификатор:

def classify_image(row):
    img = Image.open(io.BytesIO(row["bytes"]))
    w, h = img.size
    aspect = w / h
    # detect table: много строк, много контуров
    # для простоты используем соотношение сторон и размер
    # full detection with layout parser (например, Surya) — лучше, но сложнее
    if 0.4 < aspect < 2.5 and row["pixel_count"] > 30000:
        return "text_or_table"
    elif aspect > 2.5 or row["pixel_count"] < 30000:
        return "graph"  # широкие или маленькие -> скорее график
    else:
        return "other"

df_relevant["type"] = df_relevant.apply(classify_image, axis=1)

Для серьезного продакшена я рекомендую использовать LayoutLMv3 или DocTR для детекции структуры. Но эвристики уже отсеивают половину.

OCR: Surya наше всё

Для извлечения текста из текстовых картинок используем Surya OCR (версия 0.7.0). Это открытая модель, которая работает быстрее и точнее Tesseract, особенно на вертикальном тексте и разных шрифтах.

from surya.ocr import run_ocr
from surya.model.recognition import load_model, load_processor

def extract_text_surya(image_bytes):
    img = Image.open(io.BytesIO(image_bytes))
    # загружаем модель один раз глобально
    model = load_model()
    processor = load_processor()
    predictions = run_ocr([img], ["ru"], model, processor)  # язык можно менять
    return predictions[0].text

def process_text_images(df):
    text_mask = df["type"] == "text_or_table"
    for idx in df[text_mask].index:
        row = df.loc[idx]
        text = extract_text_surya(row["bytes"])
        df.loc[idx, "text"] = text
        df.loc[idx, "confidence"] = compute_confidence(text)  # простой эвристический скор
    return df

df = process_text_images(df)

💡 Совет: обрабатывайте таблицы отдельно с помощью Camelot или Table Transformer. OCR может испортить структуру.

Фолбэк на Vision LLM — только для сложных случаев

После OCR у нас остались три категории изображений:

  • графики и схемы (тип "graph")
  • изображения с низким confidence OCR (менее 0.7)
  • изображения типа "other", которые не удалось классифицировать

Их и отправляем в Vision LLM. Для локального запуска используем Qwen2.5-VL-7B (актуальная open-source модель, июнь 2026). Если хочется через API — подойдет Claude Sonnet 3.5 Vision или дешевый GPT-4o-mini.

def extract_text_with_vlm(image_bytes, prompt="Извлеки весь текст из этого изображения."):
    # пример вызова локальной VLM через transformers
    from transformers import Qwen2VLForConditionalGeneration, AutoProcessor
    model = Qwen2VLForConditionalGeneration.from_pretrained("Qwen/Qwen2.5-VL-7B-Instruct")
    processor = AutoProcessor.from_pretrained("Qwen/Qwen2.5-VL-7B-Instruct")
    
    messages = [
        {"role": "user", "content": [
            {"type": "image", "image": Image.open(io.BytesIO(image_bytes))},
            {"type": "text", "text": prompt}
        ]}
    ]
    text = processor.apply_chat_template(messages, add_generation_prompt=True)
    inputs = processor(text=[text], images=[image_bytes], return_tensors="pt")
    outputs = model.generate(**inputs, max_new_tokens=512)
    return processor.decode(outputs[0], skip_special_tokens=True)

# Отбираем только те, где confidence низкий или тип графика
mask = (df["type"] == "graph") | (df["confidence"] < 0.7) | (df["type"] == "other")
vlm_candidates = df[mask].copy()

for idx in vlm_candidates.index:
    row = df.loc[idx]
    text = extract_text_with_vlm(row["bytes"])
    df.loc[idx, "text"] = text
    df.loc[idx, "extracted_by"] = "vlm"

Обратите внимание: мы не отправляем все изображения. Только те, с которыми не справились дешевые методы. В реальных проектах это 5-15% от общего числа.

Считаем экономию: реальные цифры

Допустим, у нас PDF с 200 страниц, из которых 150 содержат по одному изображению (150 картинок). Из них 100 — скриншоты текста, 30 — таблицы, 20 — графики.

Метод Кол-во обработанных Стоимость Итого
Каскад (OCR + VLM фолбэк) 130 OCR (бесплатно) + 20 VLM $0.003/картинка VLM $0.06
Все картинки через GPT-4o-mini 150 VLM $0.003/картинка $0.45
Все картинки через GPT-4V 150 VLM $0.03/картинка $4.50

Экономия в 7-75 раз. А если у вас тысячи PDF в день, разница становится катастрофической.

Типичные грабли и как их обойти

1 Все изображения одинаково важны

Нет. Логотип в углу, декоративные линии, фото людей — они не несут текстовой информации для RAG. Отфильтруйте их на этапе энтропии и площади.

2 OCR справляется со всем текстом

Не факт. Рукописный ввод, мелкие шрифты, текст на сложном фоне — лучше сразу отправлять в VLM. Используйте confidence скор, чтобы принять решение.

3 Можно обойтись одной моделью

Я не советую. Лучше комбинировать: дешевый классификатор (Surya Layout) + OCR + VLM. Каждая модель закрывает слабости другой. Подробно про мультимодальный RAG мы писали в статье "Мультимодальный RAG: Как заставить ИИ понимать картинки, а не просто текст".

Что дальше?

Каскадная фильтрация — это не догма. Подход можно улучшить:

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

Неочевидный совет: попробуйте использовать маленькую VLM (например, Qwen2.5-VL-7B) не только для фолбэка, но и для классификации изображений. Она работает локально, бесплатно и может заменить эвристики. Тогда весь каскад станет ещё дешевле.

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