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 :
- Retrieval non pertinent : la requête utilisateur et le chunk "sémantiquement proche" ne parlent pas du même sujet. Le vecteur de la question "comment on gère les erreurs ici ?" se retrouve proche d'un chunk qui mentionne "gestion" et "erreurs" dans un contexte totalement différent. La similarité cosinus ne comprend pas l'intention.
- Débordement de contexte : vous injectez 10 chunks de 500 tokens pour être sûr d'avoir la bonne réponse quelque part. Le modèle se noie dans le bruit, dilue les chunks pertinents, et commence à synthétiser plutôt qu'à citer. Résultat : une réponse qui semble correcte mais ne l'est pas.
- Hallucination sur les cas limites : aucun chunk ne couvre vraiment la question. Le modèle comble avec ses connaissances générales. Pour un assistant interne sur des runbooks ou des ADR, c'est catastrophique — la réponse générique est pire que l'absence de réponse.
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 :
- Mettre du code source dans le vector store : les fichiers
.csne se chunksent pas bien par fixed-size, la structure syntaxique du code n'est pas du texte naturel, et les embeddings de code sont mauvais avec des modèles de langage généralistes. Pour du code, utilisez des embeddings spécialisés (text-embedding-3-large avec prefix "code:") ou un graph de dépendances. Un RAG sur du code source naïf répondra avec des snippets syntaxiquement proches mais sémantiquement faux. - Chunker sans respecter les boundaries sémantiques : couper une procédure en 3 chunks de taille fixe garantit qu'aucun chunk ne contient la procédure complète. Le modèle synthétise les 3 fragments et produit une réponse amalgamée. Utilisez les markers structurels de votre source.
- Ignorer les métadonnées de fraîcheur : une knowledge base interne a des documents obsolètes. Un ADR supplanté par un autre, un runbook pour une infra décommissionnée. Sans filtre sur
LastModifiedou un champIsActive, votre assistant répondra avec de l'information périmée avec la même confiance qu'avec de l'information actuelle.
É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 :
- Faithfulness : est-ce que la réponse finale est supportée par les chunks récupérés ? (détecte les hallucinations)
- Context relevance : est-ce que les chunks récupérés répondent effectivement à la question ? (mesure la qualité du retrieval)
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 à :
- "Quelle est la décision sur le messaging broker ?" → récupère l'ADR correspondant, cite la décision et la date
- "Comment on rollback le service de paiement ?" → récupère le runbook exact, filtrées sur DocumentType = "runbook"
- "Quel repo je clone pour démarrer en tant que nouveau dev ?" → onboarding doc, section "Premier jour"
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.