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.02 Запускаем локальную 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) # Обрезаем, если текст очень длинный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-сотрудник для обработки документов, который не берет больничный и не просит повышения.