.NETC#AI EngineeringRAGDevOpsCI/CD

Transformez vos documents en index RAG .NET traçable : découpage, embeddings, contrôles de qualité, artefacts et déploiement sécurisé en CI.

Yva Hajatiana
28 août 2026
10 min de lecture
Partager :X / TwitterLinkedIn
Composition éditoriale abstraite sur les données, l’intelligence artificielle et l’automatisation

Industrialiser l’ingestion RAG .NET avec la CI

Un RAG ne devient pas fiable parce qu’un premier document apparaît dans une base vectorielle. Sa qualité dépend aussi de la façon dont les sources sont sélectionnées, découpées, indexées et promues. Si l’ingestion est lancée à la main depuis un poste local, il est difficile de savoir quel commit a produit l’index en production, de rejouer un échec ou d’empêcher un document incomplet d’arriver dans l’assistant.

Microsoft.Extensions.DataIngestion est une brique récente de l’écosystème .NET : elle compose un lecteur de documents, des processeurs, un découpeur et un écrivain de données vectorielles. Sa version 10.9.0-preview.1 a été publiée en août 2026. Cette API ne remplace ni la revue éditoriale ni les évaluations de réponses ; elle fournit une structure .NET pour rendre la chaîne documents → chunks → index testable et observable.

Ce tutoriel construit une ingestion de documents Markdown dans SQLite, avec Azure OpenAI pour les embeddings. Nous l’encadrons ensuite par une CI GitHub Actions : chaque exécution produit un rapport, échoue explicitement si un document n’a pas été traité, puis publie l’index comme artefact. Le même modèle s’applique à Qdrant, SQL Server, Azure AI Search ou Cosmos DB via un fournisseur de données vectorielles adapté.

API en préversion. Microsoft.Extensions.DataIngestion est encore en préversion. Épinglez les versions validées, conservez le fichier de verrouillage NuGet et testez les mises à jour dans une branche avant de les promouvoir. L’API et les noms de packages peuvent évoluer.

Le contrat à automatiser

Une pipeline d’ingestion doit rendre quatre choses visibles : les entrées, la configuration, les résultats et la destination. Sans cela, un « réindexage réussi » ne dit pas grand-chose.

ÉlémentÀ versionner ou conserverPourquoi
Sourcesdocuments Markdown, règles d’inclusion et métadonnéessavoir ce que l’assistant était autorisé à connaître
Configurationmodèle d’embeddings, taille des chunks, dimensions, package lockpouvoir reproduire le découpage et la recherche
Rapportdocument, statut, horodatage, version du commitdiagnostiquer un échec partiel sans fouiller les logs
Indexartefact immuable ou version d’indexne promouvoir que le résultat validé

Le découpage est une décision produit. Des chunks trop grands diluent la réponse et alourdissent le contexte ; des chunks trop petits séparent une règle de ses exceptions. Commencez par des titres Markdown et une taille mesurée, puis ajustez à partir d’un corpus d’évaluation. L’article Évaluer une application IA .NET dans une pipeline CI couvre le contrôle de la réponse finale ; ici, nous contrôlons l’étape située avant la recherche.

documents versionnés
        |
        v
lecture + découpage + embeddings ----> rapport JSON
        |
        v
index temporaire validé
        |
        +--> artefact CI --> promotion vers l’index de production

N’écrasez pas directement un index de production au fil des fichiers. Écrivez dans une version nommée par le commit, validez-la, puis basculez un alias ou une configuration vers cette version. Le mécanisme précis dépend de votre base vectorielle ; le principe réduit le risque de servir un index à moitié rempli.

Prérequis et structure locale

Il faut le SDK .NET 10, un compte Azure OpenAI avec un déploiement de chat et un déploiement d’embeddings, et PowerShell. Le laboratoire utilise SQLite, donc aucun serveur de base de données n’est nécessaire. Les noms de déploiement Azure ne sont pas forcément les noms commerciaux des modèles : renseignez les vôtres dans les variables de configuration.

Créez une console et les répertoires suivants :

