Back to "RAG Architecture and Internals: Hoe het echt werkt"

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

AI AI-Article LLM Machine Learning RAG Semantic Search

RAG Architecture and Internals: Hoe het echt werkt

Saturday, 22 November 2025

In Deel 1, we behandelden de oorsprong van RAG's, fundamentals, en waarom het belangrijk is. Je begrijpt het concept op hoog niveau: haal relevante informatie op, dan gebruiken we het om reacties te genereren. Nu duiken we diep in de technische architectuur ... precies hoe RAG-systemen werken onder de motorkap, van whunking strategieën tot LLM internals zoals tokens en KV caches.

Inleiding

Serienavigatie: Dit is deel 2 van de RAG-reeks:

Als je deel 1 nog niet gelezen hebt, raad ik je aan om daar te beginnen om te begrijpen:

  • Wat RAG is en waarom het belangrijk is
  • De geschiedenis van trefwoord zoeken naar semantisch begrip
  • RAG vs fine-tuning en andere benaderingen

Dit artikel gaat ervan uit dat je die basisbegrippen begrijpt en focust op technische architectuur, implementatiedetails, en LLM internes.

Hoe RAG werkt: De volledige foto

Laten we precies afbakenen wat er gebeurt in een RAG-systeem, vanaf het moment dat je een document toevoegt tot wanneer een gebruiker een antwoord krijgt.

Fase 1: Indexering (voorbereiding van de kennisbasis)

Voordat RAG iets kan ophalen, moet u uw kennisbasis indexeren. Dit is een eenmalig proces (hoewel u later nieuwe documenten kunt toevoegen).

flowchart TB
    A[Source Documents] -->|1. Extract Text| B[Text Extraction]
    B -->|2. Split into Chunks| C[Chunking Service]
    C -->|3. Generate Embeddings| D[Embedding Model]
    D -->|4. Store Vectors| E[Vector Database]

    B -.Metadata.-> E

    subgraph "Example: Blog Post"
        F["Understanding Docker: A containerization platform..."]
    end

    subgraph "Chunks"
        G["Chunk 1: Title + Intro"]
        H["Chunk 2: Benefits Section"]
    end

    subgraph "Embeddings"
        I["0.234, 0.891, 0.567, ..."]
        J["0.445, 0.123, 0.789, ..."]
    end

    F --> G
    F --> H
    G --> I
    H --> J

    style D stroke:#f9f,stroke-width:2px
    style E stroke:#bbf,stroke-width:2px

Stap 1: Tekstextractie

Uitpakken van platte tekst uit uw brondocumenten. Dit kan zijn:

  • Bestanden markeren (zoals mijn blogberichten)
  • PDF's (voor documentatie)
  • HTML (voor web scraping)
  • Databaserecords
  • E-mails, chatlogs, enz.

Voorbeeld van mijn blog:

// From MarkdownRenderingService
public string ExtractPlainText(string markdown)
{
    // Remove code blocks
    var withoutCode = Regex.Replace(markdown, @"```[\s\S]*?```", "");

    // Convert markdown to plain text
    var document = Markdown.Parse(withoutCode);
    var plainText = document.ToPlainText();

    return plainText.Trim();
}

Stap 2: Chunking

Dit is waar de meeste RAG-implementaties falen. Je kunt niet zomaar splitsen op alineagrenzen - je hebt semantisch samenhangende brokken nodig.

Waarom wrikken er toe doet:

  • LLM's hebben tokenlimieten (contextvensters)
  • Kleinere brokken = nauwkeuriger ophalen
  • Maar brokken moeten genoeg context bevatten om betekenisvol te zijn

Slechte brok:

Chunk 1: "Docker is a containerization platform. It allows you"
Chunk 2: "to package applications with their dependencies. This"
Chunk 3: "ensures consistency across environments."

Goed gesnoeid:

Chunk 1: "Docker is a containerization platform. It allows you to package applications with their dependencies. This ensures consistency across environments."

Chunk 2: "Benefits of Docker:
- Isolation: Each container runs in its own environment
- Portability: Containers run anywhere Docker is installed
- Efficiency: Lightweight compared to virtual machines"

Voorbeeld van mijn semantische zoekimplementatie:

public class TextChunker
{
    private const int TargetChunkSize = 500; // ~500 words
    private const int ChunkOverlap = 50;     // 50 words overlap

    public List<Chunk> ChunkDocument(string text, string sourceId)
    {
        var chunks = new List<Chunk>();

        // Split on section boundaries first (## headers in markdown)
        var sections = SplitOnHeaders(text);

        foreach (var section in sections)
        {
            // If section is small enough, keep it whole
            if (section.WordCount < TargetChunkSize)
            {
                chunks.Add(new Chunk
                {
                    Text = section.Text,
                    SourceId = sourceId,
                    SectionHeader = section.Header
                });
            }
            else
            {
                // Split large sections on sentence boundaries
                var subChunks = SplitOnSentences(section.Text, TargetChunkSize, ChunkOverlap);
                chunks.AddRange(subChunks.Select(c => new Chunk
                {
                    Text = c,
                    SourceId = sourceId,
                    SectionHeader = section.Header
                }));
            }
        }

        return chunks;
    }
}

