.NET - RAG com Ollama e Kernel Memory


  Que tal criar um sistema usando o RAG com Ollama e o Microsoft Kernel Memory ?

O termo RAG é muito popular na inteligência artificial ele significa Retrieval-Augmented Generation e é uma arquitetura híbrida que combina um sistema de recuperação de informação com um modelo de geração de linguagem natural (como o GPT).



Em termos simples, o RAG é uma técnica que permite a uma IA:

- Buscar informações relevantes e atualizadas em uma base de dados externa (como documentos, sites internos, PDFs, etc.).
- Inserir essas informações no "contexto" ou no "prompt" do modelo de linguagem.
- Gerar uma resposta precisa, contextualizada e com base nas informações recuperadas.

Como o RAG Funciona? (O Processo em 3 Etapas)

O processo do RAG pode ser dividido em três etapas principais:

1- Recuperação (Retrieval)

Quando o usuário faz uma pergunta, o sistema primeiro a converte em uma representação numérica (um "vetor" ou "embedding").

Em seguida, ele busca em uma base de dados pré-processada (também convertida em vetores) os trechos de texto, documentos ou pedaços de informação mais semelhantes à pergunta. É como um sistema de busca super eficiente.

2- Aumento (Augmentation)

As informações mais relevantes encontradas na etapa anterior são combinadas com a pergunta original do usuário.

Tudo isso é montado em um "prompt" único e contextualizado, que é enviado para o modelo de linguagem grande (LLM). Algo como: "Com base nestes documentos [informações recuperadas], responda à seguinte pergunta: [pergunta do usuário]".

3- Geração (Generation)

O LLM (como o GPT-4, Llama, etc.) recebe esse prompt enriquecido com o contexto.

Ele, então, gera uma resposta final que não se baseia apenas no seu conhecimento interno (que pode estar desatualizado ou ser genérico), mas sim nas informações específicas e confiáveis que lhe foram fornecidas.

Embeddings : O coração do RAG

O RAG depende dos embeddings para a etapa mais crítica: a Recuperação (Retrieval).

Um embedding é uma representação numérica (vetorial) de dados como texto, imagens ou áudio. No contexto do RAG e do processamento de linguagem, falamos principalmente de embeddings de texto.

Pense no embedding como uma forma de traduzir palavras, frases ou documentos inteiros para uma linguagem que o computador entende — a linguagem da matemática e da geometria.

Veja como isso se encaixa no processo:

Fase 1: Pré-processamento da Base de Conhecimento (Fora de Linha)

Dividir: A base de dados (manuais, PDFs, sites) é dividida em pedaços menores, como parágrafos ou seções.

Embedding: Cada um desses pedaços de texto é convertido em um embedding por um modelo especializado nessa tarefa (como o text-embedding-ada-002 da OpenAI ou modelos open-source como os da SentenceTransformers).

Armazenar: Todos esses embeddings são armazenados em um banco de dados especializado, chamado Vector Database (Banco de Dados Vetorial).

Fase 2: Busca em Tempo Real (Quando o Usuário Pergunta)

Embedding da Pergunta: Quando você faz uma pergunta (ex: "Quais são os benefícios do RAG?"), essa pergunta é convertida em um embedding usando o mesmo modelo da Fase 1.

Busca por Similaridade: O sistema compara o embedding da sua pergunta com todos os embeddings dos documentos armazenados no banco de dados vetorial. Ele busca os pedaços de texto cujos embeddings são mais próximos (ou seja, mais similares em significado) ao da sua pergunta.

Recuperação do Texto Original: Os textos originais correspondentes aos embeddings mais similares são recuperados.

Fase 3: Geração da Resposta

Aumento: Os textos recuperados (que são os mais relevantes para sua pergunta) são injetados no "prompt" do modelo de linguagem (LLM).

Geração: O LLM (como o GPT-4) sintetiza aquelas informações e gera uma resposta final, precisa e contextualizada.

Entendendo o nomic-embed-text:latest

È muito importante entender o que é e como funciona o modelo de embeddings usado no exemplo.

O nomic-embed-text:latest é um modelo de embeddings (um modelo especializado em transformar textos em vetores numéricos).

Ele é mantido pela Nomic AI e está disponível no Ollama — portanto, roda localmente (sem precisar da nuvem da OpenAI) sendo responsável por converter textos em números que representam o significado do texto (não só as palavras).

Texto Representação vetorial (simplificada)

“gato preto” [0.12, -0.87, 0.33, 0.91, ...]
felino de cor escura” [0.14, -0.83, 0.30, 0.89, ...]


