Relato anonimizado. Nombre del cliente y del producto omitidos por cláusula de no competencia. La arquitectura y los patrones de stack quedan abiertos a discusión.
Contexto
El cliente era un banco tier-1 latinoamericano, y eso lo cambia todo. Entorno regulado de punta a punta: LGPD vigente, auditoría interna trabando cada release y una ventana de deploy que solo se abre cuando el comité de cambios da el visto bueno. Nada de subir a producción un martes por la tarde porque uno tiene ganas.
La misión del equipo interno era triar documentos fiscales —IRPF, la declaración de impuesto a la renta de personas físicas— a escala, con un agente LLM en el medio. El agente tenía que clasificar el tipo de declaración, extraer los campos estructurados, levantar la mano cuando algo olía a anomalía y, sobre todo, registrar cada decisión para que un auditor pudiera rehacer el camino años más tarde.
Los números que marcaban la cancha: pico de ~10K declaraciones/día y un presupuesto de latencia de ≤8s por declaración en la ruta del agente. Lo que no entrara en ese sobre se iba a una cola de revisión humana, atendida de forma asíncrona.
Dónde estaba el problema
Tres frentes, y ninguno de ellos es “el LLM a veces se equivoca”.
El primero es el desorden de la materia prima. Las declaraciones llegaban en PDF, imagen escaneada, XML estructurado (un formato más viejo) e incluso correo en texto corrido. Peor todavía: el mismo campo podía vivir en cinco lugares distintos según el año de la declaración y la categoría del declarante. No existe un “parser único” que resuelva eso.
El segundo es lo que vuelve difícil de verdad al dominio: una alucinación en contexto regulado no es un bug, es un incidente. Una clasificación equivocada no se queda ahí — se propaga hacia una franja impositiva errónea, que termina en multa y, eventualmente, en un hallazgo del regulador. Un LLM que dice “tengo un 87% de confianza” puede ser excelente en un chatbot, pero acá, por sí solo, no cuenta como evidencia.
El tercero es el más ingrato: el replay de auditoría. El auditor tiene que poder reconstruir cualquier decisión cinco años o más después. Eso significa guardar, de forma inmutable, todo — la entrada, cada hit de retrieval, el prompt exacto, la respuesta del modelo, el score de confianza y cualquier override que un humano haya hecho por encima.
Las reglas del juego
Las restricciones no eran negociables, así que mejor ponerlas sobre la mesa desde el arranque:
- LGPD por encima de todo. Ningún dato personal sale de la tenancy de Azure del cliente. Cero llamadas a una API de LLM público — toda la inferencia corre dentro del deployment de Azure OpenAI del propio cliente.
- Gate de auditoría en cada deploy. Aprobación del comité de cambios más 72h de soak en staging antes de cualquier release. El hotfix de emergencia no existe en ese mundo.
- Soft real-time: 8s de p95 en la ruta del agente, 30s de p99.
- Dependencias limpias. Nada de paquete de Python que traiga binary wheels desde un índice fuera del estándar — la revisión de seguridad lo frena al instante.
- Single-cloud en la práctica, multi-cloud en el diseño. Runtime solo en Azure, pero la biblioteca de módulos Terraform se cross-built para GCP pensando en la portabilidad más adelante.
La arquitectura
flowchart LR
A[Document ingest] --> B[Format normalizer]
B --> C[Chunker + embedder]
C --> D[(Postgres + pgvector)]
D --> E[Retrieval router]
E --> F[Azure OpenAI gpt-4-class]
F --> G[Citation extractor]
G --> H[Confidence scorer]
H -->|high conf| I[Auto-decide + log]
H -->|low conf| J[Human-review queue]
I --> K[(Audit log<br/>append-only)]
J --> K
F -.eval harness.-> L[Offline regression]El grueso del trabajo recae sobre cinco piezas.
El format normalizer es la primera línea de defensa contra la heterogeneidad. Convierte PDF, imagen, XML y correo en un envelope JSON canónico —{filing_id, year, filer_category, raw_text, raw_pages[], metadata}— y de ahí en adelante todo el sistema habla ese único idioma. PDF e imagen pasan por OCR en Azure Document Intelligence (que seguridad ya había preaprobado). El XML pasa por un parser XSD fijado. El correo cae en un extractor regex estricto, con una regla honesta: si el regex no encuentra un campo obligatorio, el documento se va derecho a “necesita humano” en vez de adivinar.
El chunker + embedder hace chunking por ventana de oraciones (300 tokens, 50 de overlap), pero con un detalle que pesa: la detección de fronteras es consciente del año. El formulario tributario brasileño tiene divisores de sección rígidos, fáciles de reconocer — así que lo usamos a favor. Los embeddings salen del text-embedding-3-large de Azure OpenAI y van a parar a Postgres con pgvector, con un índice por año para acotar el retrieval desde el vamos.
El retrieval router hace búsqueda k-NN acotada por año y por categoría de declarante, y devuelve los top-8 hits. Acá hay un punto que parece detalle y no lo es: cada hit vuelve con metadato explícito del método de retrieval, para que el audit log sepa después exactamente qué modelo de embedding y qué versión de índice produjeron ese conjunto.
El citation extractor es el corazón de la defensa contra la alucinación. Obliga al LLM a responder en el formato {decision, confidence, citations[]}, donde cada citation es un span que apunta al documento original. Y la jugada clave: el span se valida post-hoc. Si el modelo cita un fragmento que no existe en la fuente, la respuesta se rechaza y se rehace el prompt (máximo 2 retries) antes de derivar a la revisión humana. Sin cita válida, no hay decisión automática.
Por último, el audit log, que es donde lo “regulado” se vuelve código. Una tabla Postgres append-only, particionada por año. Cada fila lleva el hash de la entrada (SHA-256 del JSON canónico), el hash del conjunto de hits, el prompt entero con la versión del template, la respuesta completa del modelo, la confianza calculada, la decisión final y —si hubo escalamiento— quién revisó. El esquema se versiona con migrations flyway, y los logs se replican cada noche a una cuenta de Azure Storage separada, bajo una política WORM (write-once-read-many) que clava la retención en 5 años.
Por qué lo decidimos así
Tres elecciones merecen el foco — y cada una cobró su precio.
pgvector, no una base vectorial dedicada. Fue la primera pelea, y la operación le ganó a la performance. Con una sola extensión de Postgres cubrimos retrieval, audit log y estado operativo de un saque: una historia de backup, una de HA, una de control de acceso. En nuestra tasa pico (~50 retrievals/s), pgvector con índice HNSW maneja la latencia de sobra. El trade-off lo pongo sobre la mesa sin vueltas: por encima de unos ~500/s la cosa cambia, y Pinecone o Qdrant empiezan a sacar ventaja, como muestra el benchmark de bases vectoriales de Supabase de 2025 . No llegamos ni cerca de ese número — así que la simplicidad se impuso.
Cita forzada en el prompt, con validación post-hoc de los spans. Exigir la cita y chequear cada span contra la fuente cuesta unos ~600ms extra cuando hace falta un retry (un round trip más), y aun así fue la mejor plata de latencia que gastamos: la tasa de decisión alucinada se desplomó. La idea bebe directo del diseño de la API de citas de Anthropic — la misma lógica, solo que hecha a mano vía prompt engineering sobre los modelos gpt-4-class de Azure OpenAI. Trade-off aceptado con los ojos abiertos, porque quien manda en el proyecto es el gate de auditoría.
Retrieval acotado por año. La ley tributaria brasileña cambia de un año al otro, y no poco. Un prompt de declaración de 2018 recuperando ejemplos de 2024 se equivoca rápido y con confianza — el peor tipo de error. La solución fue tratar el año como dimensión de índice de primera clase: el router rechaza el hit que cruza años, salvo que la query lo pida de forma explícita. El costo es honesto — el índice HNSW por año casi duplica su tamaño en disco. A cambio, la contaminación entre años en el eval se fue a cero.
Lo que quedó de aprendizaje
Obligá al modelo a citar y validá el span. Si hay una sola palanca contra la alucinación, es esa. Cuesta latencia y compra una decisión que se defiende frente al regulador — un canje obvio en un banco.
El audit log append-only no se discute, se construye el día 1. Atornillar la auditoría después se convierte en brechas en los datos, y una brecha en los datos es un hallazgo de auditoría casi seguro.
Alcance del retrieval > tamaño del modelo. Vale repetirlo porque es contraintuitivo: el modelo más chico, con retrieval ajustado, le ganó al más grande en exactitud, latencia y costo a la vez.
El gate de deploy es el eval harness, no el test unitario. La regresión contra un golden set congelado (~500 declaraciones etiquetadas a mano) detecta el drift de prompt más rápido que cualquier otra cosa. Corre en cada PR y también en un batch nocturno contra los últimos 30 días de tráfico real.
La revisión humana es una feature, no un plan B. Derivar la decisión de baja confianza a un humano y capturar su razonamiento fue lo que construyó el dataset que, a su vez, hace que el eval harness mejore con el tiempo. El loop se cierra.
El stack
- Lenguaje: Python 3.12
- API: FastAPI (single-process, handlers síncronos — async no aportó nada con este throughput)
- Almacenamiento: Azure Database for PostgreSQL Flexible Server 16 + pgvector 0.7
- LLM: Azure OpenAI Service — deployment
gpt-4-classen la tenant del cliente,text-embedding-3-largepara los embeddings - OCR: Azure Document Intelligence (preview aprobado por seguridad)
- Auditoría: tabla Postgres append-only + replicación nocturna a Azure Blob Storage con política de inmutabilidad
- Infra: Terraform (biblioteca modular reutilizada en el target GCP paralelo — Cloud SQL + Vertex AI + Cloud Storage)
- CI/CD: Azure DevOps Pipelines (YAML multi-stage, con environments + approvals)
- Observabilidad: Azure Monitor + workspace Log Analytics, con dashboards personalizados para histograma de confianza y p99 de latencia de retrieval
- Eval harness: harness Python hecho en casa; ~500 declaraciones etiquetadas a mano como golden set; corre en el CI de PR y en cron nocturno
Cuándo vale copiar este patrón
- Dominio regulado —banco, legal, salud, tributario— donde el replay de auditoría es una obligación
- Corpus con marcador de frontera natural (año, categoría, jurisdicción) para acotar el retrieval
- Holgura de presupuesto para una cola de revisión interna, con humano en el loop en las decisiones de baja confianza
Cuándo solo estorba
- Chatbot de consumo open-domain — es exagerado; el overhead de auditoría y cita destruye la latencia de la UX
- Throughput por encima de 500 retrievals/s — ahí cambiá la capa de retrieval por una base vectorial dedicada
- Multi-tenant con datos cruzando tenants — el modelo de tabla única de pgvector se vuelve un desorden rápido