RAG local avec Ollama, LiteLLM et Qdrant

Couches de données futuristes avec bases empilées, flux de données et visualisations analytiques

RAG local avec Ollama, LiteLLM et Qdrant

Partie 2 sur 2 de la série “Développement IA avec Ollama et .NET” : Partie 1 – Développement IA local | 🇬🇧 Version

Ce billet montre comment construire un pipeline RAG local en .NET en utilisant Ollama pour les modèles, LiteLLM comme proxy API et Qdrant pour la recherche vectorielle.

Un exemple complet et fonctionnel est disponible ici : mongeon/code-examples · local-rag-ollama-litellm.

Comment fonctionne le RAG

RAG (Retrieval-Augmented Generation) augmente un LLM avec des connaissances externes en récupérant les documents pertinents et en les utilisant comme contexte. Au lieu de se fier uniquement aux données d’entraînement du modèle, le RAG ancre les réponses dans vos documents spécifiques, garantissant l’exactitude, la fraîcheur et la traçabilité.

Le flux se divise en deux phases :

Indexation (Ingestion)

┌─────────────────────────────────────────────────────────────┐
│                     Vos documents                            │
│              (PDFs, markdown, code, etc.)                    │
└─────────────────────────────────────────────────────────────┘
                    ┌─────────────────┐
                    │ Nettoyer & Split │  (Retirer en-têtes,
                    │   en chunks      │   normaliser)
                    └─────────────────┘
                   ┌──────────────────────┐
                   │  Convertir en Vecteurs│  (Modèle embedding :
                   │  (via Ollama/        │   nomic-embed-text)
                   │   LiteLLM)           │
                   └──────────────────────┘
                    ┌─────────────────────┐
                    │  Stocker en DB Vec. │  (Qdrant)
                    │                     │
                    └─────────────────────┘

L’indexation s’exécute une fois (ou lorsque les documents changent). Vous chargez votre base de connaissances, découpez les documents en chunks gérables (200–500 tokens chacun), et convertissez chaque chunk en vecteur dense via un modèle d’embedding. Ces vecteurs capturent le sens sémantique, les concepts similaires se regroupent dans l’espace vectoriel. Stockez-les dans une base vectorielle pour une recherche rapide par similarité.

C’est un coût unique par ensemble de documents. Vous pouvez l’exécuter en batch, la nuit, ou dans votre pipeline CI/CD. Une fois indexée, votre base de connaissances est prête pour les requêtes.

Requête

┌──────────────────────────────────────────────────────────────┐
│                   Question de l'utilisateur                  │
│            "Comment fonctionnent les embeddings?"            │
└──────────────────────────────────────────────────────────────┘
                   ┌──────────────────────┐
                   │ Convertir Q en Vecteur│  (Même modèle
                   │                      │   embedding)
                   └──────────────────────┘
                   ┌──────────────────────┐
                   │  Trouver Top-k Chunks│  (Similarité cosinus
                   │  similaires en DB Vec │   search)
                   └──────────────────────┘
          ┌───────────────────────────────────────────┐
          │ Chunks récupérés (Contexte fondateur)     │
          │ - "Les embeddings sont des vecteurs..."   │
          │ - "Ils capturent le sens sémantique..."   │
          │ - ...                                     │
          └───────────────────────────────────────────┘
    ┌─────────────────────────────────────────────────┐
    │ Construire Prompt avec Contexte                 │
    │ "Contexte: [chunks]\n\nQuestion: Comment...?"   │
    └─────────────────────────────────────────────────┘
              ┌───────────────────────────┐
              │   Générer réponse via     │  (Ollama + LiteLLM,
              │   LLM local (qwen2.5)     │   temp : 0.2)
              └───────────────────────────┘
        ┌──────────────────────────────────────┐
        │ Réponse ancrée avec citations        │
        │ "Selon le contexte récupéré,         │
        │  les embeddings sont... [cité]"      │
        └──────────────────────────────────────┘

