Привет, чемпионы!

Вы когда-нибудь сталкивались с тем, что ваш шикарный AI-пайплайн для обработки документов спотыкается на самом простом — на чтении текста с картинки? OCR выдает абракадабру, цифры перепутаны, а дальше по цепочке летит вся ваша безупречная логика. Знакомо? У нас была точно такая же боль.

Сегодня я хочу закинуть вам идею для стартапа или просто для крутого внутреннего продукта. Мы разработали и оттестили архитектуру, которая выкидывает OCR из уравнения вообще. Да-да, вы не ослышались. Никакого отдельного распознавания текста. Модель берет изображение счета, накладной или чека и сразу, в один проход, выдает готовый структурированный JSON. Кроме того - здесь еще и интегрированный модуль для QA-тасков. И это не фантастика, а уже работающая штука, которая по некоторым тестам бьет OCR-зависимые подходы и по точности, и по скорости.

Почему все ненавидят классический OCR

Давайте честно: классический пайплайн картинка -> детекция текста -> распознавание текста -> парсинг — это костыль на костыле. Каждый этап — это отдельная модель со своим процентом ошибок. И эти ошибки не складываются, а умножаются.

  1. Детектор текста может пропустить слово или определить bounding box криво.

  2. Сам OCR может перепутать «0» и «O», «5» и «S», а с рукописным текстом и вовсе беда.

  3. Парсер получает на вход уже испорченный текст и пытается как-то его понять. Если формат документа чуть изменился — всё, парсер летит в разнос.

И самое главное — это адски дорого. Каждая из этих моделей — это отдельная тяжеленная нейросеть, которую нужно разворачивать, поддерживать и масштабировать. А если документ на японском? А если он сфоткан под углом? А если это старый факсовый скан с дырявой печатью? В общем, боль, которая знакома каждому, кто работал с документами.

Наш подход: Ломаем — не строим

Мы задались вопросом: а зачем нам эти три отдельных этапа? Почему бы не научить одну модель делать всё и сразу? Не просто читать текст, а понимать документ. Чтобы она смотрела на картинку и сразу выдавала структуру.

Вот как это работает под капотом:

В основе лежит архитектура Encoder-Decoder на трансформерах. Звучит сложно, но на деле всё гениально просто.

1. Визуальный кодировщик (Encoder) — «Глаза» модели
Его задача — посмотреть на изображение документа и преобразовать его в набор умных векторов (эмбеддингов), которые описывают не только что изображено, но и как элементы расположены друг относительно друга.

Скрытый текст
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader
from torchvision import transforms
from PIL import Image
import json
import os

from transformers import BartForConditionalGeneration, BartConfig, AutoTokenizer
from transformers import SwinModel, SwinConfig

class IITCDIPDataset(Dataset):
    def __init__(self, root_dir="/datasets/IIT-CDIP", split="train", max_length=512):
        self.root_dir = root_dir
        self.image_dir = os.path.join(root_dir, "images")
        self.text_dir = os.path.join(root_dir, "texts")
        self.split_file = os.path.join(root_dir, "splits", f"{split}.txt")
        
        with open(self.split_file, 'r') as f:
            self.file_ids = [line.strip() for line in f if line.strip()]
        
        self.tokenizer = AutoTokenizer.from_pretrained("facebook/bart-large")
        self.transform = transforms.Compose([
            transforms.Resize((224, 224)),
            transforms.ToTensor(),
            transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
        ])
        self.max_length = max_length

    def __len__(self):
        return len(self.file_ids)

    def __getitem__(self, idx):
        file_id = self.file_ids[idx]
        image_path = os.path.join(self.image_dir, f"{file_id}.tif")
        text_path = os.path.join(self.text_dir, f"{file_id}.txt")
        
        image = Image.open(image_path).convert('RGB')
        with open(text_path, 'r', encoding='utf-8', errors='ignore') as f:
            text = f.read().strip()
        
        encoding = self.tokenizer(
            text,
            max_length=self.max_length,
            padding='max_length',
            truncation=True,
            return_tensors='pt'
        )
        
        return {
            'pixel_values': self.transform(image),
            'input_ids': encoding['input_ids'].flatten(),
            'attention_mask': encoding['attention_mask'].flatten()
        }

class VisionTextPreTrainingModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.encoder = DocumentEncoder()
        self.decoder = StructuredBartDecoder()
        
    def forward(self, pixel_values, labels=None):
        encoder_outputs = self.encoder(pixel_values)
        if labels is not None:
            return self.decoder(encoder_outputs, labels)
        return self.decoder(encoder_outputs)

def train_pretraining():
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    
    train_dataset = IITCDIPDataset(root_dir="/datasets/IIT-CDIP", split="train")
    val_dataset = IITCDIPDataset(root_dir="/datasets/IIT-CDIP", split="val")
    
    train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=8)
    val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4)
    
    model = VisionTextPreTrainingModel().to(device)
    optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=0.01)
    criterion = nn.CrossEntropyLoss(ignore_index=0)
    
    model.train()
    for epoch in range(10):
        total_loss = 0
        for batch in train_loader:
            pixel_values = batch['pixel_values'].to(device)
            labels = batch['input_ids'].to(device)
            
            optimizer.zero_grad()
            loss = model(pixel_values, labels)
            loss.backward()
            optimizer.step()
            
            total_loss += loss.item()
        
        print(f"Epoch {epoch+1}, Loss: {total_loss/len(train_loader):.4f}")
        
        if (epoch + 1) % 2 == 0:
            torch.save({
                'epoch': epoch,
                'model_state_dict': model.state_dict(),
                'optimizer_state_dict': optimizer.state_dict(),
                'loss': total_loss/len(train_loader),
            }, f"/checkpoints/pretraining_epoch_{epoch+1}.pt")

if __name__ == "__main__":
    train_pretraining()
  • Что используем: Мы взяли Swin Transformer. Он лучше всего заточен под изображения. В отличие от обычного Vision Transformer (ViT), который рассматривает все части картинки сразу (что очень затратно), Swin Transformer использует механизм скользящих окон. Сначала он смотрит на маленькие локальные области изображения (например, углы и границы букв), затем объединяет их и анализирует уже более крупные блоки (слова, строки, таблицы). Это позволяет понять иерархию документа: вот это — заголовок, это — цена, а это — итоговая сумма, и они находятся рядом.

  • На выходе: Не просто набор пикселей, а структурированное представление документа в виде векторов. Модель уже понимает, что важно, а что нет.

2. Текстовый декодер (Decoder) — «Мозг» модели
Его задача — взять эти «умные векторы» от кодировщика и на их основе сгенерировать текст. Но не абы какой, а строго структурированный.

Скрытый текст
from transformers import BartForConditionalGeneration, BartConfig, PreTrainedTokenizerFast
from transformers import AutoTokenizer
import torch

class StructuredBartDecoder:
    def __init__(self, model_name="facebook/bart-large"):
        self.config = BartConfig.from_pretrained(model_name)
        self.model = BartForConditionalGeneration.from_pretrained(model_name)
        self.tokenizer = AutoTokenizer.from_pretrained(model_name)
        
        special_tokens = [
            "[START class]", "[END class]", "[START item]", "[END item]",
            "[START name]", "[END name]", "[START price]", "[END price]",
            "[START count]", "[END count]", "[START total]", "[END total]",
            "[START date]", "[END date]", "[memo]", "[invoice]", "[receipt]"
        ]
        self.tokenizer.add_tokens(special_tokens)
        self.model.resize_token_embeddings(len(self.tokenizer))

    def generate_json(self, encoder_outputs, max_length=512):
        decoder_input_ids = torch.tensor([[self.tokenizer.bos_token_id]] * encoder_outputs.size(0))
        generated = self.model.generate(
            inputs_embeds=encoder_outputs,
            decoder_input_ids=decoder_input_ids,
            max_length=max_length,
            num_beams=4,
            early_stopping=True
        )
        return self.tokenizer.batch_decode(generated, skip_special_tokens=False)

    def prepare_targets(self, json_data):
        target_text = self._json_to_structured_text(json_data)
        return self.tokenizer(
            target_text, 
            return_tensors="pt", 
            padding=True, 
            truncation=True
        )

    def _json_to_structured_text(self, json_obj):
        if isinstance(json_obj, dict):
            parts = []
            for key, value in json_obj.items():
                if key == "class":
                    parts.append(f"[START class] {value} [END class]")
                elif key == "items":
                    for item in value:
                        parts.append("[START item]")
                        parts.append(self._json_to_structured_text(item))
                        parts.append("[END item]")
                else:
                    parts.append(f"[START {key}] {value} [END {key}]")
            return " ".join(parts)
        return str(json_obj)
  • Что используем: За основу взяли BART (предобученную модель для генерации текста). Его инициализировали весами многоязычной модели, так что он с рождения понимает кучу языков.

  • Как работает: Мы учим его не просто описывать картинку, а генерировать валидный JSON. По сути, он выдаёт код: {"total": 50000, "items": [{"name": "Kyoto Choco Mochi", "price": 28000}]}.

  • Фишка — специальные токены: Для унификации мы вводим служебные токены. Например, для классификации документа модель генерирует последовательность: [START class] [memo] [END class]. Для извлечения данных из чека: [START item] [START name] Kyoto Choco Mochi [END name] [START count] 2 [END count] .... Это позволяет одной модели решать множество задач.

