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: Lokala LLM för data - Del 1 av 2
Här är misstaget alla gör: de försöker mata sin CSV till en LLM. LLMs bör generera frågor, inte konsumera data.
Du har en 500MB CSV-fil och vill fråga "Vad är det genomsnittliga ordervärdet per region?" Verktyg som Medpilot i Excel kan göra detta, men tänk om din data är för känslig för molntjänster? Tänk om du behöver bygga den själv?
Den här artikeln visar hur - lokalt, privat, i C#.
Använd DuckDB för att fråga CSV- filer direkt. Använd en lokal LLM för att generera SQL. LLM ser aldrig dina data - bara schemat. Resultat: sub- 100ms frågor på million- row filer, helt offline.
För en kompletterande, mer CLI-fokuserad behandling som expanderar på att använda statistiska profiler som LLM-gränssnitt (och visar ett komplett verktyg för att implementera dessa idéer - profilering, säkert SQL-läge, syntetisk kloning och driftdetektering), se följeslagare artikeln: Datasummarizer: Snabb lokal dataprofilering - speciellt avsnittet "The Key Upgrade: Statistics as the Interface". Dessa två artiklar utgör en kort serie om praktiska lokala LLM + frågemönster.
Använda en LLM som en datalager är fel abstraktion. LLMs är i grunden oförmögna att skanna miljontals rader för att beräkna ett genomsnitt - det är inte vad de är till för. Även en 200K token sammanhangsfönster passar kanske 50.000 rader. Din 500MB CSV har miljoner.
Rätt mönster: LLM skäl, databas beräknar.
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
Lägg märke till vad som händer: LLM genererar en SQL- fråga baserat på din fråga och schemat. DuckDB kör den mot den faktiska datan. LLM berör aldrig dina data - den ser bara kolumnnamn och typer. Det är därför den är snabb, privat och korrekt.
För mer om att behandla profiler som LLM-gränssnitt (och en konkret CLI som implementerar profil-första berättande, säker SQL-stödd Q&A, register-stödda sessioner och syntetisk kloning), se Datasummarizer: Snabb lokal dataprofilering.
De självklara tillvägagångssätten har alla samma ödesdigra fel:
CsvHelper / Dataramar: Ladda hela filen i RAM. En 500MB CSV blir 2-4GB objekt. En 5GB fil? OOM krasch.
SQLite / PostgreSQL: Kräver en långsam import steg (minuter för stora filer), främre schema definitioner, och databashantering overhead.
PandasAI: Fortfarande laddar allt i minnet. Plus, verkställande LLM-genererad godtycklig kod är en säkerhetsmardröm - SQL är deklarativ och sandlåda; Python är inte.
AnkaDB är annorlunda. Den frågar CSV- filer direkt - inget importsteg, ingen laddning i minnet:
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
Mördarens drag: den behandlar filer som tabeller. Peka den på en CSV, Parquet, eller JSON fil och fråga omedelbart. Ingen SKAPA TABELL, ingen bulk insats, ingen väntan.
Faktor på CsvHelper på SQLite på DuckDB |--------|-----------|--------|--------| | Minnet till Laddar hela filen på nytt Laddar under import, strömmar från disken | Ställ in och import från länder som inte är medlemmar i EU | 500 Mbyte fil på ~2GB RAM på minuten för import | 5GB- fil OOM-kraschen är mycket långsam och fungerar bra. | Parquet Ordförande . Nej Nej på plats Ja (10-100x snabbare)
DuckDB är vad data ingenjörer använder i Python för exakt detta användningsfall. .NET-bindningar ge dig fullt ADO.NET-stöd - det känns som alla andra databaser, förutom att du frågar filer.
på komponenten och varför den här
|-----------|--------------|
| AnkaDB på fråga CSV direkt, ingen import steg på
| AnkaDB.NET på Full ADO.NET stöd, känner sig infödd
| Ollama Ordförande med lokala slutsatser, inga API-nycklar, inget moln
| Gurkväxter – ätligt skal till Realistiska testdata i vilken skala som helst
| qwen2.5-coder:7b Bästa SQL-noggrannhet vid 7B storlek
Not om säkerhet: Vi kör LLM-genererad SQL. Detta är säkrare än godtycklig kod, men kräver fortfarande validering. Se Säkerhetssektion För en bredare diskussion om att omvandla profiler till LLM-gränssnittet och en CLI som implementerar dessa mönster, se bihanget Datasummarizer: Snabb lokal dataprofilering.
Låt oss skapa ett provprojekt. Installera NuGet-paketen:
dotnet add package DuckDB.NET.Data.Full
dotnet add package OllamaSharp
dotnet add package Bogus
Dra en kodfokuserad modell som är bra på SQL:
ollama pull qwen2.5-coder:7b
Så här passar bitarna ihop:
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
Nyckelinsikten: vi ger LLM Schema- och urvalsuppgifter, inte den faktiska data. Detta håller kontexten liten och svar snabbt.
Varför detta är viktigt: LLM genererar intention (SQL). DuckDB utför det. Valideringssteget fångar syntaxfel före körning. Återförsöksloopen hanterar det enstaka misstaget. Den här separationen gör systemet både säkert och korrekt. För en mer detaljerad, CLI- centrerad referens (inklusive profil- första berättande och säkra SQL- genomförandegränser) se Datasummarizer: Snabb lokal dataprofilering.
Har du redan CSV-data? Hoppa till Steg 2: Bygg upp ett schemakontext.
Innan vi kan testa vår LLM-drivna CSV-analysator behöver vi data för att analysera. För utveckling och testning slår syntetiska data verkliga data:
Gurkväxter – ätligt skal är en .NET-port av den populära faker.js-biblioteket. Den genererar realistiska falska data - namn, adresser, e-post, datum, siffror - med korrekt lokal support. Istället för handgjorda test CSV-filer eller med hjälp av slumpmässiga sopdata, Bogus ger dig data som utseende verklig:
f.Name.FullName() → "John Smith" (inte "asdf1234")f.Internet.Email() → "[email protected]" (riktigt formaterad)f.Date.Between(start, end) → Realistisk fördelning av datumf.Commerce.ProductName() → "Handgjord granitost" (kul, men igenkännlig)Detta spelar roll eftersom realistiska data hjälper dig att upptäcka problem - konstig formatering, oväntade aggregeringar, kantfall i datumhantering - som slumpmässiga strängar skulle dölja.
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 använder ett flytande API för att definiera generationsregler:
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));
Låt oss bryta ner vad som händer:
f (Flämtare) - Generatorn instans med tillgång till alla datamoduler (namn, datum, slumpmässig, etc.)f.Random.Guid().ToString()[..8] - Skapa en GUID men ta bara de första 8 tecken för en läsbar ordning IDf.Date.Between() - Slumpmässiga datum inom ett realistiskt intervall (inte år 9999)f.PickRandom(array) - Välj slumpmässigt från fördefinierade alternativ (säkrar giltiga kategorier)f.Random.Bool(0.3f) - 30% chans att sant (30% av beställningarna får rabatt)(f, s) syntax - Få tillgång till både falska och delvis inbyggda skivor. ProductName beror på Categoryoch (f, s) Mönstret är kraftfullt - det betyder "Elektroniska" beställningar får elektronik produktnamn, inte slumpmässiga objekt. Denna samstämmighet gör de genererade data mycket mer realistiska för att testa aggregeringar som "intäkter efter kategori".
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 rader genererar ca 15MB CSV - tillräckligt för att testa med, men du kan enkelt skala till miljoner. Generationen är snabb (~2 sekunder för 100K rader) eftersom Bogus är optimerad för bulkgenerering.
Tips: Ställ in
Randomizer.Seed = new Random(12345)samma frö = samma "random" poster varje gång, vilket är ovärderligt för felsökning.
Innan LLM kan generera SQL, det måste förstå datastrukturen. Vi extraherar detta från 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.
}
Detta fångar allt LLM behöver: kolumnnamn, typer och några exempelrader för att förstå dataformatet.
DuckDB kan beskriva alla CSV utan att ladda allt:
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;
}
och DESCRIBE kommando läser bara filhuvudet plus några rader för typ inference - det är direkt även på stora filer.
Exempelrader hjälper LLM att förstå dataformat (datum, 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);
}
Tre rader är oftast tillräckligt - det visar LLM vilka format att förvänta sig utan att slösa polletter.
Det här är det svåra. Den snabba tekniken här är inte förhandlingsbar - utan strikta regler, lokala LLMs kommer att producera kreativa men trasiga SQL. Målet är determinism, inte kreativitet.
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}");
}
Regelavsnittet är avgörande - det talar om för LLM exakt hur man formaterar frågan för DuckDB. Att vara explicit om syntax (LIMIT vs TOP, strängkonkatering) förhindrar vanliga fel.
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)}}}");
}
}
Om ett tidigare försök misslyckades, inkludera felet:
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();
}
Denna försöksmekanism är viktig - lokala LLMs ibland göra syntax misstag, och ge dem felet vanligtvis fixar det på det andra försöket.
var request = new GenerateRequest { Model = _model, Prompt = prompt };
var response = await _ollama.GenerateAsync(request).StreamToEndAsync();
var sql = CleanSqlResponse(response?.Response ?? "");
och StreamToEndAsync() Väntar på det fullständiga svaret. För en bättre UX, kan du strömma polletter när de anländer.
LLMs sveper ofta SQL i kodblock trots att de uppmanas att inte:
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');
}
AnkaDB:s EXPLAIN Låter oss kontrollera SQL syntax utan att köra frågan:
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;
}
}
Om valideringen misslyckas matar vi felet tillbaka till LLM och försöker igen (upp till en gräns).
Slutligen, kör frågan och formatera utmatningen:
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;
}
och QueryResult klass (som visas i sin helhet i urvalsprojektet) omfattar en ToString() metod som formaterar resultat som en läsbar tabell.
För interaktiv analys vill användarna ofta ställa följande frågor:
"What's the total revenue?"
→ "Break that down by region"
→ "Show the top 5 regions"
Den andra och tredje frågan är bara begriplig med sammanhang från den första.
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();
}
}
Historien ger LLM sammanhang för att förstå referenser som "det", "dessa resultat", eller "bryt ner det ytterligare".
För SQL-generering fungerar kodningsfokuserade modeller bäst. Här är alternativen tillgängliga via Ollamas modellbibliotek:
på modell en storlek en hastighet en kvalitet en länk
| ------- | ------ | ------- | --------- | ------ |
|---|---|---|---|---|
deepseek-coder-v2:16b 9GB och medelstort på bästa sätt Ollama Ordförande |
||||
codellama:7b 4GB på ett snabbt och bra sätt Ollama Ordförande |
||||
llama3.2:3b 2GB och mycket snabbt på ett godtagbart sätt Ollama Ordförande |
För de flesta användningsfall, qwen2.5-coder:7b träffar den söta fläcken - korrekt SQL, bra hastighet, körs på blygsam hårdvara (8GB+ RAM).
Testning på en 100K rad CSV-fil (14MB) på en standard dev-maskin (Ryzen 5, NVMe SSD, 32GB RAM):
"Fråga typ på tid" |------------|------| Enkla kuppen på 65 m/s GRUPP AV MED SUM 58-68 MS på komplex aggregation med FILTER 63 ms MångbordsGRUPP MED BEHÖRIGHET 71 MS
Under-100 ms för analytiska frågor på 100 K rader - utan några importsteg. Fråga komplexitet betyder mer än radräkning; DuckDB kolumnar motor hanterar aggregeringar effektivt oavsett filstorlek.
För filer över 1 GB, Parquetformat är 10-100x snabbare:
using var cmd = connection.CreateCommand();
cmd.CommandText = $"COPY (SELECT * FROM '{csvPath}') TO '{parquetPath}' (FORMAT PARQUET)";
cmd.ExecuteNonQuery();
Den komprimerade Parquet-filen är också mycket mindre.
Vid körning av LLM-genererad 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-memory-läge ger också naturlig isolering - det kan inte påverka dina produktionsdatabaser.
Så här går det till:
// 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?"));
Den mentala modellen att behålla: LLMs skäl; databaser beräkna.
Mata inte data till LLM. Mata schemat, låt det generera SQL, kör att SQL mot en korrekt frågemotor. Denna separation är varför metoden fungerar i skala.
Genomförande
Resultatet: sub-100ms analytiska frågor på miljon-radsfiler, helt offline, med data som aldrig lämnar din maskin.
Det fullständiga urvalsprojektet finns tillgängligt på Mestlylucid.CsvLlm - inklusive CsvQueryService, ConversationalCsvAnalyser, och Bogus-baserad datagenerering.
© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.