Hur man analyserar stora CSV-filer med lokala LLM i C# (Svenska (Swedish))

Hur man analyserar stora CSV-filer med lokala LLM i C#

Thursday, 18 December 2025

//

18 minute read

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.

Den grundläggande insikten

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.

Varför inte bara ladda in den i minnet?

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.

Varför DuckDB

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.

Stacken

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.

Projektinställning

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

Arkitekturen

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.

Steg 1: Generera testdata med Bogus

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:

  1. Skaltest - Skapa 100K, 1M, eller 10M rader för att verifiera prestanda i olika storlekar
  2. Sekretess - Ingen risk för att exponera verkliga kund-/affärsdata i demos eller skärmdumpar
  3. Reproducerbarhet - Samma frö = samma data, vilket gör felen reproducerbara
  4. Kantfodral - Kontrollera fördelningen (t.ex., kraft 5% avkastning, specifika datumintervall)

Vad är Bogus?

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 datum
  • f.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.

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

Anpassa FakerName

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 ID
  • f.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å Category

och (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".

Skapa och skriv 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 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.

Steg 2: Bygg upp ett schemakontext

Innan LLM kan generera SQL, det måste förstå datastrukturen. Vi extraherar detta från DuckDB:

Sammanhangsmodellen

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.

Utvinning av schemat

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.

Hämta provdata

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.

Steg 3: Generera SQL med LLM

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.

Att bygga upp en snara

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.

Lägga till provdata

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

Felåterställning

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.

Ringa LLM

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.

Rengöring av svaret

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

Steg 4: Validera innan körning

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

Steg 5: Utför och formatera resultat

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.

Lägga till konversationssammanhang

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.

Spåra samtalshistorik

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

Inbegripet historik i direktionerna

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

Vilken modell att använda?

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

Prestanda

Riktmärken i verkligheten

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.

Konvertera stora filer till Parquet

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.

Säkerhetsöverväganden

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.

Fullständigt exempel

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

Sammanfattning

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

  1. AnkaDB frågar CSV direkt - ingen import, strömmar från disk
  2. Schema + prover ge LLM tillräckligt med sammanhang utan att avslöja data
  3. Stränga snabba regler kraft deterministisk SQL, inte kreativ prosa
  4. Validering via EXPLAIN Fångstfel före genomförande
  5. Försök igen med felåterkoppling hanterar tillfällig syntax slip

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.

Resurser

AnkaDB

Ollama och LLM

Testdatagenerering

Alternativ som nämns

Relaterade artiklar

Finding related posts...
logo

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