saltar al contenido
felipe alonzo

Por qué no le puedes pasar un CSV de 50,000 filas a un LLM y qué hicimos en vez de eso

Por qué no le puedes pasar un CSV de 50,000 filas a un LLM y qué hicimos en vez de eso

Si has intentado subir un CSV grande a ChatGPT o Claude y preguntarle algo numérico, ya sabes que no funciona. El modelo pierde filas, inventa totales, confunde columnas, y se queda sin contexto antes de terminar de leer el archivo. Un CSV de 50,000 filas con 20 columnas son fácilmente 15-20MB de texto plano. Incluso si cabe en el contexto del modelo, el LLM no hace aritmética real. Aproxima. Y aproximar está bien para texto, no para contabilidad.

Un transformer no es una base de datos. No hace SUM(), no filtra con índices, no agrupa. Intenta simular esas operaciones token por token, y falla.

En AINDEZ.ai nos topamos con este problema. Nuestros usuarios son contadores, gente de operaciones, de compras. Exportan reportes enormes y necesitan hacerles preguntas. Construí una solución interna y decidí extraer el core a una librería open source: sabana.

La arquitectura

Que cada herramienta haga lo que sabe hacer.

CSV/Excel sucio
     │
     ▼
  Extractor + Cleaners (sin LLM)
     │
     ▼
  Parquet limpio
     │
     ▼
  DuckDB (in-process)
     │
     ▼
  Agente Agno ←→ LLM (solo genera SQL)
     │
     ▼
  Respuesta en lenguaje natural

El LLM nunca ve los datos. Solo ve el schema (nombres de columnas, tipos, unas filas de ejemplo) y genera SQL. DuckDB ejecuta el query y la respuesta es exacta porque viene de un motor de base de datos, no de un modelo adivinando.

Por qué Parquet y no CSV

CSV es texto plano sin tipos. Una columna de montos es indistinguible de una columna de texto. No hay compresión, no hay metadata de schema. Cada herramienta que lo lee tiene que inferir todo desde cero.

Parquet es columnar, tipado y comprimido. Un CSV de 50MB baja a 5-10MB en Parquet. DuckDB lo lee directo sin parsear texto. Los tipos ya están definidos: si una columna es DOUBLE, DuckDB sabe que puede hacer SUM() sin conversión. Y al ser columnar, un query que toca 3 de 20 columnas solo lee esas 3 del disco.

Un SELECT SUM(monto) FROM ventas WHERE fecha >= '2026-01-01' sobre un Parquet de 500k filas toma milisegundos en DuckDB. Pasándole el CSV al LLM tomaría decenas de segundos, costaría tokens, y probablemente daría un número mal.

Por qué DuckDB y no SQLite

SQLite es row-oriented, óptimo para transacciones (INSERT, UPDATE). DuckDB es column-oriented, óptimo para analítica (GROUP BY, SUM, AVG, filtros sobre rangos). Nuestro caso es 100% analítico: nadie modifica la sábana, solo la consulta.

DuckDB corre in-process. No hay servidor, no hay que instalar nada aparte. pip install duckdb y ya tienes un motor analítico completo que lee Parquet de forma nativa.

import duckdb
 
conn = duckdb.connect()
df = conn.execute("""
    SELECT cliente, SUM(monto) as total
    FROM 'ventas.parquet'
    GROUP BY cliente
    ORDER BY total DESC
    LIMIT 10
""").fetchdf()

Eso corre en memoria, sin servidor, sobre un archivo Parquet. Para el 90% de las preguntas que un usuario de negocio haría, no necesitas más.

El pipeline de limpieza

La parte más difícil no es el agente ni DuckDB. Es limpiar la sábana. Si has trabajado con exports de software empresarial mexicano sabes que son un desastre.

El pipeline corre 7 pasos en orden, todos sin LLM:

drop_empty_rows / drop_empty_cols eliminan filas y columnas vacías. Lo básico.

clean_column_names normaliza headers. Los exports traen cosas como " Monto Total (MXN) " con espacios, acentos, caracteres raros. sabana lo convierte a monto_total_mxn. Esto importa porque el LLM genera SQL con esos nombres, y si el nombre tiene espacios el SQL se rompe.

unmerge_cells resuelve las celdas combinadas de Excel. Cuando exportas a CSV, una celda combinada se convierte en una celda con valor y N-1 celdas vacías abajo. sabana detecta el patrón y hace forward-fill.