La requête s’exécute à chaque question d’utilisateur. La question est convertie en vecteur avec le même modèle d’embedding, puis la base vectorielle trouve les chunks les plus similaires (typiquement 3–5). Ces chunks deviennent le contexte fourni à votre LLM, qui génère une réponse ancrée dans vos documents, pas dans ses données d’entraînement.

Ce processus est rapide (typiquement <500ms pour retrieval + génération sur matériel local) et déterministe, même question avec mêmes chunks donne des réponses cohérentes. La séparation indexation/retrieval permet de faire évoluer la base de connaissances sans réentraîner les modèles ni ralentir les requêtes.

Avantages clés

  • Exactitude : Réponses ancrées dans les documents, pas d’hallucinations.
  • Traçabilité : Utilisateurs voient quels chunks ont été utilisés, citations incluses.
  • Fraîcheur : Mises à jour du savoir par réindexation; pas de réentraînement du modèle.
  • Hors ligne : Tout tourne localement; pas d’appels cloud.

Architecture

La pile d’implémentation :

  1. Ingestion & Indexation : Charger docs → nettoyer → découper → embed → stocker vecteurs.
  2. Retrieval : À la requête, embed question → trouver top-k chunks similaires en DB vecteurs.
  3. Génération : Composer prompt avec contexte → répondre via Ollama (via proxy LiteLLM).
  4. Évaluation : Valider ancrage, mesurer pertinence et tracer latence.
  • Ollama installé avec les modèles nécessaires (ex. nomic-embed-text pour les embeddings, qwen2.5 ou llama3.2 pour la génération).
  • LiteLLM qui proxy vers Ollama.
  • Qdrant (Docker).

Exemple de config LiteLLM (litellm.yaml)

LiteLLM agit comme un proxy API unifié entre votre code .NET et Ollama. Au lieu d’appeler Ollama directement, vous pointez votre application vers LiteLLM, qui achemine les requêtes au bon modèle. Cela maintient votre code cohérent, que vous utilisiez des modèles locaux (via Ollama) ou que vous basculiez éventuellement vers des APIs cloud.

La config ci-dessous définit deux modèles :

  • nomic-embed-text : Un modèle d’embedding léger (~340M paramètres) qui convertit le texte en vecteurs denses.
  • qwen2.5 : Un LLM polyvalent pour la génération de réponses.

Les deux pointent vers votre instance Ollama locale sur le port 11434.

model_list:
  - model_name: nomic-embed-text
    litellm_params:
      model: ollama/nomic-embed-text
  - model_name: qwen2.5
    litellm_params:
      model: ollama/qwen2.5
litellm_settings:
  host: 0.0.0.0
  port: 8080
ollama_settings:
  api_base: http://localhost:11434

Lancer : litellm --config litellm.yaml