Gemeenschappelijke brokagestrategieën:

  • Vaste grootte: Eenvoudig maar doorbreekt semantische grenzen
  • Op basis van veroordelingen: Respecteert grammatica maar kan te klein zijn
  • Alinea's: Natuurlijke maar variabele grootte
  • Op basis van de afdeling: Beste voor gestructureerde inhoud (mijn voorkeur)
  • Schuifvenster met overlapping: Zorgt ervoor dat er geen context verloren gaat aan grenzen

Stap 3: Inbeddingen genereren

Inbeddingen zijn de magie die semantisch zoeken mogelijk maakt. Een inbedding is een vector (array van getallen) die de betekenis van tekst vertegenwoordigt.

Sleutelbegrip: Soortgelijke betekenissen → soortgelijke vectoren

"Docker container" → [0.234, -0.891, 0.567, ..., 0.123]
"containerization platform" → [0.221, -0.903, 0.534, ..., 0.119]
"apple fruit" → [0.891, 0.234, -0.567, ..., -0.789]

De eerste twee vectoren zouden "sluiten" in vectorruimte (hoge cosinus overeenkomst), terwijl de derde is ver weg.

Hoe inbeddingen worden gegenereerd: Moderne inbedding modellen zijn neurale netwerken getraind op enorme tekst datasets om semantische relaties te leren.

  • all-MiniLM-L6-v2: 384 dimensies, snel, goede kwaliteit (wat ik gebruik op deze blog)
  • tekst-embedding-3-small (OpenAI): 1536 afmetingen, zeer hoge kwaliteit
  • BGE-basis: 768 dimensies, state-of-the-art open source

Voorbeeld van mijn ONNX-inbeddingsdienst:

public async Task<float[]> GenerateEmbeddingAsync(string text)
{
    // Tokenize the input text
    var tokens = Tokenize(text);

    // Create input tensors for ONNX model
    var inputIds = CreateInputTensor(tokens);
    var attentionMask = CreateAttentionMaskTensor(tokens.Length);
    var tokenTypeIds = CreateTokenTypeIdsTensor(tokens.Length);

    // Run ONNX inference
    var inputs = new List<NamedOnnxValue>
    {
        NamedOnnxValue.CreateFromTensor("input_ids", inputIds),
        NamedOnnxValue.CreateFromTensor("attention_mask", attentionMask),
        NamedOnnxValue.CreateFromTensor("token_type_ids", tokenTypeIds)
    };

    using var results = _session.Run(inputs);

    // Extract the output (sentence embedding)
    var output = results.First().AsTensor<float>();
    var embedding = output.ToArray();

    // L2 normalize the vector for cosine similarity
    return NormalizeVector(embedding);
}

Waarom normalisatie belangrijk is: Na L2 normalisatie, cosinus overeenkomst wordt een eenvoudige punt product, waardoor zoeken veel sneller.

Stap 4: Opslaan in Vector Database

Vector databases zijn geoptimaliseerd voor het opslaan en zoeken van high-dimensionale vectoren. In tegenstelling tot traditionele databases die gebruik maken van SQL queries, vector databases gebruiken gelijkenis zoeken.

Sleutelbewerkingen:

  • Upsert: Een vector toevoegen of bijwerken met metadata
  • Zoeken: Zoek K meest vergelijkbare vectoren als een query vector
  • Filter: Combineer vector zoeken met metadata filters

Voorbeeld Qdrant-implementatie:

public async Task IndexDocumentAsync(
    string id,
    float[] embedding,
    Dictionary<string, object> metadata)
{
    var point = new PointStruct
    {
        Id = new PointId { Uuid = id },
        Vectors = embedding,
        Payload =
        {
            ["title"] = metadata["title"],
            ["source"] = metadata["source"],
            ["chunk_index"] = metadata["chunk_index"],
            ["created_at"] = DateTime.UtcNow.ToString("O")
        }
    };

    await _client.UpsertAsync(
        collectionName: "blog_posts",
        points: new[] { point }
    );
}

Populaire vectordatabases:

  • Qdrant: Snel, zelf-hostable, uitstekende C# ondersteuning (mijn keuze)
  • pgvector: PostgreSQL extensie (geweldig als je Postgres al gebruikt)
  • Pinecone: Managed service (dure maar goede)
  • Weaviate: Feature-rijk, goed voor complexe schema's
  • ChromaDB: Python-gericht, lichtgewicht

We onderzoeken het opzetten van deze databases in de komende artikelen.

Fase 2: Retrieval (Zoeken naar relevante informatie)

Wanneer een gebruiker een vraag stelt, moet het RAG-systeem de meest relevante informatie uit de kennisbasis vinden.

