Hur man analyserar stora CSV-Dater med Lokala LLM i C# (Svenska (Swedish))

Hur man analyserar stora CSV-Dater med Lokala LLM i C#

Thursday, 18 December 2025

//

17 minute read

Serien: Lokala LLMs för data - Del | 1 | |

Här är det misstag som alla gör. De försöker mata sin CSV till en LLM. LLMs borde generera frågor , inte konsumera data.

Du' har en 500MB CSV-Datei och vill fråga: " Vad är den genomsnittliga inställningsvaluten per region? Kopilot i Excel kan göra detta, men vad händer om dina data är för känsliga för molntjänster?

Den här artikeln visar hur - lokalt , privatt

Använd DuckDB för att fråga CSV-Dater direkt. Använd en lokal LLM för att generera SQL . Den LLM ser aldrig dina data | - bara schemat |. Resultat ♫ : ♫ sub ♫ МSK5 ♫ ms frågor på miljontals ♫

För en komplementär treatment som expanderar på användandet av statistikprofiler som LLM-gränssnittet ( och visar ett komplett verktyg för att implementera dessa idéer | - | profilering |, | säker SQL-modus | МSK5 | syntetiskt kloning och driftdetektion DataSummarizer: Vinniga lokaldataprofilering - i synnerhet i sektionen "Kysyluppgraderingen | : | Statistik som gränssnittet | МSK3 | De här två artiklarna utgör en kort serie om praktisk lokal LLM

Kerninsikten

Att använda en LLM som dataspeicher är fel abstraktion.

Det korrekta mönstret: LLM anledningar , databasberäkningar.

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 löser den mot den faktiska datan.M SK3, LDM rör aldrig vid dina data. -, det ser bara kolumnernas namn och typer.

För mer information om hur man hanterar profile som LLM-gränssnittet ( och en konkret CLI som implementerar profilen -first narration, säkra SQLM SK3bevarad Q~&A+,registret-bevarade sessioner och syntetiskt kloning~ ),~ se DataSummarizer: Vinniga lokaldataprofilering.

Varför inte bara ladda in den i minnet?

De uppenbara tillvägagångssätten har samma dödliga brist

CsvHelper / Dataframer: Lade hela filen in i RAM. En 500MB CSV blir 2-4GB av objekt

SQLite / PostgreSQL: kräver en långsam importerad steg (minuter för stora filer M SK2 framfronts schemadefinitioner , och databashantering överhuvudtaget

PandasAI: Den laddar fortfarande allt i minnet . Plus

Varför DuckDB

DuckDB är annorlunda. Den frågar CSV-filer direkt - ingen importerad steg , inget laddande 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

Killer Featuren: den behandlar filer som tabeller. Närma den till en CSV , Parquet M SK2 eller JSON-fiel och fråga direkt . Inga skapadestablar

Faktor CsvHelper SQLite m DuckDB M
minne Lader hela filen Lader under import Stromer från disken
Konfiguration Inget Schema + Import МSK3 Inget
500MB-Datei ~2 GB RAM Mina minuter att importera
5GB-Datei OOMkraschen Väldigt långsamt ♫ ♫ ♫ Fungerar bra ♫
Parquet Nej Nej

DuckDB är vad dataingenjörer använder i Python för exakt detta användandefall .NET bindinger ge dig full ADO.NET support - det känns som vilken annan databas som helst

Stanken

Komponent ♫ ♫ Varför den här ♫
DuckDB Fråga CSV direkt , ingen införingsteg
DuckDB.NET Full ADO. Net supportM SK2 känns inhemskt
Ollama Lokalt utgångssätt , inga API-kyler M SK2 ingen moln
Bogus Realistiska tester på alla skalor
qwen2.5-coder:7b Den bästa SQL-tegenskapen på 7B-storlek

Beteckning om säkerhet: Vi executerar LLM-genererad SQLM SK3 Det är säkrare än godtyckligt kodt , men kräver fortfarande validering säkerhetssektionen för säkerhetsåtgärder . För en bredare diskussion om att omvandla profiler till LLM-gränssnittet och en CLI som implementerar dessa mönster DataSummarizer: Vinniga lokaldataprofilering.

Projektuppsättning

Låt oss skapa ett exempelprojekt.

dotnet add package DuckDB.NET.Data.Full
dotnet add package OllamaSharp
dotnet add package Bogus

Ta fram en kodning, -, som fokuserar på modellen och som är bra på SQL.

ollama pull qwen2.5-coder:7b

Architekturen

Här' är hur de delar passar 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

Den viktigaste insikten vi ger LLM schema och proverdata, inte den faktiska datan . Detta gör att kontexten är liten och reaktioner snabbar

Varför är detta viktigt?: LLM genererar intentionen | ( | SQL | МSK2 | DuckDB löser det | . | Valideringsstegen fångar syntaktiska fel innan Executionen ♫ . | Retry-loopet hanterar slumpmässiga fel |. | Det är denna separation som gör systemet både säkert och korrekt ♫ DataSummarizer: Vinniga lokaldataprofilering.

steg 1: Generera testerdata med Bogus

Har redan CSV data? Spring till steg 2: bygga schemakontexten.

Innan vi kan testa vår LLM, behöver vi data att analysera.

  1. Skala tester - Generera ♫ 100 ♫ K ♫ , ♫ ♫ МSK3 ♫ mm ♫M, ♫ eller ♫ M10 ♫ rader för att kontrollera effekten på olika storlekar
  2. Privatliv - Inga risker för att exponera riktiga kunder / affärsdata i demo eller screenshots
  3. Reproduktivitet - Samma frö = samma data, gör att bugs är återskapbara
  4. Randfall - Kontrollera fördelningen | | e | . | g | мSK3 | kraft | МSK4 | återspeglar |, | specifika datumsträngar

Vad är Bogus?

Bogus är en .NET port av den populära fakern.js biblioteketM SK2 Den genererar realistiskaMST3liknande falska data MST4 namnM ST5 adressernaM st6 e-posterMst7 datumM St8 siffror | MST9 med korrekt lokalisering | МST10 | Istället för att göra test CSV-Dater eller använda slumpmässiga skräpdata |MST12 | Bogus ger dig data som ser ut riktiga:

  • f.Name.FullName() → "John SmithM SK2 ♫ ♫ ( ♫
  • f.Internet.Email() →
  • f.Date.Between(start, end) → Distribution av realistiska datum
  • f.Commerce.ProductName() → "Handgjorda granitiskt ost" | | ( roligtM SK4 men igenkännande

Detta spelar roll, eftersom realistiska data hjälper dig att upptäcka problem - konstiga formateringar , oväntade aggregationer

Definiera datamodellen

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; }
}

Konfigurera Fakern

Bogus använder en flytande API för att definiera generregler:

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 (Faker) - Genereringsinstancen med tillgång till alla datamoduler | | namn | , | datum |, | slumpmässigt | , etc | SMK5
  • f.Random.Guid().ToString()[..8] - Generera en GUID men ta bara först 8 tecken för en läsbar ordningsID
  • f.Date.Between() - Lukraket datum inom en realistisk intervall inte år
  • f.PickRandom(array) - Välj slumpmässigt från förutdefinierade alternativ bestämdar giltiga kategorier
  • f.Random.Bool(0.3f) - 30% chans att riktiga order blir beskömda
  • (f, s) syntax - Zugriff både på faker och på de delvis konstruerade rekorden - ProductName beroende av Category

Den (f, s) mönster är kraftfulla - det betyder "Elektronics" order får elektroniksprodukternas namnM SK3 inte slumpmässiga prylarMSC4 Den här konsekvencen gör datan som genereras mycket mer realistisk för att testa aggregationer som |

Generera och skriva till CSV

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 ungefär 15MB av CSV | - | tillräckligt för att testa med |, | men man kan enkelt skala upp till miljoner |

Tipp: Set Randomizer.Seed = new Random(12345) innan man genererar för att få återskapbara data

steg 2: bygga schemakontexten

Innan LLM kan generera SQL, måste den förstå datastrukturen.

Kontextmodellen

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.
}

Det fångar allt som LLM behöver, columns namn, ,, typer och några mönster rader för att förstå dataformatet.

Att extrahera schemat

DuckDB kan beskriva vilken CSV som helst utan att ladda den alla

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;
}

