Un RAG qui "fonctionne" en démo rate souvent en production. Pas parce que le modèle est mauvais, pas parce que Semantic Kernel est mal configuré — mais parce que trois décisions architecturales en amont sabotent silencieusement la qualité des réponses. Cet article ne vous explique pas ce qu'est le RAG. Il vous montre pourquoi votre RAG répond médiocrement et comment y remédier, avec du vrai C# et de vrais trade-offs.

Les trois modes d'échec du RAG naïf

Avant d'aller plus loin, posons le diagnostic. Un RAG naïf — chunks fixed-size, une seule passe de retrieval, prompt direct — échoue de trois façons distinctes :

Ces trois problèmes ont des solutions différentes. Augmenter la taille du contexte n'en résout aucun. Changer de modèle n'en résout aucun. La solution est dans le pipeline.

Le vrai choix : quelle stratégie de chunking ?

Le chunking est la décision qui a le plus d'impact sur la qualité du retrieval, et c'est celle sur laquelle on passe le moins de temps. Trois approches, avec leurs contextes d'application :

Fixed-size : simple, souvent insuffisant

512 tokens avec 50 tokens d'overlap. Facile à implémenter, prévisible en mémoire. Le problème : une décision architecturale documentée sur 800 tokens sera coupée en deux. Le chunk 1 contient le contexte, le chunk 2 contient la décision. Aucun des deux n'est suffisant seul. Et l'overlap n'aide pas vraiment — il duplique du texte, il ne reconstruit pas le sens.

Semantic chunking : coûteux, rarement nécessaire

L'idée est d'utiliser les embeddings pour détecter les ruptures sémantiques dans le texte et couper là. En théorie élégant, en pratique : lent à l'ingestion, non-déterministe, et difficile à debugger. Réservez-le aux sources non structurées (transcriptions de réunions, emails longues chaînes) où vous n'avez pas d'autre signal structurel.

Header-aware Markdown chunking : le bon choix pour de la doc technique