Ingestion et découpage (C#)

Pourquoi découper? Les gros documents ne tiennent pas dans un seul embedding. Le découpage brise les documents en segments chevauchants (~200–500 tokens) qui sont sémantiquement significatifs (phrases ou paragraphes entiers). Cet équilibre assure :

  • Complétude sémantique : Les chunks représentent des idées cohérentes.
  • Taille raisonnable : Assez court pour embed rapidement, assez long pour être utile comme contexte.
  • Chevauchement : Un chevauchement de 20–30% réduit la perte d’information aux limites.

La méthode Ingest.Chunk() :

  1. Normalise les espaces (supprime les espaces/sauts de ligne supplémentaires).
  2. Divise par limites de phrases (points).
  3. Groupe les phrases jusqu’au ~400 tokens ; puis retourne et démarre un nouveau chunk.
using System.Text.RegularExpressions;

public static class Ingest
{
    public static IEnumerable<string> Chunk(string text, int maxTokens = 400)
    {
        // Normalize whitespace
        var cleaned = Regex.Replace(text, "\\s+", " ").Trim();

        // Split by sentence
        var sentences = cleaned.Split(['.', '!', '?'], StringSplitOptions.RemoveEmptyEntries);

        var current = new List<string>();

        foreach (var sentence in sentences)
        {
            var trimmed = sentence.Trim();
            if (string.IsNullOrWhiteSpace(trimmed)) continue;

            var candidate = string.Join(" ", current.Append(trimmed));
            var tokenCount = candidate.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length;

            if (tokenCount > maxTokens)
            {
                if (current.Count > 0)
                {
                    yield return string.Join(" ", current);
                }
                current.Clear();
            }

            current.Add(trimmed);
        }

        if (current.Count > 0)
        {
            yield return string.Join(" ", current);
        }
    }
}

Embeddings via LiteLLM

Que sont les embeddings? Un embedding est un vecteur dense (liste de flottants, typiquement 384–768 dimensions) qui représente le sens sémantique du texte. Les textes similaires produisent des vecteurs similaires, mesurés par distance cosinus. Cela permet une recherche de similarité rapide dans les bases vectorielles.

Le EmbeddingClient envoie le texte à LiteLLM, qui le transmet au modèle d’embedding d’Ollama. Le modèle retourne un float[] représentant ce texte dans l’espace vectoriel. Plus tard, quand vous interrogez, vous embeddez la question avec le même modèle et cherchez les voisins les plus proches dans la base vectorielle.

using LocalRag.Core.Models;
using System.Net.Http.Json;

public class EmbeddingClient(HttpClient httpClient)
{
    public async Task<float[]> EmbedAsync(string text, string model = "nomic-embed-text")
    {
        try
        {
            var request = new EmbedRequest(text, model);
            var response = await httpClient.PostAsJsonAsync("/v1/embeddings", request);
            response.EnsureSuccessStatusCode();

            var embedResponse = await response.Content.ReadFromJsonAsync<EmbedResponse>();
            return embedResponse?.Data?.FirstOrDefault()?.Embedding ?? [];
        }
        catch (Exception ex)
        {
            Console.WriteLine($"❌ Embedding error: {ex.Message}");
            throw;
        }
    }
}

public record EmbedRequest(string Input, string Model);
public record EmbedResponse(List<EmbedData> Data);
public record EmbedData(float[] Embedding);

Stocker les vecteurs dans Qdrant

Pourquoi une base vectorielle? Stocker les embeddings en mémoire ou dans un fichier texte est lent. Les bases vectorielles comme Qdrant sont optimisées pour la recherche de similarité avec des algorithmes (HNSW, IVF) qui trouvent les voisins les plus proches en millisecondes, même avec des millions de vecteurs.

QdrantClient fournit deux opérations essentielles :

  • Upsert : Stocker (ou mettre à jour) un chunk avec son embedding et métadonnées (ex. document source, texte du chunk).
  • Search : Étant donné un embedding de requête et k, retourner les k chunks les plus proches par similarité cosinus.

Le dictionnaire de charge utile porte les métadonnées aux côtés du vecteur, permettant un contexte riche et la traçabilité.

using LocalRag.Core.Models;
using Qdrant.Client.Grpc;

public class QdrantClient(string host, int port = 6334, string collection = "docs")
{
     private readonly Qdrant.Client.QdrantClient _client = new(host, port);

    public async Task UpsertAsync(Guid id, float[] vector, Dictionary<string, object> payload)
    {
        var point = new PointStruct
        {
            Id = new PointId { Uuid = id.ToString() },
            Vectors = vector,
            Payload = { }
        };

        foreach (var kvp in payload)
        {
            point.Payload[kvp.Key] = kvp.Value switch
            {
                string s => s,
                int i => i,
                long l => l,
                double d => d,
                bool b => b,
                _ => kvp.Value.ToString() ?? string.Empty
            };
        }

        await _client.UpsertAsync(collection, [point]);
    }

    public async Task<IReadOnlyList<SearchHit>> SearchAsync(float[] vector, int k = 4)
    {
        var results = await _client.SearchAsync(
            collectionName: collection,
            vector: vector,
            limit: (ulong)k,
            payloadSelector: true
        );

        return [.. results.Select(r => new SearchHit(
            Id: Guid.Parse(r.Id.Uuid),
            Score: r.Score,
            Payload: r.Payload.ToDictionary(
                kvp => kvp.Key,
                kvp => ConvertValue(kvp.Value)
            )
        ))];
    }

    private static object ConvertValue(Value value)
    {
        return value.KindCase switch
        {
            Value.KindOneofCase.StringValue => value.StringValue,
            Value.KindOneofCase.IntegerValue => value.IntegerValue,
            Value.KindOneofCase.DoubleValue => value.DoubleValue,
            Value.KindOneofCase.BoolValue => value.BoolValue,
            _ => value.ToString()
        };
    }
}

Chaînage ingestion -> Qdrant

La classe Indexer orchestre le pipeline d’ingestion. Étant donné un ID de document et du texte :

  1. Découpe le texte en phrases (en utilisant Ingest.Chunk).
  2. Embedding chaque chunk (via EmbeddingClient).
  3. Upsert chaque embedding + métadonnées dans Qdrant.

C’est une opération batch, vous l’exécutez typiquement une fois par ensemble de documents ou selon un horaire quand les documents se mettent à jour.

using LocalRag.Core.Utils;

public class Indexer(EmbeddingClient embeddingClient, QdrantClient qdrantClient)
{
     public async Task IndexDocumentAsync(string docId, string text)
    {
        var chunks = Ingest.Chunk(text).ToList();
        Console.WriteLine($"📄 Document '{docId}' split into {chunks.Count} chunks");

        int chunkIndex = 0;
        foreach (var chunk in chunks)
        {
            try
            {
                var embedding = await embeddingClient.EmbedAsync(chunk);

                var payload = new Dictionary<string, object>
                {
                    ["content"] = chunk,
                    ["docId"] = docId,
                    ["chunkIndex"] = chunkIndex
                };

                var id = Guid.NewGuid();
                await qdrantClient.UpsertAsync(id, embedding, payload);
                chunkIndex++;
            }
            catch (Exception ex)
            {
                Console.WriteLine($"❌ Error indexing chunk {chunkIndex}: {ex.Message}");
            }
        }

        Console.WriteLine($"✅ Indexed {chunkIndex} chunks for '{docId}'\n");
    }
}

Création de collection Qdrant (one-shot) :

curl -X PUT http://localhost:6333/collections/docs \
    -H "Content-Type: application/json" \
    -d '{
        "vectors": {
            "size": 768,
            "distance": "Cosine"
        }
    }'

