Парсинг PDF в CSV с LLM: пайплайн 2025 с Unstructured и Llama 3 | AiManual
AiManual Logo Ai / Manual.
23 Мар 2026 Гайд

Извлечение данных из PDF в CSV: лучший в 2025 году пайплайн с использованием LLM (Python, Llama 3, Unstructured)

Пошаговый гайд по извлечению данных из PDF в CSV с помощью Python, Unstructured.io и Llama 3. Локальный пайплайн для полуструктурированных документов.

PDF - это не формат для данных. Это тюрьма

Вы скачали отчет, счет, прайс-лист или выписку. Внутри - цифры, таблицы, списки. Но достать их - все равно что выковыривать изюм из кекса голыми руками. Классические парсеры ломаются о полуструктурированные документы, где данные размазаны по страницам без четкой логики. Таблицы превращаются в набор строк. Многоуровневые списки - в кашу.

Старый способ - регулярные выражения, ручное копирование, слезы. Новый - LLM. Но как заставить модель понять структуру именно вашего документа и выдать чистый CSV? Ответ - гибридный пайплайн: инструмент для извлечения текста с сохранением структуры + LLM для интеллектуальной интерпретации. И все это локально, без отправки конфиденциальных документов в облака.

Важно: на 23 марта 2026 года, модели семейства Llama (включая Llama 3.1 и новейшую Llama 3.2) остаются лидерами для локального запуска. Unstructured.io обновился до версии 0.12.0 с улучшенной поддержкой таблиц и встроенным OCR. Этот пайплайн использует только актуальные на сегодня инструменты.

Почему именно эта связка работает?

  • Unstructured.io - не просто извлекает текст. Он понимает заголовки, списки, таблицы, сохраняет иерархию. Это ключ. Без этого LLM получает месиво символов.
  • Llama 3.1 8B (или 70B) - на сегодня это самая сбалансированная модель для задач структурирования. Она достаточно умна, чтобы следовать сложным инструкциям, и достаточно компактна для запуска на потребительском GPU или даже CPU с хорошей оперативкой.
  • Локальность - все работает внутри вашего контура. Никаких API-лимитов, утечек данных и счетов от OpenAI. После настройки пайплайн стоит копейки в эксплуатации.

Это не теория. Мы построим конвейер, который берет папку с PDF, крутит их через движок и выплевывает аккуратный CSV-файл. С нуля.

Что нам понадобится для старта

Сначала - железо и софт. Если у вас есть GPU с 8+ GB памяти (например, RTX 4070) - отлично. Если нет, Llama 3.1 в 8B-параметрической версии отлично работает на CPU, если у вас есть 16-32 GB оперативки. Просто будет медленнее.

1 Готовим окружение и ставим зависимости

Создаем чистый виртуальный окружение. Python 3.10 или новее. Ставим не просто библиотеки, а конкретные версии, чтобы через полгода все не сломалось.

python -m venv pdf_llm_env
source pdf_llm_env/bin/activate  # или activate.fish, или .\\pdf_llm_env\\Scripts\\activate на Windows

# Основные библиотеки для работы с документами и данными
pip install "unstructured[pdf]"==0.12.0  # Ядро пайплайна
pip install "unstructured[ocr]"  # Если PDF сканированные
pip install pypdf==4.2.0  # Фолбэк-парсер
pip install pandas==2.2.0  # Для финального CSV
pip install numpy==1.26.0

# Клиент для работы с локальной LLM через Ollama
pip install ollama==0.5.0

# Альтернатива: если решите использовать OpenAI API (не рекомендуется для конфиденциальных данных)
# pip install openai==1.30.0
💡
Unstructured.io использует под капотом несколько движков (pypdf, pdf2image, Tesseract для OCR). Установка с тегом `[pdf]` и `[ocr]` тянет все необходимое. Для OCR также понадобится системная библиотека Tesseract. На Ubuntu/Debian: `sudo apt install tesseract-ocr`. На macOS: `brew install tesseract`. На Windows - скачать инсталлер с официального сайта.

2 Запускаем локальную LLM (Ollama)

Ollama - самый простой способ запустить Llama 3.1 локально. Качаем с официального сайта, устанавливаем. После запуска сервиса качаем модель.

# В отдельном терминале запускаем сервер Ollama (он обычно запускается как служба)
# Затем в основном терминале с окружением Python:
ollama pull llama3.1:8b  # 8-миллиардная версия, оптимальная по скорость/качество
# Или, если ресурсов много:
# ollama pull llama3.1:70b

Проверяем, что модель отвечает:

