Construire un système RAG en C# avec Semantic Kernel

· 7 min de lecture

##Présentation

Si vous avez essayé d’utiliser un LLM pour répondre à des questions sur vos propres données (documents d’entreprise, spécifications de produits, bases de connaissances internes), vous avez probablement remarqué qu’il hallucine ou dit simplement « Je n’ai aucune information à ce sujet ». C’est parce que le modèle sait seulement sur quoi il a été formé.

RAG (Retrieval-Augmented Generation) corrige ce problème. Au lieu d’affiner un modèle sur vos données, vous récupérez des morceaux pertinents de vos documents au moment de la requête et les transmettez au LLM en tant que contexte. Le modèle génère ensuite des réponses fondées sur vos données réelles.

Dans cet article, je vais vous guider dans la création d’un pipeline RAG complet en C# à l’aide du noyau sémantique.

Comment fonctionne RAG

Le déroulement est simple :

  1. Ingérer : divisez vos documents en morceaux, générez des intégrations pour chaque morceau, stockez-les dans une base de données vectorielle
  2. Requête : lorsqu’un utilisateur pose une question, générez une intégration pour la requête, recherchez dans la base de données vectorielles des morceaux similaires.
  3. Générer : transmettez les morceaux récupérés comme contexte au LLM avec la question de l’utilisateur

C’est tout. La magie réside dans les intégrations : elles capturent la signification sémantique du texte sous forme de vecteurs, afin que vous puissiez trouver un contenu pertinent même lorsque les mots exacts ne correspondent pas.

Prérequis

dotnet add package Microsoft.SemanticKernel
dotnet add package Microsoft.SemanticKernel.Connectors.AzureOpenAI
dotnet add package Microsoft.Extensions.VectorData.Abstractions
dotnet add package Microsoft.SemanticKernel.Connectors.InMemory

Pour la production, vous remplaceriez le magasin en mémoire par Azure AI Search, Qdrant, Pinecone ou toute autre base de données vectorielles prise en charge. Mais la mémoire en mémoire est parfaite pour l’apprentissage et le prototypage.

Configuration du noyau

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.AzureOpenAI;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Connectors.InMemory;
using Microsoft.SemanticKernel.Embeddings;

var builder = Kernel.CreateBuilder();

builder.AddAzureOpenAIChatCompletion(
    deploymentName: "gpt-4o",
    endpoint: config["AzureOpenAI:Endpoint"],
    apiKey: config["AzureOpenAI:ApiKey"]);

builder.AddAzureOpenAITextEmbeddingGeneration(
    deploymentName: "text-embedding-3-small",
    endpoint: config["AzureOpenAI:Endpoint"],
    apiKey: config["AzureOpenAI:ApiKey"]);

var kernel = builder.Build();

Nous avons besoin de deux modèles : un pour terminer le chat (répondre aux questions) et un pour générer des intégrations (transformer le texte en vecteurs).

Définir le modèle de données

Nous avons besoin d’une classe pour représenter nos morceaux de documents dans le magasin de vecteurs :

using Microsoft.Extensions.VectorData;

public class DocumentChunk
{
    [VectorStoreRecordKey]
    public string Id { get; set; } = Guid.NewGuid().ToString();

    [VectorStoreRecordData]
    public string Content { get; set; } = string.Empty;

    [VectorStoreRecordData]
    public string Source { get; set; } = string.Empty;

    [VectorStoreRecordData]
    public int ChunkIndex { get; set; }

    [VectorStoreRecordVector(1536)]
    public ReadOnlyMemory<float> Embedding { get; set; }
}

L’attribut VectorStoreRecordVector(1536) indique au magasin de vecteurs la dimension de nos intégrations. Le modèle text-embedding-3-small produit des vecteurs à 1 536 dimensions.

## Regrouper des documents

Avant de pouvoir créer des intégrations, nous devons diviser nos documents en morceaux gérables. Voici un simple séparateur de texte :