parse_currency convierte montos como $1,234.56, (1,234.56) (notación contable para negativos), y comas europeas 1.234,56 a float. Parece trivial hasta que te preguntas: ¿1,234 es mil doscientos treinta y cuatro o es un decimal europeo? sabana usa heurísticas basadas en la distribución de valores en la columna para decidir.

parse_dates maneja fechas en dd/mm/yyyy, mm/dd/yyyy, yyyy-mm-dd y variantes. Por defecto asume convención mexicana (dd/mm) para fechas ambiguas como 03/04/2026. Si sabes el formato, lo puedes especificar con date_format.

drop_subtotals detecta y elimina filas de subtotales y totales intercaladas con los datos. Si no las quitas, cualquier SUM() cuenta doble. sabana busca filas donde una columna dice "TOTAL" o "SUBTOTAL", filas donde solo una celda tiene valor, filas que son la suma exacta de las anteriores.

Y header_detection para archivos donde el header no está en la fila 1. sabana analiza la densidad de datos y la consistencia de tipos por fila para encontrar dónde empieza la tabla real. O lo especificas con header_row.

Todo esto es código determinista. Predecible y auditable. Archivo sucio entra, Parquet limpio sale.

El agente

Para lenguaje natural usamos Agno como framework de agentes. Es provider-agnostic: el mismo código funciona con OpenAI, Anthropic, Google o Groq.

El agente recibe como contexto los nombres de las tablas (una por hoja del Excel), el schema de cada tabla (columnas y tipos), y N filas de ejemplo (configurable con sample_rows, default 3). Con eso genera SQL, DuckDB lo ejecuta, y el agente formula la respuesta.

from sabana import Sabana
from agno.models.openai import OpenAIChat
 
sb = Sabana(
    "reporte.xlsx",
    model=OpenAIChat(id="gpt-4o"),
    sample_rows=3
)
 
resp = sb.ask("¿Cuáles son los 5 clientes que más nos deben?")
print(resp.answer)  # Respuesta en español
print(resp.sql)     # SELECT cliente, SUM(saldo) ...
print(resp.data)    # DataFrame con los datos crudos

resp.sql importa. Si el usuario no sabe SQL, al menos alguien en su equipo puede revisar que el query sea correcto. resp.data devuelve el DataFrame crudo para verificar contra lo que ves en Excel.

El agente mantiene historial (max_history=20 turnos), así que puedes hacer seguimiento: "de esos 5, ¿cuáles tienen saldo vencido mayor a 90 días?"

Query directo, sin LLM

No todo necesita IA. Si sabes SQL o quieres evitar el costo de API:

sb = Sabana("reporte.xlsx")  # sin model, no necesitas API key
 
df = sb.query("""
    SELECT
        strftime(fecha, '%Y-%m') as mes,
        SUM(monto) as total
    FROM ventas
    GROUP BY 1
    ORDER BY 1
""")

Completamente local. Obtienes la limpieza automática + Parquet + DuckDB sin la capa de LLM. Cero tokens, cero latencia.

CLI

# Ver el schema
sabana schema reporte.xlsx
 
# Limpiar y exportar a Parquet
sabana clean reporte.xlsx --output limpio.parquet
 
# Query SQL directo
sabana query reporte.xlsx "SELECT COUNT(*) FROM ventas"
 
# Pregunta en lenguaje natural
sabana ask reporte.xlsx "¿Cuánto vendimos?" --model gpt-4o

sabana clean es útil por sí solo como paso de preprocesamiento en pipelines de datos. No necesitas usar el resto de la librería.

Privacidad

.ask() envía schema + filas de ejemplo + historial al proveedor de LLM. Si manejas datos sensibles: sample_rows=0 no envía filas de ejemplo, .query() es 100% local, o puedes correr un modelo local vía Ollama a través de Agno.

Lo que falta

sabana está en versión inicial. Quiero agregar soporte para múltiples archivos (JOINs entre sábanas), visualización de resultados, exportar a Excel para que el usuario lleve el resultado de vuelta a su mundo, más heurísticas para formatos específicos de software mexicano, y algún tipo de validación de respuestas comparando el SQL generado contra queries de control.

Contribuciones

El repo está en github.com/ivanadp-19/sabanas. MIT license.

Lo que más necesita: cleaners para formatos de ERPs que no conozco (si tu software exporta CSVs raros, abre un issue o manda un PR), fixtures de prueba con CSVs sucios de ejemplo sin datos reales, y feedback cuando el agente genera SQL incorrecto para una pregunta razonable. Esos son los bugs que más valen.

Si exportas sábanas en tu trabajo y estás harto de pelear con Excel, pruébalo. Y si se rompe con tu archivo, avísame.