Back to "RAG Architettura e Interni: come funziona veramente"

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 Architettura e Interni: come funziona veramente

Saturday, 22 November 2025

Dentro Parte 1, abbiamo coperto le origini di RAG, fondamentali, e perché importa. Si capisce il concetto di alto livello: recuperare informazioni pertinenti, quindi usarlo per generare risposte. Ora ci tuffiamo in profondità nell'architettura tecnica esattamente come i sistemi RAG funzionano sotto il cofano, dalle strategie di chunking a interni LLM come gettoni e cache KV.

Introduzione

Navigazione in serie: Questa è la parte 2 della serie RAG:

Se non hai letto la Parte 1, ti consiglio di iniziare da lì per capire:

  • Che cos'è RAG e perché è importante
  • La storia dalla ricerca delle parole chiave alla comprensione semantica
  • RAG vs fine-tuning e altri approcci

Questo articolo presuppone di capire queste basi e si concentra su architettura tecnica, dettagli di implementazione e interni LLM.

Come funziona RAG: L'immagine completa

Dividiamo esattamente ciò che accade in un sistema RAG, dal momento in cui si aggiunge un documento a quando un utente ottiene una risposta.

Fase 1: Indicizzazione (Preparazione della Base di Conoscenza)

Prima RAG può recuperare qualsiasi cosa, è necessario indicizzare la vostra base di conoscenze. Questo è un processo una tantum (anche se è possibile aggiungere nuovi documenti in seguito).

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

Fase 1: Estrazione di testo

Estrarre testo semplice dai documenti di origine. Questo potrebbe essere:

  • File Markdown (come i miei post sul blog)
  • PDF (per la documentazione)
  • HTML (per raschiare fotoricettore)
  • Record delle banche dati
  • E-mail, registri di chat, ecc.

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

Fase 2: Chunking

Qui è dove la maggior parte delle implementazioni RAG falliscono. Non puoi dividere solo i limiti dei paragrafi - hai bisogno di pezzi semanticamente coerenti.

Perché il chunking conta:

  • I LLM hanno limiti token (finestre di contesto)
  • Pezzi più piccoli = recupero più preciso
  • Ma i pezzi devono contenere abbastanza contesto per essere significativi

Brutta chiacchierata:

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

Buona chiacchierata:

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"

Esempio dalla mia implementazione di ricerca semantica:

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

Strategie comuni:

  • Dimensione fissa: Semplice ma rompe i confini semantici
  • Sulla base di una sentenza: Rispetta la grammatica ma può essere troppo piccola
  • Sulla base di un paragrafo: Dimensione naturale ma variabile
  • a base di sezioni: Migliore per contenuti strutturati (la mia preferenza)
  • Finestra scorrevole con sovrapposizione: Assicura che nessun contesto sia perso ai confini

Passo 3: Genera integrazione

Le inserzioni sono la magia che rende possibile la ricerca semantica. L'inserzione è un vettore (array di numeri) che rappresenta il significato del testo.

Concetto chiave: Significati simili → vettori simili

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

I primi due vettori sarebbero "vicini" nello spazio vettoriale (alta somiglianza del coseno), mentre il terzo è lontano.

Come vengono generati gli inserti: Moderni modelli di integrazione sono reti neurali addestrati su enormi set di dati di testo per imparare relazioni semantiche. Modelli popolari:

  • all-MiniLM-L6-v2: 384 dimensioni, veloce, di buona qualità (quello che uso su questo blog)
  • text-embedding-3-small (OpenAI): 1536 dimensioni, di altissima qualità
  • BGE-base: 768 dimensioni, open source all'avanguardia

Esempio dal mio servizio di integrazione ONNX:

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

Perché la normalizzazione conta: Dopo la normalizzazione L2, la somiglianza del coseno diventa un semplice prodotto puntino, rendendo la ricerca molto più veloce.

Passo 4: Memorizza nella banca dati vettoriale

I database vettoriali sono ottimizzati per la memorizzazione e la ricerca di vettori ad alta dimensione. A differenza dei database tradizionali che utilizzano query SQL, i database vettoriali utilizzano la ricerca di similarità.

