Spiga

程序员的AI体验(十):Semantic Kernel RAG

2025-06-14 17:29:33

一、RAG核心机制与组件

1. SK RAG架构与抽象层

  • 抽象层转变 Semantic Kernel为RAG应用提供了现代化、企业级的抽象层,从单一组件向精细、解耦、类型安全的抽象转变。这种转变使得系统更加健壮、可维护,分离了数据存储、嵌入生成和信息检索的关注点,提升了模块化和可测试性。
  • 数据模型映射 通过定义C#类和添加特性,可以将数据模型映射到向量数据库的模式。例如,使用VectorStoreKey、VectorStoreData等特性,能够简化与数据库的交互,避免编写复杂的数据库迁移代码,提高代码的可维护性和可扩展性。
  • 技术选型灵活性 这种架构设计增强了技术选型的灵活性,允许开发者根据项目需求选择合适的数据库和嵌入模型,同时保持系统的稳定性和可扩展性。

2. RAG完整生命周期

  • 生命周期阶段 RAG应用的核心生命周期包括数据加载与预处理、文本分块、嵌入、索引、检索和增强生成六个阶段。每个阶段都有明确的作用和输入输出,例如数据加载阶段负责读取数据,嵌入阶段将文本转换为向量。
  • 标准性与通用性 与LangChain课程中提到的生命周期相比,SemanticKernel的RAG生命周期更加标准化和通用化,适用于多种数据类型和应用场景,为开发者提供了一套清晰的开发流程。

二、文本预处理与分块

  • 文本分块的重要性 文本分块是RAG流程中的关键步骤,能够将长文本分割成适合嵌入和检索的小块,从而提高检索的准确性和效率。

  • 自定义Token计算器

    默认的Token计算器在处理中文分词时存在局限性,通过自定义Token计算器可以解决这一问题,确保分块的准确性。

  • TextChunker静态类 Semantic Kernel提供了TextChunker静态类,包含按行分割和段落组合等核心方法。通过设置最大Token数量和重叠Token参数,可以灵活地控制分块的粒度。

  • 封装不稳定依赖 为了隔离变更风险,可以创建稳定的ITextSplitter接口和SemanticKernelTextSplitter包装类,封装不稳定的依赖,提高代码的稳定性和可维护性。

public interface ITextSplitter
{
    List<string> Split(string text, int maxTokensPerChunk, int overlapTokens);
}

// 创建一个包装类来实现该接口
public class SemanticKernelTextSplitter : ITextSplitter
{
    public List<string> Split(string text, int maxTokensPerChunk, int overlapTokens)
    {
        // 内部调用不稳定的 TextChunker
        var lines = TextChunker.SplitPlainTextLines(text, maxTokensPerChunk / 3); // 粗略估计行大小
        return TextChunker.SplitPlainTextParagraphs(lines, maxTokensPerChunk, overlapTokens);
    }
}
  • 中文的处理
// 创建一个包装类来实现该接口
public class SemanticKernelTextSplitter : ITextSplitter
{
    public List<string> Split(string text, int maxTokensPerChunk, int overlapTokens)
    {
        // 内部调用不稳定的 TextChunker
        var lines = TextChunker.SplitPlainTextLines(text, maxTokensPerChunk / 3); // 粗略估计行大小
        
        var tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");
        
        return TextChunker.SplitPlainTextParagraphs(lines, maxTokensPerChunk, overlapTokens,
                       tokenCounter: input => tokenizer.CountTokens(input));
    }
}

三、向量存储与文本搜索

1. 向量存储与模型映射

  • 向量存储设计

    Semantic Kernel的向量存储设计通过定义C#类和添加特性,声明性地描述数据模型如何映射到向量数据库的模式。这种设计简化了与数据库的交互,避免了复杂的模式定义代码。

  • 核心特性 VectorStoreKey、VectorStoreData和VectorStoreVector等核心特性用于配置数据模型。例如,DocumentChunk类通过这些特性定义了数据模型的结构,使其能够无缝映射到向量数据库中。

  • 模型优先的设计理念 这种模型优先的设计理念不仅提高了代码的可维护性和可扩展性,还使得开发者能够专注于业务逻辑,而不是底层的数据库操作。

