Menu
RAG en .NET : REX d'une appli de prise de notes intelligente avec Qdrant et Llama 3

RAG en .NET : REX d'une appli de prise de notes intelligente avec Qdrant et Llama 3

Par Anaïs ALEX

12.05.2026

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é.

Description-fonctionnement-RAG

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 PostgreSQLStockage 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 sourceClient .NET  Hébergement Cas d'usage typique
Qdrant

 Oui

Officiel, matureSelf-hosted ou cloudRAG général, prod entreprise
 PGVector OuiVia Npgsql
Postgres existantStack 100% Postgres, faible volumétrie
 Weaviate OuiCommunautaireSelf-hosted ou cloudRecherche hybride native
 Pinecone Non (managed)Officiel
Cloud uniquementSaaS sans ops, démarrage rapide
 Chroma OuiCommunautaire
Self-hostedPrototypage, 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ètrePlage typique   Impact
Modèle LLMGPT-4o, Llama 3, Mistral…Qualité de génération, coût, latence.
Modèle d'embeddingNomic, BGE, OpenAI adaQualité de la recherche sémantique.
Taille de chunk500-1500 tokensTrop grand = imprécision ; trop petit = perte de contexte.
Overlap10-20% du chunk Préserve la continuité sémantique entre chunks.
Distance vectorielleCosinus / EuclidienneCosinus = défaut sémantique.
Top-K3-10 résultatsPlus grand = contexte riche mais prompt plus lourd.
Max Output Tokens256-4096Latence directe et coût.
Prompt engineeringInstructions concisesRé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

C'est à lire...