Operazioni chiave:

  • UpsertCity name (optional, probably does not need a translation): Aggiungere o aggiornare un vettore con i metadati
  • Cerca: Trova K vettori più simili a un vettore di query
  • Filtro: Combina la ricerca vettoriale con i filtri dei metadati

Esempio di implementazione di Qdrant:

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

Database vettoriali popolari:

  • QdrantCity name (optional, probably does not need a translation): Veloce, auto-ospitabile, eccellente supporto C# (la mia scelta)
  • pgvector: Estensione PostgreSQL (grande se stai già usando Postgres)
  • Pinecone: Servizio gestito (costoso ma buono)
  • WeaviateCity name (optional, probably does not need a translation): Caratteristica ricca, buona per schemi complessi
  • ChromaDB: Python-focused, leggero

Esploreremo la creazione di questi database nei prossimi articoli.

Fase 2: recupero (ricerca di informazioni pertinenti)

Quando un utente pone una domanda, il sistema RAG deve trovare le informazioni più rilevanti dalla base di conoscenze.

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

Passo 1: Generare l'integrazione della query

La domanda dell'utente viene convertita in un vettore usando il stesso modello di inserimento usato per l'indicizzazione. Questo è critico - diversi modelli producono vettori incompatibili.

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

Passo 2: Ricerca di somiglianza

Il database vettoriale calcola la somiglianza tra il vettore query e tutti i vettori memorizzati.

Similarità del coseno (più popolare per i vettori normalizzati):

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

Intervallo: da -1 a 1 (più alto = più simile)

Distanza euclidea (per vettori non normalizzati):

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

Gamma: da 0 a ✓ (più basso = più simile)

Prodotto punto (quando i vettori sono prenormalizzati):

similarity = A · B

Intervallo: da -1 a 1 (più alto = più simile)

Esempio dal mio servizio Qdrant:

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

Fase 3: Rilanciamento (facoltativo ma raccomandato)

Il recupero iniziale è veloce ma approssimativo. Rilanciare utilizza un modello più sofisticato per riscoprire i migliori risultati K.

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

Perché riranking aiuta:

  • I modelli di inserimento rapido ottimizzano la velocità, sacrificando una certa precisione
  • I modelli di giudizio sono più lenti ma più precisi
  • L'approccio in due fasi bilancia velocità e qualità

Esempio di ridimensionamento dell'attuazione:

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: Generazione (creazione della risposta)

Ora che abbiamo informazioni rilevanti, la diamo all'LLM insieme alla domanda dell'utente.

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

Passo 1: Prompt Construction

Qui è dove RAG diventa un'arte. È necessario strutturare il prompt in modo LLM:

  • Utilizza il contesto fornito (non la sua conoscenza interna)
  • Sorgenti cites quando possibile
  • Ammette quando il contesto non contiene una risposta
  • Mantiene un tono/stile costante

Esempio template prompt dal mio sistema GPT Avvocato:

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

Fase 2: Inferenza LLM

Il prompt costruito va alla LLM per la generazione. Questo può essere:

  • API cloud: OpenAI, Anthropic Claude, Google PaLM
  • Modello locale: Utilizzando lama.cpp, ONNX Runtime, o TorchSharp

Esempio usando LLM locale:

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

Parametri chiave spiegati:

  • Temperatura: Controlla la casualità (0 = sempre scegliere più probabile, 1 = campione casuale)
  • Top P: campionamento nucleo - considerare solo i gettoni che compongono la massa di probabilità P superiore
  • Max TokensCity name (optional, probably does not need a translation): Lunghezza limite di risposta
  • Fermare le conseguenze: Quando smettere di generare

Fase 3: Post-elaborazione

Dopo che l'LLM genera una risposta, abbiamo spesso bisogno di:

  • Estrarre le citazioni e convertirle in link
  • Blocchi di codice di formato
  • Aggiungi metadati (fonti, punteggi di confidenza)
  • Registra l'interazione per il debug

Esempio di post-elaborazione:

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

Comprendere gli interni LLM: Token, KV Cache e Windows Context

Prima di passare alle applicazioni pratiche, è essenziale capire come funzionano internamente gli LLM. Questa conoscenza consente di ottimizzare i sistemi RAG ed evitare insidie comuni.

Cosa sono i Token?

I token sono le unità fondamentali che LLMs elabora. Il testo non viene alimentato direttamente ai modelli - è diviso per primo in gettoni.

