¡Hola! Envíame un mensaje
Contacto

Ciudad de México, México
Disponible para proyectos remotos y presenciales.

Redes profesionales

LinkedIn · GitHub · Email
Ingeniería aplicada, resultados medibles.

Optimización de Python: cómo mejorar el rendimiento de scripts que procesan 80,000 folios con OCR + LLM

· 9 min de lectura · Samuel Torres

Curva de Lissajous 3:4
x(t) = sin(3t + pi/2), y(t) = sin(4t)

En resumen

  • Antes de optimizar, mide: el cuello de botella casi nunca está donde crees.
  • Para buscar pertenencia en 80,000 folios usa un set, no una lista.
  • Procesa en flujo con generadores y lotes; no cargues la tabla completa.
  • Un cron frecuente necesita candado y lotes idempotentes.
  • El LLM es lo más lento y lo más caro: fíltralo con reglas deterministas antes de llamarlo.

La optimización de Python rara vez empieza por reescribir código. Empieza por saber dónde se va el tiempo. Lo aprendí con un sistema que gestionó un pool de 80,000 folios en producción, con verificación de comprobantes por OCR + LLM y sin revisión manual.

En este artículo reviso tres decisiones que definen el rendimiento de un script de procesamiento masivo: la estructura de datos, el manejo de memoria y la forma de repartir el trabajo. Cada una viene con código que puedes adaptar hoy.

Una aclaración antes de empezar: los ejemplos son ilustrativos. Muestran el patrón, no el código del cliente, y los nombres de tablas y formatos de folio son inventados para el ejemplo.

El caso: 80,000 folios, OCR y un LLM en producción

El sistema es el de Panini Fan Zone FWC 2026. Emitía y controlaba un pool fijo de 80,000 folios, y verificaba los comprobantes de compra que enviaban los participantes.

La verificación era un pipeline de dos pasos. Primero Tesseract extraía el texto de la imagen. Después un modelo LLaMA 3.3 70B, servido por Groq, validaba ese texto contra las reglas de la promoción.

Todo corría solo, con un cron cada 2 minutos, sobre un VPS con AlmaLinux. No había una persona revisando comprobante por comprobante.

Ese diseño impone tres restricciones que cualquier script de este tipo comparte:

  • Cada corrida tiene una ventana corta. Si una ejecución tarda más de 2 minutos, se encima con la siguiente.
  • La memoria es compartida. El script vive en el mismo servidor que el sitio; si se come la RAM, tira el sitio.
  • El paso lento es externo. La llamada al LLM depende de la red y de los límites del proveedor, no de tu CPU.

Con eso en mente, veamos dónde sí vale la pena optimizar.

Optimizar scripts de Python con listas vs sets

El primer error es el más barato de corregir. Validar un folio es una pregunta de pertenencia: ¿este código existe en el pool?

Si el pool vive en una lista, Python compara elemento por elemento hasta encontrarlo. Con 80,000 folios, el peor caso son 80,000 comparaciones por cada consulta. Eso es O(n).

Un set guarda los elementos en una tabla hash. Calcula el hash del valor buscado y va directo a su posición. En promedio es O(1), sin importar si el pool tiene 800 o 80,000 elementos.

El cambio es una línea. La diferencia crece con el tamaño de los datos:

import timeit

folios = [f"FZ-{n:06d}" for n in range(80_000)]
folios_set = set(folios)      # se construye una sola vez, al arrancar
buscado = "FZ-079999"         # peor caso para la lista: está al final

# La lista recorre hasta encontrarlo: O(n) por cada consulta.
t_lista = timeit.timeit(lambda: buscado in folios, number=1_000)
# El set calcula un hash y va directo: O(1) en promedio.
t_set = timeit.timeit(lambda: buscado in folios_set, number=1_000)

# No confíes en cifras ajenas: mide en tu máquina, con tus datos.
print(f"lista: {t_lista:.4f} s   set: {t_set:.4f} s")

Córrelo y compara. No pongo aquí un número porque depende de tu hardware y de tu versión de Python, y porque la lección no es la cifra: es que el costo de la lista crece con el pool y el del set no.