Ключевая магия — end-to-end обучение. Ошибка считается один раз на финальном выходе. Модель сама, в процессе обучения, понимает, каким признакам на изображении нужно уделить больше внимания, чтобы правильно сгенерировать итоговый JSON. Она не полагается на кривой промежуточный результат OCR — она учится сама его «мыслить».

Как мы это учили? Секретное оружие — синтетика

Обучение такого монстра — нетривиальная задача. Мы разбили его на два больших этапа:

1. Этап Pre-training: «Учимся читать»
Здесь наша цель — научить модель связывать визуальную информацию с текстовой. Мы кормим её миллионами пар «изображение документа — текст этого документа». Для этого идеально подходит датасет IIT-CDIP (11 миллионов сканов). Текст мы брали с помощью того же OCR, но на этом этапе это не страшно — нам важно дать модели общее представление о том, как выглядят буквы и слова.

Скрытый текст
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader
from torchvision import transforms
from PIL import Image
import json
import os

from transformers import BartForConditionalGeneration, BartConfig, AutoTokenizer
from transformers import SwinModel, SwinConfig

class IITCDIPDataset(Dataset):
    def __init__(self, root_dir="/datasets/IIT-CDIP", split="train", max_length=512):
        self.root_dir = root_dir
        self.image_dir = os.path.join(root_dir, "images")
        self.text_dir = os.path.join(root_dir, "texts")
        self.split_file = os.path.join(root_dir, "splits", f"{split}.txt")
        
        with open(self.split_file, 'r') as f:
            self.file_ids = [line.strip() for line in f if line.strip()]
        
        self.tokenizer = AutoTokenizer.from_pretrained("facebook/bart-large")
        self.transform = transforms.Compose([
            transforms.Resize((224, 224)),
            transforms.ToTensor(),
            transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
        ])
        self.max_length = max_length

    def __len__(self):
        return len(self.file_ids)

    def __getitem__(self, idx):
        file_id = self.file_ids[idx]
        image_path = os.path.join(self.image_dir, f"{file_id}.tif")
        text_path = os.path.join(self.text_dir, f"{file_id}.txt")
        
        image = Image.open(image_path).convert('RGB')
        with open(text_path, 'r', encoding='utf-8', errors='ignore') as f:
            text = f.read().strip()
        
        encoding = self.tokenizer(
            text,
            max_length=self.max_length,
            padding='max_length',
            truncation=True,
            return_tensors='pt'
        )
        
        return {
            'pixel_values': self.transform(image),
            'input_ids': encoding['input_ids'].flatten(),
            'attention_mask': encoding['attention_mask'].flatten()
        }

class VisionTextPreTrainingModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.encoder = DocumentEncoder()
        self.decoder = StructuredBartDecoder()
        
    def forward(self, pixel_values, labels=None):
        encoder_outputs = self.encoder(pixel_values)
        if labels is not None:
            return self.decoder(encoder_outputs, labels)
        return self.decoder(encoder_outputs)