Den DESCRIBE kommandon läser bara fileheader plus några rader för typsuppfattning - det 's omedelbart även på stora filer.

Att få proverdata

Sammlingraden hjälper LLM att förstå dataformat (dataM SK1 IDs , 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 vanligtvis tillräckligt. - den visar LLM vilka format man ska förvänta sig utan att slösa bort token.

steg 3: Generera SQL med LLM

Det här är den hårda delen Det snabbaste ingenjörskonst här är inte

Att bygga upp springen

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}");
    }

Säcken om regler är avgörande - den berättar för LLM exakt hur man formaterar queryn för DuckDB . Att vara tydlig med syntaxen | | ( | LIMIT vs TOP

Att lägga till proverdata

    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)}}}");
        }
    }

Fout återhämta

Om ett tidigare försök misslyckades

    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();
}

Den här upprepningsmekanismen är viktig. Lokala LLM gör ibland syntaktiska fel och genom att ge dem felet löser den ofta vid det andra försöket

Att kalla LLM

var request = new GenerateRequest { Model = _model, Prompt = prompt };
var response = await _ollama.GenerateAsync(request).StreamToEndAsync();
var sql = CleanSqlResponse(response?.Response ?? "");

Den StreamToEndAsync() väntar på den kompletta svaret. För en bättre UX, skulle man kunna skicka ut token när de kommer fram