flowchart LR
    A["User Query:<br/>'How do I use Docker Compose?'"] --> B[Generate Query Embedding]
    B --> C["Query Vector:<br/>[0.445, -0.123, ...]"]
    C --> D[Vector Search]
    D --> E[Vector Database]
    E --> F[Top K Similar Chunks]
    F --> G["Results:<br/>1. Docker Compose Basics 0.92<br/>2. Multi-Container Setup 0.87<br/>3. Service Configuration 0.83"]

    style B stroke:#f9f,stroke-width:3px
    style D stroke:#bbf,stroke-width:3px

Stap 1: Inbedding van zoekopdrachten genereren

De vraag van de gebruiker wordt omgezet naar een vector met behulp van de hetzelfde inbeddingsmodel Dit is cruciaal - verschillende modellen produceren incompatibele vectoren.

public async Task<List<SearchResult>> SearchAsync(string query, int limit = 10)
{
    // Same embedding model used for indexing
    var queryEmbedding = await _embeddingService.GenerateEmbeddingAsync(query);

    // Search in vector store
    var results = await _vectorStoreService.SearchAsync(
        queryEmbedding,
        limit
    );

    return results;
}

Stap 2: Vergelijkbare zoekopdracht

De vectordatabase berekent de overeenkomst tussen de query vector en alle opgeslagen vectoren.

Cosinus-gelijksoortigheid (meest populair bij genormaliseerde vectoren):

similarity = (A · B) / (||A|| × ||B||)

Bereik: -1 tot 1 (hoger = meer vergelijkbaar)

Euclidische afstand (voor niet-genormaliseerde vectoren):

distance = sqrt(Σ(Ai - Bi)²)

Bereik: 0 tot ∞ (lager = meer vergelijkbaar)

Puntproduct (wanneer vectoren voorgenormaliseerd zijn):

similarity = A · B

Bereik: -1 tot 1 (hoger = meer vergelijkbaar)

Voorbeeld van mijn Qdrant-service:

var searchResults = await _client.SearchAsync(
    collectionName: "blog_posts",
    vector: queryEmbedding,
    limit: (ulong)limit,
    scoreThreshold: 0.7f,  // Only return results with >70% similarity
    payloadSelector: true   // Include all metadata
);

return searchResults.Select(hit => new SearchResult
{
    Text = hit.Payload["text"].StringValue,
    Title = hit.Payload["title"].StringValue,
    Score = hit.Score,
    Source = hit.Payload["source"].StringValue
}).ToList();

Stap 3: Reranking (facultatief maar aanbevolen)

De eerste retrieval is snel maar bij benadering. Reranking maakt gebruik van een meer verfijnd model om de top K resultaten te herscoreren.

flowchart LR
    A[Vector Search:<br/>Top 50 Results] --> B[Reranking Model]
    B --> C[Reranked:<br/>Top 10 Results]

    style B stroke:#f9f,stroke-width:3px

Waarom reranking helpt:

  • Snelle inbedding modellen optimaliseren voor snelheid, opofferen van enige nauwkeurigheid
  • Reranking modellen zijn langzamer, maar nauwkeuriger
  • Tweetrapsnadering balanceert snelheid en kwaliteit

Voorbeeld herranking implementatie:

public async Task<List<SearchResult>> SearchWithRerankAsync(
    string query,
    int initialLimit = 50,
    int finalLimit = 10)
{
    // Stage 1: Fast vector search
    var candidates = await SearchAsync(query, initialLimit);

    // Stage 2: Precise reranking
    var rerankedResults = await _rerankingService.RerankAsync(
        query,
        candidates
    );

    return rerankedResults.Take(finalLimit).ToList();
}

Fase 3: Generatie (het antwoord maken)

Nu we relevante informatie hebben, voeren we deze samen met de vraag van de gebruiker aan de LLM.

flowchart TB
    A[User Query] --> B[Retrieved Context 1]
    A --> C[Retrieved Context 2]
    A --> D[Retrieved Context 3]

    B --> E[Construct Prompt]
    C --> E
    D --> E
    A --> E

    E --> F["System: You are a helpful assistant...\n\nContext:\n1. Docker Compose allows...\n2. Services are defined...\n3. Volumes persist data...\n\nQuestion: How do I use Docker Compose?\n\nAnswer:"]

    F --> G[LLM]
    G --> H[Generated Answer with Citations]

    style E stroke:#f9f,stroke-width:2px
    style G stroke:#bbf,stroke-width:2px

Stap 1: Prompt Construction

Dit is waar RAG een kunst wordt. Je moet de prompt zo structureren dat de LLM:

  • Gebruikt de geboden context (niet de interne kennis)
  • Cites bronnen indien mogelijk
  • Geeft toe wanneer de context geen antwoord bevat
  • Behoudt een consistente toon/stijl

Voorbeeld prompt sjabloon van mijn Advocaat GPT systeem:

