Hand drawn pipeline showing a document photo going through OCR, cleanup, and a small LoRA model that emits JSON.

Train a Small Document Understanding Model, Then Run It in Rust

The reproducible half of the local Kenyan ID OCR system: generate synthetic OCR data, fine-tune a Supra-50M LoRA with MLX, evaluate it, fuse it, and run the fused model through Candle.

Mike Chumba Mike Chumba
5 min read
1043 words

The OCR engine reads text. The understanding model turns that text into a document record.

For Kenyan ID extraction, I wanted a small local model that does one thing: receive noisy OCR lines and return this JSON shape:

{
  "serial_number": "...",
  "id_number": "...",
  "full_names": "...",
  "date_of_birth": "...",
  "sex": "...",
  "district_of_birth": "...",
  "place_of_issue": "...",
  "date_of_issue": "..."
}

I published the code as drmhse/paddle-paddle-v6-ocr-rust . The important directory is training/.

This is the pipeline:

synthetic OCR lines
  -> MLX LoRA fine-tune
  -> held-out evaluation
  -> fused weights
  -> Candle probe
  -> Rust OCR API

Generate The Data

The generator is training/gen_id.py.

It does not generate perfect labels beside perfect values. That would train the wrong skill. The real OCR output from this document type is messier:

  • values can appear before labels
  • labels can cluster together
  • headers and labels can be garbled
  • spaces can disappear
  • dates can lose punctuation
  • junk lines can appear near signatures or stamps

The training target is field association under that noise. Names and numbers are copied verbatim from OCR. Dates are normalized to DD.MM.YYYY. Missing fields become null.

Start in the training directory:

cd ~/src/paddle-paddle-v6-ocr-rust/training
python3 -m venv .venv
./.venv/bin/pip install mlx-lm huggingface_hub boto3 fastapi "uvicorn[standard]" python-multipart requests

./.venv/bin/python gen_id.py

That writes:

data_id/train.jsonl   1600 examples
data_id/valid.jsonl    160 examples

The strongest privacy property is here: the model is trained on synthetic ID-like OCR examples, not on real identity documents.

Patch The Base Model For MLX

This part is the training side of the workflow. I use MLX on Apple Silicon to produce the adapter. The served OCR API does not require MLX; it consumes fused weights through Candle in Rust.

The base model is SupraLabs/Supra-1.5-50M-Instruct-exp, a small Llama-style model.

The upstream tokenizer metadata uses TokenizersBackend, which transformers and mlx_lm do not load directly. I patch the local model copy by snapshotting it into ./supra_local, keeping the safetensors/config/tokenizer files, then rewriting tokenizer_config.json to use PreTrainedTokenizerFast and remove the backend key.

The point is not to change the model. It is to make the tokenizer load through the standard fast-tokenizer path that MLX expects.

Prepare MLX Text Records

Supra does not ship a chat template that MLX can use here, so the training examples are written as plain text:

{system}

{user}
{completion}</s>

Generate the data_id_text/ directory from the chat-style JSONL:

mkdir -p data_id_text

./.venv/bin/python - <<'PY'
import json, os

os.makedirs("data_id_text", exist_ok=True)

for split in ("train", "valid"):
    with open(f"data_id/{split}.jsonl") as fin, open(f"data_id_text/{split}.jsonl", "w") as fout:
        for line in fin:
            messages = json.loads(line)["messages"]
            system = messages[0]["content"]
            user = messages[1]["content"]
            assistant = messages[2]["content"]
            text = f"{system}\n\n{user}\n{assistant}</s>"
            fout.write(json.dumps({"text": text}, ensure_ascii=False) + "\n")
PY

I also keep conv_pc.py for prompt/completion experiments, but the command below uses the raw text format.

Train The LoRA

Train the adapter:

./.venv/bin/python -m mlx_lm lora \
  --model ./supra_local \
  --train \
  --data ./data_id_text \
  --iters 700 \
  --batch-size 8 \
  --num-layers 8 \
  --adapter-path ./adapters_supra \
  --steps-per-eval 350

This trains about 0.6M parameters, roughly 1.16% of the model. The adapter is about 2.4 MB. In my run, validation loss settled around 0.50.

That size is the reason the approach is practical. The OCR models do the visual work. The small model only learns the document contract.

Evaluate Held-Out OCR

Do not judge this with one pretty sample.

training/eval_any.py imports the same generator with an unseen random seed and asks the adapter to extract fields from fresh examples:

./.venv/bin/python eval_any.py gen_id ./supra_local ./adapters_supra 60

My current held-out run reports:

valid JSON: 60/60
micro-average field accuracy: about 92%

The evaluator also prints per-field hits and full-record exact matches. That matters because one field can be systematically weak while the headline average still looks fine.

Fuse The Adapter

The server does not load MLX adapters at request time. The adapter is fused into the base model first:

./.venv/bin/python -m mlx_lm fuse \
  --model ./supra_local \
  --adapter-path ./adapters_supra \
  --save-path ./supra_id_fused

The deployed artifact is an f16 safetensors model. I keep the small LoRA adapter for reproducibility, but the Rust API downloads the fused weights from the model CDN and verifies the checksum before loading.

When publishing a new version, bump the model prefix and update the manifest checksum in src/remote.rs.

Cross The MLX To Candle Boundary

This is the part worth testing separately.

Training happens with MLX. Serving happens inside the Rust OCR API with Candle. Before wiring the model into the server, use training/candle-extract/ as a small inference probe.

After preparing a Candle-readable model directory with model.safetensors, config.json, and tokenizer.json, run:

cd ~/src/paddle-paddle-v6-ocr-rust/training/candle-extract

printf '%s\n' \
  'SERIAL NUMBER: 880045612' \
  'IDNUMBER: 40781236' \
  'FULL NAMES' \
  'AISHA MWAKIO JUMA' \
  'DATE OF BIRTH' \
  '03.08.1991' \
  'SEX' \
  'FEMALE' \
  'DISTRICT OF BIRTH' \
  'MOMBASA' \
  'PLACE OF ISSUE' \
  'LIKONI' \
  'DATE OF ISSUE' \
  '22.04.2014' \
  | cargo run --release -- ../supra_candle f16

The probe loads the tokenizer, config, and safetensors with Candle, builds the same extraction prompt, and greedily generates JSON. If this fails, fix the conversion before touching the web API.

training/sidecar.py is useful for development experiments, but it is not the production path. The production path is fused Supra weights loaded by the Rust server.

Ship The API Mode

The API keeps this document behavior behind an explicit mode:

mode=kenya_id&understand=true

The response includes OCR lines, detection boxes, and extracted fields. Field confidence is the understanding model’s weakest token probability while emitting the field value. It is not a PP-OCR line confidence and it is not a substitute for evaluation.

That gives you three debugging layers:

annotated image   did detection find the text?
raw OCR lines     did recognition read the text?
JSON fields       did the model associate values correctly?

What To Reuse

The reusable pattern is not “train one model for every Kenyan document.”

The reusable pattern is narrower and more useful:

one document family
one OCR mode
one schema
one synthetic generator
one held-out evaluator
one serving artifact

For another layout, start again at the generator. If the document is regular enough, a parser may beat a model. If the layout is broad or the sensitivity is low, a hosted multimodal API may be the better first version.

For this ID workflow, the small model is worth it because it keeps the image local, makes the output contract explicit, and turns OCR from a text dump into an application field record.