Construire un agent RAG en .NET avec Azure AI Foundry et Qdrant

Construire un agent RAG en .NET avec Azure AI Foundry et Qdrant

La plupart des implémentations de RAG qu'on trouve en ligne sont des prototypes Python qui ne tiennent pas la charge en production.

Voici un pipeline RAG complet en .NET 10, avec Azure AI Foundry pour les embeddings et le LLM, et Qdrant comme base vectorielle. Le code compile, les identifiants de chunks sont stables (UUID déterministes, idempotents à la réingestion) et les deux pipelines sont volontairement séparés — ingestion batch d'un côté, requête temps réel de l'autre.

📦 Projet complet

Projet complet — Agent RAG .NET (Azure AI Foundry + Qdrant)

🔧 .NET 9 · Azure AI Foundry · Qdrant 1.12📄 13 fichiers📁 12 Ko

Code C# complet, prêt à compiler. Téléchargez le projet et exécutez-le directement.

Téléchargement gratuit · 1 email suffit


L'architecture cible

flowchart TB
    subgraph ING["Pipeline d'ingestion (batch)"]
        direction LR
        DOC["📄 PDF · Markdown · HTML"] --> CHK["✂️ Chunking + overlap"]
        CHK --> EMB["🔢 Embedding\ntext-embedding-3-small"]
        EMB --> QDB
    end

    QDB[("🗄️ Qdrant\nVector DB")]

    subgraph QRY["Pipeline de requête (temps réel)"]
        direction LR
        USR["💬 Question"] --> QEMB["🔢 Embedding\nrequête"]
        QEMB --> SRCH["🔍 Top-K search\nscore ≥ 0.70"]
        SRCH --> PROMPT["📝 Prompt\n+ contexte"]
        PROMPT --> LLM["🤖 GPT-4o\nAzure AI Foundry"]
        LLM --> ANS["✅ Réponse\n+ sources + score"]
    end

    QDB -->|"chunks"| SRCH

    style QDB fill:#2A3E6F,stroke:#4A90D9,color:#E8F0FA
    style LLM fill:#3A4E7F,stroke:#E8C84A,color:#E8F0FA
    style ANS fill:#1A3A20,stroke:#4A9A5A,color:#E8F0FA

Les deux pipelines sont délibérément séparés. Les confondre dans une seule classe est la première erreur à éviter.


Prérequis NuGet

<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="9.*" />
<PackageReference Include="Azure.AI.OpenAI" Version="2.*" />
<PackageReference Include="Qdrant.Client" Version="1.*" />
<PackageReference Include="PdfPig" Version="0.1.*" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.*" />

Démarrage local — Qdrant en Docker

Avant de brancher Azure, lance Qdrant en local :

docker run -d -p 6333:6333 -p 6334:6334 qdrant/qdrant

L'interface web est sur http://localhost:6333/dashboard. Le port 6334 est le port gRPC utilisé par QdrantClient.

Dans appsettings.Development.json :

{
  "Qdrant": { "Host": "localhost" },
  "AzureOpenAI": { "Endpoint": "https://VOTRE-ENDPOINT.openai.azure.com/" }
}

Extraction des documents

PdfPig extrait le texte brut d'un PDF page par page. Le DocumentChunker prend du texte brut en entrée, quelle que soit la source.

// DocumentExtractor.cs
public static class DocumentExtractor
{
    public static string FromPdf(string filePath)
    {
        using var document = PdfDocument.Open(filePath);
        var sb = new StringBuilder();
        foreach (var page in document.GetPages())
        {
            sb.AppendLine(page.Text);
            sb.AppendLine();
        }
        return sb.ToString();
    }

    public static string FromMarkdown(string filePath) =>
        File.ReadAllText(filePath);

    public static string FromHtml(string filePath)
    {
        var html = File.ReadAllText(filePath);
        return Regex.Replace(html, "<[^>]+>", " ")
                    .Replace("&nbsp;", " ")
                    .Replace("&amp;", "&");
    }
}

Pipeline d'ingestion

1. Extraction et chunking