public static class TextChunker
{
    public static List<string> SplitText(string text, int maxChunkSize = 500, int overlap = 50)
    {
        var chunks = new List<string>();
        var paragraphs = text.Split("\n\n", StringSplitOptions.RemoveEmptyEntries);
        var currentChunk = new System.Text.StringBuilder();

        foreach (var paragraph in paragraphs)
        {
            if (currentChunk.Length + paragraph.Length > maxChunkSize && currentChunk.Length > 0)
            {
                chunks.Add(currentChunk.ToString().Trim());

                // Keep overlap from the end of the previous chunk
                var overlapText = currentChunk.ToString();
                currentChunk.Clear();
                if (overlapText.Length > overlap)
                {
                    currentChunk.Append(overlapText[^overlap..]);
                    currentChunk.Append(' ');
                }
            }

            currentChunk.Append(paragraph);
            currentChunk.Append("\n\n");
        }

        if (currentChunk.Length > 0)
        {
            chunks.Add(currentChunk.ToString().Trim());
        }

        return chunks;
    }
}

Le chevauchement est important : il garantit que le contexte à la frontière entre les morceaux n’est pas perdu. Si une phrase pertinente est divisée en deux morceaux, le chevauchement signifie qu’elle apparaîtra entièrement dans au moins l’un d’entre eux.

Ingestion de documents

Maintenant, rassemblons tout cela pour ingérer des documents dans notre magasin de vecteurs :

var vectorStore = new InMemoryVectorStore();
var collection = vectorStore.GetCollection<string, DocumentChunk>("documents");
await collection.CreateCollectionIfNotExistsAsync();

var embeddingService = kernel.GetRequiredService<ITextEmbeddingGenerationService>();

async Task IngestDocument(string content, string source)
{
    var chunks = TextChunker.SplitText(content);

    for (int i = 0; i < chunks.Count; i++)
    {
        var embedding = await embeddingService.GenerateEmbeddingAsync(chunks[i]);

        var chunk = new DocumentChunk
        {
            Content = chunks[i],
            Source = source,
            ChunkIndex = i,
            Embedding = embedding
        };

        await collection.UpsertAsync(chunk);
    }

    Console.WriteLine($"✅ Ingested {chunks.Count} chunks from {source}");
}

// Ingest some documents
var doc1 = await File.ReadAllTextAsync("docs/product-guide.md");
var doc2 = await File.ReadAllTextAsync("docs/faq.md");
var doc3 = await File.ReadAllTextAsync("docs/troubleshooting.md");

await IngestDocument(doc1, "product-guide.md");
await IngestDocument(doc2, "faq.md");
await IngestDocument(doc3, "troubleshooting.md");

Recherche de morceaux pertinents

Lorsqu’un utilisateur pose une question, nous générons une intégration pour sa requête et recherchons des morceaux similaires :

async Task<List<DocumentChunk>> SearchAsync(string query, int topK = 3)
{
    var queryEmbedding = await embeddingService.GenerateEmbeddingAsync(query);

    var searchResults = await collection.VectorizedSearchAsync(
        queryEmbedding,
        new VectorSearchOptions { Top = topK });

    var results = new List<DocumentChunk>();
    await foreach (var result in searchResults.Results)
    {
        results.Add(result.Record);
    }

    return results;
}

Générer des réponses avec le contexte

Passons maintenant à la partie RAG : nous prenons les morceaux récupérés et les incluons comme contexte dans notre invite :

using Microsoft.SemanticKernel.ChatCompletion;

var chatService = kernel.GetRequiredService<IChatCompletionService>();

async Task<string> AskAsync(string question)
{
    // Step 1: Retrieve relevant chunks
    var relevantChunks = await SearchAsync(question);

    // Step 2: Build context from chunks
    var context = string.Join("\n\n---\n\n",
        relevantChunks.Select(c => $"[Source: {c.Source}]\n{c.Content}"));

    // Step 3: Generate answer with context
    var history = new ChatHistory();
    history.AddSystemMessage($$"""
        You are a helpful assistant that answers questions based on the provided context.
        Use ONLY the information from the context to answer. If the context doesn't contain
        enough information to answer the question, say "I don't have enough information
        to answer that question."

        Do not make up information. Always cite the source document when possible.

        Context:
        {{context}}
        """);

    history.AddUserMessage(question);

    var response = await chatService.GetChatMessageContentAsync(history);
    return response.Content ?? "No response generated.";
}