Tres detalles que evitan sorpresas:

  • Construye el set una sola vez. Hacer set(folios) dentro del ciclo cuesta O(n) en cada vuelta y borra toda la ganancia.
  • El set no conserva orden ni duplicados. Si los necesitas, mantén la lista y usa el set solo como índice de búsqueda.
  • Para relacionar datos, usa un diccionario. Si además de saber que el folio existe necesitas su estado, un dict de folio a registro te da la misma velocidad de acceso.

Y una regla de arquitectura: si el pool ya vive en MySQL con un índice único, la base de datos es tu set. Traer 80,000 filas a memoria para buscar una es el mismo error con otro disfraz.

Gestión de memoria en Python: procesa en flujo

Python libera la memoria por conteo de referencias. Cuando nadie apunta a un objeto, se destruye en ese momento. El recolector de basura solo interviene para los ciclos de referencias.

La consecuencia práctica es simple: la memoria no se libera mientras conserves una referencia. Y la forma más común de conservar referencias sin querer es acumular todo en una lista.

El patrón que lo provoca se ve inocente: un fetchall() que trae toda la tabla, una lista de resultados que crece durante la corrida, imágenes abiertas que nadie cierra.

La alternativa es procesar en flujo. Un generador entrega una fila a la vez y solo mantiene en memoria el lote actual:

def comprobantes_pendientes(conexion, lote=500, tope=5_000):
    """Entrega filas pendientes sin cargar la tabla completa."""
    cursor = conexion.cursor(dictionary=True)
    # El tope acota la corrida: lo que no alcance, lo toma el siguiente cron.
    cursor.execute(
        "SELECT id, ruta_imagen FROM comprobantes "
        "WHERE estado = 'pendiente' ORDER BY id LIMIT %s",
        (tope,),
    )
    while True:
        filas = cursor.fetchmany(lote)   # la memoria queda acotada al lote
        if not filas:
            break
        yield from filas                 # el consumidor marca el ritmo
    cursor.close()

El consumo de memoria deja de depender del tamaño de la tabla. Depende del tamaño del lote, y ese lo decides tú.

Con imágenes, la regla es la misma pero más urgente. Una foto de un comprobante ocupa mucho más en memoria, ya decodificada, que en disco. Ábrela con un bloque with, extrae el texto y suéltala antes de pasar a la siguiente.

Para diagnosticar, la biblioteca estándar ya trae la herramienta:

  • tracemalloc toma fotos de la memoria y te dice qué línea reservó cada bloque. Compara una foto al inicio contra otra después de varios lotes.
  • cProfile te dice en qué funciones se va el tiempo. Úsalo antes de tocar una sola línea.
  • __slots__ reduce el tamaño de cada instancia cuando creas miles de objetos pequeños de una misma clase.

Llamar a gc.collect() a mano casi nunca es la solución. Si la memoria crece, alguien conserva una referencia. Encuéntrala.

Lotes idempotentes y concurrencia con límite

Un cron cada 2 minutos tiene un riesgo evidente: que una corrida lenta se encime con la siguiente y ambas procesen el mismo comprobante. Eso significa pagar dos veces el LLM y, peor, escribir dos resultados.

La defensa tiene dos capas. La primera es un candado de archivo: si la corrida anterior sigue viva, la nueva sale sin hacer nada. La segunda es que cada operación sea idempotente: procesar dos veces el mismo comprobante debe dejar el mismo resultado.

Sobre esa base se puede paralelizar. El OCR corre en un subproceso y el LLM es una llamada de red, así que el trabajo es de entrada y salida. Para eso los hilos sí sirven, aun con el GIL.

import fcntl
import sys
from concurrent.futures import ThreadPoolExecutor