Pour Confluence, SharePoint, des wikis, des ADR — tout ce qui est structuré en Markdown ou HTML avec des headings — cette approche est nettement supérieure. Vous coupez aux boundaries naturels du document (## Section, ### Sous-section), vous injectez le titre de la section comme métadonnée, et chaque chunk est sémantiquement cohérent par construction.

public sealed class MarkdownHeaderChunker
{
    private static readonly Regex HeaderPattern =
        new(@"^(#{1,3})\s+(.+)$", RegexOptions.Multiline | RegexOptions.Compiled);

    public IReadOnlyList<DocumentChunk> Chunk(string markdown, string documentId)
    {
        var chunks = new List<DocumentChunk>();
        var matches = HeaderPattern.Matches(markdown);

        for(var i = 0; i < matches.Count; i++)
        {
            var start = matches[i].Index;
            var end = i + 1 < matches.Count ? matches[i + 1].Index : markdown.Length;
            var content = markdown[start..end].Trim();

            if(content.Length < 50) { continue; } // ignorer les sections vides

            chunks.Add(new DocumentChunk(
                Id: $"{documentId}#{i}",
                Content: content,
                Metadata: new ChunkMetadata(
                    DocumentId: documentId,
                    SectionTitle: matches[i].Groups[2].Value,
                    HeaderLevel: matches[i].Groups[1].Length
                )
            ));
        }

        return chunks;
    }
}

Le titre de section (SectionTitle) devient une métadonnée de filtrage. Vous pouvez ensuite chercher uniquement dans les sections de niveau H2 d'un document spécifique. Ce que le fixed-size ne peut pas faire.

Setup Semantic Kernel : dev vs prod

Pour le développement local, l'in-memory vector store de Semantic Kernel suffit amplement. Pour la production, Azure AI Search. La bonne nouvelle : Semantic Kernel abstrait les deux derrière la même interface. Voici le wiring complet :

// Program.cs — configuration du kernel
var builder = Kernel.CreateBuilder();

// Embeddings — même interface, deux backends
builder.AddAzureOpenAITextEmbeddingGeneration(
    deploymentName: config["Azure:EmbeddingDeployment"]!,
    endpoint: config["Azure:OpenAIEndpoint"]!,
    apiKey: config["Azure:OpenAIKey"]!
);

// Vector store : bascule dev/prod via configuration
if(builder.Configuration.IsProduction())
{
    builder.AddAzureAISearchVectorStore(
        new Uri(config["AzureSearch:Endpoint"]!),
        new AzureKeyCredential(config["AzureSearch:Key"]!)
    );
}
else
{
    builder.AddInMemoryVectorStore();
}

var kernel = builder.Build();

// Récupération de la collection typée
var vectorStore = kernel.Services.GetRequiredService<IVectorStore>();
var collection = vectorStore.GetCollection<string, DocumentChunk>("knowledge-base");
await collection.EnsureCollectionExistsAsync();

// Ingestion d'un chunk
await collection.UpsertAsync(new DocumentChunk
{
    Id = chunk.Id,
    Content = chunk.Content,
    SectionTitle = chunk.Metadata.SectionTitle,
    DocumentId = chunk.Metadata.DocumentId,
    ContentEmbedding = await embeddingService.GenerateEmbeddingAsync(chunk.Content)
});

La clé : IVectorStore et IVectorStoreRecordCollection sont des interfaces. Votre code d'ingestion et de retrieval ne change pas entre dev et prod. Seul le wiring DI change.

Le query rewriting : l'amélioration à ROI le plus élevé

C'est le changement le plus sous-estimé dans un pipeline RAG. Un utilisateur tape "comment on déploie en prod ?". Le moteur vectoriel va chercher des chunks proches de cette formulation conversationnelle courte. Ce qu'il trouve : des chunks qui contiennent les mots "déploie" et "prod" dans n'importe quel contexte.

Le query rewriting demande au LLM de transformer la question en une description sémantiquement dense, optimisée pour la recherche vectorielle :

public sealed class QueryRewriter(Kernel kernel)
{
    private const string RewritePrompt = """
        Tu es un expert en recherche documentaire.
        Transforme la question suivante en une requête de recherche dense,
        sans pronoms ni formulations conversationnelles.
        Inclus les synonymes techniques pertinents.
        Réponds uniquement avec la requête réécrite, rien d'autre.

        Question : {{$question}}
        """;

    public async Task<string> RewriteAsync(string question, CancellationToken ct = default)
    {
        var function = kernel.CreateFunctionFromPrompt(RewritePrompt);
        var result = await kernel.InvokeAsync(function,
            new KernelArguments { ["question"] = question },
            ct);

        return result.GetValue<string>() ?? question; // fallback sur la question originale
    }
}

// Dans le pipeline de retrieval
public async Task<IReadOnlyList<DocumentChunk>> RetrieveAsync(string userQuery)
{
    var rewrittenQuery = await _queryRewriter.RewriteAsync(userQuery);

    var searchOptions = new VectorSearchOptions<DocumentChunk>
    {
        Top = 5,
        VectorPropertyName = nameof(DocumentChunk.ContentEmbedding)
    };

    var queryEmbedding = await _embeddingService.GenerateEmbeddingAsync(rewrittenQuery);
    var results = await _collection.VectorizedSearchAsync(queryEmbedding, searchOptions);

    return await results.Results
        .Select(r => r.Record)
        .ToListAsync();
}

"comment on déploie en prod ?" devient "procédure déploiement production pipeline CI/CD étapes validation environnement release". La similarité cosinus avec vos runbooks de déploiement passe d'environ 0.72 à plus de 0.89 dans mes mesures. Ce delta change tout.

Hybrid search : quand la sémantique ne suffit pas

La recherche vectorielle pure a un angle mort : les identifiants techniques exacts. Noms de services (PaymentGatewayAdapter), codes d'erreur (ERR_4023), noms de commandes CLI (az acr import). Ces termes ont des embeddings proches de beaucoup de choses sémantiquement — l'embedding d'un nom de classe ressemble à n'importe quelle classe. BM25 (recherche full-text classique) les trouve précisément.

Azure AI Search supporte nativement la recherche hybride avec RRF (Reciprocal Rank Fusion). En Semantic Kernel, la configuration est dans les options de recherche :

// Avec Azure AI Search — hybrid search activé
var searchOptions = new VectorSearchOptions<DocumentChunk>
{
    Top = 5,
    VectorPropertyName = nameof(DocumentChunk.ContentEmbedding),
    // Active le hybrid search : vecteurs + BM25 fusionnés via RRF
    HybridSearch = new HybridSearchOptions<DocumentChunk>
    {
        AdditionalPropertyName = nameof(DocumentChunk.Content),
        Query = rewrittenQuery // aussi utilisé pour BM25
    }
};

// Filtrage par métadonnée — ne récupérer que les runbooks
var filter = new VectorSearchFilter()
    .EqualTo(nameof(DocumentChunk.DocumentType), "runbook");

searchOptions.Filter = filter;

Le filtrage par métadonnée est souvent oublié mais crucial. Sur une knowledge base qui contient des ADR, des runbooks et de la documentation d'onboarding, injecter des chunks de nature différente dans le même contexte crée de la confusion. Filtrez d'abord, puis cherchez.

Ce qu'il ne faut pas faire

Quelques anti-patterns vus en mission, avec les raisons pour lesquelles ils échouent :

Évaluer la qualité sans test suite : LLM-as-judge

Comment savoir si votre pipeline s'améliore entre deux configurations ? Sans dataset labellisé, utilisez le pattern LLM-as-judge en deux étapes :

Étape 1 — génération de questions synthétiques : pour chaque chunk ingéré, demandez au LLM de générer 2-3 questions auxquelles ce chunk répond. Vous obtenez un dataset question/chunk_attendu sans travail manuel.

Étape 2 — évaluation automatique : pour chaque question générée, lancez votre pipeline RAG et évaluez deux métriques :

public sealed class RagEvaluator(Kernel kernel)
{
    private const string FaithfulnessPrompt = """
        Contexte récupéré :
        {{$context}}

        Réponse générée :
        {{$answer}}

        La réponse est-elle entièrement supportée par le contexte fourni ?
        Réponds par un JSON : {"score": 0-1, "issues": ["..."]}
        Score 1 = entièrement supportée, 0 = hallucination complète.
        """;

    public async Task<EvaluationResult> EvaluateFaithfulnessAsync(
        string context,
        string answer,
        CancellationToken ct = default)
    {
        var function = kernel.CreateFunctionFromPrompt(FaithfulnessPrompt);
        var result = await kernel.InvokeAsync(function, new KernelArguments
        {
            ["context"] = context,
            ["answer"] = answer
        }, ct);

        return JsonSerializer.Deserialize<EvaluationResult>(
            result.GetValue<string>()!)!;
    }
}

Ce n'est pas une évaluation parfaite — le LLM juge peut lui-même se tromper. Mais c'est suffisant pour comparer deux configurations de pipeline et identifier laquelle améliore la faithfulness moyenne sur votre corpus. Courez cette évaluation en CI avant de déployer un changement de chunking ou de retrieval.

Cas concret : knowledge base interne pour une équipe dev

Le cas d'usage où ce pipeline brille vraiment : une équipe de 15 développeurs avec 3 ans de Confluence, 40 ADR, des runbooks d'incidents, et des process d'onboarding éparpillés. L'assistant interne répond à :

La clé est l'ingestion incrémentale avec webhook Confluence : chaque modification de page déclenche une réingestion du document modifié. Vos chunks restent frais. Ajoutez un champ LastModified dans les métadonnées et un filtre qui pondère les chunks récents plus fortement dans le score final — ou simplement exclut les chunks de plus de 18 mois pour les runbooks.

"Un RAG médiocre est pire qu'une bonne recherche full-text. Il donne des réponses fausses avec l'autorité d'un oracle. Un RAG bien conçu est le meilleur investissement de productivité pour une équipe documentée."

Pour aller plus loin

Ce pipeline — header chunking, query rewriting, hybrid search, LLM-as-judge — couvre 90% des cas d'usage d'assistant interne. Les 10% restants concernent des sources hétérogènes (PDFs scannés, slides) où vous aurez besoin d'une étape OCR/parsing en amont, et des corpus en plusieurs langues où la langue du query doit matcher la langue des chunks.

Si vous travaillez sur des agents plus autonomes qui orchestrent plusieurs sources documentaires, la prochaine étape logique est l'orchestration d'agents IA — où le RAG devient un outil parmi d'autres dans un plan d'exécution.

Vous implémentez un assistant documentaire en .NET ?

Je peux auditer votre pipeline RAG, configurer votre indexation Azure AI Search
ou intervenir en mission sur vos projets IA & .NET.

✉ Me contacter Voir mes services →