L’utiliser

// Ask questions about your documents
var answer1 = await AskAsync("How do I reset my password?");
Console.WriteLine($"Q: How do I reset my password?\nA: {answer1}\n");

var answer2 = await AskAsync("What are the system requirements?");
Console.WriteLine($"Q: What are the system requirements?\nA: {answer2}\n");

var answer3 = await AskAsync("What's the capital of France?");
Console.WriteLine($"Q: What's the capital of France?\nA: {answer3}\n");
// Should respond with "I don't have enough information" since it's not in the docs

Passage en production

Le magasin de vecteurs en mémoire est idéal pour le prototypage, mais pour la production, vous aurez besoin d’une base de données de vecteurs persistante. Le noyau sémantique dispose de connecteurs pour plusieurs options :

# Azure AI Search
dotnet add package Microsoft.SemanticKernel.Connectors.AzureAISearch

# Qdrant
dotnet add package Microsoft.SemanticKernel.Connectors.Qdrant

# Redis
dotnet add package Microsoft.SemanticKernel.Connectors.Redis

L’échange est simple puisqu’ils implémentent tous la même interface IVectorStore :

// Instead of InMemoryVectorStore, use:
using Azure;
using Microsoft.SemanticKernel.Connectors.AzureAISearch;

var vectorStore = new AzureAISearchVectorStore(
    new Azure.Search.Documents.Indexes.SearchIndexClient(
        new Uri(config["AzureAISearch:Endpoint"]),
        new AzureKeyCredential(config["AzureAISearch:ApiKey"])));
```Tout le reste reste pareil. C'est la beauté de l'abstraction.

## Conseils pour la création de systèmes RAG

Quelques choses que j'ai apprises à mes dépens :

- **La taille des morceaux compte beaucoup.** Trop petite et vous perdez le contexte. Trop volumineux et vous gaspillez des jetons sur du contenu non pertinent. Commencez avec 500 à 800 jetons et ajustez en fonction de vos données.
- **Le chevauchement évite les problèmes de limites.** Un chevauchement de 50 à 100 jetons entre les morceaux est généralement suffisant.
- **Récupérez plus que vous ne le pensez.** Commencez par `topK = 5` et réduisez si vous obtenez trop de bruit. Il est préférable d'avoir un contexte supplémentaire plutôt que de manquer le morceau pertinent.
- **Les invites système sont cruciales.** Soyez très explicite sur l'utilisation uniquement du contexte fourni. Sans cette instruction, le modèle hallucinera volontiers « sur la base de ses données dentraînement ».
- **Suivez les sources.** Stockez toujours les métadonnées avec vos morceaux afin de pouvoir citer d'où vient la réponse. Les utilisateurs font davantage confiance aux réponses lorsquils peuvent vérifier la source.
- **Reclassez si nécessaire.** La similarité des vecteurs n'est pas parfaite. Pour les applications critiques, ajoutez une étape de reclassement à laide dun modèle multi-encodeur pour améliorer la précision.

## Conclusion

RAG est actuellement lun des modèles les plus pratiques en IA. Il vous permet de créer des systèmes de questions-réponses basés sur l'IA sur vos propres données sans réglage fin, et Semantic Kernel le rend étonnamment propre en C#. Commencez par le magasin en mémoire, obtenez votre segmentation et vos invites correctes, puis échangez dans une véritable base de données vectorielles lorsque vous êtes prêt pour la production.

Bon codage !

## Ressources

- [Documentation du magasin de vecteurs du noyau sémantique](https://learn.microsoft.com/en-us/semantic-kernel/concepts/vector-store-connectors/)
- [Modèle RAG avec Azure AI Search](https://learn.microsoft.com/en-us/azure/search/retrieval-augmented-generation-overview)
- [Modèles d'intégration de texte](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models#embeddings)