import ollama
response = ollama.chat(model='llama3.1:8b', messages=[{'role': 'user', 'content': 'Привет'}]
print(response['message']['content'])

Ошибка номер один: забыть, что Ollama должен быть запущен. Проверьте `ollama serve`. Ошибка номер два: пытаться запустить 70B-модель на 16 GB оперативки. 8B-модель требует ~8-10 GB RAM на CPU. Играйтесь с квантованием (`ollama pull llama3.1:8b-q4_K_M`), если ресурсов в обрез.

3 Извлекаем структурированный текст из PDF

Теперь главный фокус. Мы не будем использовать `pypdf` напрямую - он выдает плоский текст. Нам нужна семантика. Функция `partition_pdf` из Unstructured - наш выбор. Она возвращает список элементов (Title, NarrativeText, Table, Header и т.д.).

from unstructured.partition.pdf import partition_pdf
import json

# Путь к вашему PDF
pdf_path = "invoice.pdf"

# Извлечение элементов с стратегией "hi_res" для сложных документов
# "hi_res" использует OCR и компьютерное зрение, но медленнее.
# "fast" - быстрый, но только для текстовых PDF.
elements = partition_pdf(
    filename=pdf_path,
    strategy="hi_res",  # Меняйте на "fast" для чистых цифровых PDF
    infer_table_structure=True,  # Ключевая опция! Распознает таблицы как целое.
    languages=["rus", "eng"],  # Языки для OCR
    extract_images_in_pdf=False,  # Не извлекать картинки (экономия памяти)
    max_partition=1500,  # Максимальный размер чанка в символах
    include_metadata=True
)

# Посмотрим, что получилось
for elem in elements[:10]:  # Первые 10 элементов
    print(f"{elem.category}: {elem.text[:100]}...")

# Сохраним сырые элементы для отладки (очень полезно)
with open("extracted_elements.json", "w", encoding="utf-8") as f:
    # Сериализуем, преобразуя элементы в dict
    data = [{"type": el.category, "text": el.text, "metadata": el.metadata.to_dict()} for el in elements]
    json.dump(data, f, ensure_ascii=False, indent=2)

Если вы работаете с таблицами, проверьте статью про поиск по картинкам в PDF - там есть нюансы по обработке таблиц в изображениях.

4 Готовим промпт для LLM: превращаем хаос в JSON

Теперь у нас есть структурированные куски текста. Но они все еще не являются данными. Нужно сказать LLM, что именно извлекать. Секрет - строгий промпт с примером (one-shot learning) и требование вывода в JSON.

def build_extraction_prompt(text_chunks, fields):
    """
    fields: dict, где ключ - название поля в JSON, значение - описание, что искать.
    Пример: {"invoice_number": "Номер счета, обычно в формате INV-XXXX",
             "date": "Дата выставления счета в формате ДД.ММ.ГГГГ",
             "total_amount": "Общая сумма к оплате, число с плавающей точкой"}
    """
    fields_str = json.dumps(fields, ensure_ascii=False, indent=2)
    
    prompt = f"""Ты - ассистент по извлечению структурированных данных из документов.
Текст документа извлечен и разбит на логические части:

{text_chunks}

---

ИЗВЛЕКИ СЛЕДУЮЩИЕ ДАННЫЕ:

{fields_str}

ИНСТРУКЦИИ:
1. Ищи информацию во всем предоставленном тексте.
2. Если поле не найдено, верни null для этого поля.
3. Не придумывай данные. Только факты из текста.
4. Верни ответ ТОЛЬКО в виде валидного JSON, без каких-либо пояснений, Markdown или обратных кавычек.
5. Структура JSON должна точно соответствовать ключам из описания полей выше.

ПРИМЕР ОТВЕТА для других полей:
{{"client_name": "ООО \"Ромашка\"", "amount": 15000.50}}

ТВОЙ ОТВЕТ:
"""
    return prompt

# Подготовим текст для LLM: объединим элементы, но с указанием их типа (для контекста)
text_for_llm = ""
for i, elem in enumerate(elements):
    # Если это таблица, можно добавить пометку
    prefix = "[ТАБЛИЦА]" if elem.category == "Table" else f"[{elem.category}]"
    text_for_llm += f"{prefix} {elem.text}\n\n"

# Определяем поля для извлечения (пример для счета)
fields_to_extract = {
    "invoice_number": "Номер счета (например, INV-2024-001, СЧ-123)",
    "invoice_date": "Дата счета в формате ГГГГ-ММ-ДД",
    "supplier_name": "Название поставщика (продавца)",
    "client_name": "Название клиента (покупателя)",
    "total_without_vat": "Сумма без НДС, число",
    "vat_amount": "Сумма НДС, число",
    "total_with_vat": "Итоговая сумма к оплате (с НДС), число",
    "currency": "Валюта (RUB, USD, EUR и т.д.)",
    "payment_due_date": "Срок оплаты в формате ГГГГ-ММ-ДД"
}

prompt = build_extraction_prompt(text_for_llm[:6000], fields_to_extract)  # Обрезаем, если текст очень длинный
💡
Обрезка текста (здесь до 6000 символов) - важный момент. Контекстное окно Llama 3.1 - 8192 токена. Учитывайте, что промпт тоже занимает место. Если документ огромный, разбейте его на части (по страницам или разделам) и обрабатывайте последовательно. Или используйте более продвинутую технику, как в семантическом пайплайне для LLM.

5 Запускаем LLM и парсим ответ

Отправляем промпт в Ollama. Ждем ответ. Парсим JSON из ответа модели. Это самый нервный момент - модель может "заболтаться" и добавить лишний текст. Наш промпт строгий, но нужен запасной план.

import ollama
import json
import re

def extract_json_from_response(llm_response):
    """Вытаскивает JSON из текста ответа, даже если там есть мусор."""
    # Ищем блок между ```json и ``` или просто первый { и последний }
    pattern = r'```(?:json)?\s*({.*?})\s*```'
    match = re.search(pattern, llm_response, re.DOTALL)
    if match:
        json_str = match.group(1)
    else:
        # Просто ищем первую { и последнюю }
        start = llm_response.find('{')
        end = llm_response.rfind('}') + 1
        if start != -1 and end != 0:
            json_str = llm_response[start:end]
        else:
            raise ValueError("JSON не найден в ответе")
    
    # Чистим возможные экранированные символы
    json_str = json_str.replace('\\n', '').replace('\\"', '"')
    return json.loads(json_str)

# Отправляем запрос
response = ollama.chat(
    model='llama3.1:8b',
    messages=[{'role': 'user', 'content': prompt}],
    options={
        'temperature': 0.1,  # Низкая температура для детерминированности
        'num_predict': 1024  # Максимальное количество токенов в ответе
    }
)

llm_output = response['message']['content']
print("Сырой ответ LLM:", llm_output[:500])

try:
    extracted_data = extract_json_from_response(llm_output)
    print("Извлеченные данные:", json.dumps(extracted_data, ensure_ascii=False, indent=2))
except (json.JSONDecodeError, ValueError) as e:
    print(f"Ошибка парсинга JSON: {e}")
    # Запасной вариант: можно попробовать почистить ответ вручную или перезапустить с другим промптом
    extracted_data = {}

Если модель упорно отказывается выдавать чистый JSON, поможет техника Structured Output. В статье про чистку номенклатур есть детали, как заставить LLM соблюдать формат.

6 Собираем CSV из множества PDF

Один документ - это хорошо. Но папка с 1000 PDF - это реальность. Автоматизируем процесс. Создадим функцию-обработчик и запустим для всех файлов. Результаты соберем в DataFrame и сохраним.

import pandas as pd
import os
from pathlib import Path

def process_pdf_to_data(pdf_path, fields):
    """Обрабатывает один PDF и возвращает dict с данными."""
    try:
        # Шаг 1: Извлечение элементов
        elements = partition_pdf(
            filename=pdf_path,
            strategy="fast",  # Меняйте по необходимости
            infer_table_structure=True,
            languages=["rus", "eng"]
        )
        # Шаг 2: Подготовка текста
        text_for_llm = "\n\n".join([f"[{el.category}] {el.text}" for el in elements])
        # Шаг 3: Построение промпта (используем функцию из предыдущего шага)
        prompt = build_extraction_prompt(text_for_llm[:6000], fields)
        # Шаг 4: Запрос к LLM
        response = ollama.chat(model='llama3.1:8b', messages=[{'role': 'user', 'content': prompt}], options={'temperature': 0.1})
        # Шаг 5: Парсинг JSON
        data = extract_json_from_response(response['message']['content'])
        data["source_file"] = os.path.basename(pdf_path)
        return data
    except Exception as e:
        print(f"Ошибка обработки {pdf_path}: {e}")
        return {"source_file": os.path.basename(pdf_path), "error": str(e)}

# Основной цикл
pdf_folder = "./invoices/"
all_data = []

for pdf_file in Path(pdf_folder).glob("*.pdf"):
    print(f"Обрабатываю {pdf_file}...")
    result = process_pdf_to_data(str(pdf_file), fields_to_extract)
    all_data.append(result)
    # Небольшая пауза, чтобы не перегреть систему
    import time
    time.sleep(1)

# Преобразуем в DataFrame и сохраняем
df = pd.DataFrame(all_data)
# Переставляем колонки, чтобы source_file была первой
cols = ["source_file"] + [c for c in df.columns if c != "source_file" and c != "error"]
df = df[cols]

df.to_csv("extracted_invoices.csv", index=False, encoding="utf-8-sig")
print(f"Готово! Извлечено {len(df)} записей. Столбцы: {list(df.columns)}")

Вот и весь пайплайн. От папки с PDF до CSV за один скрипт.

Где собака зарыта: нюансы, которые сведут вас с ума

Теперь о том, о чем молчат в туториалах. Реальные документы - это не идеальные примеры.

ПроблемаРешениеКомментарий
PDF со сканами (картинками)Используйте `strategy="hi_res"` и установите Tesseract. Будет медленно, но работает.Качество OCR зависит от качества скана. Размытый текст - это боль.
Многостраничные таблицыUnstructured иногда разрывает таблицы на страницах. Попробуйте `include_page_breaks=False` и объединяйте таблицы в постобработке.В идеале - искать заголовки таблиц и логически склеивать. Можно поручить это LLM, как в Recursive Data Cleaner.
Нестандартные форматы дат и чиселВ промпте явно укажите ожидаемый формат. Пример: "Дата в формате ДД.ММ.ГГГГ или '15 марта 2025 г.'"LLM хорошо понимает естественные описания дат, но на выходе лучше требовать ISO-формат.
Документы на смеси языковУкажите все языки в `languages` (например, ["rus", "eng", "deu"]).Модель Llama 3.1 мультиязычная, но промпт лучше давать на основном языке документа.
Ошибки распознавания таблицЕсли таблица превратилась в текст, попробуйте библиотеку `camelot` или `tabula-py` как fallback. Или используйте специализированный инструмент вроде LiteParse.Таблицы - самое слабое место большинства парсеров. Всегда делайте визуальную проверку на нескольких документах.

А если не хочется писать код с нуля?

Весь этот пайплайн можно собрать из готовых блоков. Например, NumbyAI - это готовый фреймворк для обработки финансовых документов с помощью локальной LLM. Или использовать RAG-систему для индексации PDF, а затем запрашивать данные через вопросы.

Но суть в том, что универсального решения нет. Ваши PDF уникальны. Ваши поля извлечения - свои. Поэтому умение собрать пайплайн под свою задачу - это суперсилла на 2025-2026 годы.

Вопросы, которые вы зададите себе через три дня

Q: Скорость убийственная. Один PDF обрабатывается минуту. Как ускорить?
A: Три пути: 1) Перейдите на более быструю модель (Llama 3.1 8B уже быстрая, но есть квантованные версии). 2) Используйте пакетную обработку: извлекайте текст из всех PDF сразу, а затем отправляйте батчи текстов в LLM. 3) Замените локальную LLM на API (например, GPT-4o), если скорость критична, а конфиденциальность - нет.