def train_pretraining():
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    
    train_dataset = IITCDIPDataset(root_dir="/datasets/IIT-CDIP", split="train")
    val_dataset = IITCDIPDataset(root_dir="/datasets/IIT-CDIP", split="val")
    
    train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=8)
    val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4)
    
    model = VisionTextPreTrainingModel().to(device)
    optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=0.01)
    criterion = nn.CrossEntropyLoss(ignore_index=0)
    
    model.train()
    for epoch in range(10):
        total_loss = 0
        for batch in train_loader:
            pixel_values = batch['pixel_values'].to(device)
            labels = batch['input_ids'].to(device)
            
            optimizer.zero_grad()
            loss = model(pixel_values, labels)
            loss.backward()
            optimizer.step()
            
            total_loss += loss.item()
        
        print(f"Epoch {epoch+1}, Loss: {total_loss/len(train_loader):.4f}")
        
        if (epoch + 1) % 2 == 0:
            torch.save({
                'epoch': epoch,
                'model_state_dict': model.state_dict(),
                'optimizer_state_dict': optimizer.state_dict(),
                'loss': total_loss/len(train_loader),
            }, f"/checkpoints/pretraining_epoch_{epoch+1}.pt")

if __name__ == "__main__":
    train_pretraining()

2. Этап Fine-tuning: «Учимся понимать»
А вот здесь — самый сок. Мы доучиваем модель на конкретных прикладных задачах:

  • Классификация: Это счёт или договор?

  • Извлечение информации (Information Extraction): Вытащить сумму, дату, реквизиты.

  • Вопрос-ответ (DocVQA): Ответить на вопрос по документу («Какая итоговая сумма?»).

Скрытый текст
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader
from torchvision import transforms
from PIL import Image
import json
from typing import Dict, List, Optional
import os

from document_encoder import DocumentEncoder
from structured_bart_decoder import StructuredBartDecoder

class DocumentClassificationDataset(Dataset):
    def __init__(self, data_dir, split="train"):
        self.data_dir = data_dir
        self.image_dir = os.path.join(data_dir, "images")
        self.labels_file = os.path.join(data_dir, f"{split}_labels.json")
        
        with open(self.labels_file, 'r') as f:
            self.labels_data = json.load(f)
        
        self.transform = transforms.Compose([
            transforms.Resize((224, 224)),
            transforms.ToTensor(),
            transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
        ])
        self.classes = ["invoice", "contract", "receipt", "memo", "other"]

    def __len__(self):
        return len(self.labels_data)

    def __getitem__(self, idx):
        item = self.labels_data[idx]
        image_path = os.path.join(self.image_dir, item["image_id"] + ".jpg")
        image = Image.open(image_path).convert('RGB')
        
        return {
            'pixel_values': self.transform(image),
            'label': self.classes.index(item["class"])
        }

class InformationExtractionDataset(Dataset):
    def __init__(self, data_dir, split="train"):
        self.data_dir = data_dir
        self.image_dir = os.path.join(data_dir, "images")
        self.annotations_file = os.path.join(data_dir, f"{split}_annotations.json")
        
        with open(self.annotations_file, 'r') as f:
            self.annotations = json.load(f)
        
        self.transform = transforms.Compose([
            transforms.Resize((224, 224)),
            transforms.ToTensor(),
            transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
        ])

    def __len__(self):
        return len(self.annotations)

    def __getitem__(self, idx):
        item = self.annotations[idx]
        image_path = os.path.join(self.image_dir, item["image_id"] + ".jpg")
        image = Image.open(image_path).convert('RGB')
        
        return {
            'pixel_values': self.transform(image),
            'target_text': item["structured_text"]
        }

class DocVQADataset(Dataset):
    def __init__(self, data_dir, split="train"):
        self.data_dir = data_dir
        self.image_dir = os.path.join(data_dir, "images")
        self.qa_file = os.path.join(data_dir, f"{split}_qa.json")
        
        with open(self.qa_file, 'r') as f:
            self.qa_data = json.load(f)
        
        self.transform = transforms.Compose([
            transforms.Resize((224, 224)),
            transforms.ToTensor(),
            transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
        ])

    def __len__(self):
        return len(self.qa_data)

    def __getitem__(self, idx):
        item = self.qa_data[idx]
        image_path = os.path.join(self.image_dir, item["image_id"] + ".jpg")
        image = Image.open(image_path).convert('RGB')
        
        return {
            'pixel_values': self.transform(image),
            'question': item["question"],
            'answer': item["answer"]
        }

class DocumentClassifier(nn.Module):
    def __init__(self, num_classes=5):
        super().__init__()
        self.encoder = DocumentEncoder()
        self.classifier = nn.Sequential(
            nn.Linear(768, 512),
            nn.ReLU(),
            nn.Dropout(0.3),
            nn.Linear(512, num_classes)
        )

    def forward(self, pixel_values):
        features = self.encoder(pixel_values)
        cls_features = features.mean(dim=1)
        return self.classifier(cls_features)

class InformationExtractionModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.encoder = DocumentEncoder()
        self.decoder = StructuredBartDecoder()

    def forward(self, pixel_values, target_text=None):
        encoder_outputs = self.encoder(pixel_values)
        if target_text is not None:
            return self.decoder(encoder_outputs, target_text)
        return self.decoder.generate_json(encoder_outputs)

class DocVQAModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.encoder = DocumentEncoder()
        self.decoder = StructuredBartDecoder()
        
    def forward(self, pixel_values, question, answer=None):
        encoder_outputs = self.encoder(pixel_values)
        if answer is not None:
            return self.decoder(encoder_outputs, f"Q: {question} A: {answer}")
        return self.decoder.generate_json(encoder_outputs)

class DocumentUnderstandingPipeline:
    def __init__(self, model_path=None):
        self.classifier = DocumentClassifier()
        self.ie_model = InformationExtractionModel()
        self.qa_model = DocVQAModel()
        
        if model_path:
            self.load_models(model_path)
        
        self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
        self.to(self.device)

    def load_models(self, model_path):
        checkpoint = torch.load(model_path, map_location='cpu')
        self.classifier.load_state_dict(checkpoint['classifier'])
        self.ie_model.load_state_dict(checkpoint['ie_model'])
        self.qa_model.load_state_dict(checkpoint['qa_model'])

    def save_models(self, save_path):
        torch.save({
            'classifier': self.classifier.state_dict(),
            'ie_model': self.ie_model.state_dict(),
            'qa_model': self.qa_model.state_dict()
        }, save_path)

    def to(self, device):
        self.classifier.to(device)
        self.ie_model.to(device)
        self.qa_model.to(device)

    def classify_document(self, image_path):
        image = Image.open(image_path).convert('RGB')
        transform = transforms.Compose([
            transforms.Resize((224, 224)),
            transforms.ToTensor(),
            transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
        ])
        pixel_values = transform(image).unsqueeze(0).to(self.device)
        
        with torch.no_grad():
            outputs = self.classifier(pixel_values)
            predicted_class = torch.argmax(outputs, dim=1).item()
        
        classes = ["invoice", "contract", "receipt", "memo", "other"]
        return classes[predicted_class]

    def extract_information(self, image_path):
        image = Image.open(image_path).convert('RGB')
        transform = transforms.Compose([
            transforms.Resize((224, 224)),
            transforms.ToTensor(),
            transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
        ])
        pixel_values = transform(image).unsqueeze(0).to(self.device)
        
        with torch.no_grad():
            result = self.ie_model(pixel_values)
        
        return self._parse_structured_output(result[0])

    def answer_question(self, image_path, question):
        image = Image.open(image_path).convert('RGB')
        transform = transforms.Compose([
            transforms.Resize((224, 224)),
            transforms.ToTensor(),
            transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
        ])
        pixel_values = transform(image).unsqueeze(0).to(self.device)
        
        with torch.no_grad():
            result = self.qa_model(pixel_values, question)
        
        return result[0].split("A: ")[-1] if "A: " in result[0] else result[0]

    def _parse_structured_output(self, text):
        result = {}
        current_key = None
        
        for token in text.split():
            if token.startswith("[START"):
                current_key = token.replace("[START", "").replace("]", "").strip()
            elif token.startswith("[END"):
                current_key = None
            elif current_key:
                if current_key not in result:
                    result[current_key] = []
                result[current_key].append(token)
        
        for key in result:
            result[key] = " ".join(result[key])
        
        return result

  if __name__ == "__main__":
    main()

    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    
    pipeline = DocumentUnderstandingPipeline("/models/trained_model.pth")
    image_path = "/data/documents/invoice_001.jpg"
    
    # 1. Классификация документа
    doc_type = pipeline.classify_document(image_path)
    print(f"Тип документа: {doc_type}")
    
    # 2. Извлечение информации
    extracted_info = pipeline.extract_information(image_path)
    print(f"Извлеченная информация: {extracted_info}")
    
    # 3. Ответ на вопрос
    question = "Какая итоговая сумма?"
    answer = pipeline.answer_question(image_path, question)
    print(f"Вопрос: {question}")
    print(f"Ответ: {answer}")

