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: Lokale LLM's voor gegevens - Deel 1 van 2
Dit is de fout die iedereen maakt: ze proberen hun CSV te voeden met een LLM. LLM's moeten vragen genereren, geen gegevens consumeren.
Je hebt een 500MB CSV bestand en wilt vragen "Wat is de gemiddelde orderwaarde per regio?" Tools zoals Medepiloot in Excel kan dit doen, maar wat als uw gegevens te gevoelig zijn voor cloud-services? Wat als u het zelf moet bouwen?
Dit artikel laat je zien hoe - lokaal, privé, in C#.
Gebruik DuckDB om direct CSV-bestanden op te vragen. Gebruik een lokale LLM om de SQL te genereren. De LLM ziet nooit uw gegevens - alleen het schema. Resultaat: sub-100ms queries op miljoen-rij bestanden, volledig offline.
Voor een aanvullende, meer CLI-gerichte behandeling die zich uitbreidt op het gebruik van statistische profielen als LLM-interface (en toont een volledige tool die deze ideeën implementeert - profiling, veilige SQL-modus, synthetisch klonen en driftdetectie), zie het bijbehorende artikel: DataSummarizer: Snelle lokale gegevensprofilering - vooral de rubriek "The Key Upgrade: Statistics as the Interface." Deze twee artikelen vormen een korte serie over praktische lokale LLM + query patronen.
Het gebruik van een LLM als data store is de verkeerde abstractie. LLM's zijn fundamenteel niet in staat om miljoenen rijen te scannen om een gemiddelde te berekenen - daar zijn ze niet voor. Zelfs een 200K token context venster past misschien 50.000 rijen. Uw 500MB CSV heeft miljoenen.
Het juiste patroon: LLM redenen, database computes.
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
Let op wat er gebeurt: de LLM genereert een SQL query op basis van uw vraag en het schema. DuckDB voert het uit tegen de werkelijke gegevens. De LLM raakt nooit uw gegevens aan - het ziet alleen kolomnamen en -typen. Daarom is het snel, privé en accuraat.
Voor meer over het behandelen van profielen als de LLM-interface (en een concrete CLI die profiel-eerste vertelling implementeert, veilige SQL-backed Q&A, registry-backed sessies en synthetisch klonen), zie DataSummarizer: Snelle lokale gegevensprofilering.
De voor de hand liggende benaderingen delen allemaal dezelfde fatale fout:
CsvHelper / Dataframes: Laad het hele bestand in RAM. Een 500MB CSV wordt 2-4GB objecten. Een 5GB bestand? OOM crash.
SQLite / PostgreSQL: Vereist een langzame import stap (minuten voor grote bestanden), vooraf schema definities, en database management overhead.
PandasAI: Nog steeds laadt alles in het geheugen. Plus, het uitvoeren van LLM-gegenereerde willekeurige code is een beveiligingsnachtmerrie - SQL is declarative en sandboxable; Python niet.
DuckDB is anders. Het vraagt CSV-bestanden rechtstreeks - geen stap importeren, geen laden in het geheugen:
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
De killer functie: het behandelt bestanden als tabellen. Richt het op een CSV, Parket, of JSON bestand en vraag onmiddellijk. Geen CREATE TABLE, geen bulk insert, geen wachten.
Factor CsvHelper SQLite DuckDB |--------|-----------|--------|--------| | Geheugen Laadt het hele bestand Laadt tijdens het importeren van Streams van de schijf | Instellen Geen Schema + import Geen | 500MB bestand ~2GB RAM Minuten om te importeren | 5GB-bestand OOM-crash Zeer langzaam Werkt prima | Parket Nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee, nee
DuckDB is wat data engineers gebruiken in Python voor precies deze use case. .NET bindingen geven u volledige ADO.NET ondersteuning - het voelt als elke andere database, behalve dat je op zoek naar bestanden.
Waarom deze?
|-----------|--------------|
| DuckDB Vraagt direct naar CSV, geen importstap
| DuckDB.NET Full ADO.NET support, voelt native
| Ollama Lokaal gevolg, geen API sleutels, geen cloud
| Bogus Realistische testgegevens op elke schaal
| qwen2.5-coder:7b De beste SQL-nauwkeurigheid op 7B-grootte
Notitie over beveiliging: We voeren LLM-gegenereerde SQL uit. Dit is veiliger dan willekeurige code, maar vereist nog steeds validatie. Afdeling Veiligheid Voor een bredere discussie over het omzetten van profielen in de LLM interface en een CLI die deze patronen implementeert, zie het bijbehorende stuk DataSummarizer: Snelle lokale gegevensprofilering.
Laten we een voorbeeldproject maken. Installeer de NuGet-pakketten:
dotnet add package DuckDB.NET.Data.Full
dotnet add package OllamaSharp
dotnet add package Bogus
Trek een op coderen gericht model dat goed is bij SQL:
ollama pull qwen2.5-coder:7b
Hier is hoe de stukken in elkaar passen:
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
Het belangrijkste inzicht: we geven de LLM schema en steekproefgegevens, niet de werkelijke gegevens. Dit houdt context klein en reacties snel.
Waarom dit belangrijk is: De LLM genereert intent (SQL). DuckDB voert het uit. De valideringsstap vangt syntaxisfouten voor de uitvoering. De retrylus verwerkt de incidentele fout. Deze scheiding is wat het systeem zowel veilig als nauwkeurig maakt. Voor een meer gedetailleerde, CLI-gecentreerde referentie (inclusief profiel-eerste vertelling en veilige SQL-uitvoeringslimieten) zie DataSummarizer: Snelle lokale gegevensprofilering.
Heb je al CSV-gegevens? Skip to Stap 2: Bouw de Schema Context.
Voordat we onze LLM-aangedreven CSV-analysator kunnen testen, hebben we data nodig om te analyseren. Voor ontwikkeling en testen, worden synthetische data beter dan echte data:
Bogus is een .NET-poort van de populaire faker.js-bibliotheek. Het genereert realistisch uitziende nepgegevens - namen, adressen, e-mails, data, nummers - met de juiste locale ondersteuning. In plaats van hand-crafting testen CSV-bestanden of het gebruik van willekeurige afvalgegevens, Bogus geeft u gegevens die blikken Echt:
f.Name.FullName() → "John Smith" (niet "asdf1234")f.Internet.Email() → "[email protected]" (goed geformatteerd)f.Date.Between(start, end) → Realistische datumverdelingf.Commerce.ProductName() → "Handcrafted Granite Cheese" (pret, maar herkenbaar)Dit is belangrijk omdat realistische gegevens helpt je problemen te spotten - rare opmaak, onverwachte aggregaties, randgevallen in datering - die willekeurige strings zouden verbergen.
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 gebruikt een vloeiend API om generatieregels te definiëren:
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));
Laten we afbakenen wat er gebeurt:
f (Faker) - De generator instantie met toegang tot alle data modules (Naam, Datum, Willekeurig, enz.)f.Random.Guid().ToString()[..8] - Genereer een GUID maar neem alleen de eerste 8 tekens voor een leesbare volgorde IDf.Date.Between() - Willekeurige datum binnen een realistisch bereik (niet jaar 9999)f.PickRandom(array) - Selecteer willekeurig uit vooraf gedefinieerde opties (zorgt voor geldige categorieën)f.Random.Bool(0.3f) - 30% kans op waar (30% van de bestellingen krijgen een korting)(f, s) syntaxis - Toegang tot zowel faker en de gedeeltelijk gebouwde plaat. ProductName afhankelijk van CategoryDe (f, s) patroon is krachtig - het betekent "Elektronica" bestellingen krijgen elektronica productnamen, niet willekeurige items. Deze samenhang maakt de gegenereerde gegevens veel realistischer voor het testen van aggregaties zoals "revenue per categorie."
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 rijen genereren ongeveer 15MB CSV - genoeg om mee te testen, maar je kunt gemakkelijk tot miljoenen schalen. De generatie is snel (~2 seconden voor 100K rijen) omdat Bogus geoptimaliseerd is voor bulkgeneratie.
Tip: Ingesteld
Randomizer.Seed = new Random(12345)voordat het genereren om reproduceerbaare gegevens te krijgen. Zelfde zaad = dezelfde "random" records elke keer, die van onschatbare waarde is voor het debuggen.
Voordat de LLM SQL kan genereren, moet het de datastructuur begrijpen. We halen dit uit 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.
}
Dit legt alles vast wat de LLM nodig heeft: kolomnamen, typen en een paar sample rijen om het dataformaat te begrijpen.
DuckDB kan elke CSV beschrijven zonder het allemaal te laden:
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;
}
De DESCRIBE commando leest alleen de bestand header plus een paar rijen voor type gevolgtrekking - het is instant zelfs op grote bestanden.
Monsterrijen helpen de LLM gegevensformaten (data, ID's, enz.) te begrijpen:
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);
}
Drie rijen is meestal genoeg - het toont de LLM welke formaten te verwachten zonder het verspillen van tokens.
Dit is het moeilijke deel. De prompt engineering is hier niet onderhandelbaar - zonder strikte regels zullen lokale LLM's creatieve maar gebroken SQL produceren. Het doel is determinisme, niet creativiteit.
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}");
}
De regels sectie is cruciaal - het vertelt de LLM precies hoe de query voor DuckDB te formatteren. Expliciet over syntax (LIMIT vs TOP, string concatenation) voorkomt veel voorkomende fouten.
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)}}}");
}
}
Als een vorige poging mislukt is, neem dan de fout op:
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();
}
Dit retry mechanisme is belangrijk - lokale LLM's maken soms syntax fouten, en geven ze de fout meestal lost het op de tweede poging.
var request = new GenerateRequest { Model = _model, Prompt = prompt };
var response = await _ollama.GenerateAsync(request).StreamToEndAsync();
var sql = CleanSqlResponse(response?.Response ?? "");
De StreamToEndAsync() Wacht op het volledige antwoord. Voor een betere UX, kunt u tokens streamen als ze aankomen.
LLM's vaak wrap SQL in markdown code blokken ondanks dat wordt gezegd niet:
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 laat ons SQL syntax controleren zonder de query uit te voeren:
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;
}
}
Als validatie mislukt, voeren we de fout terug naar de LLM en opnieuw proberen (tot een limiet).
Ten slotte, voer de query en formatteer de uitvoer:
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;
}
De QueryResult klasse (volledig weergegeven in het steekproefproject) omvat a ToString() methode die formatteert als een leesbare tabel.
Voor interactieve analyse willen gebruikers vaak vervolgvragen stellen:
"What's the total revenue?"
→ "Break that down by region"
→ "Show the top 5 regions"
De tweede en derde vraag hebben alleen zin in de context van de eerste vraag.
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();
}
}
De geschiedenis geeft de LLM context om verwijzingen als "dat," "die resultaten" of "verder afbreken" te begrijpen.
Voor SQL generatie, coderen-gerichte modellen werken het beste. Hier zijn de opties beschikbaar via Ollama's modelbibliotheek:
Model Grootte Snelheid Kwaliteit Link
| ------- | ------ | ------- | --------- | ------ |
|---|---|---|---|---|
deepseek-coder-v2:16b 9GB Medium Best Ollama |
||||
codellama:7b 4GB snel goed Ollama |
||||
llama3.2:3b 2GB zeer snel en ondoordringbaar Ollama |
Voor de meeste gebruiks gevallen, qwen2.5-coder:7b raakt de sweet spot - nauwkeurige SQL, goede snelheid, draait op bescheiden hardware (8GB+ RAM).
Testen op een 100K rij CSV bestand (14MB) op een standaard dev machine (Ryzen 5, NVMe SSD, 32GB RAM):
Query Type . . Tijd . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . |------------|------| Eenvoudig bedrag 65ms VOLTOOIING VAN GROEPEN MET SUM 58-68ms Complexe aggregatie met FILTER 63ms Multi-table GROEP MET HAVING 71ms
Sub-100ms voor analytische vragen op 100K-rijen - zonder enige importstap. Zoekopdracht complexiteit is belangrijker dan rij tellen; DuckDB's columnar engine behandelt aggregaties efficiënt, ongeacht de bestandsgrootte.
Voor bestanden van meer dan 1GB, Parketformaat is 10-100x sneller:
using var cmd = connection.CreateCommand();
cmd.CommandText = $"COPY (SELECT * FROM '{csvPath}') TO '{parquetPath}' (FORMAT PARQUET)";
cmd.ExecuteNonQuery();
Het gecomprimeerde parketbestand is ook veel kleiner.
Bij het uitvoeren van LLM-gegenereerde SQL:
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));
}
DuckDB's in-geheugen modus biedt ook natuurlijke isolatie - het kan geen invloed hebben op uw productie databases.
Dit is hoe het allemaal samenkomt:
// 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?"));
Het mentale model om te behouden: LLM's reden; databases berekenen.
Voer geen gegevens naar de LLM. Voer het schema in, laat het SQL genereren, voer die SQL uit tegen een juiste query-engine. Deze scheiding is de reden waarom de aanpak op schaal werkt.
De uitvoering:
Het resultaat: sub-100ms analytische vragen op miljoen-rij bestanden, volledig offline, met gegevens die nooit verlaat uw machine.
Het volledige steekproefproject is beschikbaar op Meestal lucid.CsvLlm - omvat CsvQueryService, ConversationalCsvAnalyser, en op Bogus gebaseerde data generatie.
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.