L'idée clé, c'est la fenêtre glissante avec overlap : on conserve les derniers caractères du chunk précédent pour ne pas couper une idée en deux entre deux chunks.

// DocumentChunker.cs — le cœur : fenêtre glissante avec overlap
public IReadOnlyList<DocumentChunk> Chunk(string documentId, string content)
{
    var chunks  = new List<DocumentChunk>();
    var current = new StringBuilder();
    int index   = 0;

    foreach (var paragraph in SplitIntoParagraphs(content))
    {
        // Dépassement de taille → on ferme le chunk courant…
        if (current.Length + paragraph.Length > _options.MaxChunkSize && current.Length > 0)
        {
            chunks.Add(CreateChunk(documentId, index++, current.ToString()));

            // …et on REPART avec les N derniers caractères (overlap = continuité du contexte)
            var tail = current.ToString()[^Math.Min(_options.OverlapSize, current.Length)..];
            current.Clear();
            current.Append(tail);
        }
        current.AppendLine(paragraph);
    }
    if (current.Length > 0) chunks.Add(CreateChunk(documentId, index, current.ToString()));
    return chunks;
}

public record ChunkingOptions(int MaxChunkSize = 1000, int OverlapSize = 150);

CreateChunk, SplitIntoParagraphs et le record DocumentChunk sont dans le projet complet 📦

2. Génération des embeddings

// EmbeddingService.cs
public class EmbeddingService
{
    private readonly IEmbeddingGenerator<string, Embedding<float>> _generator;

    public EmbeddingService(IEmbeddingGenerator<string, Embedding<float>> generator)
        => _generator = generator;

    public async Task<float[]> GenerateAsync(string text, CancellationToken ct = default)
    {
        var result = await _generator.GenerateEmbeddingAsync(text, cancellationToken: ct);
        return result.Vector.ToArray();
    }

    public async Task<IReadOnlyList<float[]>> GenerateBatchAsync(
        IReadOnlyList<string> texts,
        CancellationToken ct = default)
    {
        var results = await _generator.GenerateAsync(texts, cancellationToken: ct);
        return results.Select(r => r.Vector.ToArray()).ToList();
    }
}

3. Stockage dans Qdrant