2. 文本搜索服务

  • TextSearch接口 Semantic Kernel提供了ITextSearch接口,将复杂的向量检索过程封装成一个简单的基于自然语言的搜索操作。通过在数据模型上添加TextSearchResultValue等特性,可以将检索到的自定义数据模型转换为标准格式。

  • 解耦与灵活性

    这种分层抽象的设计实现了上层应用代码与底层数据库技术的解耦。通过更换向量数据库连接器可以无缝迁移到底层不同的数据库,体现了企业级软件设计的灵活性和可维护性。

四、构建标准RAG应用流程

  1. 添加nuget包,禁用警告,创建用户机密文件,准备一个data文件

        <ItemGroup>
          <PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.8" />
          <PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="9.0.8" />
          <PackageReference Include="Microsoft.SemanticKernel" Version="1.63.0" />
            <PackageReference Include="Microsoft.ML.Tokenizers" Version="1.0.2" />
            <PackageReference Include="Microsoft.ML.Tokenizers.Data.Cl100kBase" Version="1.0.2" />
            <PackageReference Include="Microsoft.Extensions.AI" Version="9.8.0" />
            <PackageReference Include="OpenAI" Version="2.3.0" />
            <PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.62.0-preview" />
            <PackageReference Include="Microsoft.SemanticKernel.Agents.Core" Version="1.62.0" />
        </ItemGroup>
        <PropertyGroup>
            <OutputType>Exe</OutputType>
            <TargetFramework>net9.0</TargetFramework>
            <ImplicitUsings>enable</ImplicitUsings>
            <Nullable>enable</Nullable>
            <NoWarn>$(NoWarn);SKEXP0001;SKEXP0110;SKEXP0050;SKEXP0130</NoWarn>
            <UserSecretsId>5067eb98-c685-44c1-b897-d95f6ca2bb36</UserSecretsId>
        </PropertyGroup>
    
        <ItemGroup>
          <None Update="data.txt">
            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
          </None>
        </ItemGroup>
    
  2. data.txt

    Semantic Kernel 是一个开源 SDK,可让您轻松构建能够调用现有代码的代理。作为一个高度可扩展的 SDK,您可以将 Semantic Kernel 与 OpenAI、Azure OpenAI、Hugging Face 等模型结合使用!插件是 Semantic Kernel 的构建块。插件是一个包含向 AI 模型公开的函数的类。插件可以包含本机函数(C#、Python 或 Java 代码)和语义函数(提示)。这允许您将自己的代码添加到 Semantic Kernel 中,并让 AI 模型调用它。
    
  3. 创建一个记录类型

    public class DocumentChunk
    {
        // 标记这是记录的唯一标识符
        [VectorStoreKey]
        public string Id { get; set; }
    
        // 标记这是需要存储的元数据
        [VectorStoreData]
        [TextSearchResultName]
        public string DocumentName { get; set; }
    
        [VectorStoreData]
        [TextSearchResultLink]
        public int Index { get; set; }
    
        // 存储块的原始文本内容
        [VectorStoreData]
        [TextSearchResultValue]
        public string Text { get; set; }
    
        // 标记这是存储嵌入向量的字段
        [VectorStoreVector(Dimensions:2048)]
        public string Embedding => Text;
    }
    
  4. 数据加载与分块

    Console.WriteLine("--- 步骤 1: 数据加载与分块 ---");
    
    // 从文件读取文本内容
    var text = await File.ReadAllTextAsync("data.txt");
    
    var tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");
    
    // 使用 TextChunker 将文本分割成段落
    // 这里设置每个块最大 100 个令牌,重叠 10 个令牌
    var paragraphs = TextChunker.SplitPlainTextParagraphs(
        TextChunker.SplitPlainTextLines(text, 20), // 先按行粗略切分
        100,
        10,
        tokenCounter: input => tokenizer.CountTokens(input)
    );
    
    Console.WriteLine($"成功将文本分割成 {paragraphs.Count} 个块。");
    Console.WriteLine();
    
  5. 配置嵌入服务

    Console.WriteLine("--- 步骤 2: 配置嵌入服务 ---");
    
    // 获取配置
    var configuration = new ConfigurationBuilder()
        .AddUserSecrets<Program>() 
        .Build();
    var apiKey = configuration["Qianwen:ApiKey"]!;
    var endpoint = "https://dashscope.aliyuncs.com/compatible-mode/v1";
    
    var clientOptions = new OpenAIClientOptions
    {
        Endpoint = new Uri(endpoint)
    };
    
    // 构建文本嵌入服务
    var embeddingGenerator = new OpenAIClient(new ApiKeyCredential(apiKey), clientOptions)
        .GetEmbeddingClient("text-embedding-v4")
        .AsIEmbeddingGenerator().AsTextEmbeddingGenerationService();
    
    Console.WriteLine("嵌入服务配置完成。");
    Console.WriteLine();
    
  6. 配置向量数据库

    Console.WriteLine("--- 步骤 3: 配置向量数据库 ---");
    
    // 创建一个内存向量存储实例
    var vectorStore = new InMemoryVectorStore();
    
    // 从向量存储中获取一个强类型的集合
    // 集合名称为 "sk-docs",键类型为 string,记录类型为 DocumentChunk
    var collection = vectorStore.GetCollection<string, DocumentChunk>("sk-docs");
    // 确保集合存在,不存在则创建
    await collection.EnsureCollectionExistsAsync();
    
    Console.WriteLine("内存向量数据库和集合配置完成。");
    Console.WriteLine();
    
  7. 文本嵌入与持久化

    Console.WriteLine("--- 步骤 4: 文本嵌入与持久化 ---");
    
    // 遍历所有文本块,生成嵌入并存储
    for (var i = 0; i < paragraphs.Count; i++)
    {
        // 为当前文本块生成嵌入向量
        var embedding = await embeddingGenerator.GenerateEmbeddingAsync(paragraphs[i]);
        // 创建一个 DocumentChunk 实例
        var chunk = new DocumentChunk
        {
            Id = Guid.NewGuid().ToString(),
            DocumentName = "data.txt",
            Index = i,
            Text = paragraphs[i],
            // Embedding = embedding
        };
    
        // 将该实例存入向量集合中
        await collection.UpsertAsync(chunk);
    }
    
    Console.WriteLine($"已成功嵌入并存储了 {paragraphs.Count} 个文本块。");
    Console.WriteLine();
    
  8. 向量检索

    Console.WriteLine("--- 步骤 5: 向量检索 ---");
    
    // 用户问题
    var userQuery = "Semantic Kernel 中的插件是什么?";
    
    // 为用户问题生成嵌入向量
    var queryEmbedding = await embeddingGenerator.GenerateEmbeddingAsync(userQuery);
    
    // 在向量集合中执行相似性搜索,获取最相关的 2 个结果
    var searchResults = collection.SearchAsync(queryEmbedding, 2);
    
    // 收集检索到的文本内容
    var context = "";
    await foreach (var searchResult in searchResults)
    {
        context += searchResult.Record.Text + "\n---\n";
    }
    
    Console.WriteLine("检索到的上下文:");
    Console.WriteLine(context);
    
  9. 检索增强生成

    Console.WriteLine("--- 步骤 6: 检索增强生成 ---");
    
    // 构建内核对象,注册AI补全服务
    var kernel = Kernel.CreateBuilder()
        .AddOpenAIChatCompletion(
            modelId: "qwen-plus",
            apiKey: apiKey,
            endpoint: new Uri(endpoint)
        ).Build();
    
    // 定义一个提示模板,包含上下文和问题两个变量
    var promptTemplate = """
                         请根据提供的上下文来回答用户的问题。
    
                         上下文:
                         {{$context}}
    
                         用户问题:
                         {{$query}}
                         """;
    // 使用内核执行提示
    var result = await kernel.InvokePromptAsync(
        promptTemplate,
        new KernelArguments
        {
            { "context", context },
            { "query", userQuery }
        }
    );
    
    Console.WriteLine("LLM 生成的答案:");
    Console.WriteLine(result);
    

五、SK RAG与LangChain RAG对比

1. 设计理念对比

  • Semantic Kernel设计理念 Semantic Kernel的核心设计理念是编排,强调强类型、面向对象、依赖注入和关注点分离,适合构建长期维护、稳定性和可扩展性要求高的生产级AI应用。
  • LangChain设计理念 LangChain的设计理念更侧重于灵活性和快速实验,采用函数式、声明式的编程范式,适合快速原型验证和频繁调整的场景。
  • 差异影响 两种设计理念的差异对开发者体验和应用场景产生了显著影响。SK更适合企业级应用,而LangChain更适合快速开发和实验。

2. 开发体验与生态系统

  • SK开发体验 Semantic Kernel在C#环境下提供了类型安全的优势,支持OpenTelemetry,为企业级应用提供了可观测性。
  • LangChain开发体验 LangChain基于动态类型Python,拥有庞大的AI社区和丰富的第三方工具、模型和数据源集成。通过对比分析,帮助观众理解两个框架在开发效率、错误捕获、性能监控和生态系统支持方面的不同特点。

3. 场景选择建议

  • 选择SK的场景 建议在深度集成.NET企业应用、追求稳定性和可维护性、构建标准化的AI服务等场景中选择Semantic Kernel.
  • 选择LangChain的场景 建议在快速原型和研究探索、利用丰富的生态系统、Python技术栈主导等场景中选择LangChain。通过具体场景分析,帮助观众根据项目需求和技术栈选择合适的框架。
特性/组件 SK LangChain 对比分析
数据加载 无内置加载器生态,需手动编码(如File.ReadAllTextAsync) 拥有庞大的DocumentLoader生态,支持数百种数据源(PDF,Wrod,Web等) LangChain在数据接入的便捷性和广度上具有明显优势,开箱即用。SK需要开发者自行实现数据加载逻辑
文本分块 提供TextChunker静态类,支持按行、段落和重叠分块 提供丰富的TextSplitter抽象和实现,支持按代码、md等多种方式分块 LangChain的分块策略更多样化,适应性更强。SK的TextChunker功能基础但够用,且API尚不太稳定
向量存储 采用抽象的强类型,与C#数据模型紧密绑定 提供统一的向量存储包装器接口,支持大量向量数据库的快速集成 SK的方法提供了编译时类型安全和更好的IDE支持。LangChain的方法则提供了更广泛的数据库支持和更快的集成速度
RAG流程构建 手动流程:SearchAsync检索-->手动构建上下文-->InvokePromptAsync生成 高度封装:提供高级抽象,一行代码即可构建完整RAG链 LangChain在RAG模式的封装程度上远高于SK,极大地简化了开发。SK则提供了更底层的控制,让开发者能精细地控制流程的每一步

六、利用Agent框架构建智能RAG

1. Agentic RAG核心思想

  • 传统RAG局限性:传统RAG缺乏动态决策能力,无法处理模糊查询和多步推理任务。
  • Agent概念:Agentic RAG引入智能体(Agent)的概念,通过推理与规划、工具选择与使用、迭代执行来处理更复杂的任务。
  • 动态循环优势:这种动态的、基于推理的循环提升了RAG系统的灵活性和智能性。通过具体例子,可以展示Agentic RAG在实际应用中的优势。

2. TextSearchProvider实现原理

  • 实现lAlContextProvider接囗:TextSearchProvider实现了IAlContextProvider接口,在Agent调用语言模型之前被触发,分析用户输入并进行相关性搜索。
  • 按需RAG功能:按需RAG功能使Agent能够自主决定是否需要从知识库中拉取信息,提高了系统的效率和灵活性。

3. 标准RAG与智能RAG的工作模式

  • 标准RAG:推模式--->总是先执行检索,推送给语言模型
  • 智能RAG:拉模式--->Agent根据内部的推理,自主决定是否需要从数据库中拉取信息

4. 代码示例

public static async Task Run()
{
    Console.WriteLine("--- 步骤 1: 数据加载与分块 ---");

    // 从文件读取文本内容
    var text = await File.ReadAllTextAsync("data.txt");

    var tokenizer = TiktokenTokenizer.CreateForModel("gpt-4");

    // 使用 TextChunker 将文本分割成段落
    // 这里设置每个块最大 100 个令牌,重叠 10 个令牌
    var paragraphs = TextChunker.SplitPlainTextParagraphs(
        TextChunker.SplitPlainTextLines(text, 20), // 先按行粗略切分
        100,
        10,
        tokenCounter: input => tokenizer.CountTokens(input)
    );

    Console.WriteLine($"成功将文本分割成 {paragraphs.Count} 个块。");
    Console.WriteLine();

    Console.WriteLine("--- 步骤 2: 配置嵌入服务 ---");

    // 获取配置
    var configuration = new ConfigurationBuilder()
        .AddUserSecrets<Program>() 
        .Build();
    var apiKey = configuration["Qianwen:ApiKey"]!;
    var endpoint = "https://dashscope.aliyuncs.com/compatible-mode/v1";

    var clientOptions = new OpenAIClientOptions
    {
        Endpoint = new Uri(endpoint)
    };

    // 构建文本嵌入服务
    var embeddingGenerator = new OpenAIClient(new ApiKeyCredential(apiKey), clientOptions)
        .GetEmbeddingClient("text-embedding-v4")
        .AsIEmbeddingGenerator();

    Console.WriteLine("嵌入服务配置完成。");
    Console.WriteLine();

    Console.WriteLine("--- 步骤 3: 配置向量数据库 ---");

    // 创建一个内存向量存储实例
    var vectorStore = new InMemoryVectorStore(new InMemoryVectorStoreOptions
                                              {
                                                  EmbeddingGenerator = embeddingGenerator
                                              });

    // 从向量存储中获取一个强类型的集合
    // 集合名称为 "sk-docs",键类型为 string,记录类型为 DocumentChunk
    var collection = vectorStore.GetCollection<string, DocumentChunk>("sk-docs");
    // 确保集合存在,不存在则创建
    await collection.EnsureCollectionExistsAsync();

    Console.WriteLine("内存向量数据库和集合配置完成。");
    Console.WriteLine();

    Console.WriteLine("--- 步骤 4: 文本嵌入与持久化 ---");

    // 遍历所有文本块,生成嵌入并存储
    for (var i = 0; i < paragraphs.Count; i++)
    {
        // 创建一个 DocumentChunk 实例
        var chunk = new DocumentChunk
        {
            Id = Guid.NewGuid().ToString(),
            DocumentName = "data.txt",
            Index = i,
            Text = paragraphs[i]
        };

        // 将该实例存入向量集合中
        await collection.UpsertAsync(chunk);
    }

    Console.WriteLine($"已成功嵌入并存储了 {paragraphs.Count} 个文本块。");
    Console.WriteLine();

    Console.WriteLine("--- 步骤 5: 构建内核,创建 Agent ---");

    var kernel = Kernel.CreateBuilder()
        .AddOpenAIChatCompletion(
        modelId: "qwen-plus",
        apiKey: apiKey,
        endpoint: new Uri(endpoint)
    ).Build();

    // 创建一个 ChatCompletionAgent
    var agent = new ChatCompletionAgent
    {
        Name = "小唆",
        Instructions = "你是一个乐于助人的助手,可以回答有关 Semantic Kernel 的问题。请使用提供的上下文来回答问题。",
        Kernel = kernel,
        // 这个设置指示是否启用按需 RAG 功能
        UseImmutableKernel = true
    };

    // 创建向量搜索实例,泛型表示向量模型,需要提供向量集合与通用嵌入生成服务
    var textSearch = new VectorStoreTextSearch<DocumentChunk>(collection, embeddingGenerator);
    // 按需RAG核心组件,需要提供向量搜索实例
    var textSearchProvider = new TextSearchProvider(textSearch);

    // 创建一个 Agent 线程用于管理对话历史
    var agentThread = new ChatHistoryAgentThread();

    // 将 TextSearchProvider 添加到线程的上下文提供者列表中
    agentThread.AIContextProviders.Add(textSearchProvider);

    Console.WriteLine("智能 RAG 助手已启动。输入 'exit' 退出。");
    while (true)
    {
        Console.Write("用户 > ");
        var userInput = Console.ReadLine();
        if (string.IsNullOrEmpty(userInput)) continue;
        if (userInput?.ToLower() == "exit") break;

        // 调用 Agent,传入用户输入和配置了 RAG 的线程
        Console.Write("助手 > ");
        await foreach (var response in agent.InvokeStreamingAsync(userInput, agentThread))
        {
            Console.Write(response.Message.Content);
        }
        Console.WriteLine();
    }
}