Esempio di tokenizzazione:

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

Diversi modelli utilizzano diverse strategie di tokenizzazione:

  • Modelli GPT: Usa la codifica Byte-Pair (BPE) con vocabolario ~50K
  • Claude.: Simile approccio BPE
  • Modelli Llama: SentencePiece tokenization

Perché la tokenization è importante per 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;
    }
}

Limiti della finestra contestuale:

  • GPT-3.5: Token 16K
  • GPT-4: Token 8K-128K (a seconda della variante)
  • Claude 3.5 Sonnet: 200K gettoni
  • Llama 3: Token 8K (anche se possono essere estesi)

Nei sistemi RAG, è necessario adattarsi:

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

Se il tuo RAG recupera 10 documenti di 500 token ciascuno, sono 5.000 token solo per il contesto - prima della query e della risposta!

Gestione pratica del token RAG:

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

La cache di KV: l'arma segreta di LLM

Quando un LLM genera testo, non rielabora tutto da zero per ogni token. Cache del valore chiave (KV) per ricordare ciò che ha già calcolato.

Come funzionano i trasformatori (semplificato)

I trasformatori usano un meccanismo di "attenzione" dove ogni token "attenda" (guarda) tutti i token precedenti per capire il contesto.

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

Senza cache KV:

  • Fase 1: Processo 1 token → O(1)
  • Fase 2: Processo 2 gettoni da zero → O(2)
  • Fase 3: Processo 3 gettoni da zero → O(3)
  • Totale: O(1 + 2 + 3 + ... + N) = O(N2)

Con la cache di KV:

  • Passo 1: Processo 1 token, cache K,V → O(1)
  • Fase 2: Processo 1 nuovo token, riutilizzo cached K,V → O(1)
  • Passo 3: Processo 1 nuovo token, riutilizzo cached K,V → O(1)
  • Totale: O(N)

Questo rende la generazione drammaticamente più veloce - la differenza tra 10 gettoni/secondo e 100 gettoni/secondo.

La struttura dell'albero della cache di KV

La cache KV forma un "albero" a causa di come funziona l'attenzione nei trasformatori. Ogni livello del modello ha le proprie matrici K,V.

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

Ogni layer memorizza:

  • Chiavi (K): Rappresentazioni utilizzate per calcolare i punteggi di attenzione
  • Valori (V): Rappresentazioni che si mescolano sulla base dell'attenzione

Per un modello con:

  • 32 strati
  • 4096 dimensioni nascoste
  • 32 teste di attenzione
  • Finestra di contesto 8K

La cache di KV per una sequenza è:

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

Questo è il motivo per cui le lunghe finestre di contesto sono ad alta intensità di memoria.

Cache KV nei sistemi RAG

I sistemi RAG possono sfruttare l'ottimizzazione della cache KV in modi intelligenti:

Prompt cacheing (sostenuto da alcune API come 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);
    }
}

Perché questo è potente:

  • Prima query: Calcola la cache di KV per il prompt di sistema + il contesto (lento)
  • Query successive con lo stesso contesto: Riutilizza la cache KV (10x più veloce!)
  • Solo la porzione di query dell'utente ha bisogno di un nuovo calcolo

Esempio pratico:

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

Tutte e tre le query usano lo stesso contesto recuperato, quindi la cache di KV per quel contesto viene riutilizzata.

Limiti di taglio e strategia sugli orientamenti

Capire i gettoni e la cache di KV informa le decisioni dell'architettura RAG:

1. Dimensione di taglio

Pezzi più piccoli = recupero più preciso, ma più in alto:

// 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. Utilizzo della finestra di contesto

Non superare la finestra contestuale - lasciare spazio per la generazione:

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. Conversazioni multi-turn RAG

Nei chatbot, la storia della conversazione cresce ad ogni turno:

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!

Soluzione: Finestra scorrevole con 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. Ottimizzazione dei costi del token

API-based LLM carica per token. RAG può esplodere i costi se non attenzione:

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

Strategie di riduzione dei costi:

  1. Recupera meno documenti migliori
  2. Utilizzare cache prompt (Anthropic Claude: 90% più economico per gettoni cache)
  3. Utilizzare modelli più economici per ri-ranking, costosi per la generazione finale
  4. Comprimi il contesto usando la sintesi

