Construyendo un sistema RAG en C# con Semantic Kernel
Introducción
Si ha intentado utilizar un LLM para responder preguntas sobre sus propios datos (documentos de la empresa, especificaciones de productos, bases de conocimiento internas) probablemente haya notado que alucina o simplemente dice “No tengo información sobre eso”. Esto se debe a que el modelo sólo sabe en qué fue entrenado.
RAG (Generación aumentada de recuperación) soluciona este problema. En lugar de ajustar un modelo a partir de sus datos, recupera fragmentos relevantes de sus documentos en el momento de la consulta y los pasa al LLM como contexto. Luego, el modelo genera respuestas basadas en sus datos reales.
En esta publicación, lo guiaré en la construcción de una canalización RAG completa en C# usando Semantic Kernel.
Cómo funciona RAG
El flujo es sencillo:
- Ingesta: divida sus documentos en fragmentos, genere incrustaciones para cada fragmento y guárdelos en una base de datos vectorial.
- Consulta: cuando un usuario hace una pregunta, genera una incrustación para la consulta, busca fragmentos similares en la base de datos de vectores.
- Generar: pasar los fragmentos recuperados como contexto al LLM junto con la pregunta del usuario.
Eso es todo. La magia está en las incrustaciones: capturan el significado semántico del texto como vectores, por lo que puedes encontrar contenido relevante incluso cuando las palabras exactas no coinciden.
Requisitos previos
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 producción, cambiaría el almacén en memoria por Azure AI Search, Qdrant, Pinecone o cualquier otra base de datos vectorial compatible. Pero la memoria es perfecta para aprender y crear prototipos.
Configurando el 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();
Necesitamos dos modelos: uno para completar el chat (responder preguntas) y otro para generar incrustaciones (convertir texto en vectores).
Definiendo el modelo de datos
Necesitamos una clase para representar nuestros fragmentos de documentos en el almacén de vectores:
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; }
}
El atributo VectorStoreRecordVector(1536) le dice al almacén de vectores la dimensión de nuestras incrustaciones. El modelo text-embedding-3-small produce vectores de 1536 dimensiones.
Dividir documentos
Antes de que podamos crear incrustaciones, debemos dividir nuestros documentos en partes manejables. Aquí hay un divisor de texto simple:
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;
}
}
La superposición es importante: garantiza que no se pierda el contexto en el límite entre fragmentos. Si una oración relevante se divide en dos partes, la superposición significa que aparecerá completamente en al menos una de ellas.
Ingesta de documentos
Ahora juntemos todo para incorporar documentos a nuestro almacén de vectores:
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");
Buscando fragmentos relevantes
Cuando un usuario hace una pregunta, generamos una inserción para su consulta y buscamos fragmentos similares:
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;
}
Generando respuestas con contexto
Ahora la parte RAG: tomamos los fragmentos recuperados y los incluimos como contexto en nuestro mensaje:
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.";
}
Utilizándolo
// 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
Pasando a producción
El almacén de vectores en memoria es excelente para la creación de prototipos, pero para la producción querrás una base de datos de vectores persistente. Semantic Kernel tiene conectores para varias opciones:
# 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
El intercambio es sencillo ya que todos implementan la misma interfaz 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 lo demás sigue igual. Esa es la belleza de la abstracción.
## Consejos para la construcción de sistemas RAG
Algunas cosas que he aprendido por las malas:
- **El tamaño del fragmento importa mucho.** Si es demasiado pequeño, se pierde el contexto. Demasiado grande y desperdiciarás tokens en contenido irrelevante. Comience con 500-800 tokens y ajústelos según sus datos.
- **La superposición evita problemas de límites.** Una superposición de 50 a 100 tokens entre fragmentos suele ser suficiente.
- **Recupera más de lo que crees.** Comienza con `topK = 5` y redúcelo si obtienes demasiado ruido. Es mejor tener contexto adicional que perderse la parte relevante.
- **Las indicaciones del sistema son cruciales.** Sea muy explícito acerca de utilizar solo el contexto proporcionado. Sin esa instrucción, el modelo felizmente alucinará "basándose en sus datos de entrenamiento".
- **Seguimiento de fuentes.** Almacene siempre metadatos con sus fragmentos para que pueda citar de dónde vino la respuesta. Los usuarios confían más en las respuestas cuando pueden verificar la fuente.
- **Reclasificar si es necesario.** La similitud de vectores no es perfecta. Para aplicaciones críticas, agregue un paso de reclasificación utilizando un modelo de codificador cruzado para mejorar la precisión.
## Conclusión
RAG es uno de los patrones más prácticos en IA en este momento. Le permite crear sistemas de preguntas y respuestas basados en IA a partir de sus propios datos sin necesidad de realizar ajustes, y Semantic Kernel lo hace sorprendentemente limpio en C#. Comience con el almacén en memoria, obtenga la fragmentación y las indicaciones correctas, luego intercambie una base de datos vectorial real cuando esté listo para la producción.
¡Feliz codificación!
## Recursos
- [Documentación del almacén de vectores del kernel semántico](https://learn.microsoft.com/en-us/semantic-kernel/concepts/vector-store-connectors/)
- [Patrón RAG con Azure AI Search](https://learn.microsoft.com/en-us/azure/search/retrieval-augmented-generation-overview)
- [Modelos de incrustación de texto](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models#embeddings)