Retrieval et réponse

La phase de requête. Quand un utilisateur pose une question, RagService exécute trois étapes :

  1. Embed la question en utilisant le même modèle d’embedding que l’indexation (crucial : la cohérence assure une similarité correcte).
  2. Récupère top-k chunks de Qdrant (typiquement 3–5 ; compromis entre taille de contexte et pertinence).
  3. Compose un prompt avec les chunks comme contexte et envoie au LLM via LiteLLM.

Le LLM génère une réponse ancrée dans le contexte récupéré, pas seulement dans ses données d’entraînement, réduisant les hallucinations et améliorant la précision.

using LocalRag.Core.Models;
using System.Net.Http.Json;

public class RagService(EmbeddingClient embeddingClient, QdrantClient qdrantClient, HttpClient httpClient)
{
    private readonly EmbeddingClient _embeddingClient = embeddingClient;
    private readonly QdrantClient _qdrantClient = qdrantClient;
    private readonly HttpClient _httpClient = httpClient;

    public async Task<string> AskAsync(string question)
    {
        try
        {
            Console.WriteLine($"🔍 Question: {question}");

            // Étape 1 : Embedding de la question
            var questionVector = await _embeddingClient.EmbedAsync(question);
            Console.WriteLine("✓ Question embarquée");

            // Étape 2 : Récupérer les chunks pertinents
            var hits = await _qdrantClient.SearchAsync(questionVector, 4);
            Console.WriteLine($"✓ {hits.Count} chunks récupérés");

            if (hits.Count == 0)
            {
                return "⚠️ Aucun document pertinent trouvé dans la base de connaissances.";
            }

            // Afficher les chunks récupérés
            var context = new System.Text.StringBuilder();
            int i = 1;
            foreach (var hit in hits)
            {
                var content = hit.Payload["content"]?.ToString() ?? string.Empty;
                Console.WriteLine($"  [{i}] (Score: {hit.Score:F4}) {content[..Math.Min(80, content.Length)]}...");
                context.AppendLine(content);
                i++;
            }

            // Étape 3 : Générer la réponse avec le contexte
            var messages = new List<ChatMessage>
            {
                new("system", "Tu es un assistant utile. Réponds aux questions en te basant sur le contexte fourni. Cites toujours le contexte."),
                new("user", $"Contexte:\n{context}\n\nQuestion: {question}")
            };

            var chatRequest = new ChatRequest("qwen2.5", messages, 0.2);
            var response = await _httpClient.PostAsJsonAsync("http://localhost:8080/v1/chat/completions", chatRequest);
            response.EnsureSuccessStatusCode();

            var chatResponse = await response.Content.ReadFromJsonAsync<ChatResponse>();
            var answer = chatResponse?.Choices?.FirstOrDefault()?.Message?.Content ?? "Aucune réponse générée.";

            Console.WriteLine($"\n💡 Réponse: {answer}\n");
            return answer;
        }
        catch (Exception ex)
        {
            Console.WriteLine($"❌ Erreur dans la requête RAG: {ex.Message}");
            throw;
        }
    }
}