Visualizzazione del flusso di RAG completo con i token

Ecco come i gettoni, la cache di KV e i RAG si adattano insieme:

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

Intuizioni chiave:

  1. Token di ingresso (2.812) vengono elaborati una volta per costruire la cache iniziale di KV
  2. Generazione accade un gettone alla volta, riutilizzando la cache di KV
  3. Ogni nuovo token aggiunge alla cache di KV per i token futuri a cui partecipare
  4. Totale VRAM necessario = Pesi modello + cache KV per tutti i gettoni
  5. Contesto più lungo = cache KV più grande = più VRAM

Implicazioni pratiche per gli orientamenti

Capire i gettoni e la cache di KV porta a una migliore progettazione RAG:

1. Pre-computa e cache contesti comuni:

// 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. Ottimizzare i confini del pezzo:

// 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. Monitorare l'uso token nella produzione:

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

Conclusione: Architettura Mastery

Abbiamo coperto l'architettura tecnica completa dei sistemi RAG:

Fase 1: Indicizzazione

  • Estrazione testo da varie fonti
  • Strategie di riflessione (section-based, frase-based, with suppostion)
  • Generazione di integrazione (ONNX, servizi API)
  • Stoccaggio vettoriale (Qdrant, pgvector, Pinecone)

Fase 2: recupero

  • Embedding query (stesso modello di indicizzazione!)
  • Ricerca di similarità (coseno, euclideo, prodotto punto)
  • Rilanciamento opzionale per una migliore precisione
  • Filtraggio dei metadati per risultati raffinati

Fase 3: Generazione

  • Prompt construction (context + istruzioni + query)
  • Inferenza LLM (temperatura, top-p, max token)
  • Post-elaborazione (estrazione di citazioni, formattazione)

Interni LLM

  • Tokens: Le unità fondamentali (non caratteri!)
  • Cache KV: Perché la generazione è veloce (lineare, non quadratica)
  • Finestre di contesto: Gestione dei limiti dei token in RAG
  • Ottimizzazione dei costi: cache, compressione, recupero intelligente

Principali intuizioni tecniche:

  1. Stesso modello di inserimento per l'indicizzazione e il recupero (critico!)
  2. Chunk è più importante di quanto pensi - preserva la coerenza semantica
  3. Rilanciando migliora la precisione al costo della latenza
  4. La gestione dei token è essenziale - stima prima dell'interrogazione
  5. KV cacheing rende RAG fattibile - riutilizzare i calcoli tempestivi
  6. Le finestre del contesto riempiono velocemente - 10 documenti × 500 gettoni = 5K gettoni

Continuare alla parte 3: RAG nella pratica

Ora capisci. come agisce RAG a livello tecnico. Ma la teoria ti porta solo fino ad ora. Come si costruiscono effettivamente questi sistemi? Quali sfide affronterete? Quali tecniche avanzate si possono utilizzare?

Dentro Parte 3: RAG nella pratica, passiamo dall'architettura all'implementazione:

Applicazioni del mondo reale:

  • Related posts recommend on this blog
  • Ricerca blog semantica
  • Costruire un assistente di scrittura "Avvocato GPT"

Sfide e soluzioni comuni:

  • Strategie di riflessione che preservano il contesto
  • Migliorare la qualità di integrazione per il tuo dominio
  • Gestire dinamicamente le finestre di contesto
  • Prevenire l'allucinazione nonostante il contesto
  • Mantenere l'indice aggiornato

Tecniche avanzate:

  • Inserimento di documenti ipotetici (HyDE)
  • Autochirurgia con filtri LLM
  • RAG multiquery per risultati completi
  • Compressione contestuale per ridurre l'uso del token
  • RAG multi-hop per interrogazioni complesse
  • Memoria di conversazione a lungo termine

Iniziare:

  • Piano di attuazione settimanale
  • Esempi pratici di codici
  • Strategie di ottimizzazione
  • Quando NON usare RAG

Continuare alla parte 3: RAG nella pratica →

Risorse

Documenti di fondazione:

Strumenti e quadri:

Ulteriore lettura:

Navigazione in serie:

Continua alla parte 3 →

logo

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