dotnet new console --framework net10.0 --name KnowledgeIngestion
Set-Location KnowledgeIngestion
New-Item -ItemType Directory -Path data, artifacts -Force
dotnet user-secrets init

dotnet add package Azure.AI.OpenAI
dotnet add package Microsoft.Extensions.AI.OpenAI --prerelease
dotnet add package Microsoft.Extensions.Configuration
dotnet add package Microsoft.Extensions.Configuration.UserSecrets
dotnet add package Microsoft.Extensions.DataIngestion --prerelease
dotnet add package Microsoft.Extensions.DataIngestion.Markdig --prerelease
dotnet add package Microsoft.Extensions.Logging.Console
dotnet add package Microsoft.ML.Tokenizers.Data.O200kBase
dotnet add package Microsoft.SemanticKernel.Connectors.SqliteVec --prerelease
dotnet restore --use-lock-file

À la première restauration, NuGet crée packages.lock.json. Commitez-le avec le projet de l’ingestion. Avant une nouvelle restauration en CI, utilisez --locked-mode afin qu’une dépendance de préversion ne change pas silencieusement le comportement.

Pour l’essai local, placez les secrets hors du dépôt :

dotnet user-secrets set AZURE_OPENAI_ENDPOINT "https://<ressource>.openai.azure.com/"
dotnet user-secrets set AZURE_OPENAI_API_KEY "<cle-locale>"
dotnet user-secrets set AZURE_OPENAI_CHAT_DEPLOYMENT "<deploiement-chat>"
dotnet user-secrets set AZURE_OPENAI_EMBEDDING_DEPLOYMENT "<deploiement-embeddings>"

Ajoutez par exemple data/retention.md :

# Politique de conservation

## Journaux d’application

Les journaux applicatifs sont conservés 30 jours. Les demandes légales suspendent la suppression automatique jusqu’à leur clôture.

## Accès

Seule l’équipe SRE peut exporter un journal de production après ouverture d’un ticket.

Ne mélangez pas aveuglément des exports de production, des contrats ou des données personnelles dans ce dossier. L’ingestion les transforme et les envoie potentiellement au service d’embeddings. Définissez une classification des sources, une liste d’exclusion et une revue humaine avant que le contenu soit admissible au pipeline.

1. Construire une ingestion qui signale les échecs

Le code suivant repose sur le pipeline officiel : MarkdownReader convertit les sources dans le format d’ingestion, SemanticSimilarityChunker préserve autant que possible les unités de sens, et VectorStoreWriter génère les embeddings avant l’écriture. La différence importante pour l’exploitation est le rapport JSON et le code de sortie non nul lorsqu’au moins un document échoue.

Remplacez Program.cs par :

using System.Text.Json;
using Azure;
using Azure.AI.OpenAI;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DataIngestion;
using Microsoft.Extensions.DataIngestion.Chunkers;
using Microsoft.Extensions.DataIngestion.Readers;
using Microsoft.Extensions.Logging;
using Microsoft.ML.Tokenizers;
using Microsoft.SemanticKernel.Connectors.SqliteVec;
using Microsoft.Extensions.VectorData;

IConfigurationRoot configuration = new ConfigurationBuilder()
    .AddUserSecrets<Program>()
    .AddEnvironmentVariables()
    .Build();

string Require(string name) => configuration[name]
    ?? throw new InvalidOperationException($"La configuration {name} est absente.");

string endpoint = Require("AZURE_OPENAI_ENDPOINT");
string apiKey = Require("AZURE_OPENAI_API_KEY");
string chatDeployment = Require("AZURE_OPENAI_CHAT_DEPLOYMENT");
string embeddingDeployment = Require("AZURE_OPENAI_EMBEDDING_DEPLOYMENT");

using ILoggerFactory loggerFactory = LoggerFactory.Create(builder =>
    builder.AddSimpleConsole(options => options.SingleLine = true));

AzureOpenAIClient azureClient = new(new Uri(endpoint), new AzureKeyCredential(apiKey));
IEmbeddingGenerator<string, Embedding<float>> embeddings = azureClient
    .GetEmbeddingClient(embeddingDeployment)
    .AsIEmbeddingGenerator();

