Los equipos de operaciones y finanzas dedican horas a transcribir a hojas de cálculo facturas, órdenes de compra, notas de entrega y presupuestos de proveedores. Con OCR para leer escaneos y fotos, y un modelo de lenguaje local que devuelve exactamente los campos que usted define, se puede automatizar la mayor parte de ese trabajo de transcripción. Esta guía combina Tesseract y OCRmyPDF con la salida estructurada de Ollama: cada documento se convierte en un archivo JSON con los campos extraídos, y una hoja de resumen marca los documentos cuyas líneas no cuadran con el total, de modo que las personas solo revisan las excepciones. Todo se ejecuta en su propio servidor.
# Debian / Ubuntu: PDF text, OCR engine with English and Simplified Chinese, OCR for PDFs
apt install -y python3-venv poppler-utils tesseract-ocr tesseract-ocr-eng tesseract-ocr-chi-sim ocrmypdf
python3 -m venv /opt/extract
/opt/extract/bin/pip install ollama
ollama pull qwen2.5:7b
La configuración de Ollama se explica en ejecutar un LLM local con Ollama. Para otros idiomas, instale el paquete de Tesseract correspondiente, por ejemplo tesseract-ocr-jpn, tesseract-ocr-tha o tesseract-ocr-rus.
# Digital PDF (created by software): the text can be read directly
pdftotext -layout invoice.pdf - | head -40
# Scanned PDF: add a text layer with OCR first, then read it
ocrmypdf -l eng+chi_sim --skip-text scan.pdf scan-ocr.pdf
pdftotext -layout scan-ocr.pdf - | head -40
# Photo or screenshot
tesseract photo.jpg - -l eng+chi_sim | head -40
Si el texto que obtiene aquí es legible, el modelo podrá extraer los campos. Si sale ilegible, mejore primero la entrada: escanee a 300 ppp y fotografíe los documentos planos y bien iluminados.
El SCHEMA define qué campos desea obtener. Ollama obliga a que la respuesta del modelo siga exactamente esta estructura, por lo que la salida siempre es JSON válido.
cat > /opt/extract/extract.py <<'EOF2'
import csv, json, os, subprocess, sys, tempfile
import ollama
MODEL = os.environ.get("MODEL", "qwen2.5:7b")
LANG = os.environ.get("OCR_LANG", "eng+chi_sim")
SCHEMA = {
"type": "object",
"properties": {
"supplier": {"type": "string"},
"document_number": {"type": "string"},
"date": {"type": "string", "description": "YYYY-MM-DD"},
"currency": {"type": "string"},
"total": {"type": "number"},
"tax": {"type": "number"},
"items": {"type": "array", "items": {"type": "object", "properties": {
"description": {"type": "string"}, "quantity": {"type": "number"},
"unit_price": {"type": "number"}, "amount": {"type": "number"}},
"required": ["description", "quantity", "amount"]}},
},
"required": ["supplier", "document_number", "date", "currency", "total", "tax", "items"],
}
def text_of(path):
run = lambda cmd: subprocess.run(cmd, capture_output=True, text=True).stdout
if not path.lower().endswith(".pdf"):
return run(["tesseract", path, "-", "-l", LANG])
text = run(["pdftotext", "-layout", path, "-"])
if len(text.strip()) > 50:
return text
with tempfile.TemporaryDirectory() as tmp: # scanned PDF: OCR it first
ocr = os.path.join(tmp, "ocr.pdf")
subprocess.run(["ocrmypdf", "-q", "-l", LANG, "--skip-text", path, ocr], check=True)
return run(["pdftotext", "-layout", ocr, "-"])
rows = []
for path in sys.argv[1:]:
if not os.path.isfile(path):
continue
r = ollama.chat(model=MODEL, format=SCHEMA, options={"temperature": 0, "num_ctx": 8192}, messages=[
{"role": "system", "content": "Extract the requested fields from the document text. "
"Copy names, numbers and IDs exactly as written. Use an empty string or 0 "
"for anything that is not in the text. Do not guess."},
{"role": "user", "content": text_of(path)[:15000]}])
d = json.loads(r.message.content)
items = round(sum(i.get("amount", 0) for i in d["items"]), 2)
ok = d["total"] > 0 and min(abs(items - d["total"]), abs(items + d["tax"] - d["total"])) < 0.02
with open(os.path.splitext(path)[0] + ".json", "w", encoding="utf-8") as f:
json.dump(d, f, ensure_ascii=False, indent=2)
rows.append([os.path.basename(path), d["supplier"], d["document_number"], d["date"],
d["currency"], d["total"], d["tax"], len(d["items"]), "OK" if ok else "CHECK"])
print(path, "OK" if ok else "CHECK", flush=True)
with open("summary.csv", "w", newline="", encoding="utf-8-sig") as f:
w = csv.writer(f)
w.writerow(["file", "supplier", "document_number", "date", "currency", "total", "tax", "lines", "check"])
w.writerows(rows)
EOF2
cd /data/invoices/2026-10
/opt/extract/bin/python /opt/extract/extract.py *.pdf *.jpg *.png
# For a large folder, run it in the background:
nohup /opt/extract/bin/python /opt/extract/extract.py *.pdf > extract.log 2>&1 &
Junto a cada documento se crea un archivo .json con todos los campos y líneas, y summary.csv contiene una fila por documento. Ábralo en Excel y filtre la columna check: OK significa que la suma de las líneas coincide con el total (con o sin impuestos), y CHECK, que una persona debe revisar ese documento.
buyer, delivery_date y campos de SKU en las líneas.tracking_number, consignee, weight_kg y packages.valid_until, payment_terms y los precios unitarios para comparar proveedores en una sola hoja.Una breve description en un campo, como la de date en el ejemplo anterior, indica al modelo el formato que espera. Si procesa varios tipos de documentos, mantenga un esquema por tipo en scripts o carpetas separados.
qwen2.5:14b, manejan mejor los diseños complejos y las tablas largas, a costa de velocidad.En un servidor con CPU, normalmente desde unas decenas de segundos para una factura de una página hasta unos minutos para documentos largos de varias páginas, según el modelo. Ejecute los lotes grandes durante la noche.
Muchas API de IA también admiten salida estructurada, pero en ese caso los documentos salen de su servidor. Revise primero sus obligaciones de privacidad, las condiciones del proveedor y sus regiones admitidas. Para facturas, contratos y datos de clientes, la configuración local suele ser la opción más segura.
La mayoría de los sistemas pueden importar archivos CSV; asigne las columnas de summary.csv o convierta los archivos JSON al formato de importación. Importe automáticamente solo las filas marcadas como OK y deje las filas CHECK para revisión manual.
¿Sigue sin resolverlo después de seguir estos pasos? Abra un ticket de soporte y el equipo técnico de IMIDC, disponible 24/7, le ayudará. Incluya la IP del servidor, la versión del sistema operativo, el modelo que utiliza, la salida del error y, si es posible, un documento de ejemplo sin datos sensibles, para que podamos localizar el problema más rápido.