Esses vetores ficam próximos entre si no espaço multidimensional, porque têm significados semelhantes — ambos falam sobre o mesmo assunto “gato preto” e “felino de cor escura” mesmo usando palavras diferentes.

Assim, o Kernel Memory consegue buscar por similaridade semântica, mesmo que as palavras sejam diferentes. A seguir temos um resumo do fluxo interno que ocorre você faz uma pergunta:

Nesta etapa, o texto da pergunta é transformado em um vetor numérico que representa o seu significado. O sistema então compara esse vetor com os vetores dos documentos indexados.
Assim, mesmo que as palavras não coincidam, o sistema entende o sentido da pergunta e encontra os trechos mais relevantes.”

Criando o projeto exemplo

Para ilustrar o uso deste recurso na prática vamos criar uma aplicação Console no VS 2022 usando o Ollama e Microsoft.Kernel Memory.

Vamos usar o Ollama instalado localmente e em execução na porta 11434, o LLM mistral:latest baixado localmente e o modelo de embeddings : nomic-embed-text:latest

Crie um novo projeto Console no VS 2022 chamado Ollama_Rag e neste projeto crie a pasta Data, a pasta Models e a pasta Utils.

Adicione os seguintes pacotes Nuget no projeto:

  • Microsoft.KernelMemory.Core
    Microsoft.KernelMemory.AI.Ollama
    Microsoft.Extensions.Configuration
    Microsoft.Extensions.Configuration.Json

  • Dentro da pasta Models crie o arquivo AppConfig.cs com o seguinte conteúdo:

    public class AppConfig
    {
        public OllamaSettings Ollama { get; set; } = new();
        public List<string> DocumentPaths { get; set; } = new();
        public MemorySettings Memory { get; set; } = new();
    }
    public class OllamaSettings
    {
        public string Endpoint { get; set; } = "http://localhost:11434/";
        public ModelSettings TextModel { get; set; } = new();
        public ModelSettings EmbeddingModel { get; set; } = new();
    }
    public class ModelSettings
    {
        public string Name { get; set; } = "";
        public int MaxTokenTotal { get; set; } = 2048;
        public int Seed { get; set; } = 42;
        public int TopK { get; set; } = 7;
    }
    public class MemorySettings
    {
        public int ChunkSize { get; set; } = 1024;
        public int ChunkOverlap { get; set; } = 200;
        public int MaxResults { get; set; } = 5;
        public double MinRelevance { get; set; } = 0.3;
        public int MaxChatHistory { get; set; } = 10;
    }

    AppConfig - É a classe raiz que reúne tudo e que será retornada pelo método LoadConfiguration() na inicialização da aplicação console. 

    OllaSettings -  Configura o endereço do Ollama e os dois modelos usados:
    – um para gerar texto (TextModel)
    – outro para gerar embeddings (EmbeddingMode)

    ModelSettings - Define parâmetros internos do modelo (nome, limite de tokens, aleatoriedade, profundidade de busca Top-K).

    MemorySettings - Controla como o Kernel Memory corta, sobrepõe e recupera os textos.

    A seguir crie na raiz do projeto o arquivo appsettings.json com este conteúdo:

    {
      "Ollama": {
        "Endpoint": "http://localhost:11434/",
        "TextModel": {
          "Name": "mistral:latest",
          "MaxTokenTotal": 125000,
          "Seed": 42,
          "TopK": 7
        },
        "EmbeddingModel": {
          "Name": "nomic-embed-text:latest",
          "MaxTokenTotal": 2048
        }
      },
      "DocumentPaths": [
        "Data/AdrianStellar.txt"
      ],
      "Memory": {
        "ChunkSize": 1024,
        "ChunkOverlap": 200,
        "MaxResults": 5,
        "MinRelevance": 0.3,
        "MaxChatHistory": 10
      }
    }

    A classe AppConfig serve para mapear o conteúdo do appsettings.json para as classes definidas
    permitindo que todo o seu código use configurações fortemente tipadas, organizadas e fáceis de ajustar,
    sem precisar alterar o código principal. O JSON de appsettings.json é mapeado recursivamente para dentro das classes, como se você estivesse montando um objeto complexo a partir do arquivo.

    Dentro da pasta Models crie a classe ChatMessage:

    public class ChatMessage
    {
        public string Role { get; set; }
        public string Content { get; set; }
        public DateTime Timestamp { get; set; }
        public ChatMessage(string role, string content)
        {
            Role = role;
            Content = content;
            Timestamp = DateTime.UtcNow;
        }
    }

    Essa classe não faz parte da configuração, e portanto não é preenchida pelo appsettings.json.
    Ela é usada durante a execução do programa — mais especificamente, para armazenar as mensagens trocadas na conversa.

    Na pasta Data vamos incluir um arquivo texto chamado AdrianStellar.txt com o seguinte conteúdo:

    Adrian Stellar was a young inventor from the city of Lumina Prime.
    He built the first solar-powered airship capable of crossing the Frozen Sea.
    People called him “The Engineer of Light” because his inventions used sun crystals.
    In 2095, he discovered a lost library hidden beneath the desert of Arion.
    There, he found ancient blueprints for machines that could store human memories.
    Adrian devoted his life to building a device called the Mind Mirror.
    The Mind Mirror allowed people to relive their happiest moments in perfect detail.
    One day, a malfunction trapped Adrian inside his own memories forever.
    His final message was a note saying, “Light remembers what time forgets.”
    To this day, the city of Lumina Prime celebrates the Festival of Light in his honor.

    Este arquivo contém uma história fictícia que um personagem fictício que é  uma história exclusiva, impossível de o modelo conhecer de antemão. Assim, qualquer resposta correta será prova de que o contexto do arquivo foi realmente usado.

    Nosso objetivo é implementar o RAG e vamos oriendar o LLM para responder apenas com base no conteúdo deste arquivo.  Assim podemos fazer perguntas do tipo:

    Who was Adrian Stellar?
    How people called Adrian Stellar ?
    What did Adrian Stellar invent?
    What is the Mind Mirror?
    What was his final message?
    Where was the lost library discovered?
    What the city of Lumina Prime celebrates ?

    Se o RAG estiver funcionando, ele responderá com trechos da história (por exemplo:  “Adrian Stellar was a young inventor from the city of Lumina Prime.”).

    Se o modelo responder algo genérico (“I don’t know” ou inventar algo incoerente), é sinal de que o RAG não recuperou contexto corretamente.

    Nota: O Texto esta em inglês, e, as perguntas devem ser feitas em inglês, porque tanto o LLM usado como o modelo de embedding foram treinados em inglês e funcionam em conjunto muito bem. (Não funcionariam corretamente para textos em português)

    Na pasta Utils crie a classe KernelMemoryHelper:

    using Microsoft.Extensions.Configuration;
    using Microsoft.KernelMemory;
    using Microsoft.KernelMemory.AI.Ollama;
    using Ollama_RAG.Models;
    using System.Text;
    namespace Ollama_RAG.Utils;
    public static class KernelMemoryHelper
    {
        // 🧩 Lê e vincula o appsettings.json nas classes AppConfig
        public static AppConfig LoadConfiguration()
        {
            var configuration = new ConfigurationBuilder()
                .SetBasePath(Directory.GetCurrentDirectory())
                .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
                .Build();
            var cfg = new AppConfig();
            configuration.Bind(cfg);
            return cfg;
        }
        // ⚙️ Inicializa o Kernel Memory configurando os modelos e o Ollama
        public static Task<IKernelMemory> InitializeKernelMemoryAsync(AppConfig cfg)
        {
            var ollamaConfig = new OllamaConfig
            {
                Endpoint = cfg.Ollama.Endpoint,
                TextModel = new OllamaModelConfig(cfg.Ollama.TextModel.Name)
                {
                    MaxTokenTotal = cfg.Ollama.TextModel.MaxTokenTotal,
                    Seed = cfg.Ollama.TextModel.Seed,
                    TopK = cfg.Ollama.TextModel.TopK
                },
                EmbeddingModel = new OllamaModelConfig(cfg.Ollama.EmbeddingModel.Name)
                {
                    MaxTokenTotal = cfg.Ollama.EmbeddingModel.MaxTokenTotal
                }
            };
            var memory = new KernelMemoryBuilder()
                .WithOllamaTextGeneration(ollamaConfig)
                .WithOllamaTextEmbeddingGeneration(ollamaConfig)
                .WithSimpleVectorDb()
                .WithSimpleFileStorage()
                .Build<MemoryServerless>();
            return Task.FromResult<IKernelMemory>(memory);
        }
        // 📁 Indexa os documentos (lê, gera embeddings e salva no banco vetorial)
        public static async Task IndexDocumentsAsync(IKernelMemory mem, IEnumerable<string> paths)
        {
            var tasks = paths
                .Where(File.Exists)
                .Select(async path =>
                {
                    Console.WriteLine($"Indexando: {path}");
                    var name = Path.GetFileNameWithoutExtension(path);
                    await mem.ImportTextAsync(
                        await File.ReadAllTextAsync(path),
                        documentId: name,
                        tags: new TagCollection { { "sourceName", name } } // ✅ salva como tag
                    );
                });
            await Task.WhenAll(tasks);
            Console.WriteLine("Indexação de documentos concluída.");
        }
        // 💬 Constrói a query contextual com base no histórico de conversa
        public static string ComposeQuery(List<ChatMessage> history, string input)
        {
            var sb = new StringBuilder("Conversa anterior:\n");
            foreach (var msg in history.TakeLast(6))
                sb.AppendLine($"{msg.Role}: {msg.Content}");
            sb.AppendLine($"\nQuestão atual: {input}");
            return sb.ToString();
        }
        // 🤖 Gera a resposta com base nos resultados do RAG
        public static async Task<string> GenerateResponseAsync(IKernelMemory mem, string question, SearchResult results)
        {
            var sb = new StringBuilder();
            sb.AppendLine("Você é um assistente. Use apenas o contexto fornecido.");
            sb.AppendLine("\nCONTEXTO:");
            foreach (var res in results.Results)
                foreach (var part in res.Partitions)
                    sb.AppendLine(part.Text + "\n---");
            sb.AppendLine($"\nQUESTÃO: {question}\nRESPOSTA:");
            try
            {
                var answer = await mem.AskAsync(sb.ToString());
                return answer.Result;
            }
            catch (Exception ex)
            {
                return $"Erro a gerar resposta: {ex.Message}";
            }
        }
    }
     

    Esta classe atua da seguinte forma:

    - Centraliza toda a lógica de inicialização e configuração do Kernel Memory e do Ollama.
    - Lê o arquivo appsettings.json e converte suas configurações nas classes C# (AppConfig).
    - Cria e configura o Kernel Memory com os modelos de texto e de embeddings.
    - Realiza a indexação dos documentos, gerando e armazenando os embeddings.
    - Monta consultas contextuais combinando a pergunta e o histórico do chat.
    - Gera respostas usando o RAG, com base nos trechos recuperados dos documentos.
    - Atua como uma camada utilitária reutilizável, separando a lógica de infraestrutura do código principal

    Finalmente na classe Program vamos incluir o código a seguir:

    using Ollama_RAG.Models;
    using Ollama_RAG.Utils; // 👈 Importa a classe estática
    
    Console.ForegroundColor = ConsoleColor.Green;
    Console.WriteLine("=== RAG com Ollama ===\n");
    
    // 🧩 Usa os métodos estáticos da nova classe
    var config = KernelMemoryHelper.LoadConfiguration();
    Console.WriteLine("Inicializando o Kernel Memory...");
    
    var memory = await KernelMemoryHelper.InitializeKernelMemoryAsync(config);
    Console.WriteLine("Indexando documentos...");
    
    await KernelMemoryHelper.IndexDocumentsAsync(memory, config.DocumentPaths);
    
    Console.ForegroundColor = ConsoleColor.Cyan;
    Console.WriteLine("\nRAG está pronto! Faça perguntas sobre o documento indexado.");
    Console.WriteLine("Digite 'sair' para encerrar.\n");
    Console.ResetColor();
    
    var chatHistory = new List<ChatMessage>();
    
    while (true)
    {
      Console.ForegroundColor = ConsoleColor.Gray;
      Console.Write("\nVocê: ");
      Console.ResetColor();
    
      var userInput = Console.ReadLine();
    
      if (string.IsNullOrWhiteSpace(userInput)) continue;
      if (userInput.Equals("sair", StringComparison.OrdinalIgnoreCase)) break;
    
      chatHistory.Add(new ChatMessage("User", userInput));
    
      var contextualQuery = KernelMemoryHelper.ComposeQuery(chatHistory, userInput);
    
      var searchResults = await memory.SearchAsync(
      query: contextualQuery,
      limit: config.Memory.MaxResults,
      minRelevance: config.Memory.MinRelevance
    );
    
    if (!searchResults.Results.Any())
    {
       Console.WriteLine("Modelo: Nenhuma informação relevante encontrada.");
       continue;
    }
    
    var response = await KernelMemoryHelper.GenerateResponseAsync(memory, userInput, searchResults);
    
    Console.ForegroundColor = ConsoleColor.Cyan;
    Console.Write("Modelo: ");
    Console.ForegroundColor = ConsoleColor.White;
    Console.WriteLine(response);
    Console.ResetColor();
    
    chatHistory.Add(new ChatMessage("Modelo", response));
    
    if (chatHistory.Count > config.Memory.MaxChatHistory * 2)
       chatHistory.RemoveRange(0, config.Memory.MaxChatHistory);
    }
    

    Esta classe coordena a execução do projeto RAG (Retrieval-Augmented Generation) integrando o usuário, o Kernel Memory e o Ollama.

    1- Inicializa o console e exibe mensagens iniciais
    → Mostra o título e prepara o ambiente para interação do usuário.

    2- Carrega as configurações do sistema
    → Usa KernelMemoryHelper.LoadConfiguration() para ler e vincular o appsettings.json nas classes C# (AppConfig).

    3- Inicializa o Kernel Memory e os modelos do Ollama
    → Chama KernelMemoryHelper.InitializeKernelMemoryAsync() para configurar o modelo de texto (mistral) e o modelo de embeddings (nomic-embed-text).

    4- Indexa os documentos
    → Executa KernelMemoryHelper.IndexDocumentsAsync() para converter os arquivos em embeddings e armazená-los para busca semântica.

    5- Entra no loop principal de interação
    → Lê as perguntas do usuário no console e controla o fluxo do chat.

    6- Monta e executa consultas RAG
    → Usa ComposeQuery e SearchAsync para buscar trechos relevantes nos documentos e gerar respostas contextualizadas.

    7- Exibe a resposta e mantém o histórico da conversa
    → Mostra a resposta no console, armazena as mensagens no chatHistory e garante que o modelo mantenha o contexto entre as perguntas.

    Importante destacar que na linha de código:

    Console.WriteLine("\nRAG está pronto! Faça perguntas sobre o documento indexado.");

    Estamos limitando o LLM a consultar o documento AdrianStellar.txt

    Executando o projeto e fazendo uma pergunta teremos o seguinte resultado:

    O projeto RAG foi configurado para indexar um único documento (AdrianStellar.txt), contendo uma história fictícia.

    Esse documento é a única fonte de conhecimento do modelo. Portanto, tudo que o modelo souber deve vir deste arquivo, não do seu conhecimento prévio.

    Pergunta :  Who was Adrian Stellar ?

    O que aconteceu internamente:

    O sistema converteu sua pergunta em embeddings usando o modelo nomic-embed-text:latest.
    O Kernel Memory comparou esses embeddings com os vetores criados a partir do texto do arquivo.
    Como o documento contém várias informações sobre Adrian Stellar, a busca encontrou alta similaridade semântica e retornou os trechos correspondentes.
    Esses trechos foram enviados como contexto ao modelo mistral:latest, com a instrução:
    “Use only the provided context.”

    Resposta do modelo:

    “Adrian Stellar was a young inventor from the city of Lumina Prime...”


    O modelo respondeu exatamente com base nas informações do texto, sem inventar nada.

    Ele citou corretamente que Adrian era “a young inventor from the city of Lumina Prime” e mencionou os elementos da história (Mind Mirror, sun crystals, Festival of Light).

    Isso prova que o RAG funcionou corretamente, pois ele buscou e gerou a resposta com base no conteúdo indexado.

    Vamos fazer uma pergunta que não esta no contexto do documento :

    Pergunta : When Adrian Stellar was born ?

    Aqui a pergunta foi novamente transformada em embeddings.

    O Kernel Memory procurou por trechos semanticamente próximos à ideia de “data de nascimento”.

    Nenhum trecho no documento continha essa informação (não há “born”, “birthday”, “date”, etc.).

    O RAG, portanto, não encontrou contexto relevante. O prompt enviado ao modelo continha apenas o aviso:
    “The context provided does not specify when Adrian Stellar was born.”

    Resposta do modelo:

    “The context provided does not specify when Adrian Stellar was born. Therefore, I cannot provide an answer for this question based on the information given.”

    O modelo não inventou uma resposta — ele seguiu a instrução do prompt:
    Use only the provided context.”

    Isso mostra que o modelo respeitou a limitação do RAG, e não usou conhecimento “de fora”.

    Essa é uma característica desejada em sistemas de IA responsáveis: só responder com base em dados confiáveis.

    O resultado mostra claramente como o sistema RAG atua em duas fases:

    1- Primeiro, ele recupera informação dos documentos (Retrieval),
    2- Depois, gera uma resposta textual baseada nesses dados (Generation).


    Se a informação está no documento, ele responde.

    Se não está, ele diz que não sabe — e isso é o comportamento correto de um assistente de IA conectado a uma base de conhecimento.”

    Aqui o RAG mostra seu comportamento ideal: quando a resposta não existe no contexto recuperado, o modelo não ‘chuta’, não 'alucina', apenas informa que a informação não está disponível.”

    E estamos conversados...  

    "Porque pela graça sois salvos, por meio da fé; e isto não vem de vós, é dom de Deus.
    Não vem das obras, para que ninguém se glorie"
    Efésios 2:8,9

    Referências:


    José Carlos Macoratti