Serie: LLM locales para los datos - Parte 1 de 2
Este es el error que todo el mundo comete: tratan de alimentar su CSV en un LLM. No. Los LLM deben generar consultas, no consumir datos.
Tienes un archivo CSV de 500MB y quieres preguntar "¿Cuál es el valor medio del pedido por región?" Copiloto en Excel puede hacer esto, pero ¿qué pasa si sus datos son demasiado sensibles para los servicios en la nube?
Este artículo muestra cómo - localmente, en privado, en C#.
Utilice DuckDB para consultar archivos CSV directamente. Utilice un LLM local para generar el SQL. El LLM nunca ve sus datos - sólo el esquema. Resultado: consultas sub-100ms en archivos de millones de filas, completamente fuera de línea.
Para un tratamiento complementario, más centrado en CLI que se expande en el uso de perfiles estadísticos como la interfaz LLM (y muestra una herramienta completa implementando estas ideas - perfilamiento, modo SQL seguro, clonación sintética y detección de deriva), vea el artículo de acompañamiento: DataSummarizer: Profiling rápido de datos locales - especialmente la sección "The Key Upgrade: Statistics as the Interface". Estos dos artículos forman una serie corta sobre prácticas LLM + patrones de consulta locales.
El uso de un LLM como una tienda de datos es la abstracción equivocada. Los LLM son fundamentalmente incapaces de escanear millones de filas para calcular un promedio - eso no es para lo que están. Incluso una ventana de contexto token de 200K encaja tal vez 50.000 filas. Su CSV de 500MB tiene millones.
El patrón correcto: Razones de LLM, computaciones de base de datos.
flowchart LR
A[User Question] --> B[LLM]
B --> C[SQL Query]
C --> D[DuckDB]
D --> E[Results]
style B stroke:#333,stroke-width:4px
style D stroke:#333,stroke-width:4px
Observe lo que está sucediendo: el LLM genera una consulta SQL basada en su pregunta y el esquema. DuckDB lo ejecuta contra los datos reales. El LLM nunca toca sus datos - sólo ve nombres y tipos de columnas. Por eso es rápido, privado y preciso.
Para obtener más información sobre cómo tratar los perfiles como la interfaz LLM (y un CLI concreto que implementa la narración de perfil-primero, preguntas y respuestas seguras respaldadas por SQL, sesiones respaldadas por registro y clonación sintética), véase DataSummarizer: Profiling rápido de datos locales.
Los enfoques obvios comparten el mismo defecto fatal:
CsvHelper / DataFrames: Cargar archivo entero en RAM. Un CSV de 500MB se convierte en 2-4GB de objetos. ¿Un archivo de 5GB?
SQLite / PostgreSQL: Requiere un paso de importación lento (minutos para archivos grandes), definiciones de esquemas iniciales y administración de bases de datos.
PandasAI: Todavía carga todo en la memoria. Además, ejecutar código arbitrario generado por LLM es una pesadilla de seguridad - SQL es declarativo y sandboxable; Python no lo es.
DuckDB es diferente. Consulta archivos CSV directamente - ningún paso de importación, ninguna carga en la memoria:
using var connection = new DuckDBConnection("DataSource=:memory:");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT Region, SUM(Amount) FROM 'sales.csv' GROUP BY Region";
// Executes directly against the file - no import, no memory explosion
La característica del asesino: trata los archivos como tablas. Apuntalo a un archivo CSV, Parquet o JSON y consulta de inmediato. No CREAR TABLE, no insertar a granel, no esperar.
Factor CsvHelper SQLite DuckDB |--------|-----------|--------|--------| | Memoria Carga archivos enteros Cargas durante la importación Corrientes desde el disco | Configuración # Ninguno # # Esquema + importación # # Ninguno # | Archivo 500MB ~2GB RAM Minutos para importar Instantánea | Archivo 5GB # Muy lento # # Obras finas # | Parquet No No Sí (10-100x más rápido)
DuckDB es lo que los ingenieros de datos utilizan en Python exactamente para este caso de uso. Encuadernaciones .NET darle soporte completo ADO.NET - se siente como cualquier otra base de datos, excepto que usted está consultando archivos.
¿Por qué éste?
|-----------|--------------|
| DuckDB Pregunta CSV directamente, ningún paso de importación
| DuckDB.NET Apoyo completo ADO.NET, se siente nativo
| Ollama Inferencia local, sin claves API, sin nube
| Bogus Datos de prueba realistas a cualquier escala
| qwen2.5-coder:7b Mejor precisión SQL en tamaño 7B
Nota sobre la seguridad: Estamos ejecutando SQL generado por LLM. Esto es más seguro que el código arbitrario, pero todavía requiere validación. Sección de seguridad Para una discusión más amplia sobre convertir perfiles en la interfaz LLM y un CLI que implementa estos patrones, vea la pieza de acompañamiento DataSummarizer: Profiling rápido de datos locales.
Vamos a crear un proyecto de ejemplo. Instale los paquetes NuGet:
dotnet add package DuckDB.NET.Data.Full
dotnet add package OllamaSharp
dotnet add package Bogus
Tire de un modelo centrado en la codificación que es bueno en SQL:
ollama pull qwen2.5-coder:7b
Así es como encajan las piezas:
flowchart TB
subgraph Input
Q[User Question]
CSV[CSV File]
end
subgraph Processing
Schema[Extract Schema]
Sample[Get Sample Rows]
Context[Build LLM Context]
LLM[Generate SQL]
Validate[Validate SQL]
Execute[Execute Query]
end
subgraph Output
Results[Query Results]
end
CSV --> Schema
CSV --> Sample
Schema --> Context
Sample --> Context
Q --> Context
Context --> LLM
LLM --> Validate
Validate -->|Error| LLM
Validate -->|OK| Execute
CSV --> Execute
Execute --> Results
style LLM stroke:#333,stroke-width:4px
style Execute stroke:#333,stroke-width:4px
La perspicacia clave: damos el LLM esquema y datos de muestra, no los datos reales. Esto mantiene el contexto pequeño y las respuestas rápidas.
¿Por qué importa esto?: El LLM genera intent (SQL). DuckDB lo ejecuta. El paso de validación captura errores de sintaxis antes de la ejecución. El bucle de reintent maneja el error ocasional. Esta separación es lo que hace que el sistema sea seguro y preciso. Para una referencia más detallada, centrada en CLI (incluyendo la narración de perfil-primero y los límites de ejecución segura SQL) vea DataSummarizer: Profiling rápido de datos locales.
¿Ya tiene datos CSV? Saltar a Paso 2: Construir el contexto del esquema.
Antes de que podamos probar nuestro analizador CSV de LLM, necesitamos datos para analizar. Para el desarrollo y las pruebas, los datos sintéticos son mejores que los datos reales:
Bogus es un puerto .NET de la biblioteca popular faker.js. Genera datos falsos de aspecto realista - nombres, direcciones, correos electrónicos, fechas, números - con el apoyo local adecuado. En lugar de hacer pruebas manuales de archivos CSV o el uso de datos de basura aleatorios, Bogus le da datos que Mira. real:
f.Name.FullName() → "John Smith" (no "asdf1234")f.Internet.Email() → "[email protected]" (correctamente formateado)f.Date.Between(start, end) → Distribución realista de la fechaf.Commerce.ProductName() → "Queso de granito artesanal" (divertido, pero reconocible)Esto importa porque los datos realistas te ayudan a detectar problemas -formateo raro, agregados inesperados, casos de borde en el manejo de la fecha - que las cadenas aleatorias ocultarían.
internal class SaleRecord
{
public string OrderId { get; set; } = "";
public DateTime OrderDate { get; set; }
public string CustomerId { get; set; } = "";
public string CustomerName { get; set; } = "";
public string Region { get; set; } = "";
public string Category { get; set; } = "";
public string ProductName { get; set; } = "";
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal Discount { get; set; }
public bool IsReturned { get; set; }
}
Bogus utiliza una API fluida para definir reglas de generación:
var categories = new[] { "Electronics", "Clothing", "Home & Garden", "Sports", "Books" };
var regions = new[] { "North", "South", "East", "West", "Central" };
var faker = new Faker<SaleRecord>()
.RuleFor(s => s.OrderId, f => f.Random.Guid().ToString()[..8].ToUpper())
.RuleFor(s => s.OrderDate, f => f.Date.Between(
new DateTime(2022, 1, 1),
new DateTime(2024, 12, 31)))
.RuleFor(s => s.CustomerId, f => $"CUST-{f.Random.Number(10000, 99999)}")
.RuleFor(s => s.CustomerName, f => f.Name.FullName())
.RuleFor(s => s.Region, f => f.PickRandom(regions))
.RuleFor(s => s.Category, f => f.PickRandom(categories))
.RuleFor(s => s.ProductName, (f, s) => GenerateProductName(f, s.Category))
.RuleFor(s => s.Quantity, f => f.Random.Number(1, 20))
.RuleFor(s => s.UnitPrice, f => f.Random.Decimal(9.99m, 299.99m))
.RuleFor(s => s.Discount, f => f.Random.Bool(0.3f) ? f.Random.Decimal(0.05m, 0.25m) : 0m)
.RuleFor(s => s.IsReturned, f => f.Random.Bool(0.05f));
Vamos a romper lo que está pasando:
f (Faker) - La instancia generadora con acceso a todos los módulos de datos (Nombre, Fecha, Aleatorio, etc.)f.Random.Guid().ToString()[..8] - Generar un GUID pero tomar sólo los primeros 8 caracteres para una identificación de orden legiblef.Date.Between() - Fecha aleatoria dentro de un rango realista (no año 9999)f.PickRandom(array) - Seleccione aleatoriamente entre opciones predefinidas (asegura categorías válidas)f.Random.Bool(0.3f) - 30% de probabilidad de verdad (el 30% de los pedidos obtienen un descuento)(f, s) sintaxis - Acceda tanto al falsificador como al disco parcialmente construido. ProductName depende de CategoryLos (f, s) patrón es potente - significa "Electronics" pedidos obtener nombres de productos electrónicos, no elementos aleatorios. Esta coherencia hace que los datos generados mucho más realista para las pruebas de agregación como "ingreso por categoría".
var records = faker.Generate(100_000); // Adjust for your testing needs
await using var writer = new StreamWriter(csvPath, false, Encoding.UTF8);
await writer.WriteLineAsync("OrderId,OrderDate,CustomerId,CustomerName,Region,Category,...");
foreach (var record in records)
{
var total = record.Quantity * record.UnitPrice * (1 - record.Discount);
await writer.WriteLineAsync($"{record.OrderId},{record.OrderDate:yyyy-MM-dd},...");
}
Las filas de 100K generan alrededor de 15MB de CSV - lo suficiente para probar con, pero puede escalar fácilmente a millones. La generación es rápida (~2 segundos para las filas de 100K) porque Bogus está optimizado para la generación a granel.
Consejo: Set
Randomizer.Seed = new Random(12345)antes de generar para obtener datos reproducibles. Misma semilla = los mismos registros "aleatorios" cada vez, lo cual es invaluable para la depuración.
Antes de que el LLM pueda generar SQL, necesita entender la estructura de datos. Extraemos esto de DuckDB:
public class DataContext
{
public string CsvPath { get; set; } = "";
public List<ColumnInfo> Columns { get; set; } = new();
public List<Dictionary<string, string>> SampleRows { get; set; } = new();
public long RowCount { get; set; }
}
public class ColumnInfo
{
public string Name { get; set; } = "";
public string Type { get; set; } = ""; // VARCHAR, DOUBLE, TIMESTAMP, etc.
}
Esto captura todo lo que el LLM necesita: nombres de columna, tipos y algunas filas de muestra para entender el formato de datos.
DuckDB puede describir cualquier CSV sin cargarlo todo:
private DataContext BuildContext(DuckDBConnection connection, string csvPath)
{
var context = new DataContext { CsvPath = csvPath };
// Get schema - DuckDB infers types from the CSV
using var cmd = connection.CreateCommand();
cmd.CommandText = $"DESCRIBE SELECT * FROM '{csvPath}'";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
context.Columns.Add(new ColumnInfo
{
Name = reader.GetString(0), // Column name
Type = reader.GetString(1) // Inferred type
});
}
return context;
}
Los DESCRIBE comando lee sólo el encabezado del archivo más unas pocas filas para la inferencia de tipo - es instantáneo incluso en archivos enormes.
Las filas de muestra ayudan al LLM a entender los formatos de datos (fechas, ID, etc.):
using var cmd = connection.CreateCommand();
cmd.CommandText = $"SELECT * FROM '{csvPath}' LIMIT 3";
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
var row = new Dictionary<string, string>();
for (int i = 0; i < reader.FieldCount; i++)
{
var value = reader.IsDBNull(i) ? "NULL" : reader.GetValue(i)?.ToString() ?? "";
row[reader.GetName(i)] = value;
}
context.SampleRows.Add(row);
}
Tres filas suele ser suficiente - muestra el LLM qué formatos esperar sin desperdiciar tokens.
Esta es la parte difícil. La ingeniería rápida aquí es no negociable - sin reglas estrictas, los LLM locales producirán SQL creativo pero roto. El objetivo es el determinismo, no la creatividad.
private string BuildPrompt(DataContext context, string question, string? previousError)
{
var sb = new StringBuilder();
sb.AppendLine("You are a SQL expert. Generate a DuckDB SQL query to answer the user's question.");
sb.AppendLine();
sb.AppendLine("IMPORTANT RULES:");
sb.AppendLine("1. The table is accessed directly from the CSV file path");
sb.AppendLine("2. Use single quotes around the file path in FROM clause");
sb.AppendLine("3. DuckDB syntax - use LIMIT not TOP, use || for string concat");
sb.AppendLine("4. Return ONLY the SQL query, no explanation, no markdown");
sb.AppendLine();
sb.AppendLine($"CSV File: '{context.CsvPath}'");
sb.AppendLine($"Row Count: {context.RowCount:N0}");
sb.AppendLine();
// Schema
sb.AppendLine("Schema:");
foreach (var col in context.Columns)
{
sb.AppendLine($" - {col.Name}: {col.Type}");
}
La sección de reglas es crucial - le dice al LLM exactamente cómo formatear la consulta para DuckDB. Ser explícito acerca de la sintaxis (LIMIT vs TOP, cadena de concatenación) previene errores comunes.
if (context.SampleRows.Count > 0)
{
sb.AppendLine();
sb.AppendLine("Sample data (first 3 rows):");
foreach (var row in context.SampleRows)
{
var values = row.Select(kv => $"{kv.Key}='{kv.Value}'");
sb.AppendLine($" {{{string.Join(", ", values)}}}");
}
}
Si un intento anterior falló, incluya el error:
if (previousError != null)
{
sb.AppendLine();
sb.AppendLine("YOUR PREVIOUS QUERY HAD AN ERROR:");
sb.AppendLine(previousError);
sb.AppendLine("Please fix the query based on this error.");
}
sb.AppendLine();
sb.AppendLine($"Question: {question}");
sb.AppendLine();
sb.AppendLine("SQL Query (no markdown, no explanation):");
return sb.ToString();
}
Este mecanismo de reintentar es importante - los LLM locales a veces cometen errores de sintaxis, y dándoles el error generalmente lo corrige en el segundo intento.
var request = new GenerateRequest { Model = _model, Prompt = prompt };
var response = await _ollama.GenerateAsync(request).StreamToEndAsync();
var sql = CleanSqlResponse(response?.Response ?? "");
Los StreamToEndAsync() espera la respuesta completa. Para una mejor UX, usted podría transmitir tokens a medida que llegan.
Los LLM a menudo envuelven SQL en bloques de código Markdown a pesar de que se les dijo que no:
private string CleanSqlResponse(string response)
{
var sql = response.Trim();
// Remove markdown code blocks if present
if (sql.StartsWith("```"))
{
var lines = sql.Split('\n').ToList();
lines.RemoveAt(0); // Remove opening ```sql
if (lines.Count > 0 && lines[^1].Trim().StartsWith("```"))
{
lines.RemoveAt(lines.Count - 1); // Remove closing ```
}
sql = string.Join('\n', lines);
}
return sql.Trim('`', ' ', '\n', '\r');
}
DuckDB's EXPLAIN nos permite comprobar la sintaxis SQL sin ejecutar la consulta:
private string? ValidateSql(DuckDBConnection connection, string sql)
{
try
{
using var cmd = connection.CreateCommand();
cmd.CommandText = $"EXPLAIN {sql}";
cmd.ExecuteNonQuery();
return null; // Valid
}
catch (Exception ex)
{
return ex.Message;
}
}
Si la validación falla, se devuelve el error al LLM y se reintenta (hasta un límite).
Finalmente, ejecute la consulta y formatee la salida:
private QueryResult ExecuteQuery(DuckDBConnection connection, string sql)
{
var result = new QueryResult { Sql = sql };
try
{
using var cmd = connection.CreateCommand();
cmd.CommandText = sql;
using var reader = cmd.ExecuteReader();
// Capture column names
for (int i = 0; i < reader.FieldCount; i++)
{
result.Columns.Add(reader.GetName(i));
}
// Capture rows
while (reader.Read())
{
var row = new List<object?>();
for (int i = 0; i < reader.FieldCount; i++)
{
row.Add(reader.IsDBNull(i) ? null : reader.GetValue(i));
}
result.Rows.Add(row);
}
result.Success = true;
}
catch (Exception ex)
{
result.Success = false;
result.Error = ex.Message;
}
return result;
}
Los QueryResult clase (se muestra en su totalidad en el proyecto de la muestra) incluye un ToString() método que formatee los resultados como una tabla legible.
Para el análisis interactivo, los usuarios a menudo quieren hacer preguntas de seguimiento:
"What's the total revenue?"
→ "Break that down by region"
→ "Show the top 5 regions"
Las preguntas segunda y tercera sólo tienen sentido con el contexto de la primera.
public class ConversationTurn
{
public string Question { get; set; } = "";
public string Sql { get; set; } = "";
public bool Success { get; set; }
public int RowCount { get; set; }
public string Summary { get; set; } = ""; // "Single value: 1234567.89"
}
if (_history.Count > 0)
{
sb.AppendLine();
sb.AppendLine("CONVERSATION HISTORY (for context):");
foreach (var turn in _history.TakeLast(5)) // Last 5 turns
{
sb.AppendLine($"Q: {turn.Question}");
sb.AppendLine($"SQL: {turn.Sql}");
if (turn.Success)
{
sb.AppendLine($"Result: {turn.Summary}");
}
sb.AppendLine();
}
}
La historia le da al contexto LLM para entender referencias como "eso", "estos resultados", o "descomponerlo aún más".
Para la generación SQL, los modelos centrados en la codificación funcionan mejor. Estas son las opciones disponibles a través de Biblioteca modelo de Ollama:
| ------- | ------ | ------- | --------- | ------ |
|---|---|---|---|---|
deepseek-coder-v2:16b 9GB Medio Mejor Ollama |
||||
codellama:7b # 4GB # # Rápido # # Bueno # Ollama |
||||
llama3.2:3b 2GB Muy rápido Aceptable Ollama |
Para la mayoría de los casos de uso, qwen2.5-coder:7b golpea el punto dulce - SQL precisa, buena velocidad, se ejecuta en hardware modesto (8GB + RAM).
Ensayo en un archivo CSV de 100K fila (14MB) en una máquina de desarrollo estándar (Ryzen 5, NVMe SSD, 32GB RAM):
Tipo de consulta Tiempo |------------|------|
GRUPO CON SUM 58-68ms Agregación compleja con FILTER 63ms GROUP DE Mesas Múltiples por con TENER 71ms
Sub-100ms para consultas analíticas en filas de 100K - sin ningún paso de importación. La complejidad de la consulta importa más que el conteo de filas; el motor columnar de DuckDB maneja las agregación de manera eficiente independientemente del tamaño del archivo.
Para archivos de más de 1 GB, Formato Parquet es 10-100x más rápido:
using var cmd = connection.CreateCommand();
cmd.CommandText = $"COPY (SELECT * FROM '{csvPath}') TO '{parquetPath}' (FORMAT PARQUET)";
cmd.ExecuteNonQuery();
El archivo comprimido Parquet también es mucho más pequeño.
Al ejecutar SQL generado por LLM:
private bool IsSafeQuery(string sql)
{
var dangerous = new[] { "DROP", "DELETE", "TRUNCATE", "UPDATE", "INSERT", "ALTER", "CREATE" };
var upperSql = sql.ToUpperInvariant();
return !dangerous.Any(d => upperSql.Contains(d));
}
El modo en memoria de DuckDB también proporciona aislamiento natural - no puede afectar a sus bases de datos de producción.
Así es como todo se une:
// Generate test data
await GenerateSalesCsvAsync("sales.csv", 100_000);
// Simple query
using var service = new CsvQueryService("qwen2.5-coder:7b", verbose: true);
var result = await service.QueryAsync("sales.csv", "What are total sales by region?");
Console.WriteLine(result);
// Conversational analysis
using var analyser = new ConversationalCsvAnalyser("sales.csv", "qwen2.5-coder:7b");
Console.WriteLine(await analyser.AskAsync("What's the total revenue?"));
Console.WriteLine(await analyser.AskAsync("Break that down by category"));
Console.WriteLine(await analyser.AskAsync("Which category has the most returns?"));
El modelo mental para mantener: LLMs razón; bases de datos computar.
No alimente datos al LLM. Feed it schema, deje que genere SQL, ejecute ese SQL contra un motor de consulta adecuado. Esta separación es la razón por la que el enfoque funciona a escala.
La aplicación:
El resultado: consultas analíticas sub-100ms en archivos de millones de filas, completamente fuera de línea, con datos que nunca salen de su máquina.
El proyecto muestral completo está disponible en: La mayoría de lucid.CsvLlm - incluye CsvQueryService, ConversationalCsvAnalyser, y la generación de datos basada en Bogus.
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.