public record ChatMessage(string Role, string Content);
public record ChatRequest(string Model, List<ChatMessage> Messages, double Temperature = 0.2);
public record ChatResponse(List<ChatChoice> Choices);
public record ChatChoice(ChatMessage Message);

Exemple complet d’ingestion (Program.cs)

Relier le tout. Ci-dessous se trouve une application console complète qui :

  1. Configure l’injection de dépendances pour les clients HTTP (LiteLLM, Qdrant) et Indexer.
  2. Scanne un dossier documents/ pour les fichiers markdown.
  3. Ingére chaque fichier : découpe → embedding → stockage dans Qdrant.
  4. Teste le retrieval avec une requête d’exemple pour confirmer que le pipeline fonctionne.

Ceci est votre point d’entrée, exécutez-le avec dotnet run pour remplir votre base vectorielle.

using LocalRag.Core.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var builder = Host.CreateDefaultBuilder(args);

builder.ConfigureServices((context, services) =>
{
    // HttpClient pour LiteLLM (Embedding + Chat)
    services.AddHttpClient<EmbeddingClient>(c =>
    {
        c.BaseAddress = new Uri("http://localhost:8080");
        c.Timeout = TimeSpan.FromSeconds(30);
    });

    // Client Qdrant (SDK gRPC)
    services.AddSingleton<QdrantClient>(sp =>
        new QdrantClient("localhost", 6334, "docs"));

    // Service RAG (nécessite embedding et qdrant, plus un HttpClient général)
    services.AddHttpClient<RagService>(c =>
    {
        c.Timeout = TimeSpan.FromSeconds(60);
    });

    services.AddScoped<Indexer>();
});

var host = builder.Build();

// Exécuter le pipeline RAG
using var scope = host.Services.CreateScope();
var indexer = scope.ServiceProvider.GetRequiredService<Indexer>();
var ragService = scope.ServiceProvider.GetRequiredService<RagService>();

// Créer le dossier documents s'il n'existe pas
var docFolder = "documents";
if (!Directory.Exists(docFolder))
{
    Directory.CreateDirectory(docFolder);
}

