Construindo um sistema RAG em C# com Kernel Semântico

· 7 min de leitura

Introdução

Se você tentou usar um LLM para responder perguntas sobre seus próprios dados – documentos da empresa, especificações de produtos, bases de conhecimento internas – você provavelmente notou que ele alucina ou apenas diz “Não tenho informações sobre isso”. Isso porque o modelo só sabe no que foi treinado.

RAG (Geração Aumentada de Recuperação) corrige isso. Em vez de ajustar um modelo em seus dados, você recupera partes relevantes de seus documentos no momento da consulta e os transmite ao LLM como contexto. O modelo então gera respostas baseadas em seus dados reais.

Nesta postagem, orientarei você na construção de um pipeline RAG completo em C# usando Kernel Semântico.

Como funciona o RAG

O fluxo é direto:

  1. Ingestão: divida seus documentos em partes, gere embeddings para cada parte e armazene-os em um banco de dados vetorial
  2. Consulta: Quando um usuário faz uma pergunta, gere uma incorporação para a consulta, pesquise no banco de dados de vetores por partes semelhantes
  3. Gerar: Passe os pedaços recuperados como contexto para o LLM junto com a pergunta do usuário

É isso. A mágica está nos embeddings – eles capturam o significado semântico do texto como vetores, para que você possa encontrar conteúdo relevante mesmo quando as palavras exatas não correspondem.

Pré-requisitos

dotnet add package Microsoft.SemanticKernel
dotnet add package Microsoft.SemanticKernel.Connectors.AzureOpenAI
dotnet add package Microsoft.Extensions.VectorData.Abstractions
dotnet add package Microsoft.SemanticKernel.Connectors.InMemory

Para produção, você trocaria o armazenamento na memória pelo Azure AI Search, Qdrant, Pinecone ou qualquer outro banco de dados de vetores compatível. Mas o in-memory é perfeito para aprendizado e prototipagem.

Configurando o kernel

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.AzureOpenAI;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Connectors.InMemory;
using Microsoft.SemanticKernel.Embeddings;

var builder = Kernel.CreateBuilder();

builder.AddAzureOpenAIChatCompletion(
    deploymentName: "gpt-4o",
    endpoint: config["AzureOpenAI:Endpoint"],
    apiKey: config["AzureOpenAI:ApiKey"]);

builder.AddAzureOpenAITextEmbeddingGeneration(
    deploymentName: "text-embedding-3-small",
    endpoint: config["AzureOpenAI:Endpoint"],
    apiKey: config["AzureOpenAI:ApiKey"]);

var kernel = builder.Build();

Precisamos de dois modelos: um para completar o chat (responder perguntas) e outro para gerar embeddings (transformar texto em vetores).

Definindo o modelo de dados

Precisamos de uma classe para representar nossos pedaços de documentos no armazenamento de vetores:

using Microsoft.Extensions.VectorData;

public class DocumentChunk
{
    [VectorStoreRecordKey]
    public string Id { get; set; } = Guid.NewGuid().ToString();

    [VectorStoreRecordData]
    public string Content { get; set; } = string.Empty;

    [VectorStoreRecordData]
    public string Source { get; set; } = string.Empty;

    [VectorStoreRecordData]
    public int ChunkIndex { get; set; }

    [VectorStoreRecordVector(1536)]
    public ReadOnlyMemory<float> Embedding { get; set; }
}

O atributo VectorStoreRecordVector(1536) informa ao armazenamento vetorial a dimensão de nossos embeddings. O modelo text-embedding-3-small produz vetores de 1536 dimensões.

Fragmentação de documentos

Antes de podermos criar embeddings, precisamos dividir nossos documentos em partes gerenciáveis. Aqui está um divisor de texto simples:

public static class TextChunker
{
    public static List<string> SplitText(string text, int maxChunkSize = 500, int overlap = 50)
    {
        var chunks = new List<string>();
        var paragraphs = text.Split("\n\n", StringSplitOptions.RemoveEmptyEntries);
        var currentChunk = new System.Text.StringBuilder();

        foreach (var paragraph in paragraphs)
        {
            if (currentChunk.Length + paragraph.Length > maxChunkSize && currentChunk.Length > 0)
            {
                chunks.Add(currentChunk.ToString().Trim());

                // Keep overlap from the end of the previous chunk
                var overlapText = currentChunk.ToString();
                currentChunk.Clear();
                if (overlapText.Length > overlap)
                {
                    currentChunk.Append(overlapText[^overlap..]);
                    currentChunk.Append(' ');
                }
            }

            currentChunk.Append(paragraph);
            currentChunk.Append("\n\n");
        }

        if (currentChunk.Length > 0)
        {
            chunks.Add(currentChunk.ToString().Trim());
        }

        return chunks;
    }
}

A sobreposição é importante — ela garante que o contexto na fronteira entre os pedaços não seja perdido. Se uma frase relevante for dividida em dois blocos, a sobreposição significa que ela aparecerá integralmente em pelo menos um deles.

Ingestão de documentos

Agora vamos juntar tudo para ingerir documentos em nosso armazenamento de vetores:

var vectorStore = new InMemoryVectorStore();
var collection = vectorStore.GetCollection<string, DocumentChunk>("documents");
await collection.CreateCollectionIfNotExistsAsync();

var embeddingService = kernel.GetRequiredService<ITextEmbeddingGenerationService>();

async Task IngestDocument(string content, string source)
{
    var chunks = TextChunker.SplitText(content);

    for (int i = 0; i < chunks.Count; i++)
    {
        var embedding = await embeddingService.GenerateEmbeddingAsync(chunks[i]);

        var chunk = new DocumentChunk
        {
            Content = chunks[i],
            Source = source,
            ChunkIndex = i,
            Embedding = embedding
        };

        await collection.UpsertAsync(chunk);
    }

    Console.WriteLine($"✅ Ingested {chunks.Count} chunks from {source}");
}

// Ingest some documents
var doc1 = await File.ReadAllTextAsync("docs/product-guide.md");
var doc2 = await File.ReadAllTextAsync("docs/faq.md");
var doc3 = await File.ReadAllTextAsync("docs/troubleshooting.md");

await IngestDocument(doc1, "product-guide.md");
await IngestDocument(doc2, "faq.md");
await IngestDocument(doc3, "troubleshooting.md");

Procurando por pedaços relevantes

Quando um usuário faz uma pergunta, geramos uma incorporação para sua consulta e procuramos por partes semelhantes:

async Task<List<DocumentChunk>> SearchAsync(string query, int topK = 3)
{
    var queryEmbedding = await embeddingService.GenerateEmbeddingAsync(query);

    var searchResults = await collection.VectorizedSearchAsync(
        queryEmbedding,
        new VectorSearchOptions { Top = topK });

    var results = new List<DocumentChunk>();
    await foreach (var result in searchResults.Results)
    {
        results.Add(result.Record);
    }

    return results;
}

Gerando respostas com contexto

Agora a parte RAG – pegamos os pedaços recuperados e os incluímos como contexto em nosso prompt:

using Microsoft.SemanticKernel.ChatCompletion;

var chatService = kernel.GetRequiredService<IChatCompletionService>();

async Task<string> AskAsync(string question)
{
    // Step 1: Retrieve relevant chunks
    var relevantChunks = await SearchAsync(question);

    // Step 2: Build context from chunks
    var context = string.Join("\n\n---\n\n",
        relevantChunks.Select(c => $"[Source: {c.Source}]\n{c.Content}"));

    // Step 3: Generate answer with context
    var history = new ChatHistory();
    history.AddSystemMessage($$"""
        You are a helpful assistant that answers questions based on the provided context.
        Use ONLY the information from the context to answer. If the context doesn't contain
        enough information to answer the question, say "I don't have enough information
        to answer that question."

        Do not make up information. Always cite the source document when possible.

        Context:
        {{context}}
        """);

    history.AddUserMessage(question);

    var response = await chatService.GetChatMessageContentAsync(history);
    return response.Content ?? "No response generated.";
}