Мы разработали свой синтетический генератор документов (типа SynthDoG). Он берёт статьи из Википедии на нужном язык (английский, русский, китайский, etc.) и генерирует правдоподобные документы-фейки: чеки, счета, накладные. Он сам расставляет лейблы, сам рисует bounding boxes, сам создаёт итоговый JSON. Это решает титаническую проблему нехватки размеченных данных для обучения. Нужно обучить модель на корейском? Генерируем 500 тысяч synthetic samples — и проблема решена.

Так что же в сухом остатке? Цифры говорят сами за себя.

Мы не верим на слово — мы тестировали на стандартных бенчмарках:

  • Классификация документов (RVL-CDIP датасет):

    • Наша модель: 98.39% точности.

    • LayoutLMv2 (OCR-зависимый SOTA): 96.67%.

    • Выигрыш в скорости: Наша модель обрабатывает документ за 752 мс, тогда как конкуренты — почти в два раза дольше (~1400-1500 мс).

  • Извлечение информации из чеков (CORD датасет):

    • F1-мера (точность извлечения полей): 85.1%. Это на уровне лучших OCR-зависимых моделей, но без их головной боли.

    • Точность восстановления иерархии (Tree Edit Distance): 90.9%. Наша модель не просто вытаскивает текст, она понимает структуру: что является родительским элементом, а что дочерним.

  • Вопросно-ответные системы (DocVQA):

    • Модель смотрит на документ и отвечает на вопросы по нему. Справляется даже с рукописным текстом, где традиционные OCR пасуют.

И всё это — вдвое быстрее и дешевле, потому что мы не гоняем данные через три разные модели, а делаем всё за один проход.

Бизнес-план: Как превратить эту технологию в печатный станок

Итак, технология есть, она рабочая и крутая. Давайте теперь прикинем, как на этом сделать бизнес. Это не просто идея — это пошаговый план.

1. Проблема, которую мы решаем:
Ручной ввод данных и кривая автоматизация. От этого страдают:

  • Бухгалтерии: Счета-фактуры, акты, накладные.

  • Логистические компании: Товарно-транспортные накладные (TTN), заказы.

  • E-commerce: Обработка заказов, возвратов.

  • Банки и финтех: Кредитные заявки, анкеты, сканы паспортов.

  • Госсектор: Обработка заявлений, отчетности.

2. Наше решение:
SaaS-сервис с простейшим API. Клиент закидывает нам изображение документа (JPG, PNG, PDF) через API или веб-интерфейс, а в ответ получает готовый, чистый, структурированный JSON со всеми вытащенными полями. Всё. Никаких настроек, моделей, шаблонов. Всю магию берём на себя.

3. Целевая аудитория:

  • Средний и крупный бизнес с документооборотом от 1000 документов в месяц.

  • Разработчики fintech, edtech, retailtech сервисов, которые хотят встроить распознавание документов в свой продукт.

  • Системные интеграторы, которые могут использовать наше API как компонент в своих крупных решениях для корпораций.

4. Техническая упаковка:

  • Бэкенд: Python (FastAPI для легковесного и быстрого API).

  • Модель: Наша предобученная OCR-независимая модель на PyTorch/TensorFlow.

  • Инфраструктура: Облако (Yandex Cloud, SberCloud, AWS) с GPU-инстансами для инференса. Контейнеризация (Docker), оркестрация (Kubernetes) для масштабирования.

  • API: Простой REST API. Пример запроса:

    curl -X POST "https://api.your-saas.com/v1/process" \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -F "document=@receipt.jpg" \
    -F "document_type=receipt"
    

    В ответ — чистый JSON.

  • Безопасность: Все данные шифруются, сертификаты SSL, соблюдение GDPR/152-ФЗ если будем работать с данными.