# ⚠️ Producción: respalda la tabla antes de cambiar el flujo de estados.
candado = open("/var/lock/verificador.lock", "w")
try:
    # Si la corrida anterior sigue viva, esta sale: nada se procesa doble.
    fcntl.flock(candado, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
    sys.exit(0)

# Pocos workers a propósito: el límite real lo pone el proveedor del LLM.
with ThreadPoolExecutor(max_workers=4) as pool:
    pendientes = comprobantes_pendientes(conexion)
    for resultado in pool.map(verificar, pendientes):
        guardar(resultado)   # un solo hilo escribe: sin carreras en MySQL

Hay un detalle fino: pool.map consume el generador completo al encolar las tareas. Por eso la consulta lleva un tope. Sin él, el paralelismo te regresa al problema de memoria que acabas de resolver.

El número de workers no se elige por intuición. Se ajusta al límite de peticiones de tu proveedor y se sube solo si las mediciones lo justifican.

La misma disciplina aplica a las pruebas. En el módulo de registro de ese proyecto, la suite incluía una prueba de concurrencia con 50 workers simultáneos contra la misma ruta, para confirmar que solo un registro ganara cada cupo. La concurrencia se demuestra, no se supone.

Buenas prácticas para scripts de Python eficientes

Con la estructura de datos, la memoria y los lotes resueltos, lo que queda es orden. Estas son las prácticas que más rinden en un pipeline de OCR + LLM:

  • Perfila antes de optimizar. Si el 90% del tiempo se va esperando al LLM, optimizar el ciclo de Python no cambia nada.
  • Filtra barato antes de gastar caro. Si el OCR devolvió texto vacío o ilegible, rechaza ahí. No pagues una llamada al modelo para confirmar lo obvio.
  • Valida la salida del LLM con reglas deterministas. Pide una respuesta estructurada y comprueba cada campo en código. El modelo propone; tus reglas deciden.
  • Pon tiempo límite y reintentos con espera creciente. Una API remota va a fallar algún día. Que un fallo no detenga el lote completo.
  • No repitas trabajo. Guarda el hash de cada imagen procesada. Un comprobante reenviado no debe costar otra vuelta de OCR y modelo.
  • Registra por lote, no por línea. Cuántos entraron, cuántos pasaron, cuántos fallaron y cuánto tardó. Con eso detectas una degradación antes de que sea una caída.

Ninguna de estas prácticas es exótica. Lo difícil es aplicarlas todas a la vez, y hacerlo antes de que el volumen te obligue.

Lo que NO debes hacer

  • Optimizar sin medir. Reescribir un ciclo con trucos ilegibles para ganar milisegundos, mientras el script pasa segundos esperando una API. Mide primero; optimiza solo lo que el perfil señale.
  • Cargar todo en memoria «porque cabe». Cabe hoy, en tu máquina, con datos de prueba. En producción la tabla crece y el servidor se comparte con el sitio.
  • Paralelizar sin límite. Lanzar cientos de hilos contra el LLM no acelera nada: dispara el límite de peticiones, llena el registro de errores y vuelve impredecible el costo.
  • Confiar en el LLM como única validación. Un modelo de lenguaje puede equivocarse con total seguridad. Las reglas del negocio viven en código y en restricciones de la base de datos.

El siguiente paso

La optimización de Python que importa en producción no es un truco de sintaxis. Es elegir la estructura correcta, acotar la memoria, y repartir el trabajo sin que las corridas se pisen.

Si tu equipo enfrenta cuellos de botella en procesamiento masivo, pipelines de OCR o integraciones con LLM que no terminan de ser confiables, en Conqr Digital Agency diseñamos arquitecturas que escalan. Revisemos tu caso.

Si quieres ver el sistema completo detrás de este artículo, está documentado en el caso de Panini Fan Zone. Y si lo tuyo es automatizar procesos con IA, revisa el servicio de automatización con IA.

Preguntas frecuentes

¿Cuándo conviene un set en lugar de una lista en Python?
Cuando la operación dominante es preguntar si un elemento existe. La lista recorre elemento por elemento; el set resuelve por hash en tiempo constante en promedio. Si necesitas orden o duplicados, conserva la lista y construye un set aparte para las búsquedas.
¿Hilos o procesos para un pipeline de OCR + LLM?
Si el OCR corre en un subproceso y el LLM es una API remota, el trabajo es de entrada y salida, y los hilos bastan. Si el OCR corre dentro del mismo proceso de Python y satura un núcleo, usa procesos.
¿Cómo sé si mi script tiene una fuga de memoria?
Toma dos fotos con tracemalloc, una al inicio y otra después de varios lotes, y compáralas. Si el mismo punto del código crece lote tras lote, ahí se están acumulando objetos.
Samuel Torres Pichardo

Samuel Torres Pichardo
Ingeniero de software y cloud en Ciudad de México, con unos 3 años de experiencia profesional.
LinkedIn · GitHub · Contacto

Contacto profesional:

Ciudad de México, México

Disponible para proyectos remotos y presenciales.
Ingeniero en Tecnologías de Computación y Telecomunicaciones

© 2026 Ing. Samuel Torres. Ingeniería aplicada.