Deux points méritent l'attention : l'UUID déterministe (réingérer le même document ne crée pas de doublons) et le seuil de score à la recherche (qui filtre le bruit avant même d'atteindre le LLM).

// QdrantVectorStore.cs — extraits clés
public async Task UpsertChunksAsync(
    IReadOnlyList<(DocumentChunk Chunk, float[] Embedding)> items, CancellationToken ct = default)
{
    var points = items.Select(item => new PointStruct
    {
        // UUID déterministe : même chunk → même ID → réingestion idempotente (zéro doublon)
        Id      = new PointId { Uuid = ToStableUuid(item.Chunk.Id) },
        Vectors = item.Embedding,
        Payload = { ["content"] = item.Chunk.Content, ["document_id"] = item.Chunk.DocumentId }
    }).ToList();
    await _client.UpsertAsync(CollectionName, points, cancellationToken: ct);
}

public async Task<IReadOnlyList<RetrievedChunk>> SearchAsync(
    float[] query, int topK = 5, float scoreThreshold = 0.70f, CancellationToken ct = default)
{
    var results = await _client.SearchAsync(CollectionName, query,
        limit: (ulong)topK, scoreThreshold: scoreThreshold, cancellationToken: ct); // ← seuil = filtre qualité
    return results.Select(r => new RetrievedChunk(
        r.Payload["content"].StringValue, r.Payload["document_id"].StringValue, r.Score)).ToList();
}

// MD5 → Guid : un même ID texte donne toujours le même UUID, sans collision
private static string ToStableUuid(string input) =>
    new Guid(MD5.HashData(Encoding.UTF8.GetBytes(input))).ToString();

EnsureCollectionExistsAsync, le constructeur et le record RetrievedChunk sont dans le projet complet 📦

4. Pipeline d'ingestion complet

// IngestionPipeline.cs — orchestration en 3 étapes
public async Task IngestAsync(string documentId, string content, CancellationToken ct)
{
    // 1. Découpage en chunks
    var chunks = _chunker.Chunk(documentId, content);

    // 2. Embeddings EN BATCH (un seul appel API au lieu de N — divise le coût et la latence)
    var texts      = chunks.Select(c => c.Content).ToList();
    var embeddings = await _embeddings.GenerateBatchAsync(texts, ct);

    // 3. Stockage vectoriel
    var items = chunks.Zip(embeddings, (c, e) => (c, e)).ToList();
    await _store.UpsertChunksAsync(items, ct);
}

Constructeur, injection des dépendances et logging dans le projet complet 📦


Pipeline de requête (RAG proprement dit)

Le RAG tient en 5 étapes. Le point critique est le prompt système : c'est lui qui interdit au modèle d'inventer hors du contexte récupéré (première défense anti-hallucination).

// RagAgent.cs — le cœur du RAG en 5 étapes
public async Task<RagResponse> AnswerAsync(string question, CancellationToken ct = default)
{
    var queryEmbedding = await _embeddings.GenerateAsync(question, ct);            // 1. embedding question

    var chunks = await _store.SearchAsync(queryEmbedding, topK: 5,                 // 2. recherche sémantique
        scoreThreshold: 0.70f, ct);

    if (chunks.Count == 0)                                                         // 3. garde-fou : rien trouvé
        return RagResponse.NotFound;                                              //    → on n'invente pas

    var systemPrompt = """
        Réponds UNIQUEMENT à partir du CONTEXTE fourni.
        Si l'information n'y est pas, dis-le. N'ajoute aucune connaissance générale.
        """;
    var messages = new List<ChatMessage>                                          // 4. prompt + contexte
    {
        new(ChatRole.System, systemPrompt),
        new(ChatRole.User, $"CONTEXTE :\n{BuildContext(chunks)}\n\nQUESTION : {question}")
    };

    var response = await _llm.CompleteAsync(messages, cancellationToken: ct);      // 5. génération

    return new RagResponse(
        response.Message.Text ?? "",
        chunks.Select(c => c.DocumentId).Distinct().ToList(),
        chunks.Average(c => c.Score));   // score de confiance = similarité moyenne
}

BuildContext, le constructeur et le record RagResponse (avec NotFound) sont dans le projet complet 📦


Configuration et injection de dépendances

L'authentification passe par DefaultAzureCredentialaucune clé API en dur (Managed Identity en prod, Azure CLI en local).

// Program.cs — l'essentiel de la configuration
var builder  = Host.CreateApplicationBuilder(args);
var endpoint = new Uri(builder.Configuration["AzureOpenAI:Endpoint"]!);

// Azure AI Foundry — embeddings + chat (sans clé : DefaultAzureCredential)
builder.Services.AddEmbeddingGenerator<string, Embedding<float>>(_ =>
    new AzureOpenAIClient(endpoint, new DefaultAzureCredential())
        .AsEmbeddingGenerator("text-embedding-3-small"));
builder.Services.AddChatClient(_ =>
    new AzureOpenAIClient(endpoint, new DefaultAzureCredential())
        .AsChatClient("gpt-4o"));

// Qdrant (port 6334 = gRPC)
builder.Services.AddSingleton(_ =>
    new QdrantClient(builder.Configuration["Qdrant:Host"]!, 6334));

// Pipeline
builder.Services.AddSingleton(new ChunkingOptions(MaxChunkSize: 1000, OverlapSize: 150));
builder.Services.AddSingleton<DocumentChunker>();
builder.Services.AddSingleton<EmbeddingService>();
builder.Services.AddSingleton<QdrantVectorStore>();
builder.Services.AddSingleton<IngestionPipeline>();
builder.Services.AddSingleton<RagAgent>();

Build de l'app + initialisation de la collection Qdrant au démarrage dans le projet complet 📦


Utilisation

// Ingérer un document PDF
var pipeline = app.Services.GetRequiredService<IngestionPipeline>();
var text = DocumentExtractor.FromPdf("contrats/durand-sa.pdf");
await pipeline.IngestAsync("durand-sa", text, CancellationToken.None);

// Interroger
var agent = app.Services.GetRequiredService<RagAgent>();
var response = await agent.AnswerAsync("Quelles sont les clauses de résiliation ?");

Console.WriteLine(response.Answer);
Console.WriteLine($"Sources  : {string.Join(", ", response.Sources)}");
Console.WriteLine($"Confiance: {response.ConfidenceScore:P0}");

Ce qui manque dans la plupart des implémentations

Reranking avec Cohere. Les top-K résultats de Qdrant sont ordonnés par similarité vectorielle (cosinus entre embeddings) — efficace mais approximatif. Un reranker cross-encoder relit la paire (question, chunk) ensemble pour calculer une pertinence contextuelle réelle, bien plus précise pour les questions complexes.

L'API Cohere Rerank retourne un score de pertinence contextuelle fin pour chaque passage — sans fine-tuning ni modèle hébergé.

// http : HttpClient injecté, cohereApiKey : string depuis IConfiguration
var payload = new
{
    model     = "rerank-v3.5",
    query     = question,
    documents = chunks.Select(c => c.Content).ToArray(),
    top_n     = chunks.Count
};

using var req = new HttpRequestMessage(HttpMethod.Post,
    "https://api.cohere.com/v2/rerank");
req.Headers.Authorization =
    new AuthenticationHeaderValue("Bearer", cohereApiKey);
req.Content = JsonContent.Create(payload);

var res  = await http.SendAsync(req, ct);
var body = await res.Content.ReadFromJsonAsync<JsonElement>(ct);

// Réordonne les chunks par pertinence Cohere avant de les injecter dans le prompt
chunks = body.GetProperty("results")
    .EnumerateArray()
    .OrderByDescending(r => r.GetProperty("relevance_score").GetSingle())
    .Select(r => chunks[r.GetProperty("index").GetInt32()])
    .ToList();

Gestion du contexte trop long. Si vos 5 chunks font ensemble 6 000 tokens et votre question 200 tokens, vous êtes à 6 200 tokens de contexte. Pour GPT-4o, ça passe. Pour un modèle local avec une fenêtre de 4 096 tokens, ça plante. Calculez la longueur du contexte avant le call LLM.

Cache des embeddings. Recalculer l'embedding de la même question à chaque fois est un gaspillage. Un cache Redis avec TTL d'une heure sur les embeddings de requête divise les coûts API par 3 à 5 sur un système en production.

Évaluation continue avec RAGAS. Savoir que votre RAG "répond" ne suffit pas en production. RAGAS (RAG Assessment) est un framework open-source Python qui évalue votre pipeline de façon automatisée sur quatre métriques :

Métrique Ce qu'elle mesure
Faithfulness La réponse utilise-t-elle uniquement les infos du contexte ? (détection d'hallucination)
Answer Relevance La réponse répond-elle réellement à la question posée ?
Context Precision Les chunks récupérés sont-ils pertinents à la question ?
Context Recall Tous les documents utiles ont-ils bien été retrouvés ?

RAGAS génère des questions synthétiques à partir de vos documents, exécute votre pipeline, et retourne un score 0–1 par métrique. Intégré dans votre CI, il détecte les régressions dès qu'un changement de prompt, de chunk size, ou de score threshold dégrade la qualité.


Olivier Alessandri — Architecte IA & .NET · Mirakai Agents autonomes · Azure AI Foundry · Microsoft Orleans · Architecture multi-agents

📖 Aller plus loin sur ce sujet

Architecturer des agents IA en .NET

Architecturer des agents IA en .NET

Du prompt à la production : concevoir, fiabiliser et déployer des agents autonomes avec .NET, Microsoft Agent Framework et Azure AI Foundry

29 € one-shot · ou inclus avec Premium

Découvrir →

📦 Ressources

💾
Projet complet — Agent RAG .NET (Azure AI Foundry + Qdrant) Code source · 12 Ko
⬇ .ZIP

© 2026 Mirakai — Olivier Alessandri. Tous droits réservés. Cet article, son code et ses ressources sont protégés par le droit d'auteur. Toute reproduction ou diffusion sans autorisation écrite est interdite.

Un projet d'agents IA en tête ?