5. Детализация монетизации:
Здесь можно играть на двух полях:

  • Pay-As-You-Go (Послужная модель): Идеально для старта и привлечения первых клиентов.

    • Цена: $0.15 за обработку одного документа.

    • Для кого: Небольшие компании, стартапы, те, у кого документооборот нерегулярный.

    • Плюс: Клиент платит только за то, что использует. Нет абонентской платы.

  • Подписка (SaaS-модель): Основная модель для стабильного дохода.

    • Тариф «Старт»: $99/месяц — до 1000 документов.

    • Тариф «Бизнес»: $299/месяц — до 5000 документов, приоритетная очередь, кастомные форматы документов.

    • Тариф «Enterprise»: $999+/месяц — безлимитное количество документов, выделенные GPU-ресурсы, индивидуальное дообучение модели под документы заказчика, SLA, техническая поддержка 24/7.

    • Доп. продажи: Пакеты документов сверх лимита по сниженной цене.

  • On-Premise лицензия: Для самых параноидальных и крупных клиентов (банки, госкомпании).

    • Разовый платеж: От $50 000 до $200 000+ (в зависимости от масштаба).

    • Что входит: Развертывание решения на инфраструктуре клиента, интеграция, техническая поддержка и обновления на год.

    • Годовой платеж за поддержку: 20-30% от стоимости лицензии.

6. Почему это выстрелит? У нас есть преимущества:

  • Точность: Меньше ошибок -> меньше ручной работы -> больше счастья для клиента.

  • Скорость: Обработка документов в реальном времени.

  • Универсальность: Одна модель обучена на многих типах документов и языках.

  • Дешевле в долгосрочной перспективе: Клиенту не нужно покупать и поддерживать отдельные OCR-решения (например, Google Cloud Vision API тоже стоит денег).

  • Простота интеграции: Один API-запрос вместо построения целого пайплайна.

7. Оценка рынка, конкуренция и первые шаги:

  • Рынок: Огромен. Автоматизация документооборота — это multi-billion долларовый рынок, который только растет.

  • Конкуренция: Есть крупные игроки (Google Document AI, Amazon Textract), но они предлагают именно OCR. Мы предлагаем принципиально другой, более простой и точный подход «все-в-одном». Мы будем брать не мощностью, а элегантностью решения.

  • Стратегия выхода:

    1. MVP: Сделать простейший работающий прототип на 1-2 типа документов (например, чеки и счета-фактуры).

    2. Пилотные проекты: Найти 5-10 первых клиентов (через нетворкинг, хабы) и отдать им доступ бесплатно в обмен на фидбек и их данные для дообучения модели.

    3. Фокусировка на нише: Начать с узкой ниши, где боль особенно сильна. Например, автоматизация обработки чеков в HoReCa (кафе, рестораны) или счетов-фактур для малого бизнеса.

    4. Маркетинг: Контент-маркетинг (вот такие вот статьи на Хабре!), SEO, таргетированная реклама для CFO, бухгалтеров и IT-директоров.

Технология уже есть и она рабочая. Осталось упаковать ее в удобный сервис и нести в массы. Что думаете, друзья? Готовы ли рынок к тому, чтобы отказаться от старого доброго OCR в пользу единой end-to-end модели?

Статья написана в сотрудничестве с Сироткиной Анастасией Сергеевной.

? Ставьте лайк и пишите, какие темы разобрать дальше! Главное — пробуйте и экспериментируйте!


✔️ Присоединяйтесь к нашему Telegram-сообществу @datafeeling, где мы делимся новыми инструментами, кейсами, инсайтами и рассказываем, как всё это применимо к реальным задачам

Комментарии (0)


  1. mst_72
    22.09.2025 05:28

    Цена: $0.15 за обработку одного документа

    Судя по цене, если отбросить всю радостную шумиху про "мы самые лучшие" вы явно прикрутили что-то вроде GPT (VLM) и решили проблему (вариант - протестировали какую-нибудь ламу/квена, которые с картинками могут работать, чтобы on-premise).
    Иначе всё как-то слишком дорого

    Мы не верим на слово — мы тестировали на стандартных бенчмарках

    А мы, значит, дожны верить на слово очередному "проактивному и решившему все проблемы" продавцу? Точно-точно.

    PS. Что-то мне это всё напомнило гендиректора одной недавно успешно сдувшейся компании. Один в один была стилистика выступлений. Везде абсолютный успешный-успех. Правда потом стали выясняться нюансы


    1. digtatordigtatorov
      22.09.2025 05:28

      Да, первый нюанс, что на рынке есть точно такое же решение уже около года от рандомных студентов вышки

      А второй, что они тупо взяли готовую модель (я хз уже года несколько существующую) и тупо по инструкции повторили все из описания статьи, даже не постеснялись взять те же бенчи (не на русском, а тогда и какой смысл для нашего рынка то :)?

      Хотя бы поэтому можно поулыбаться), читая про «успешный успех»