This is a viewer only at the moment see the article on how this works.
To update the preview hit Ctrl-Alt-R (or ⌘-Alt-R on Mac) or Enter to refresh. The Save icon lets you save the markdown file to disk
This is a preview from the server running through my markdig pipeline
Thursday, 18 December 2025
Serie: LLM locali per i dati - Parte 1 di 2
Ecco l'errore che tutti commettono: cercano di alimentare il loro CSV in un LLM. Non farlo. I LLM dovrebbero generare query, non consumare dati.
Hai un file CSV da 500MB e vuoi chiedere "Qual è il valore medio dell'ordine per regione?" Copilota in Excel può farlo, ma se i tuoi dati sono troppo sensibili per i servizi cloud? Cosa succede se hai bisogno di costruirli da soli?
Questo articolo mostra come - localmente, privatamente, in C#.
Usa DuckDB per interrogare direttamente i file CSV. Usa un LLM locale per generare SQL. Il LLM non vede mai i tuoi dati - solo lo schema. Risultato: query sub-100ms su file da milioni di righe, completamente offline.
Per un trattamento complementare e più focalizzato su CLI che si espande sull'utilizzo di profili statistici come interfaccia LLM (e mostra uno strumento completo che implementa queste idee - profilazione, modalità SQL sicura, clonazione sintetica e rilevamento della deriva), vedere l'articolo seguente: DataSummarizer: Profilazione rapida dei dati locali - in particolare la sezione "The Key Upgrade: Statistics as the Interface." Questi due articoli formano una breve serie sui modelli pratici locali LLM + query.
Usare un LLM come archivio dati è l'astrazione sbagliata. I LLM sono fondamentalmente incapaci di scansionare milioni di righe per calcolare una media - non è quello che servono. Anche una finestra di contesto token da 200K misura forse 50.000 righe. Il tuo CSV da 500MB ha milioni.
Il modello corretto: Motivi dell'LLM, calcolo del database.
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
Notate cosa sta succedendo: l'LLM genera una query SQL basata sulla vostra domanda e sullo schema. DuckDB la esegue contro i dati reali. L'LLM non tocca mai i vostri dati - vede solo i nomi delle colonne e i tipi. Ecco perché è veloce, privato e preciso.
Per maggiori informazioni sul trattamento dei profili come interfaccia LLM (e una CLI concreta che implementa la prima narrazione del profilo, il Q&A protetto da SQL, le sessioni supportate dal registro e la clonazione sintetica), vedere DataSummarizer: Profilazione rapida dei dati locali.
Gli approcci ovvi hanno tutti lo stesso difetto fatale:
CsvHelper / DataFrames: Caricare l'intero file in RAM. Un CSV da 500MB diventa 2-4GB di oggetti. Un file da 5GB? Crash OOM.
SQLite / PostgreSQL: Richiede un passo lento di importazione (minuti per file di grandi dimensioni), definizioni di schema upfront, e gestione del database overhead.
PandasAICity name (optional, probably does not need a translation): Ancora carica tutto in memoria. Inoltre, l'esecuzione di codice arbitrario generato da LLM è un incubo di sicurezza - SQL è dichiarativo e sandboxable; Python non lo è.
AnatraDBunit synonyms for matching user input è diverso. Interroga i file CSV direttamente - nessun passaggio di importazione, nessun caricamento in 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 caratteristica killer: tratta i file come tabelle. Puntalo su un file CSV, Parquet o JSON e chiedi immediatamente. Nessun CREATE TABLE, nessun inserto sfuso, nessuna attesa.
| Fattore | CsvHelper | SQLite | DuckDB |
|---|---|---|---|
| Memoria | Carica tutto il file | Carica durante l'importazione | Streams from disc |
| Configurazione | Nessuno | Schema + importazione | Nessuno |
| File 500MB | ~2GB RAM | Minuti di importazione | Istantanea |
| File 5GB | Crash OOM | Molto lento | Funziona bene |
| Parquet | No | No | Sì (10-100x più veloce) |
DuckDB è ciò che gli ingegneri di dati utilizzano in Python per esattamente questo caso d'uso. Legame .NET dare il supporto completo ADO.NET - si sente come qualsiasi altro database, tranne che si sta interrogando i file.
| Componente | Perché questo |
|---|---|
| AnatraDBunit synonyms for matching user input | Queries CSV directly, no import step |
| DuckDB.NET | Supporto completo ADO.NET, si sente nativo |
| OllamaCity name (optional, probably does not need a translation) | Inferenza locale, nessuna chiave API, nessuna nuvola |
| BogusCity name (optional, probably does not need a translation) | Dati di prova realistici su qualsiasi scala |
qwen2.5-coder:7b |
Migliore precisione SQL a dimensione 7B |
Nota sulla sicurezza: Stiamo eseguendo SQL generato da LLM. Questo è più sicuro del codice arbitrario, ma richiede comunque la convalida. Sezione Sicurezza Per una più ampia discussione sulla trasformazione dei profili nell'interfaccia LLM e in una CLI che implementa questi modelli, vedere il pezzo di accompagnamento DataSummarizer: Profilazione rapida dei dati locali.
Creiamo un progetto di esempio. Installa i pacchetti NuGet:
dotnet add package DuckDB.NET.Data.Full
dotnet add package OllamaSharp
dotnet add package Bogus
Tirare un modello focalizzato sulla codifica che è bravo in SQL:
ollama pull qwen2.5-coder:7b
Ecco come i pezzi si adattano insieme:
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
L'intuizione chiave: diamo l'LLM schema e dati del campione, non i dati reali. Questo mantiene il contesto piccolo e le risposte veloci.
Perche' questo e' importante?: Il LLM genera intent (SQL). DuckDB lo esegue. Il passo di validazione cattura gli errori di sintassi prima dell'esecuzione. Il loop di riprova gestisce l'errore occasionale. Questa separazione è ciò che rende il sistema sia sicuro che preciso. Per un riferimento più dettagliato, CLI-centrato (incluso profilo-primo narrazione e sicuri limiti di esecuzione SQL) vedere DataSummarizer: Profilazione rapida dei dati locali.
Hai già i dati CSV? Skip to Passo 2: Costruire il contesto schema.
Prima di poter testare il nostro analizzatore CSV alimentato da LLM, abbiamo bisogno di dati da analizzare. Per lo sviluppo e il test, i dati sintetici battono i dati reali:
BogusCity name (optional, probably does not need a translation) è una porta .NET della libreria popolare faker.js. Genera dati falsi realistici - nomi, indirizzi, e-mail, date, numeri - con un adeguato supporto locale. Invece di file CSV di test artigianali o utilizzando dati casuali della spazzatura, Bogus ti dà dati che sguardi reale:
f.Name.FullName() → "John Smith" (non "asdf1234")f.Internet.Email() → "[email protected]" (proprio formattato)f.Date.Between(start, end) → Distribuzione realistica delle datef.Commerce.ProductName() → "Formaggio di Granito artigianale" (divertente, ma riconoscibile)Questo è importante perché dati realistici ti aiutano a individuare problemi - formattazione strana, aggregazioni inaspettate, casi di bordo nella gestione delle date - che stringhe casuali si nascondono.
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 utilizza un'API fluente per definire le regole di generazione:
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));
Distruggiamo quello che sta succedendo:
f (Faker) - L'istanza generatore con accesso a tutti i moduli di dati (Nome, Data, Casuale, ecc.)f.Random.Guid().ToString()[..8] - Generare un GUID ma prendere solo i primi 8 caratteri per un ID ordine leggibilef.Date.Between() - Data casuale entro un intervallo realistico (non anno 9999)f.PickRandom(array) - Selezionare in modo casuale da opzioni predefinite (esprime categorie valide)f.Random.Bool(0.3f) - 30% di probabilità di Vero (30% di ordini ottenere uno sconto)(f, s) sintassi - Accedi sia al falso che al disco parzialmente costruito. ProductName dipende da CategoryLa (f, s) pattern è potente - significa che gli ordini "Electronics" ottengono i nomi dei prodotti elettronici, non oggetti casuali. Questa coerenza rende i dati generati molto più realistici per testare le aggregazioni come "revenue per categoria."
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},...");
}
100K righe genera circa 15MB di CSV - abbastanza per testare, ma si può facilmente scalare a milioni. La generazione è veloce (~ 2 secondi per 100K righe) perché Bogus è ottimizzato per la generazione all'ingrosso.
Suggerimento: Imposta
Randomizer.Seed = new Random(12345)prima di generare per ottenere dati riproducibili. Stesso seme = stesso "casuale" registra ogni volta, che è inestimabile per il debug.
Prima che l'LLM possa generare SQL, deve capire la struttura dei dati. Lo estraiamo da 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.
}
Questo cattura tutto ciò di cui l'LLM ha bisogno: nomi delle colonne, tipi e alcune righe di esempio per capire il formato dei dati.
DuckDB può descrivere qualsiasi CSV senza caricare tutto:
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;
}
La DESCRIBE comando legge solo l'intestazione del file più alcune righe per l'inferenza di tipo - è istantaneo anche su file enormi.
Le righe di esempio aiutano l'LLM a comprendere i formati dei dati (date, ID, ecc.):
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);
}
Tre righe sono di solito sufficienti - mostra al LLM quali formati aspettarsi senza sprecare gettoni.
Questa è la parte difficile. L'ingegneria rapida qui non è negoziabile - senza regole rigorose, LLM locali produrrà SQL creativo ma rotto. L'obiettivo è il determinismo, non la creatività.
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 sezione regole è cruciale - dice al LLM esattamente come formattare la query per DuckDB. Essere espliciti sulla sintassi (LIMIT vs TOP, concatenazione delle stringhe) impedisce errori comuni.
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)}}}");
}
}
Se un tentativo precedente non è riuscito, includere l'errore:
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();
}
Questo meccanismo di riprovazione è importante - gli LLM locali a volte commettono errori di sintassi, e dando loro l'errore di solito lo corregge al secondo tentativo.
var request = new GenerateRequest { Model = _model, Prompt = prompt };
var response = await _ollama.GenerateAsync(request).StreamToEndAsync();
var sql = CleanSqlResponse(response?.Response ?? "");
La StreamToEndAsync() Aspetta la risposta completa. Per un UX migliore, si potrebbe trasmettere i gettoni quando arrivano.
I LLM spesso avvolgono SQL nei blocchi di codice markdown nonostante sia stato detto di non:
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 ci permette di controllare la sintassi SQL senza eseguire la query:
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;
}
}
Se la convalida fallisce, rimettiamo l'errore alla LLM e riproviamo (fino a un limite).
Infine, eseguire la query e formattare l'output:
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;
}
La QueryResult class (mostrato per intero nel progetto campione) include un ToString() metodo che formatta risultati come una tabella leggibile.
Per l'analisi interattiva, gli utenti spesso vogliono porre domande di follow-up:
"What's the total revenue?"
→ "Break that down by region"
→ "Show the top 5 regions"
La seconda e la terza questione hanno senso solo con il contesto dalla prima.
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 storia dà al contesto LLM per comprendere riferimenti come "quello," "quelli risultati," o "disgregarlo ulteriormente."
Per la generazione SQL, i modelli focalizzati sulla codifica funzionano meglio. Ecco le opzioni disponibili tramite La libreria modello di Ollama:
| Modello | Dimensioni | Velocità | Qualità | Collegamento |
|---|---|---|---|---|
qwen2.5-coder:7b |
4.7GB | Fast | Excellent | OllamaCity name (optional, probably does not need a translation) |
deepseek-coder-v2:16b |
9GB | Medio | Migliore | OllamaCity name (optional, probably does not need a translation) |
codellama:7b |
4GB | Fast | Good | OllamaCity name (optional, probably does not need a translation) |
llama3.2:3b |
2GB | Molto veloce | Accettabile | OllamaCity name (optional, probably does not need a translation) |
Per la maggior parte dei casi d'uso, qwen2.5-coder:7b colpisce il punto dolce - SQL accurato, buona velocità, funziona su hardware modesto (8GB + RAM).
Test su un file CSV a riga 100K (14MB) su una macchina dev standard (Ryzen 5, NVMe SSD, 32GB RAM):
| Tipo di domanda | Tempo |
|---|---|
| COUNT semplice | 65ms |
| GROUP BY with SUM | 58-68ms |
| Aggregazione complessa con FILTRO | 63ms |
| GROUPO multitavola BY con RISPARMIO | 71ms |
Sotto-100ms per le query analitiche su righe 100K - senza alcun passaggio di importazione. La complessità di query conta più del numero di righe; il motore colonnare di DuckDB gestisce le aggregazioni in modo efficiente indipendentemente dalla dimensione del file.
Per file superiori a 1GB, Formato del parquet è 10-100x più veloce:
using var cmd = connection.CreateCommand();
cmd.CommandText = $"COPY (SELECT * FROM '{csvPath}') TO '{parquetPath}' (FORMAT PARQUET)";
cmd.ExecuteNonQuery();
Anche il file Parquet compresso è molto più piccolo.
Quando si esegue SQL generato da 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));
}
La modalità in-memory di DuckDB fornisce anche l'isolamento naturale - non può influenzare i database di produzione.
Ecco come si fondono le cose:
// 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?"));
Il modello mentale da mantenere: LLMs ragione; basi di dati calcolare.
Non caricare i dati sull'LLM. Lo schema, lascialo generare SQL, esegue SQL contro un motore di query corretto. Questa separazione è il motivo per cui l'approccio funziona in scala.
L'attuazione:
Il risultato: query analitiche sub-100ms su file million-row, completamente offline, con dati che non lasciano mai la tua macchina.
Il progetto completo del campione è disponibile all'indirizzo: Per lo più Lucid.CsvLlm - comprende: CsvQueryService, ConversationalCsvAnalyser, e generazione di dati basati su Bogus.
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.