Qu'est-ce que le RAG ? Définition courte
Le RAG (Retrieval-Augmented Generation) est une architecture qui combine un moteur de recherche vectoriel et un LLM. Avant de générer une réponse, le système récupère les documents pertinents dans une base de connaissances privée, puis les injecte dans le prompt envoyé au modèle. Le LLM produit alors une réponse ancrée dans ces données au lieu de se limiter à ses connaissances d'entraînement.
Le flux RAG en trois étapes :
- Retrieval : recherche sémantique dans une base vectorielle pour récupérer les documents les plus proches de la requête.
- Augmentation : injection des documents récupérés dans le prompt envoyé au LLM.
- Generation : le LLM produit une réponse fondée sur le contexte injecté.

Le projet : une appli de prise de notes intelligente
Le cas d'usage est volontairement simple : permettre à un utilisateur de saisir des notes (un compte-rendu de réunion, par exemple), de les stocker, puis de les interroger ensuite par recherche sémantique pour en générer des résumés.
Concrètement, l'utilisateur peut écrire plusieurs notes au fil du temps. Plus tard, il tape un mot-clé ou une question (par exemple "Star Wars") et l'application va chercher les notes les plus proches sémantiquement, puis demande au LLM un résumé global. Aucune base de mots-clés n'est nécessaire : c'est la similarité vectorielle qui fait le travail.
Je me suis amusée à mettre des Disney dans ma base de données. Si je fais une recherche sur Star Wars, ça me trouve bien les bonnes notes et ça me fait un résumé sur les cinq premières qui ressortent.
Architecture : stack .NET, Qdrant, Llama 3 et Nomic Embed Text
L'application repose sur une stack 100 % .NET côté backend. Voici les composants retenus :
| Composant | Choix retenu | Justification |
| LLM | Llama 3 (forge interne) | Fenêtre de contexte de 131 072 tokens, support multilingue, hébergement souverain, accessible en API REST. |
| Base vectorielle | Qdrant | Client .NET mature, démarrage rapide, peu de configuration nécessaire. |
| Modèle d'embeddings | Nomic Embed Text | Multilingue, vecteurs de 768 dimensions, taille fixe pratique pour l'indexation. |
| Base relationnelle | PostgreSQL | Stockage texte des notes pour permettre la recherche par ID et l'affichage classique. |
| Backend | .NET + OpenAI SDK + Qdrant.Client | Authentification OpenID Connect via la forge. |
| Frontend (optionnel) | Blazor | Cohérence stack .NET, prototypage rapide. |
Comparatif rapide des bases vectorielles
Qdrant n'est pas la seule option, et le choix dépend du contexte projet. Voici les principales alternatives évaluées :
| bASE | Open source | Client .NET | Hébergement | Cas d'usage typique |
| Qdrant | Oui | Officiel, mature | Self-hosted ou cloud | RAG général, prod entreprise |
| PGVector | Oui | Via Npgsql
| Postgres existant | Stack 100% Postgres, faible volumétrie |
| Weaviate | Oui | Communautaire | Self-hosted ou cloud | Recherche hybride native |
| Pinecone | Non (managed) | Officiel
| Cloud uniquement | SaaS sans ops, démarrage rapide |
| Chroma | Oui | Communautaire
| Self-hosted | Prototypage, projets Python à l'origine |
PGVector a été testé en .NET mais le support n'était pas encore parfaitement stable au moment du dev ; l'intégration progresse mais reste perfectible. Qdrant a été retenu pour sa simplicité de mise en route.
Bases vectorielles : comprendre le principe
Une base vectorielle stocke les données sous forme de vecteurs mathématiques. Chaque texte est transformé via un modèle d'embedding en un vecteur de taille fixe ( ici 768 dimensions ) qui encode sa sémantique. La similarité entre deux textes est ensuite mesurée par une distance vectorielle.
Dans ce projet, c'est la distance cosinus qui a été retenue. Elle mesure l'angle entre deux vecteurs (leur direction sémantique), indépendamment de leur magnitude. Concrètement, deux textes proches sémantiquement auront des vecteurs pointant dans des directions similaires, peu importe leur longueur , exactement ce qu'on veut pour de la recherche par sens.
Snippet 1 : Configuration du service Qdrant
Le QdrantService est injecté en singleton. Il encapsule le client Qdrant, le HttpClient utilisé pour appeler l'API d'embeddings, et la configuration LLM. La collection vectorielle est créée au démarrage si elle n'existe pas, avec une taille de vecteur de 768 (correspondant à Nomic Embed Text) et une distance cosinus.
public class QdrantService
{
private const string _collectionName = "notesEmbedding";
private readonly IOptions<LlmConfiguration> _llmConfiguration;
private readonly HttpClient _httpClient;
private readonly Embeddings _embeddings;
private QdrantClient _client;
public QdrantService(
QdrantClient qdrantClient,
IOptions<LlmConfiguration> llmConfiguration,
HttpClient httpClient)
{
_client = qdrantClient;
_llmConfiguration = llmConfiguration
?? throw new ArgumentNullException(nameof(llmConfiguration));
_httpClient = httpClient
?? throw new ArgumentNullException(nameof(httpClient));
_embeddings = new Embeddings(_httpClient, _llmConfiguration);
Task.Run(async () =>
{
var isCollectionExist = await _client
.CollectionExistsAsync(_collectionName);
if (!isCollectionExist)
{
await _client.CreateCollectionAsync(
_collectionName,
new VectorParams { Size = 768, Distance = Distance.Cosine }
);
}
}).Wait();
}
}
À retenir : le Size = 768 doit correspondre exactement à la dimension de sortie du modèle d'embedding utilisé. Une incohérence ici provoquera des erreurs silencieuses au moment des recherches.
Snippet 2 : Génération d'un embedding via appel HTTP direct
La génération d'embedding est faite par appel HTTP brut vers l'API du modèle Nomic Embed Text exposée sur la forge interne. Le SDK OpenAI ne supportait pas correctement ce modèle, donc le passage en HTTP donne le contrôle total sur les headers, le payload et la désérialisation.
public async Task<EmbeddingResponse?> GenerateEmbeddingAsync(string note)
{
var request = new HttpRequestMessage(
HttpMethod.Post,
$"{_llmConfiguration.Value.BaseUrl}embeddings");
request.Headers.Add(
"Authorization",
$"Bearer {_llmConfiguration.Value.Key}");
var body = new
{
model = _llmConfiguration.Value.Embeddings,
input = note
};
request.Content = new StringContent(
JsonSerializer.Serialize(body),
Encoding.UTF8,
"application/json");
var response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<EmbeddingResponse>(
json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
}
Pipeline d'ingestion et de recherche
Ingestion d'une note
Lorsqu'un utilisateur soumet une note, deux flux parallèles s'exécutent : la note est persistée au format texte dans PostgreSQL (pour la recherche par ID et l'affichage classique), et en parallèle elle est transformée en vecteur par le modèle d'embedding, lequel est stocké dans Qdrant avec une référence à l'ID Postgres. Ce processus de génération d'embedding est un service interne, non exposé directement dans l'API publique.
Recherche sémantique : snippet 3
La méthode SearchSimilarVector orchestre les deux étapes du retrieval : transformer la requête utilisateur en vecteur, puis interroger Qdrant pour récupérer les top-K notes les plus proches en distance cosinus.
public async Task<List<string>?> SearchSimilarVector(
string note,
int limit)
{
var embeddingResponse = await _embeddings.GenerateEmbeddingAsync(note);
var vector = embeddingResponse?.Data?
.FirstOrDefault()?
.Embedding?
.ToArray();
if (vector != null)
{
var points = await _client.SearchAsync(
_collectionName,
vector,
limit: (ulong)limit);
return points
.Select(p => p.Payload["note"]
.ToString()
.Replace("\"stringValue\":", ""))
.ToList();
}
return null;
}
L'enchaînement SearchSimilarVector → SummarizeNotes → Ok traduit en code .NET les trois étapes du RAG (retrieval, augmentation, generation). Les annotations OpenAPI permettent une documentation Swagger automatique.
Génération du résumé : snippet 4
Pour la génération de texte, on utilise le SDK OpenAI .NET, qui fonctionne avec n'importe quel endpoint compatible avec l'API OpenAI (cas de Llama 3 sur la forge). Le prompt est volontairement minimaliste et fournit deux contraintes : la longueur cible en mots, et le contenu à résumer.
public async Task<ChatMessageContent> SummarizeNote(
string note,
int numberOfWord,
string llmModel)
{
try
{
var client = _openAIClient.GetChatClient(llmModel);
string prompt = $"""
Summarize the following text in {numberOfWord} words more or less:
{note}
""";
var response = await client.CompleteChatAsync(
new OpenAI.Chat.ChatMessage[]
{
OpenAI.Chat.ChatMessage.CreateSystemMessage(prompt)
});
return response.Value.Content;
}
catch (Exception ex)
{
throw new Exception(
$"The llm is not able to summarise the request: {ex}");
}
}
Endpoint REST RAG : snippet 5
Voici l'endpoint qui orchestre l'ensemble du flux RAG. Il reçoit une requête utilisateur, lance la recherche vectorielle, puis demande au LLM de produire un résumé global des notes récupérées.
[HttpGet("/notes/searchandresume/notes/{note}/{maxNote}")]
[Consumes("application/json")]
[Tags("Get closest notes")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> SearchAndSummarizeNotes(
string note,
int maxNote = 10,
string llmModel = "Llama-3_3-70b")
{
InputValidator.Validate(note);
var notesList = await _vectorService
.SearchSimilarVector(note, maxNote);
if (notesList != null && notesList.Any())
{
var summary = await _llmService
.SummarizeNotes(notesList, 200, llmModel);
return Ok(summary.First().Text);
}
return NotFound("No similar notes found.");
}
L'enchaînement SearchSimilarVector → SummarizeNotes → Ok traduit en code .NET les trois étapes du RAG (retrieval, augmentation, generation). Les annotations OpenAPI permettent une documentation Swagger automatique.
LE Piège du max token et la stratégie de chuncking
Chaque LLM dispose d'une fenêtre de contexte maximale, exprimée en tokens. Pour Llama 3, c'est 131 072 tokens. Cette fenêtre inclut l'input (le prompt envoyé) et l'output (la réponse générée). Un token équivaut à environ 4 caractères pour du texte sans caractères spéciaux. Si on dépasse cette limite, le modèle rejette la requête ; il faut donc prévoir un buffer de sécurité.
Le chunking : pourquoi et comment
Quand le volume des données récupérées dépasse la fenêtre de contexte, il faut découper les documents en chunks. Ici, la limite est fixée à 1 000 tokens par chunk. Le chunking implique une stratégie de résumé en cascade : chaque chunk est d'abord résumé indépendamment, puis l'ensemble de ces micro-résumés est envoyé dans un second prompt pour produire un résumé global cohérent. Cette approche en deux passes évite de saturer le modèle tout en maintenant la cohérence éditoriale.
L'overlapping entre chunks
Sans chevauchement, on risque de couper une phrase ou une idée en plein milieu. La bonne pratique est un overlap de 10 à 20 %, pour que chaque chunk conserve suffisamment de contexte. Dans la première version du projet, l'overlap n'a pas été implémenté ; un choix assumé pour ne pas complexifier inutilement le démarrage. C'est une optimisation identifiée pour les notes volumineuses.
Pièges concrets rencontrés avec les librairies .NET
Piège 1 : Max Token par défaut dépassé dans le SDK OpenAI .NET
La librairie OpenAI pour .NET configure par défaut une valeur de tokens supérieure au maximum autorisé par certains modèles cibles. Résultat : les requêtes sont rejetées en silence. Le paramètre est présent mais marqué comme protected, donc non modifiable côté consommateur. La solution a été de réécrire manuellement les appels HTTP à l'API, avec les bons headers et la bonne valeur de Max Token ; opération simple en HTTP brut, mais non anticipée si on s'appuie aveuglément sur le SDK.
Piège 2 : paramètres disparus dans les versions récentes
Certains paramètres présents dans les anciennes versions du SDK OpenAI .NET ont disparu de l'API publique tout en restant dans le modèle objet sous-jacent. Cela peut contraindre à basculer sur des appels HTTP directs ou à pinner une version antérieure du package.
Piège 3 : vecteurs trop proches sur des corpus homogènes
Cas observé en test : sur un corpus de CV, les vecteurs générés étaient extrêmement proches les uns des autres.
« Sur du CV, j'avais tellement d'overlap entre deux profils que les vecteurs étaient sensiblement les mêmes. La différence se jouait sur le nombre d'années d'expérience ou sur des détails et sur ton volume de texte total, ça ne suffit pas à différencier. »
Raison : les CV partagent un vocabulaire très standardisé, et la longueur de texte par document n'est pas suffisante pour discriminer finement les profils. Solutions : un chunking plus fin, une recherche hybride combinant vectoriel et BM25, ou un reranking via un cross-encoder en post-traitement.
Recherche hybride : quand combiner vectoriel et mot-clé
La recherche vectorielle excelle pour la proximité sémantique sans correspondance exacte de mots. Mais elle est faible sur les recherches précises : un identifiant, un nom propre, un code produit. Pour ces cas, la recherche textuelle classique reste plus efficace. C'est pourquoi l'architecture maintient les deux bases en parallèle : Qdrant pour le sémantique, PostgreSQL pour le structuré. La recommandation émergente dans la communauté RAG est de combiner les deux types de recherche puis de fusionner les résultats, par exemple via un Reciprocal Rank Fusion.
Les paramètres clés à maîtriser pour un RAG performant
L'un des constats les plus marquants de cette implémentation, c'est le nombre de paramètres à calibrer. Pour une appli simple avec des volumes modérés, les valeurs par défaut suffisent. Pour de la production à grande échelle, chaque paramètre mérite une attention spécifique.
| Paramètre | Plage typique | Impact |
| Modèle LLM | GPT-4o, Llama 3, Mistral… | Qualité de génération, coût, latence. |
| Modèle d'embedding | Nomic, BGE, OpenAI ada | Qualité de la recherche sémantique. |
| Taille de chunk | 500-1500 tokens | Trop grand = imprécision ; trop petit = perte de contexte. |
| Overlap | 10-20% du chunk | Préserve la continuité sémantique entre chunks. |
| Distance vectorielle | Cosinus / Euclidienne | Cosinus = défaut sémantique. |
| Top-K | 3-10 résultats | Plus grand = contexte riche mais prompt plus lourd. |
| Max Output Tokens | 256-4096 | Latence directe et coût. |
| Prompt engineering | Instructions concises | Réduit hallucinations, force le format. |
Conclusion : RAG en .NET, un choix viable avec quelques ajustements
Implémenter un système RAG complet en .NET est tout à fait accessible aujourd'hui : l'écosystème de librairies est mature, les modèles sont accessibles via API REST standardisées, et les bases vectorielles comme Qdrant se déploient en quelques minutes. Les principales difficultés ne sont pas conceptuelles mais opérationnelles : des librairies en maturation qui obligent parfois à redescendre au niveau HTTP pour contourner des limitations, et un nombre important de paramètres à calibrer pour obtenir un comportement optimal. Une fois ces écueils anticipés, .NET est un choix tout à fait pertinent pour du RAG en environnement entreprise.
FAQ : Questions fréquentes sur le RAG en .NET
Quelle différence entre RAG et fine-tuning ?
Le fine-tuning modifie les poids d'un modèle pour l'adapter à un domaine, ce qui est coûteux et lent à mettre à jour. Le RAG injecte les données pertinentes au moment de la requête : c'est plus économique, plus réactif aux mises à jour, et compatible avec n'importe quel LLM accessible par API.
Quelle base vectorielle choisir pour un projet .NET ?
Pour un projet en production avec un client .NET mature, Qdrant est un excellent choix par défaut. Pour une stack 100 % PostgreSQL avec faible volumétrie, PGVector permet d'éviter une nouvelle dépendance. Pinecone reste pertinent si on cherche un service entièrement managé.
Pourquoi appeler l'API d'embedding en HTTP brut plutôt qu'avec le SDK ?
Trois raisons : certains paramètres clés ne sont pas exposés dans le SDK OpenAI .NET, on garde la portabilité vers n'importe quel fournisseur compatible REST, et le contrôle des headers d'authentification est total , utile sur des forges internes avec des schémas d'auth spécifiques.
Comment éviter les hallucinations dans un système RAG ?
Trois leviers principaux : un prompt explicite imposant le recours strict au contexte fourni, une recherche vectorielle de qualité qui retourne réellement les documents pertinents, et un mécanisme de citation des sources permettant à l'utilisateur de vérifier l'origine de chaque réponse. En pratique, sur ce projet, peu d'hallucinations ont été observées tant que le prompt était bien formulé.
Quelle taille de chunk choisir ?
La plage typique est 500 à 1 500 tokens, avec un overlap de 10 à 20 %. Pour des documents structurés (FAQ, doc technique), des chunks plus petits (300-500 tokens) fonctionnent bien. Pour des documents narratifs (rapports, comptes-rendus), des chunks plus grands (1 000-1 500 tokens) préservent mieux le contexte.
Le RAG fonctionne-t-il en multilingue ?
Oui, à condition d'utiliser un modèle d'embedding multilingue (Nomic Embed Text, BGE-M3, multilingual-e5) et un LLM lui-même multilingue. C'est l'une des raisons du choix de Nomic Embed Text dans ce projet : il supporte le français nativement.
Vous avez un projet RAG en .NET ?
Chez KAIZEN Solutions, nous accompagnons les équipes tech sur la conception, l'implémentation et la mise en production de systèmes RAG robustes, du choix de la base vectorielle au prompt engineering avancé, en passant par la sécurisation des données métier.
Discutons de votre cas d'usage
Retour aux articles