Att rena reaktionen

LLM wrapar ofta SQL i markerdown-kodblock trots att de fått höra att inte göra det.

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');
}

steg 4: Validatera före att utföra

DuckDB's EXPLAIN låter oss kontrollera SQL syntax utan att köra 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;
    }
}

Om bekräftelset misslyckas, skickar vi felet tillbaka till LLM och testar igen (till en gräns

steg 5: utföra och formatera resultat

Slutligen, köra queryn och formatera utgången

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;
}

Den QueryResult klass ( fullkomligt visat i exemplet projekt ToString() metod som formaterar resultat som en läsbar tabell.

Att lägga till konversationskontext

När det gäller interaktiv analys vill användare ofta ställa frågor som följer:

"What's the total revenue?"
→ "Break that down by region"
→ "Show the top 5 regions"

De andra och tredje frågorna är logiska bara i sammanhanget från den första

Att följa konversationers historia

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"
}

Inklusive historia i prompt

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: s kontext för att förstå referenser som "som ", | | " dessa resultat

Vilken modell att använda?

För SQL-generationen, kodering - fokuserade modeller fungerar bäst. Här är de tillgängliga alternativen via Ollamas modellbibliotek:

Modell Storlek Rymden МSK3 Jakt
qwen2.5-coder:7b 4.7GB snabb Utmärkt Ollama
deepseek-coder-v2:16b 9 GB Medel bäst Ollama
codellama:7b 4GB snabb Bra Ollama
llama3.2:3b Ollama

För de flesta användande fall, qwen2.5-coder:7b träffar den bästa punkten. - korrekt SQL.

Förmågor

Real-World Benchmarks

Testande på en 100K rad CSV-Datei (14MB) på en standard- dev-maskin | | (Ryzen | МSK4 | NVMe SSD |

frågeställningstyp tid
Enkel siffra ♫ ♫ ♫
GROUP BY med SUM 58-68ms
Kompleks aggregation med FILTER 63ms
Multi -table GROUP BY with HAVING

Sub-100ms för analytiska queryer på | 100 | K rader |- | utan någon importerad steg | МSK3 | Query-komplexiteten är viktigare än radsräkningen ♫ ; | DuckDB | ' | vår pilarmotor hanterar agregationer effektivt oavsett filestorlek ♫

Konvertera stora filer till Parquet

För filer över 1GB, Parquet-format är 10-100x snabbareM SK1

using var cmd = connection.CreateCommand();
cmd.CommandText = $"COPY (SELECT * FROM '{csvPath}') TO '{parquetPath}' (FORMAT PARQUET)";
cmd.ExecuteNonQuery();

Den komprimerade Parquet-fiilen är också mycket mindre.

säkerhetsfrågor

När man sätter igång 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));
}

DuckDB's i memory-modus ger också naturlig isolering - den kanM SK3 inte påverka dina produktionsdatabaser

Ett komplett exempel

Här är "'", det är hur allt går ihop.

// 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?"));

sammanfattning

Den mentala modellen för att hålla LLMs skäl; databasernas beräkningar

Sorg inte data till LLM. Sorg det schemat. Schick det ,. Sorg att det genererar SQL. ,. Släng det SQL mot en äkta sökmotor.

Implementeringen:

  1. DuckDB queries CSV direkt - inga importer , strömmar från disket
  2. Schema + prover ge LLM tillräckligt med kontext utan att avslöja data
  3. Strict prompt-regler kraft deterministisk SQL, inte kreativ prosa
  4. Validering via EXPLAIN fångar fel före uitvoeringen
  5. Försök igen med fel återkoppling hanterar den slumpmässiga syntaxen

Resultatet: sub-100 ms analytiska queryr på miljontals filer - rader files M SK3 helt offline , med data som aldrig lämnar din maskin

Det kompletta exemplaret är tillgängligt Mostlylucid.CsvLlm - inkluderar CsvQueryService, ConversationalCsvAnalyser, och Bogus

resurser

DuckDB

Ollama & LLMs

Testa datagenerering

Alternativer Beskrivna

Finding related posts...
logo

© 2026 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.