Q: Как валидировать, что модель не нафантазировала числа?
A: Постройте конвейер проверки. Например: 1) Второй, более консервативный промпт ("только найди числа в тексте, соответствующие описанию"). 2) Перекрестная проверка: если в документе есть итоги, проверьте арифметику извлеченных позиций. 3) Человеческая выборочная проверка - пока ничего лучше не придумали.

Q: А если у меня 100 тысяч PDF?
A: Тогда нужна промышленная инфраструктура. Рассмотрите распределенную обработку: очередь задач (Celery, Redis Queue), пул воркеров, каждый с GPU. Или сервисы вроде AWS Textract + Bedrock, но это уже облако и деньги. Локально можно использовать кластер из нескольких машин с Ollama, как в пайплайне для обработки 100k слов.

Главный прогноз на 2026: граница между документом и базой данных окончательно сотрется. PDF, Word, сканы - все это станет сырьем для LLM-пайплайнов, которые будут живым мостом между человеческими документами и машиночитаемыми структурами. Ваша задача - не просто извлечь данные, а построить систему, которая учится на ваших документах и становится точнее с каждой итерацией. Начните с этого скрипта, добавьте логирование, мониторинг, обратную связь от пользователей. Через полгода у вас будет собственный AI-сотрудник для обработки документов, который не берет больничный и не просит повышения.

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