Usando

// Ask questions about your documents
var answer1 = await AskAsync("How do I reset my password?");
Console.WriteLine($"Q: How do I reset my password?\nA: {answer1}\n");

var answer2 = await AskAsync("What are the system requirements?");
Console.WriteLine($"Q: What are the system requirements?\nA: {answer2}\n");

var answer3 = await AskAsync("What's the capital of France?");
Console.WriteLine($"Q: What's the capital of France?\nA: {answer3}\n");
// Should respond with "I don't have enough information" since it's not in the docs

Passando para produção

O armazenamento de vetores na memória é ótimo para prototipagem, mas para produção você precisará de um banco de dados de vetores persistente. O Kernel Semântico possui conectores para diversas opções:

# Azure AI Search
dotnet add package Microsoft.SemanticKernel.Connectors.AzureAISearch

# Qdrant
dotnet add package Microsoft.SemanticKernel.Connectors.Qdrant

# Redis
dotnet add package Microsoft.SemanticKernel.Connectors.Redis

A troca é simples, pois todos implementam a mesma interface IVectorStore:

// Instead of InMemoryVectorStore, use:
using Azure;
using Microsoft.SemanticKernel.Connectors.AzureAISearch;

var vectorStore = new AzureAISearchVectorStore(
    new Azure.Search.Documents.Indexes.SearchIndexClient(
        new Uri(config["AzureAISearch:Endpoint"]),
        new AzureKeyCredential(config["AzureAISearch:ApiKey"])));
```Todo o resto permanece igual. Essa é a beleza da abstração.

## Dicas para construir sistemas RAG

Algumas coisas que aprendi da maneira mais difícil:

- **O tamanho do pedaço é muito importante.** Muito pequeno e você perde o contexto. Muito grande e você desperdiça tokens em conteúdo irrelevante. Comece com 500-800 tokens e ajuste com base nos seus dados.
- **A sobreposição evita problemas de limite.** Uma sobreposição de 50-100 tokens entre pedaços geralmente é suficiente.
- **Recupere mais do que você pensa.** Comece com `topK = 5` e reduza se estiver recebendo muito ruído. É melhor ter contexto extra do que perder a parte relevante.
- **As instruções do sistema são cruciais.** Seja bem explícito ao usar apenas o contexto fornecido. Sem essa instrução, o modelo terá alucinações felizes com base em seus dados de treinamento.
- **Rastreie as fontes.** Sempre armazene metadados com seus pedaços para que você possa citar de onde veio a resposta. Os usuários confiam mais nas respostas quando podem verificar a fonte.
- **Reclassifique se necessário.** A similaridade do vetor não é perfeita. Para aplicações críticas, adicione uma etapa de reclassificação usando um modelo de codificador cruzado para melhorar a precisão.

## Conclusão

RAG é um dos padrões mais práticos em IA atualmente. Ele permite que você crie sistemas de perguntas e respostas com tecnologia de IA sobre seus próprios dados sem ajuste fino, e o Kernel Semântico o torna surpreendentemente limpo em C#. Comece com o armazenamento na memória, acerte a fragmentação e os prompts e, em seguida, troque por um banco de dados vetorial real quando estiver pronto para produção.

Boa codificação!

## Recursos

- [Documentação do armazenamento de vetores do kernel semântico](https://learn.microsoft.com/en-us/semantic-kernel/concepts/vector-store-connectors/)
- [Padrão RAG com Azure AI Search](https://learn.microsoft.com/en-us/azure/search/retrieval-augmented-generation-overview)
- [Modelos de incorporação de texto](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models#embeddings)