使用语义内核在 C# 中构建 RAG 系统
简介
如果您尝试使用法学硕士来回答有关您自己的数据(公司文档、产品规格、内部知识库)的问题,您可能会注意到它要么产生幻觉,要么只是说“我没有这方面的信息”。这是因为模型只知道它接受的训练内容。
RAG(检索增强生成)解决了这个问题。您无需在数据上微调模型,而是在查询时检索文档的相关块并将它们作为上下文传递给 LLM。然后,该模型会根据您的实际数据生成答案。
在这篇文章中,我将引导您使用语义内核在 C# 中构建完整的 RAG 管道。
RAG 的工作原理
流程很简单:
- 摄取:将文档分割成块,为每个块生成嵌入,将它们存储在矢量数据库中
- 查询:当用户提出问题时,生成查询的嵌入,在向量数据库中搜索相似的块
- 生成:将检索到的块作为上下文以及用户的问题传递给 LLM
就是这样。神奇之处在于嵌入 - 它们将文本的语义捕获为向量,因此即使确切的单词不匹配,您也可以找到相关内容。
先决条件
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
对于生产,您可以将内存存储替换为 Azure AI Search、Qdrant、Pinecone 或任何其他支持的矢量数据库。但内存中非常适合学习和原型设计。
设置内核
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();
我们需要两种模型:一种用于聊天完成(回答问题),另一种用于生成嵌入(将文本转换为向量)。
定义数据模型
我们需要一个类来表示向量存储中的文档块:
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; }
}
VectorStoreRecordVector(1536) 属性告诉向量存储我们嵌入的维度。 text-embedding-3-small 模型生成 1536 维向量。
分块文档
在创建嵌入之前,我们需要将文档分割成可管理的块。这是一个简单的文本分割器:
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;
}
}
重叠很重要——它确保块之间边界的上下文不会丢失。如果相关句子被分成两个块,则重叠意味着它将完全出现在至少其中一个块中。
摄取文档
现在,让我们将它们放在一起,将文档提取到我们的矢量存储中:
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");
搜索相关块
当用户提出问题时,我们会为其查询生成嵌入并搜索相似的块:
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;
}
根据上下文生成答案
现在是 RAG 部分 - 我们获取检索到的块并将它们作为上下文包含在提示中:
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.";
}
使用它
// 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
转向生产
内存中的矢量存储非常适合原型设计,但对于生产,您需要一个持久的矢量数据库。语义内核有多个选项的连接器:
# 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
交换很简单,因为它们都实现相同的 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"])));
```其他一切都保持不变。这就是抽象的美妙之处。
## 构建 RAG 系统的技巧
我通过艰难的方式学到了一些东西:
- **块大小很重要。**太小,你会失去上下文。太大,你会在不相关的内容上浪费代币。从 500-800 个令牌开始,然后根据您的数据进行调整。
- **重叠可防止边界问题。** 块之间 50-100 个令牌重叠通常就足够了。
- **检索到的内容比您想象的要多。** 从 `topK = 5` 开始,如果您收到太多噪音,请减少。拥有额外的上下文比错过相关块要好。
- **系统提示至关重要。** 非常明确地仅使用提供的上下文。如果没有该指令,模型将很乐意“根据其训练数据”产生幻觉。
- **跟踪来源。** 始终将元数据与您的块一起存储,以便您可以引用答案的来源。当用户可以验证来源时,他们会更信任答案。
- **如果需要的话重新排名。**向量相似性并不完美。对于关键应用程序,使用交叉编码器模型添加重新排名步骤以提高精度。
## 结论
RAG 是目前人工智能中最实用的模式之一。它可以让您在自己的数据上构建人工智能驱动的问答系统,而无需进行微调,并且 Semantic Kernel 使其在 C# 中变得异常干净。从内存存储开始,正确进行分块和提示,然后在准备好生产时交换到真实的矢量数据库。
快乐编码!
## 资源
- [语义内核向量存储文档](https://learn.microsoft.com/en-us/semantic-kernel/concepts/vector-store-connectors/)
- [使用 Azure AI 搜索的 RAG 模式](https://learn.microsoft.com/en-us/azure/search/retrieval-augmented-generation-overview)
- [文本嵌入模型](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models#embeddings)