public string BuildRAGPrompt(string query, List<SearchResult> context)
{
    var sb = new StringBuilder();

    sb.AppendLine("You are a technical writing assistant. Your task is to answer the user's question using ONLY the provided context from past blog posts.");
    sb.AppendLine();
    sb.AppendLine("CONTEXT:");
    sb.AppendLine("========");

    for (int i = 0; i < context.Count; i++)
    {
        sb.AppendLine($"[{i + 1}] {context[i].Title}");
        sb.AppendLine($"Source: {context[i].Source}");
        sb.AppendLine($"Content: {context[i].Text}");
        sb.AppendLine($"Relevance: {context[i].Score:P0}");
        sb.AppendLine();
    }

    sb.AppendLine("========");
    sb.AppendLine();
    sb.AppendLine("INSTRUCTIONS:");
    sb.AppendLine("- Answer the question using the provided context");
    sb.AppendLine("- Cite sources using [1], [2], etc.");
    sb.AppendLine("- If the context doesn't contain enough information, say so");
    sb.AppendLine("- Maintain the technical, practical tone of the blog");
    sb.AppendLine();
    sb.AppendLine($"QUESTION: {query}");
    sb.AppendLine();
    sb.AppendLine("ANSWER:");

    return sb.ToString();
}

Stap 2: LLM-inferentie

De gebouwde prompt gaat naar de LLM voor generatie. Dit kan zijn:

  • Cloud API: OpenAI, Antropic Claude, Google PaLM
  • Lokaal model: Het gebruik van lama.cpp, ONNX Runtime, of TorchSharp

Voorbeeld met lokale LLM:

public async Task<string> GenerateResponseAsync(string prompt)
{
    var result = await _llamaSharp.InferAsync(prompt, new InferenceParams
    {
        Temperature = 0.7f,      // Creativity (0 = deterministic, 1 = creative)
        TopP = 0.9f,             // Nucleus sampling
        MaxTokens = 500,         // Response length limit
        StopSequences = new[] { "\n\n", "User:", "Question:" }
    });

    return result.Text.Trim();
}

Belangrijkste parameters uitgelegd:

  • Temperatuur: Controleert willekeurigheid (0 = altijd het meest waarschijnlijke kiezen, 1 = willekeurig monster)
  • Boven P: Nucleus sampling - overweeg alleen tokens die de bovenste P waarschijnlijkheidsmassa vormen
  • Max Tokens: Limit response length
  • Gevolgen stoppenWanneer moet ik stoppen met genereren?

Stap 3: Postverwerking

Nadat de LLM een reactie genereert, moeten we vaak:

  • Citaten uitpakken en omzetten naar links
  • Formaatcode blokken
  • Metadata toevoegen (bronnen, betrouwbaarheidsscores)
  • Log de interactie voor debuggen

Voorbeeld na verwerking:

public RAGResponse PostProcess(string llmOutput, List<SearchResult> sources)
{
    var response = new RAGResponse
    {
        Answer = llmOutput,
        Sources = new List<Source>()
    };

    // Extract citations like [1], [2]
    var citations = Regex.Matches(llmOutput, @"\[(\d+)\]");

    foreach (Match match in citations)
    {
        int index = int.Parse(match.Groups[1].Value) - 1;
        if (index >= 0 && index < sources.Count)
        {
            var source = sources[index];
            response.Sources.Add(new Source
            {
                Title = source.Title,
                Url = GenerateUrl(source.Source),
                RelevanceScore = source.Score
            });
        }
    }

    // Convert markdown citations to hyperlinks
    response.FormattedAnswer = Regex.Replace(
        llmOutput,
        @"\[(\d+)\]",
        m => {
            int index = int.Parse(m.Groups[1].Value) - 1;
            if (index >= 0 && index < sources.Count)
            {
                var url = GenerateUrl(sources[index].Source);
                return $"[[{m.Groups[1].Value}]]({url})";
            }
            return m.Value;
        }
    );

    return response;
}

Begrijpen LLM internen: Tokens, KV Cache en Context Windows

Voordat we overgaan naar praktische toepassingen, is het essentieel om te begrijpen hoe LLM's intern werken. Deze kennis helpt u RAG-systemen te optimaliseren en gemeenschappelijke valkuilen te voorkomen.

Wat zijn Tokens?

Tokens zijn de fundamentele eenheden die LLM's verwerken. Tekst wordt niet rechtstreeks naar modellen gevoerd - het wordt eerst afgebroken in tokens.

Voorbeeld tokenization:

Input:  "Understanding Docker containers"
Tokens: ["Under", "standing", " Docker", " containers"]

Verschillende modellen gebruiken verschillende tokenization strategieën:

  • GPT-modellen: Gebruik Byte-Pair codering (BPE) met ~50K woordenschat
  • Claude: Soortgelijke BPE-aanpak
  • Llama modellen: SentencePiece tokenization

Waarom tokenization belangrijk is voor RAG:

public class TokenCounter
{
    // Rough approximation: 1 token ≈ 0.75 words (English)
    public int EstimateTokens(string text)
    {
        var wordCount = text.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;
        return (int)(wordCount / 0.75);
    }

    public int EstimateTokensAccurate(string text, ITokenizer tokenizer)
    {
        // Use actual tokenizer for precision
        return tokenizer.Encode(text).Count;
    }
}