IngestionDocumentReader reader = new MarkdownReader();
IngestionChunkerOptions chunkerOptions = new(TiktokenTokenizer.CreateForModel(chatDeployment))
{
    MaxTokensPerChunk = 800,
    OverlapTokens = 80,
};
IngestionChunker<string> chunker = new SemanticSimilarityChunker(embeddings, chunkerOptions);

using SqliteVectorStore vectorStore = new(
    "Data Source=artifacts/knowledge.db;Pooling=false",
    new() { EmbeddingGenerator = embeddings });
using VectorStoreWriter<string> writer = new(
    vectorStore,
    dimensionCount: 1536,
    new VectorStoreWriterOptions { CollectionName = "knowledge" });
using IngestionPipeline<string> pipeline = new(reader, chunker, writer, loggerFactory: loggerFactory);

var results = new List<IngestionRunResult>();
await foreach (IngestionResult result in pipeline.ProcessAsync(
    new DirectoryInfo("data"), searchPattern: "*.md"))
{
    results.Add(new IngestionRunResult(result.DocumentId, result.Succeeded));
    Console.WriteLine($"{result.DocumentId}: {(result.Succeeded ? "OK" : "ECHEC")}");
}

Directory.CreateDirectory("artifacts");
await using FileStream reportFile = File.Create("artifacts/ingestion-report.json");
await JsonSerializer.SerializeAsync(reportFile, new IngestionRun(
    DateTimeOffset.UtcNow,
    Environment.GetEnvironmentVariable("GITHUB_SHA") ?? "local",
    embeddingDeployment,
    results));

return results.All(result => result.Succeeded) ? 0 : 1;

public sealed record IngestionRunResult(string DocumentId, bool Succeeded);
public sealed record IngestionRun(
    DateTimeOffset StartedAtUtc,
    string SourceRevision,
    string EmbeddingDeployment,
    IReadOnlyList<IngestionRunResult> Documents);

L’exemple utilise 1536, la dimension de text-embedding-3-small dans la documentation Microsoft. Cette valeur doit correspondre exactement au déploiement choisi. Si vous changez de modèle ou de dimension, créez un nouvel index : ne réutilisez pas une collection contenant des vecteurs d’une dimension différente.

Lancez le laboratoire :

dotnet run
Get-Content artifacts/ingestion-report.json

Le rapport ne doit contenir ni texte de documents, ni prompts, ni clé API. C’est un artefact de diagnostic, pas une copie de la base de connaissances. Pour produire des métriques plus riches — nombre de chunks, durée et coût — ajoutez-les au rapport après avoir vérifié qu’elles ne divulguent pas de contenu sensible.

2. Tester le contrat sans appeler le modèle

Les appels d’embeddings sont coûteux et variables ; ils n’ont pas leur place dans chaque test unitaire. Isolez les règles déterministes : présence des sources, absence de fichiers interdits et lecture valide du rapport. Ce test ne requiert aucune clé.

Créez un projet de tests :

dotnet new xunit --framework net10.0 --name KnowledgeIngestion.Tests
dotnet add KnowledgeIngestion.Tests reference KnowledgeIngestion

Dans KnowledgeIngestion.Tests/SourcePolicyTests.cs, ajoutez :

namespace KnowledgeIngestion.Tests;

public sealed class SourcePolicyTests
{
    [Fact]
    public void Knowledge_base_contains_only_markdown_sources()
    {
        var sourceDirectory = new DirectoryInfo(
            Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "data"));

        FileInfo[] files = sourceDirectory.GetFiles("*", SearchOption.AllDirectories);

        Assert.NotEmpty(files);
        Assert.All(files, file => Assert.Equal(".md", file.Extension, ignoreCase: true));
        Assert.DoesNotContain(files, file => file.Name.Contains("secret", StringComparison.OrdinalIgnoreCase));
    }
}