// Créer des documents d'exemple pour les tests
var sampleFiles = new Dictionary<string, string>
{
    ["embeddings.md"] = @"Les embeddings sont des vecteurs denses qui représentent le sens sémantique du texte.
            Chaque embedding compte typiquement 384–768 dimensions.
            Les concepts similaires se regroupent dans l'espace vectoriel.
            Les embeddings sont créés par des modèles de ML spécialisés en encodage sémantique.
            Ils permettent une recherche de similarité rapide dans les bases vectorielles.",

    ["rag.md"] = @"RAG signifie Retrieval-Augmented Generation.
            Il combine la récupération d'information avec la génération par modèle de langage.
            RAG améliore la justesse en ancrant les réponses dans les documents récupérés.
            Le processus a deux phases : indexation (offline) et requête (online).
            RAG réduit les hallucinations des grands modèles.",

    ["ollama.md"] = @"Ollama est un framework pour exécuter des modèles de langage localement.
            Il supporte des modèles comme Llama, Mistral, Qwen, et bien d'autres.
            Ollama peut fonctionner sur du matériel grand public avec des performances raisonnables.
            Il fournit une API compatible OpenAI sur le port 11434.
            Ollama est entièrement libre et open source."
};

Console.WriteLine("📚 Configuration des documents d'exemple...\n");
foreach (var (filename, content) in sampleFiles)
{
    var filepath = Path.Combine(docFolder, filename);
    if (!File.Exists(filepath))
    {
        await File.WriteAllTextAsync(filepath, content);
        Console.WriteLine($"✓ Créé {filename}");
    }
}

Console.WriteLine("\n⏳ Indexation des documents dans Qdrant...\n");

// Indexer tous les documents
foreach (var file in Directory.GetFiles(docFolder, "*.md"))
{
    var docId = Path.GetFileNameWithoutExtension(file);
    var text = await File.ReadAllTextAsync(file);
    await indexer.IndexDocumentAsync(docId, text);
}

Console.WriteLine("✅ Indexation terminée !\n");
Console.WriteLine("=".PadRight(60, '='));
Console.WriteLine("\n🚀 Démarrage des tests de requête RAG\n");
Console.WriteLine("=".PadRight(60, '=') + "\n");

// Requêtes de test
var testQueries = new[]
{
        "Que sont les embeddings ?",
        "Comment fonctionne RAG ?",
        "Qu'est-ce que Ollama ?",
        "Comment utiliser les embeddings pour la recherche de similarité ?"
    };

foreach (var query in testQueries)
{
    try
    {
        await ragService.AskAsync(query);
        Console.WriteLine("-".PadRight(60, '-') + "\n");
    }
    catch (Exception ex)
    {
        Console.WriteLine($"❌ Échec de la requête: {ex.Message}\n");
    }
}

Console.WriteLine("✅ Tous les tests sont terminés !");

Points clés :

  • Injection de dépendances : Enregistrer EmbeddingClient, QdrantClient et Indexer dans le conteneur.
  • Dossier documents : Placer fichiers .md dans un dossier documents/ ; l’exemple crée un exemple s’il n’existe pas.
  • Boucle indexation : Lit chaque fichier, le découpe, embed les chunks, les stocke dans Qdrant.
  • Requête test : Montre embedding d’une question et retrieval des 2 chunks similaires.

Lancez avec dotnet run pour remplir votre base vectorielle. Vous êtes maintenant prêt pour la phase de requête!

Bases de l’évaluation

  • Ancrage : Vérifier que la réponse cite les chunks récupérés (regex sur des IDs ou des citations explicites).
  • Pertinence : Mesurer si les chunks contiennent les mots-clés de la question ; consigner les scores top-k.
  • Grille qualité : Demander à un modèle juge local : « Note la justesse 1–5 et indique ce qui manque ».
  • Latence : Suivre chaque étape : embedding, recherche, génération.

Conseils

  • Figez les modèles et la config LiteLLM dans le code source ; documentez taille et RAM nécessaires.
  • Gardez les chunks petits (200–500 tokens) avec chevauchement de 20–30 % pour mieux rappeler.
  • Normalisez le texte (minuscules, en-têtes/pieds retirés) avant embedding.
  • Ajoutez un cache d’embeddings pour les documents inchangés.

Vous disposez maintenant d’un stack RAG local : ingestion et embedding des documents, stockage vectoriel, retrieval de contexte pertinent et génération de réponses ancrées, sans quitter votre machine.


Cet article a été créé avec l’assistance de l’IA.


Suggestions de lecture :