Aufbau eines RAG-Systems in C# mit Semantic Kernel
Einführung
Wenn Sie versucht haben, ein LLM zu verwenden, um Fragen zu Ihren eigenen Daten zu beantworten – Unternehmensdokumente, Produktspezifikationen, interne Wissensdatenbanken –, ist Ihnen wahrscheinlich aufgefallen, dass es entweder halluziniert oder einfach sagt: „Ich habe keine Informationen darüber.“ Das liegt daran, dass das Modell nur weiß, worauf es trainiert wurde.
RAG (Retrieval-Augmented Generation) behebt dieses Problem. Anstatt ein Modell für Ihre Daten zu verfeinern, rufen Sie relevante Teile Ihrer Dokumente zum Zeitpunkt der Abfrage ab und übergeben sie als Kontext an das LLM. Das Modell generiert dann Antworten, die auf Ihren tatsächlichen Daten basieren.
In diesem Beitrag werde ich Sie durch den Aufbau einer vollständigen RAG-Pipeline in C# mit Semantic Kernel führen.
So funktioniert RAG
Der Ablauf ist unkompliziert:
- Ingest: Teilen Sie Ihre Dokumente in Blöcke auf, generieren Sie Einbettungen für jeden Block und speichern Sie sie in einer Vektordatenbank
- Abfrage: Wenn ein Benutzer eine Frage stellt, generieren Sie eine Einbettung für die Abfrage und durchsuchen Sie die Vektordatenbank nach ähnlichen Blöcken
- Generieren: Übergeben Sie die abgerufenen Blöcke zusammen mit der Frage des Benutzers als Kontext an das LLM
Das ist es. Der Zauber liegt in den Einbettungen – sie erfassen die semantische Bedeutung von Text als Vektoren, sodass Sie relevante Inhalte finden können, auch wenn die genauen Wörter nicht übereinstimmen.
Voraussetzungen
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
Für die Produktion würden Sie den In-Memory-Speicher gegen Azure AI Search, Qdrant, Pinecone oder eine andere unterstützte Vektordatenbank austauschen. Aber In-Memory eignet sich perfekt zum Lernen und Prototyping.
Einrichten des Kernels
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();
Wir benötigen zwei Modelle: eines für die Chat-Vervollständigung (Beantwortung von Fragen) und eines für die Generierung von Einbettungen (Text in Vektoren umwandeln).
Definieren des Datenmodells
Wir benötigen eine Klasse, um unsere Dokumentblöcke im Vektorspeicher darzustellen:
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; }
}
Das VectorStoreRecordVector(1536)-Attribut teilt dem Vektorspeicher die Dimension unserer Einbettungen mit. Das text-embedding-3-small-Modell erzeugt 1536-dimensionale Vektoren.
Dokumente aufteilen
Bevor wir Einbettungen erstellen können, müssen wir unsere Dokumente in überschaubare Teile aufteilen. Hier ist ein einfacher Textsplitter:
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;
}
}
Die Überlappung ist wichtig – sie stellt sicher, dass der Kontext an der Grenze zwischen den Blöcken nicht verloren geht. Wenn ein relevanter Satz auf zwei Abschnitte aufgeteilt wird, bedeutet die Überlappung, dass er in mindestens einem von ihnen vollständig vorkommt.
Dokumente werden aufgenommen
Lassen Sie uns nun alles zusammenfügen, um Dokumente in unseren Vektorspeicher aufzunehmen:
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");
Suche nach relevanten Chunks
Wenn ein Benutzer eine Frage stellt, generieren wir eine Einbettung für seine Anfrage und suchen nach ähnlichen Blöcken:
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;
}
Antworten mit Kontext generieren
Jetzt der RAG-Teil – wir nehmen die abgerufenen Blöcke und fügen sie als Kontext in unsere Eingabeaufforderung ein:
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.";
}
Benutze es
// 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
Übergang zur Produktion
Der In-Memory-Vektorspeicher eignet sich hervorragend für die Prototypenerstellung, für die Produktion benötigen Sie jedoch eine persistente Vektordatenbank. Semantic Kernel verfügt über Konnektoren für mehrere Optionen:
# 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
Der Austausch ist unkompliziert, da alle die gleiche IVectorStore-Schnittstelle implementieren:
// 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"])));
```Alles andere bleibt gleich. Das ist das Schöne an der Abstraktion.
## Tipps zum Aufbau von RAG-Systemen
Ein paar Dinge, die ich auf die harte Tour gelernt habe:
- **Die Blockgröße ist sehr wichtig.** Zu klein und Sie verlieren den Kontext. Zu groß und Sie verschwenden Token für irrelevante Inhalte. Beginnen Sie mit 500–800 Token und passen Sie diese basierend auf Ihren Daten an.
- **Überlappung verhindert Grenzprobleme.** Eine Überlappung von 50–100 Token zwischen Blöcken reicht normalerweise aus.
- **Mehr abrufen, als Sie denken.** Beginnen Sie mit `topK = 5` und reduzieren Sie die Lautstärke, wenn Sie zu viel Lärm bekommen. Es ist besser, zusätzlichen Kontext zu haben, als den relevanten Teil zu verpassen.
- **Systemaufforderungen sind von entscheidender Bedeutung.** Seien Sie sehr deutlich und verwenden Sie nur den bereitgestellten Kontext. Ohne diese Anweisung wird das Modell „basierend auf seinen Trainingsdaten“ glücklich halluzinieren.
- **Quellen verfolgen.** Speichern Sie immer Metadaten mit Ihren Chunks, damit Sie angeben können, woher die Antwort stammt. Benutzer vertrauen Antworten mehr, wenn sie die Quelle überprüfen können.
- **Bei Bedarf neu einordnen.** Die Vektorähnlichkeit ist nicht perfekt. Fügen Sie bei kritischen Anwendungen einen Re-Ranking-Schritt mithilfe eines Cross-Encoder-Modells hinzu, um die Präzision zu verbessern.
## Fazit
RAG ist derzeit eines der praktischsten Muster in der KI. Damit können Sie ohne Feinabstimmung KI-gestützte Q&A-Systeme auf Ihren eigenen Daten aufbauen, und Semantic Kernel macht es in C# überraschend sauber. Beginnen Sie mit dem In-Memory-Speicher, sorgen Sie für die richtige Aufteilung und Aufforderung und tauschen Sie dann eine echte Vektordatenbank ein, wenn Sie für die Produktion bereit sind.
Viel Spaß beim Codieren!
## Ressourcen
- [Semantic Kernel Vector Store-Dokumentation](https://learn.microsoft.com/en-us/semantic-kernel/concepts/vector-store-connectors/)
– [RAG-Muster mit Azure AI Search](https://learn.microsoft.com/en-us/azure/search/retrieval-augmented-generation-overview)
- [Texteinbettungsmodelle](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models#embeddings)