Adaptez la règle à vos politiques réelles. Par exemple, une équipe peut autoriser les PDF seulement après une étape d’extraction antivirus et de classification. Le point clé est que l’admission des sources soit vérifiable sans dépendre d’un LLM.

3. Exécuter l’ingestion dans GitHub Actions

Une exécution sur chaque pull request peut se contenter des tests déterministes. Réservez les appels au modèle et la production de l’index à un déclenchement manuel ou à une fusion sur la branche protégée : cela limite les coûts et évite que des contributions non approuvées utilisent une identité de production.

Créez .github/workflows/ingest-knowledge.yml :

name: Ingest knowledge base

on:
  workflow_dispatch:
  push:
    branches: [main]
    paths:
      - "knowledge/**"
      - ".github/workflows/ingest-knowledge.yml"

permissions:
  contents: read
  id-token: write

jobs:
  ingest:
    runs-on: ubuntu-latest
    environment: production
    defaults:
      run:
        working-directory: knowledge/KnowledgeIngestion
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: "10.0.x"

      - name: Restore locked dependencies
        run: dotnet restore --locked-mode

      - name: Run source-policy tests
        run: dotnet test ../KnowledgeIngestion.Tests --no-restore

      - name: Sign in to Azure with OIDC
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Build a candidate index
        env:
          AZURE_OPENAI_ENDPOINT: ${{ vars.AZURE_OPENAI_ENDPOINT }}
          AZURE_OPENAI_CHAT_DEPLOYMENT: ${{ vars.AZURE_OPENAI_CHAT_DEPLOYMENT }}
          AZURE_OPENAI_EMBEDDING_DEPLOYMENT: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT }}
          AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
        run: dotnet run --no-restore

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: knowledge-index-${{ github.sha }}
          path: |
            knowledge/KnowledgeIngestion/artifacts/ingestion-report.json
            knowledge/KnowledgeIngestion/artifacts/knowledge.db
          if-no-files-found: error

id-token: write et azure/login préparent une authentification fédérée OIDC ; la clé API reste dans cet exemple car le programme officiel ci-dessus l’emploie. Dans une mise en production, remplacez-la par une identité managée ou par un jeton Azure AD accepté par votre configuration Azure OpenAI. Ne donnez à l’identité CI que les permissions nécessaires à l’index candidat, et protégez l’environnement production par des approbateurs.

Le mot production dans le workflow ne doit pas être une simple convention : créez l’environnement GitHub correspondant, limitez les branches autorisées et placez-y les secrets à portée élevée. Une pull request ne doit jamais pouvoir déclencher cette identité.

Points de vigilance avant la promotion

  • Échecs partiels : ProcessAsync retourne un résultat par document. Traitez un Succeeded: false comme un résultat incomplet, même si les autres fichiers sont indexés. La pipeline doit alors arrêter la promotion, mais conserver le rapport.
  • Suppression et obsolescence : une ingestion qui ne fait qu’ajouter des chunks laisse les anciens contenus répondables. Prévoyez un identifiant de source, une stratégie de suppression et un test de documents retirés.
  • Réindexation reproductible : un changement de chunking ou de modèle d’embeddings change le sens des vecteurs. Versionnez la configuration et construisez un nouvel index au lieu de mélanger les générations.
  • Coût et quotas : établissez un plafond de documents et de tokens par exécution, avec une alerte lorsque la taille de l’entrée dérive. Les enrichisseurs LLM facultatifs amplifient le coût ; commencez sans eux.
  • Données sensibles : les chunks, embeddings, traces et sauvegardes de l’index doivent suivre la même politique de conservation et d’accès que les sources. Un embedding n’est pas une anonymisation garantie.
  • Qualité de recherche : un pipeline qui termine correctement ne prouve pas que les bons chunks sont retrouvés. Gardez un corpus de questions-réponses et mesurez la pertinence de la récupération, puis la qualité de la réponse.

Sources

Mots-clés :.NETC#AI EngineeringRAGDevOpsCI/CD
Y

Yva Hajatiana

Articles techniques sur l'ingénierie logicielle, .NET, le cloud et l'intelligence artificielle appliquée aux applications.