Randen van het contextvenster:

  • GPT-3.5: 16K tokens
  • GPT-4: 8K-128K tokens (afhankelijk van de variant)
  • Claude 3.5 Sonnet: 200K tokens
  • Llama 3: 8K tokens (hoewel kan worden verlengd)

In RAG-systemen moet je passen op:

Total tokens = System prompt + Retrieved context + User query + Response buffer

Als uw RAG 10 documenten van elk 500 tokens ophaalt, is dat 5.000 tokens alleen voor context - voordat de vraag en reactie!

Praktisch beheer van RAG-token:

public class ContextWindowManager
{
    private readonly int _maxContextTokens;
    private readonly int _systemPromptTokens;
    private readonly int _responseBufferTokens;

    public ContextWindowManager(
        int totalContextWindow = 4096,
        int systemPromptTokens = 300,
        int responseBufferTokens = 500)
    {
        _maxContextTokens = totalContextWindow;
        _systemPromptTokens = systemPromptTokens;
        _responseBufferTokens = responseBufferTokens;
    }

    public List<SearchResult> FitContextInWindow(
        List<SearchResult> retrievedDocs,
        string query)
    {
        var queryTokens = EstimateTokens(query);

        // Available tokens for retrieved context
        var availableForContext = _maxContextTokens
            - _systemPromptTokens
            - queryTokens
            - _responseBufferTokens;

        var selectedDocs = new List<SearchResult>();
        var currentTokens = 0;

        foreach (var doc in retrievedDocs.OrderByDescending(d => d.Score))
        {
            var docTokens = EstimateTokens(doc.Text);

            if (currentTokens + docTokens <= availableForContext)
            {
                selectedDocs.Add(doc);
                currentTokens += docTokens;
            }
            else
            {
                break; // Context window full
            }
        }

        return selectedDocs;
    }

    private int EstimateTokens(string text)
    {
        // Rule of thumb: 1 token ≈ 4 characters
        return text.Length / 4;
    }
}

The KV Cache: LLM's Secret Weapon

Wanneer een LLM tekst genereert, verwerkt het niet alles vanaf nul voor elk token. Het maakt gebruik van een Sleutelwaarde-cache (KV) om te onthouden wat het al berekend heeft.

Hoe Transformers werken (vereenvoudigd)

Transformers gebruiken een "aandacht" mechanisme waar elk token "toevoegt aan" (ziet) alle vorige tokens om de context te begrijpen.

flowchart TB
    subgraph "Generation Step 1: 'Docker'"
        A1[Input: 'Docker'] --> B1[Compute K,V for 'Docker']
        B1 --> C1[Store in KV Cache]
        C1 --> D1[Generate: 'is']
    end

    subgraph "Generation Step 2: 'is'"
        A2[Input: 'is'] --> B2[Compute K,V for 'is']
        B2 --> C2[Store in KV Cache]
        C2 --> E2[Retrieve KV for 'Docker']
        E2 --> F2[Attend: 'is' to 'Docker']
        F2 --> D2[Generate: 'a']
    end

    subgraph "Generation Step 3: 'a'"
        A3[Input: 'a'] --> B3[Compute K,V for 'a']
        B3 --> C3[Store in KV Cache]
        C3 --> E3[Retrieve KV for 'Docker', 'is']
        E3 --> F3[Attend: 'a' to all previous]
        F3 --> D3[Generate: 'container']
    end

    D1 --> A2
    D2 --> A3

    style C1 stroke:#f9f,stroke-width:3px
    style C2 stroke:#f9f,stroke-width:3px
    style C3 stroke:#f9f,stroke-width:3px

Zonder KV-cache:

  • Stap 1: Proces 1 token → O(1)
  • Stap 2: Proces 2 tokens vanaf nul → O(2)
  • Stap 3: Proces 3 tokens vanaf nul → O(3)
  • Totaal: O(1 + 2 + 3 + ... + N) = O(N2)

Met KV-cache:

  • Stap 1: Proces 1 token, cache K,V → O(1)
  • Stap 2: Proces 1 nieuwe token, hergebruik gecached K,V → O(1)
  • Stap 3: Proces 1 nieuwe token, hergebruik gecached K,V → O(1)
  • Totaal: O(N)

Dit maakt generatie dramatisch sneller - het verschil tussen 10 tokens/seconden en 100 tokens/seconden.

De structuur van de KV-cacheboom

De KV cache vormt een "boom" vanwege hoe aandacht werkt in transformatoren. Elke laag in het model heeft zijn eigen KV matrices.

graph TB
    A[Input Tokens:<br/>'What is Docker?'] --> B[Layer 1 Attention]
    B --> C[Layer 1 KV Cache]

    B --> D[Layer 2 Attention]
    D --> E[Layer 2 KV Cache]

    D --> F[Layer 3 Attention]
    F --> G[Layer 3 KV Cache]

    F --> H[... up to Layer N]
    H --> I[Output: 'Docker is']

    C -.Key-Value pairs<br/>for all input tokens.-> C
    E -.Key-Value pairs<br/>for all input tokens.-> E
    G -.Key-Value pairs<br/>for all input tokens.-> G

    style C stroke:#bbf,stroke-width:2px
    style E stroke:#bbf,stroke-width:2px
    style G stroke:#bbf,stroke-width:2px

