Почему ваш сканированный сербский договор — это цифровой кошмар
Представьте: у вас папка старых сербских контрактов, отсканированных на дешевом МФУ в 2005-м. Нужно вытащить даты, суммы, имена. GPT-4o справляется за $15 в час, но данные конфиденциальны. Локальные модели вроде Mistral 7B через Ollama — логичный выбор. Но вы запускаете скрипт, а на выходе получаете абракадабру с кириллицей, латиницей и цифрами в случайном порядке. Знакомо?
Проблема в трех слоях ада: плохое качество сканов, специфика сербского языка (две азбуки — ћирилица и латиница), и ограничения локальных моделей, которые не видят картинки. Стандартный рецепт "OCR + LLM" здесь ломается на первом же шаге.
Главная ошибка: пытаться скормить сырой OCR-текст сразу в LLM. Без очистки, без понимания структуры документа, без постобработки — точность будет около 30-40%. На сербском — еще ниже.
Стек, который реально работает (и почему)
После десятка экспериментов с разными комбинациями, я остановился на этом стеке:
- EasyOCR для распознавания текста. Почему не Tesseract? У EasyOCR из коробки лучше работает с кириллицей и смешанными алфавитами. Плюс можно дообучить на специфичных сербских шрифтах.
- Layout detection через собственные эвристики или библиотеку типа Docling. Зачем? Чтобы LLM понимала, где заголовок, где таблица, где подпись.
- Mistral 7B через Ollama для извлечения структурированных данных. Альтернативы — Llama 2.5 или GLM 4.5 Air, но Mistral балансирует между качеством и потреблением памяти.
- Post-processing на правилах для исправления типичных OCR-ошибок в сербском.
Пошаговый разбор: от скана до JSON
1 Подготовка и установка
Сначала ставим необходимое. Предполагаем, что Python 3.9+ уже есть.
pip install easyocr pillow pypdf2
pip install ollama # или используем API LocalAI
Ollama должна быть установлена отдельно и запущена с нужной моделью:
ollama pull mistral:7b
ollama run mistral:7b # Запускаем в отдельном терминале
Важно: Mistral 7B потребляет около 14GB RAM. Если железа мало, смотрите гайд по запуску на доступном железе. Можно взять меньшую модель, но потеряете в качестве.
2 OCR с пониманием сербского контекста
Вот как НЕ надо делать:
# ПЛОХО: базовый OCR без контекста
import easyocr
reader = easyocr.Reader(['en']) # Только английский!
text = reader.readtext('serbian_contract.pdf', detail=0)
Почему плохо? EasyOCR будет пытаться распознать сербские символы как английские. "Ш" станет "W", "Ђ" — непонятно чем. Точность упадет катастрофически.
Правильный подход:
import easyocr
from PIL import Image
import numpy as np
# Явно указываем языки: сербский (кириллица и латиница) + английский для цифр/дат
reader = easyocr.Reader(['sr', 'en'], gpu=False) # gpu=False если нет видеокарты
def extract_text_from_pdf(pdf_path):
# Конвертируем PDF в изображения (упрощенно, для продакшена используйте pdf2image)
images = convert_pdf_to_images(pdf_path)
all_text = []
for img in images:
# Увеличиваем контраст для старых сканов
img_array = np.array(img)
img_enhanced = enhance_contrast(img_array)
# Ключевой момент: детализированный вывод с координатами
results = reader.readtext(img_enhanced, paragraph=True, detail=1)
# Сортируем по положению на странице (сверху вниз, слева направо)
results.sort(key=lambda x: (x[0][0][1], x[0][0][0])) # по Y, потом по X
page_text = '\n'.join([result[1] for result in results])
all_text.append(page_text)
return '\n--- PAGE BREAK ---\n'.join(all_text)
def enhance_contrast(image_array):
"""Простой контраст для плохих сканов"""
from PIL import ImageEnhance
img = Image.fromarray(image_array)
enhancer = ImageEnhance.Contrast(img)
return np.array(enhancer.enhance(1.5))
3 Чистка и нормализация сербского текста
OCR ошибается предсказуемо. На сербском особенно:
- "c" (латинское) вместо "с" (кириллическое)
- "њ" читается как "nј" или вообще "h"
- Диакритические знаки (акценты) теряются
- Даты в формате "31.12.2005." превращаются в "31,12,2005"
Добавляем постобработку:
import re
def clean_serbian_text(text):
"""Исправляем типичные OCR-ошибки для сербского"""
# Заменяем латинские буквы, которые часто путают с кириллицей
replacements = {
'c': 'с', # латинское c -> кириллическое с
'C': 'С', # заглавная
'a': 'а', # латинское a -> кириллическое а
'e': 'е', # и так далее...
'o': 'о',
'p': 'р',
'x': 'х',
'y': 'у',
'T': 'Т',
'M': 'М',
'H': 'Н',
'B': 'В',
'K': 'К',
}
for lat, cyr in replacements.items():
text = re.sub(rf'\b{lat}\b', cyr, text) # Только целые слова
# Исправляем даты
text = re.sub(r'(\d{1,2})\.(\d{1,2})\.(\d{4})\.', r'\1.\2.\3.', text)
# Восстанавливаем диграфы
text = re.sub(r'њ', 'њ', text)
text = re.sub(r'љ', 'љ', text)
text = re.sub(r'ђ', 'ђ', text)
text = re.sub(r'ћ', 'ћ', text)
return text
4 Детекция структуры документа
Теперь самая хитрая часть. LLM нужно понимать, какой текст к чему относится. Просто склеить все строки — гарантированный провал.
Используем координаты из EasyOCR (detail=1) для восстановления layout:
def detect_document_structure(ocr_results):
"""Группируем текст по смысловым блокам"""
# ocr_results = [(bbox, text, confidence), ...]
# Сортируем по Y-координате (сверху вниз)
ocr_results.sort(key=lambda x: x[0][0][1])
blocks = []
current_block = []
current_y = None
for bbox, text, confidence in ocr_results:
y_pos = bbox[0][1]
if current_y is None:
current_y = y_pos
# Если разница по Y больше 20 пикселей — новый блок
if abs(y_pos - current_y) > 20:
if current_block:
blocks.append(' '.join(current_block))
current_block = []
current_y = y_pos
current_block.append(text)
if current_block:
blocks.append(' '.join(current_block))
return blocks
Для сложных документов с таблицами лучше взять готовое решение — например, Docling с его стратегиями чанкинга. Но для большинства контрактов хватит и простой группировки.
5 Извлечение данных через LLM с умным промптом
Теперь главное — промпт. Вот как НЕ надо:
# ПЛОХО: слишком общий промпт
prompt = """Извлеки данные из текста:
{текст}
"""
Mistral выдаст что-то вроде "В тексте говорится о договоре..." — бесполезно.
Правильный промпт для сербских юридических документов:
import json
import ollama
def extract_with_llm(cleaned_text):
prompt = f"""
TI SI STRUKTURIRAJUĆI ASISTENT ZA SRPSKE PRAVNE DOKUMENTE.
Tvoj zadatak: Izvući tačne podatke iz sledećeg teksta.
UPUTSTVA:
1. Koristi isključivo podatke koji su eksplicitno navedeni u tekstu
2. Za datume koristi format DD.MM.GGGG.
3. Za valutu koristi "EUR", "RSD" ili "USD" kako je navedeno
4. Ako podatak nedostaje, stavi "N/A"
5. Vrati ODGOVOR SAMO U JSON formatu, bez dodatnog teksta
POLJA ZA IZVLАČENJE:
- ugovorne_strane: lista imena strana u ugovoru
- datum_ugovora: datum potpisivanja
- mesto_zaključenja: grad gde je ugovor potpisan
- iznos: brojčani iznos sa valutom
- rok_izvršenja: datum do kada se ugovor izvršava
- potpisnici: lista lica koja potpisuju ugovor
TEKST DOKUMENTA:
{cleaned_text}
JSON ODGOVOR:
"""
response = ollama.chat(
model='mistral:7b',
messages=[{'role': 'user', 'content': prompt}],
options={'temperature': 0.1} # Низкая температура для консистентности
)
# Пытаемся вытащить JSON из ответа
response_text = response['message']['content']
# Иногда LLM добавляет пояснения до или после JSON
json_match = re.search(r'\{.*\}', response_text, re.DOTALL)
if json_match:
try:
return json.loads(json_match.group())
except json.JSONDecodeError:
# Fallback: ручная чистка
return clean_json_response(response_text)
return {"error": "Failed to parse LLM response"}
Типичные грабли и как их обойти
1. Смешение азбук в одном документе
Сербы часто используют и ћирилицу и латиницу в одном тексте. Иногда даже в одном предложении. EasyOCR с параметром ['sr', 'en'] справляется, но LLM может запутаться.
Решение: нормализовать к одной азбуке перед отправкой в LLM. Я предпочитаю латиницу — с ней меньше проблем у моделей, обученных преимущественно на английском.
def latin_to_cyrillic_serbian(text):
"""Простая транслитерация латиницы в ћирилицу для сербского"""
mapping = {
'a': 'а', 'b': 'б', 'c': 'ц', 'č': 'ч', 'ć': 'ћ',
'd': 'д', 'đ': 'ђ', 'e': 'е', 'f': 'ф', 'g': 'г',
'h': 'х', 'i': 'и', 'j': 'ј', 'k': 'к', 'l': 'л',
'lj': 'љ', 'm': 'м', 'n': 'н', 'nj': 'њ', 'o': 'о',
'p': 'п', 'r': 'р', 's': 'с', 'š': 'ш', 't': 'т',
'u': 'у', 'v': 'в', 'z': 'з', 'ž': 'ж',
}
# ... логика замены
return text
2. LLM "галлюцинирует" недостающие данные
Mistral, особенно с temperature > 0.3, может придумать дату или сумму, если их нет в тексте. В юридических документах это недопустимо.
3. Медленная работа на больших документах
Если документ на 50 страниц, отправлять его целиком — долго и может превысить контекстное окно.
Решение: извлекать данные с каждой страницы отдельно, затем агрегировать. Или использовать RAG-подход для длинных PDF, где LLM получает только релевантные чанки.
А что с мультимодальными моделями?
Логичный вопрос: зачем вообще OCR, если есть VLMs вроде LLaVA или Qwen-VL, которые могут читать текст с изображений напрямую?
Увы, на сканированных документах они часто ломаются. Особенно на сербском — данных для обучения мало, качество распознавания ниже, чем у специализированного OCR. Плюс локальные VLMs требуют еще больше ресурсов.
Но есть компромисс: используем OCR для получения текста, а VLM — для понимания структуры (где заголовок, где подпись, где печать). На практике для большинства задач хватает комбинации EasyOCR + layout detection + Mistral.
Метрики качества: как понять, что всё работает
Без измерений вы в неведении. Минимум, что нужно отслеживать:
| Метрика | Цель | Как измерять |
|---|---|---|
| Точность распознавания символов | > 95% для четких сканов | CER (Character Error Rate) на размеченной выборке |
| Точность извлечения полей | > 85% для ключевых полей | Сравнение с ручной разметкой 100 документов |
| Скорость обработки | < 30 сек на страницу | Среднее время на тестовом наборе |
| Стабильность JSON | 100% валидный JSON | Доля ответов, которые парсятся json.loads() |
Создайте тестовый набор из 20-30 документов с ручной разметкой. Запускайте пайплайн на них после каждого изменения. Без этого вы не отличите улучшение от деградации.
Альтернативы для продвинутых случаев
Когда точность OCR критична
Для медицинских или финансовых документов, где ошибка в одной цифре — катастрофа, рассмотрите специализированные OCR-модели вроде TrOCR или Donut, дообученные на сербских данных.
Когда документов миллионы
Масштабируйте пайплайн через Pathway + Ollama или аналогичные инструменты для потоковой обработки.
Когда нужна максимальная скорость
Замените Mistral на более легкую модель вроде Phi-3-mini или даже GLM 4.5 Air с отключенным thinking. Потеряете немного в качестве, но выиграете в скорости 2-5 раз.
Финальный совет: начинайте с простого
Не пытайтесь сразу построить идеальный пайплайн. Сначала добейтесь 70% точности на простом потоке: EasyOCR → чистка текста → промпт в Mistral. Затем добавляйте layout detection, post-validation, улучшенные промпты.
И помните главное: даже 80% точность с локальными моделями, работающими на вашем железе без отправки данных в облако, для многих сценариев лучше, чем 95% точность через OpenAI, но с рисками утечки.
Сербские сканы перестанут быть кошмаром, когда вы поймете их анатомию. Текст — это не просто последовательность символов. Это структура, контекст, языковые особенности. И теперь у вас есть карта этой территории.