Extracción de Datos Estructurados desde Texto con LLMs
Extrae datos estructurados de texto con Python, Pydantic y LLMs: convierte facturas y ofertas de trabajo en objetos Python tipados y validados.
Los pipelines de datos asumen entradas estructuradas. La realidad entrega PDFs, correos electrónicos, contratos y texto libre. En esa brecha es donde mueren los proyectos de ingeniería de datos.
Los LLMs cambian esta ecuación. Con el prompting correcto y un esquema bien definido, puedes convertir cualquier documento de texto en un objeto Python validado — con la confiabilidad suficiente para usarlo en pipelines de producción.
El Problema de la Extracción Ingenua
El primer intento obvio es pedirle a un LLM que "extraiga la fecha de la factura y el monto total" y parsear su respuesta. Esto falla de tres formas:
- El LLM devuelve nombres de campo ligeramente diferentes cada vez
- Las fechas llegan en formatos inconsistentes (
ene 5,2025-01-05,05/01/25) - No hay validación de esquema — un campo puede ser string cuando esperabas un float
La solución es forzar al LLM a devolver JSON que coincida con un esquema Pydantic, y validarlo antes de que entre en tu pipeline.
Instalación
Usaremos la librería instructor, que parchea cualquier cliente compatible con OpenAI para devolver objetos Pydantic validados:
pip install instructor openai pydantic
import instructor
from openai import OpenAI
client = instructor.from_openai(OpenAI())
Si prefieres usar Claude de Anthropic:
pip install instructor anthropic
import instructor
import anthropic
client = instructor.from_anthropic(anthropic.Anthropic())
El resto del código es idéntico independientemente del modelo subyacente.
Ejemplo 1: Extrayendo Facturas
from pydantic import BaseModel, Field
from typing import Optional
from datetime import date
from decimal import Decimal
class LineaDetalle(BaseModel):
descripcion: str
cantidad: int
precio_unitario: Decimal
total: Decimal
class Factura(BaseModel):
numero_factura: str
nombre_proveedor: str
fecha_emision: date
fecha_vencimiento: Optional[date] = None
lineas: list[LineaDetalle]
subtotal: Decimal
tasa_impuesto: Optional[float] = Field(None, ge=0, le=1)
total: Decimal
moneda: str = 'COP'
TEXTO_FACTURA = """
FACTURA #FAC-2025-0847
Proveedor: Soluciones TI Andinas S.A.S.
Fecha: 12 de marzo de 2025
Vence: 11 de abril de 2025
Servicios:
- Desarrollo de Pipeline de Datos (40 hrs a $150.000/hr): $6.000.000
- Configuración de Infraestructura Cloud (1 unidad a $800.000): $800.000
- Soporte y Documentación (8 hrs a $100.000/hr): $800.000
Subtotal: $7.600.000
IVA (19%): $1.444.000
TOTAL A PAGAR: $9.044.000
"""
factura = client.chat.completions.create(
model='gpt-4o-mini',
response_model=Factura,
messages=[
{
'role': 'user',
'content': f'Extrae todos los datos de factura del siguiente texto:\n\n{TEXTO_FACTURA}',
}
],
)
print(f"Factura: {factura.numero_factura}")
print(f"Proveedor: {factura.nombre_proveedor}")
print(f"Fecha: {factura.fecha_emision}") # datetime.date(2025, 3, 12) — siempre un objeto date
print(f"Total: {factura.total}") # Decimal('9044000') — siempre Decimal
print(f"Líneas: {len(factura.lineas)}")
for linea in factura.lineas:
print(f" - {linea.descripcion}: ${linea.total:,}")
Pydantic valida cada campo: fecha_emision es siempre un objeto Python date, total es siempre un Decimal, y si el LLM alucina un campo que no coincide con el esquema, Instructor reintenta automáticamente.
Ejemplo 2: Ofertas de Trabajo
from enum import Enum
from typing import Optional
class NivelExperiencia(str, Enum):
JUNIOR = 'junior'
MID = 'mid'
SENIOR = 'senior'
LEAD = 'lead'
PRINCIPAL = 'principal'
class RangoSalario(BaseModel):
min_cop: Optional[int] = None
max_cop: Optional[int] = None
es_divulgado: bool
class OfertaTrabajo(BaseModel):
titulo: str
empresa: str
ubicacion: str
modalidad: str # 'remoto', 'híbrido', 'presencial'
nivel_experiencia: NivelExperiencia
habilidades_requeridas: list[str]
habilidades_deseadas: list[str]
salario: RangoSalario
responsabilidades: list[str] = Field(max_length=8)
TEXTO_OFERTA = """
Ingeniero Senior de ML — Startup de IA (Remoto / Medellín)
Buscamos un Ingeniero Senior de Machine Learning para unirse a nuestro equipo.
Serás responsable del ciclo completo de ML: recolección de datos, entrenamiento de modelos,
evaluación y despliegue.
Lo que necesitamos:
5+ años de experiencia en ML/Ciencia de Datos
Python avanzado (scikit-learn, PyTorch o TensorFlow)
Experiencia en producción con servicio de modelos (FastAPI, Docker, Kubernetes)
Familiaridad con MLflow u otras herramientas de seguimiento de experimentos
Deseable:
Experiencia con fine-tuning de LLMs
Rust (nuestro motor de inferencia está en Rust)
Responsabilidades:
Diseñar y entrenar modelos para nuestro sistema de recomendación
Configurar y mantener nuestra infraestructura de ML en AWS
Colaborar con producto para definir features impulsadas por ML
Mentorear a ingenieros junior
Compensación: $15.000.000 – $22.000.000 COP/mes + equity.
"""
oferta = client.chat.completions.create(
model='gpt-4o-mini',
response_model=OfertaTrabajo,
messages=[{'role': 'user', 'content': f'Extrae los datos de la oferta de trabajo:\n\n{TEXTO_OFERTA}'}],
)
print(f"Cargo: {oferta.titulo} ({oferta.nivel_experiencia.value})")
print(f"Empresa: {oferta.empresa} | {oferta.modalidad}")
print(f"Salario: ${oferta.salario.min_cop:,} – ${oferta.salario.max_cop:,} COP/mes")
print(f"Requerido: {', '.join(oferta.habilidades_requeridas)}")
Ejemplo 3: Procesamiento en Batch con Estadísticas de Validación
En producción procesas cientos de documentos, normalmente como un paso dentro de un pipeline orquestado (cubro ese montaje en orquesta pipelines de ML con Prefect). A esa escala necesitas conocer tu tasa de éxito de extracción:
from pydantic import ValidationError
class ResultadoExtraccion(BaseModel):
exitoso: bool
datos: Optional[Factura] = None
error: Optional[str] = None
preview_texto: str
def extraer_factura_seguro(texto: str) -> ResultadoExtraccion:
try:
factura = client.chat.completions.create(
model='gpt-4o-mini',
response_model=Factura,
max_retries=2,
messages=[{
'role': 'user',
'content': f'Extrae los datos de la factura:\n\n{texto}',
}],
)
return ResultadoExtraccion(exitoso=True, datos=factura, preview_texto=texto[:100])
except Exception as e:
return ResultadoExtraccion(exitoso=False, error=str(e), preview_texto=texto[:100])
# Procesar un batch
textos_facturas = [TEXTO_FACTURA, "Esto no es una factura", TEXTO_FACTURA]
resultados = [extraer_factura_seguro(t) for t in textos_facturas]
exitosos = [r for r in resultados if r.exitoso]
fallidos = [r for r in resultados if not r.exitoso]
print(f"Tasa de éxito: {len(exitosos)}/{len(resultados)} ({len(exitosos)/len(resultados)*100:.0f}%)")
for f in fallidos:
print(f" Falló: {f.error}")
Manejo de Estructuras Anidadas Complejas
Para documentos con estructuras condicionales o complejas, agrega lógica de validación directamente en Pydantic:
from pydantic import field_validator, model_validator
from datetime import datetime
class Factura(BaseModel):
# ... campos como antes ...
@field_validator('fecha_emision', 'fecha_vencimiento', mode='before')
@classmethod
def parsear_fecha_flexible(cls, v):
if v is None:
return v
if isinstance(v, date):
return v
# Manejar formatos de fecha comunes de LLMs
for fmt in ('%Y-%m-%d', '%d de %B de %Y', '%d/%m/%Y', '%B %d, %Y'):
try:
return datetime.strptime(str(v), fmt).date()
except ValueError:
continue
raise ValueError(f"No se puede parsear la fecha: {v}")
@model_validator(mode='after')
def validar_totales(self) -> 'Factura':
computado = sum(linea.total for linea in self.lineas)
if abs(computado - self.subtotal) > Decimal('1.00'):
raise ValueError(
f"La suma de líneas ({computado}) no coincide con el subtotal ({self.subtotal})"
)
return self
El model_validator detecta casos donde el LLM extrae líneas individuales cuya suma no coincide con el subtotal declarado — una fuente común de errores en datos reales de facturas.
Consideraciones de Costo y Latencia
La extracción estructurada con gpt-4o-mini cuesta aproximadamente 0.001 por documento según su longitud. Para pipelines de alto volumen:
- Usa
gpt-4o-minipara la mayoría de las extracciones (rápido, barato, preciso) - Reserva
gpt-4opara casos complejos que mini no maneja bien - Cachea respuestas para documentos idénticos
- Procesa en batch cuando sea posible (reduce overhead por solicitud)
# Estimación aproximada de costos
COSTO_POR_1K_TOKENS_ENTRADA = 0.00015 # gpt-4o-mini entrada
COSTO_POR_1K_TOKENS_SALIDA = 0.0006 # gpt-4o-mini salida
tokens_entrada_promedio = 500 # factura típica
tokens_salida_promedio = 200 # JSON de salida
costo_por_doc = (tokens_entrada_promedio / 1000) * COSTO_POR_1K_TOKENS_ENTRADA \
+ (tokens_salida_promedio / 1000) * COSTO_POR_1K_TOKENS_SALIDA
print(f"~${costo_por_doc:.5f} por documento")
print(f"~${costo_por_doc * 10_000:.2f} por 10,000 documentos")
# ~$0.00020 por documento
# ~$1.95 por 10,000 documentos
Conclusión
La combinación de Pydantic + Instructor transforma los LLMs de generadores creativos de texto en motores confiables de extracción de datos. La clave es que definir el esquema no se trata solo de estructura: se trata de codificar tu conocimiento de dominio (cómo se ven las fechas, qué campos son obligatorios, qué invariantes deben cumplirse) en una forma que el LLM está obligado a respetar. El resultado son pipelines de extracción en los que realmente puedes confiar en producción.
Una distinción final: la extracción brilla cuando la salida es un esquema fijo por documento. Si necesitas responder preguntas abiertas sobre una colección de documentos, eso es un problema de RAG; y si necesitas cómputo multi-paso sobre los datos ya estructurados, lo tuyo es un agente con herramientas.
Sobre el autor
Rodrigo Arenas es arquitecto de software e ingeniero de machine learning en Medellín, Colombia. Construye productos, plataformas y software open source para IA, Data Engineering y Machine Learning — es el creador de Ciaren, sklearn-genetic-opt y PyWorkforce.
Lectura recomendada
Rodrigo Arenas — ML Engineer & Open Source Maintainer
¿Estás trabajando en algo similar? Puedo ayudarte a llevarlo a producción.