Elke laag slaat op:

  • Sleutels (K): Vertegenwoordigingen gebruikt om aandachtsscores te berekenen
  • Waarden (V): Vertegenwoordigingen die zich mengen op basis van aandacht

Voor een model met:

  • 32 lagen
  • 4096 verborgen afmetingen
  • 32 aandachtskoppen
  • 8K contextvenster

De KV-cache voor één reeks is:

2 (K and V) × 32 layers × 4096 dimensions × 8192 tokens × 2 bytes (FP16)
≈ 4.3 GB of VRAM!

Daarom zijn lange contextvensters geheugenintensief.

KV Cache in RAG-systemen

RAG-systemen kunnen gebruik maken van KV-cacheoptimalisatie op slimme manieren:

Prompt caching (ondersteund door sommige API's zoals Anthropic Claude):

public class CachedRAGService
{
    // System prompt and retrieved context can be cached!
    public async Task<string> GenerateWithCachedContextAsync(
        string systemPrompt,          // Cached
        List<SearchResult> context,   // Cached
        string userQuery)             // Not cached, changes each time
    {
        var contextText = FormatContext(context);

        // The KV cache for systemPrompt + contextText is reused across queries
        var prompt = $@"
{systemPrompt}

CONTEXT:
{contextText}

QUERY: {userQuery}

ANSWER:";

        return await _llm.GenerateAsync(prompt, useCaching: true);
    }
}

Waarom dit krachtig is:

  • Eerste query: compileert KV-cache voor systeemprompt + context (slow)
  • Volgende vragen met dezelfde context: Hergebruik gecached KV (10x sneller!)
  • Alleen het query-gedeelte van de gebruiker heeft een nieuwe berekening nodig

Praktisch voorbeeld:

Query 1: "How do I use Docker?" → 2 seconds (no cache)
Query 2: "What are Docker benefits?" → 0.2 seconds (cache hit!)
Query 3: "Docker vs VMs?" → 0.2 seconds (cache hit!)

Alle drie de queries gebruiken dezelfde opgehaalde context, zodat de KV cache voor die context wordt hergebruikt.

Token Grenzen en RAG-strategie

Het begrijpen van tokens en KV cache informeert uw RAG architectuur beslissingen:

1. Chunking Size

Kleinere brokken = nauwkeuriger ophalen, maar meer overhead:

// Option A: Small chunks (200 tokens each)
// Retrieve 20 chunks = 4,000 tokens
// Pro: Very precise, only relevant info
// Con: More KV cache entries, slower attention

// Option B: Larger chunks (500 tokens each)
// Retrieve 8 chunks = 4,000 tokens
// Pro: Better context coherence, fewer KV entries
// Con: More noise, less precise

public class AdaptiveChunker
{
    public int DetermineChunkSize(int contextWindowSize)
    {
        if (contextWindowSize <= 4096)
            return 200; // Small chunks for limited windows

        if (contextWindowSize <= 16384)
            return 500; // Medium chunks

        return 1000; // Large chunks for big windows
    }
}

2. Context Venster Gebruik

Geef geen maximum aan het contextvenster - laat ruimte voor generatie:

public class SafeContextManager
{
    public int GetSafeContextLimit(int totalContextWindow)
    {
        // Use only 75% for input, reserve 25% for output
        return (int)(totalContextWindow * 0.75);
    }

    // Example: 4K model
    // Total: 4096 tokens
    // Safe input: 3072 tokens
    // Reserved for output: 1024 tokens
}

3. Multi-Turn RAG gesprekken

In chatbots groeit de conversatiegeschiedenis bij elke beurt:

Turn 1:
System + Context + Query1 = 3000 tokens
Response1 = 300 tokens
Total: 3300 tokens

Turn 2:
System + Context + Query1 + Response1 + Query2 = 3650 tokens
Response2 = 300 tokens
Total: 3950 tokens

Turn 3:
System + Context + Query1 + Response1 + Query2 + Response2 + Query3 = 4250 tokens
ERROR: Context window exceeded!

Oplossing: schuifvenster met re-retrieval

public class ConversationalRAG
{
    private readonly int _maxHistoryTokens = 1000;

    public async Task<string> ChatAsync(
        List<ConversationTurn> history,
        string newQuery)
    {
        // Re-retrieve context based on current query
        var context = await RetrieveContextAsync(newQuery);

        // Keep only recent conversation history
        var relevantHistory = TrimHistory(history, _maxHistoryTokens);

        var prompt = BuildPrompt(context, relevantHistory, newQuery);

        return await _llm.GenerateAsync(prompt);
    }

    private List<ConversationTurn> TrimHistory(
        List<ConversationTurn> history,
        int maxTokens)
    {
        var trimmed = new List<ConversationTurn>();
        var currentTokens = 0;

        // Keep most recent turns
        foreach (var turn in history.Reverse())
        {
            var turnTokens = EstimateTokens(turn.Query) + EstimateTokens(turn.Response);

            if (currentTokens + turnTokens <= maxTokens)
            {
                trimmed.Insert(0, turn);
                currentTokens += turnTokens;
            }
            else
            {
                break;
            }
        }

        return trimmed;
    }
}

4. Token Kostenoptimalisatie

API-gebaseerde LLM's laden per token. RAG kan kosten ontploffen als niet voorzichtig:

public class CostAwareRAG
{
    // OpenAI GPT-4 pricing (example):
    // Input: $0.03 per 1K tokens
    // Output: $0.06 per 1K tokens

    public decimal EstimateQueryCost(
        int systemPromptTokens,
        int retrievedContextTokens,
        int queryTokens,
        int expectedResponseTokens)
    {
        var inputTokens = systemPromptTokens + retrievedContextTokens + queryTokens;
        var outputTokens = expectedResponseTokens;

        var inputCost = (inputTokens / 1000m) * 0.03m;
        var outputCost = (outputTokens / 1000m) * 0.06m;

        return inputCost + outputCost;
    }

    // Example:
    // System: 300 tokens
    // Context: 3000 tokens (10 retrieved docs)
    // Query: 50 tokens
    // Response: 500 tokens
    //
    // Cost = ((300 + 3000 + 50) / 1000 * 0.03) + (500 / 1000 * 0.06)
    //      = (3350 / 1000 * 0.03) + (500 / 1000 * 0.06)
    //      = $0.1005 + $0.03
    //      = $0.1305 per query
    //
    // At 1000 queries/day = $130/day = $3,900/month!
}

Kostenreductiestrategieën:

  1. Minder, beter gerangschikte documenten ophalen
  2. Gebruik prompt caching (Antropische Claude: 90% goedkoper voor gecachede tokens)
  3. Gebruik goedkopere modellen voor herranking, duur voor de uiteindelijke generatie
  4. Context comprimeren met behulp van samenvattingen

Visualiseren van de volledige RAG-stroom met tokens

Hier is hoe tokens, KV cache en RAG bij elkaar passen:

flowchart TB
    A[User Query:<br/>'How does Docker work?'<br/>≈ 12 tokens] --> B[Generate Query Embedding]

    B --> C[Vector Search]
    C --> D[Retrieved Docs:<br/>5 docs × 500 tokens<br/>= 2,500 tokens]

    D --> E[Construct Prompt]
    A --> E

    E --> F["Complete Prompt:<br/>System: 300 tokens<br/>Context: 2,500 tokens<br/>Query: 12 tokens<br/>Total: 2,812 tokens"]

    F --> G[Tokenize Prompt]
    G --> H["Token IDs:<br/>[245, 1034, 8829, ...]<br/>2,812 token IDs"]

    H --> I[LLM Layer 1]
    I --> J[Compute K,V]
    J --> K[KV Cache Layer 1:<br/>2,812 K,V pairs]

    I --> L[LLM Layer 2]
    L --> M[Compute K,V]
    M --> N[KV Cache Layer 2:<br/>2,812 K,V pairs]

    L --> O[... Layers 3-32]
    O --> P[Generate Token 1: 'Docker']

    P --> Q[Add to KV Cache]
    Q --> R[Generate Token 2: 'is']
    R --> S[Add to KV Cache]
    S --> T[... until completion]

    T --> U["Response: 'Docker is a containerization platform...'<br/>≈ 400 tokens"]

    style K stroke:#f9f,stroke-width:2px
    style N stroke:#f9f,stroke-width:2px
    style Q stroke:#bbf,stroke-width:2px
    style S stroke:#bbf,stroke-width:2px

Belangrijkste inzichten:

  1. Invoertekens (2.812) worden eenmaal verwerkt om initiële KV-cache te bouwen
  2. Generatie gebeurt een token tegelijk, het hergebruiken van de KV-cache
  3. Elke nieuwe token voegt toe aan de KV-cache voor toekomstige tokens om bij te wonen
  4. Totaal VRAM nodig = Modelgewichten + KV-cache voor alle tokens
  5. Langere context = grotere KV cache = meer VRAM

Praktische implicaties voor RAG

Het begrijpen van tokens en KV cache leidt tot een beter RAG-ontwerp:

1. Pre-compute en cache gemeenschappelijke contexten:

// Cache KV for frequently used system prompts + static context
var cachedSystemContext = await _llm.PrecomputeKVCache(systemPrompt + staticContext);

// Reuse for each query (much faster)
foreach (var query in userQueries)
{
    var response = await _llm.GenerateAsync(query, reuseKVCache: cachedSystemContext);
}

2. Optimaliseer brokranden:

// Bad: Arbitrary 500-character chunks
var chunks = text.Chunk(500);

// Good: Chunk on sentence boundaries, measure in tokens
public List<string> ChunkByTokens(string text, int maxTokensPerChunk)
{
    var sentences = SplitIntoSentences(text);
    var chunks = new List<string>();
    var currentChunk = new StringBuilder();
    var currentTokens = 0;

    foreach (var sentence in sentences)
    {
        var sentenceTokens = EstimateTokens(sentence);

        if (currentTokens + sentenceTokens > maxTokensPerChunk && currentTokens > 0)
        {
            chunks.Add(currentChunk.ToString());
            currentChunk.Clear();
            currentTokens = 0;
        }

        currentChunk.Append(sentence).Append(" ");
        currentTokens += sentenceTokens;
    }

    if (currentTokens > 0)
        chunks.Add(currentChunk.ToString());

    return chunks;
}

3. Monitor token gebruik in de productie:

public class RAGTelemetry
{
    public void LogRAGQuery(
        string query,
        List<SearchResult> retrievedDocs,
        string response)
    {
        var queryTokens = EstimateTokens(query);
        var contextTokens = retrievedDocs.Sum(d => EstimateTokens(d.Text));
        var responseTokens = EstimateTokens(response);
        var totalTokens = queryTokens + contextTokens + responseTokens;

        _logger.LogInformation(
            "RAG Query: {Query} | Context: {ContextTokens} tokens from {DocCount} docs | " +
            "Response: {ResponseTokens} tokens | Total: {TotalTokens} tokens",
            query, contextTokens, retrievedDocs.Count, responseTokens, totalTokens
        );

        // Alert if approaching context limit
        if (totalTokens > _maxTokens * 0.9)
        {
            _logger.LogWarning("Approaching token limit: {TotalTokens}/{MaxTokens}",
                totalTokens, _maxTokens);
        }
    }
}

Conclusie: Architectuurmeesterschap

We hebben de complete technische architectuur van RAG systemen besproken:

Fase 1: Indexering

  • Tekstextractie uit verschillende bronnen
  • Chunking strategieën (sectie-gebaseerd, zinsgebonden, met overlapping)
  • Inbeddingsgeneratie (ONNX, API-diensten)
  • Vectoropslag (Qdrant, pgvector, Pinecone)

Fase 2: Terugwinning

  • Query inbedding (hetzelfde model als indexeren!)
  • Vergelijkbare zoekopdracht (cosinus, euclidaan, dot product)
  • Facultatief herschikken voor een betere precisie
  • Metadatafiltering voor verfijnde resultaten

Fase 3: Generatie

  • Snelle constructie (context + instructies + query)
  • LLM-interferentie (temperatuur, top-p, max tokens)
  • Nabewerking (extractie van citaat, opmaak)

LLM Internen

  • Tokens: De fundamentele eenheden (geen karakters!)
  • KV Cache: Waarom generatie snel is (lineair, niet kwadratisch)
  • Contextvensters: Tokenlimieten beheren in RAG
  • Kostenoptimalisatie: Caching, compressie, smart retrieval

Belangrijkste technische inzichten:

  1. Zelfde inbeddingsmodel voor indexeren en ophalen (kritisch!)
  2. Chunken is belangrijker dan je denkt. - behoudt semantische samenhang
  3. Reranking verbetert de precisie ten koste van de latentie
  4. Token management is essentieel - raming voor het opvragen
  5. KV caching maakt RAG haalbaar - reuse prompt computings
  6. Contextvensters vullen snel - 10 docs × 500 tokens = 5K tokens

Doorgaan naar deel 3: RAG in de praktijk

Je begrijpt het nu. hoe RAG werkt Op technisch niveau. Maar theorie brengt je alleen zover. Hoe bouw je eigenlijk deze systemen? Welke uitdagingen ga je het hoofd bieden? Welke geavanceerde technieken kun je gebruiken?

In Deel 3: RAG in de praktijk, gaan we van architectuur naar implementatie:

Toepassingen in de praktijk:

  • Gerelateerde berichten aanbeveling op deze blog
  • Semantische blog zoeken
  • Bouwen van een "Advocaat GPT" schrijven assistent

Gemeenschappelijke uitdagingen en oplossingen:

  • Chunking strategieën die context behouden
  • Verbetering van de inbeddingskwaliteit voor uw domein
  • Het dynamisch beheren van contextvensters
  • Het voorkomen van hallucinatie ondanks het hebben van context
  • Uw index up-to-date houden

Geavanceerde technieken:

  • Hypothetisch document inbeddingen (HyDE)
  • Zelfbeluchten met LLM-geparseerde filters
  • Multiquery RAG voor uitgebreide resultaten
  • Contextuele compressie om tokengebruik te verminderen
  • Multi-hop RAG voor complexe vragen
  • Lange termijn conversatiegeheugen

Aan de slag:

  • Uitvoeringsplan per week
  • Voorbeelden van praktische code
  • Optimalisatiestrategieën
  • Wanneer mag u RAG NIET gebruiken

Doorgaan naar deel 3: RAG in de praktijk →

Middelen

Fundamentele papers:

Instrumenten en kaders:

Meer lezen:

Serienavigatie:

Ga verder naar deel 3 →

logo

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