基于Microsoft.Extensions.AI核心库实现RAG应用

大家好,我是Edison。

之前我们了解 Microsoft.Extensions.AIMicrosoft.Extensions.VectorData 两个重要的AI应用核心库。基于对他们的了解,今天我们就可以来实战一个RAG问答应用,把之前所学的串起来。

前提知识点:向量存储、词嵌入、向量搜索、提示词工程、函数调用。

案例需求背景

假设我们在一家名叫"易速鲜花"的电商网站工作,顾名思义,这是一家从事鲜花电商的网站。我们有一些运营手册、员工手册之类的文档(例如下图所示的一些pdf文件),想要将其导入知识库并创建一个AI机器人,负责日常为员工解答一些政策性的问题。

例如,员工想要了解奖励标准、行为准备、报销流程等等,都可以通过和这个AI机器人对话就可以快速了解最新的政策和流程。

在接下来的Demo中,我们会使用以下工具:

(1) LLM 采用 Qwen2.5-7B-Instruct,可以使用SiliconFlow平台提供的API,你也可以改为你喜欢的其他模型如DeepSeek,但是建议不要用大炮打蚊子哈。

注册地址:点此注册

(2) Qdrant 作为 向量数据库,可以使用Docker在你本地运行一个:

复制代码
docker run -p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant

(3) Ollama 运行 bge-m3 模型 作为 Emedding生成器,可以自行拉取一个在你本地运行:

复制代码
ollama pull bge-m3

构建你的RAG应用

创建一个控制台应用程序,添加一些必要的文件目录 和 配置文件(json),最终的解决方案如下图所示。

在Documents目录下放了我们要导入的一些pdf文档,例如公司运营手册、员工手册等等。

在Models目录下放了一些公用的model类,其中TextSnippet类作为向量存储的实体类,而TextSearchResult类则作为向量搜索结果的模型类。

(1)TextSnippet

这里我们的TextEmbedding字段就是我们的向量值,它有1024维。

注意:这里的维度是我们自己定义的,你也可以改为你想要的维度数量,但是你的词嵌入模型需要支持你想要的维度数量。

复制代码
public sealed class TextSnippet<TKey>
{
    [VectorStoreRecordKey]
    public required TKey Key { get; set; }

    [VectorStoreRecordData]
    public string? Text { get; set; }

    [VectorStoreRecordData]
    public string? ReferenceDescription { get; set; }

    [VectorStoreRecordData]
    public string? ReferenceLink { get; set; }

    [VectorStoreRecordVector(Dimensions: 1024)]
    public ReadOnlyMemory<float> TextEmbedding { get; set; }
}

(2)TextSearchResult

这个类主要用来返回给LLM做推理用的,我这里只需要三个字段:Value, Link 和 Score 即可。

复制代码
public class TextSearchResult
{
    public string  Value { get; set; }
    public string? Link { get; set; }
    public double? Score { get; set; }
}

(3)RawContent

这个类主要用来在PDF导入时作为一个临时存储源数据文档内容。

复制代码
public sealed class RawContent
{
    public string? Text { get; init; }

    public int PageNumber { get; init; }
}

在Plugins目录下放了一些公用的帮助类,如PdfDataLoader可以实现PDF文件的读取和导入向量数据库,VectorDataSearcher可以实现根据用户的query搜索向量数据库获取TopN个近似文档,而UniqueKeyGenerator则用来生成唯一的ID Key。

(1)PdfDataLoader

作为PDF文件的导入核心逻辑,它实现了PDF文档读取、切分、生成指定维度的向量 并 存入向量数据库。

注意:这里只考虑了文本格式的内容,如果你还想考虑文件中的图片将其转成文本,你需要增加一个LLM来帮你做图片转文本的工作。

复制代码
public sealed class PdfDataLoader<TKey> where TKey : notnull
{
    private readonly IVectorStoreRecordCollection<TKey, TextSnippet<TKey>> _vectorStoreRecordCollection;
    private readonly UniqueKeyGenerator<TKey> _uniqueKeyGenerator;
    private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;

    public PdfDataLoader(
        UniqueKeyGenerator<TKey> uniqueKeyGenerator,
        IVectorStoreRecordCollection<TKey, TextSnippet<TKey>> vectorStoreRecordCollection,
        IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator)
    {
        _vectorStoreRecordCollection = vectorStoreRecordCollection;
        _uniqueKeyGenerator = uniqueKeyGenerator;
        _embeddingGenerator = embeddingGenerator;
    }

    public async Task LoadPdf(string pdfPath, int batchSize, int betweenBatchDelayInMs)
    {
        // Create the collection if it doesn't exist.
        await _vectorStoreRecordCollection.CreateCollectionIfNotExistsAsync();

        // Load the text and images from the PDF file and split them into batches.
        var sections = LoadAllTexts(pdfPath);
        var batches = sections.Chunk(batchSize);

        // Process each batch of content items.
        foreach (var batch in batches)
        {
            // Get text contents
            var textContentTasks = batch.Select(async content =>
            {
                if (content.Text != null)
                    return content;

                return new RawContent { Text = string.Empty, PageNumber = content.PageNumber };
            });
            var textContent = (await Task.WhenAll(textContentTasks))
                .Where(c => !string.IsNullOrEmpty(c.Text))
                .ToList();

            // Map each paragraph to a TextSnippet and generate an embedding for it.
            var recordTasks = textContent.Select(async content => new TextSnippet<TKey>
            {
                Key = _uniqueKeyGenerator.GenerateKey(),
                Text = content.Text,
                ReferenceDescription = $"{new FileInfo(pdfPath).Name}#page={content.PageNumber}",
                ReferenceLink = $"{new Uri(new FileInfo(pdfPath).FullName).AbsoluteUri}#page={content.PageNumber}",
                TextEmbedding = await _embeddingGenerator.GenerateEmbeddingVectorAsync(content.Text!)
            });

            // Upsert the records into the vector store.
            var records = await Task.WhenAll(recordTasks);
            var upsertedKeys = _vectorStoreRecordCollection.UpsertBatchAsync(records);
            await foreach (var key in upsertedKeys)
            {
                Console.WriteLine($"Upserted record '{key}' into VectorDB");
            }

            await Task.Delay(betweenBatchDelayInMs);
        }
    }

    private static IEnumerable<RawContent> LoadAllTexts(string pdfPath)
    {
        using (PdfDocument document = PdfDocument.Open(pdfPath))
        {
            foreach (Page page in document.GetPages())
            {
                var blocks = DefaultPageSegmenter.Instance.GetBlocks(page.GetWords());
                foreach (var block in blocks)
                    yield return new RawContent { Text = block.Text, PageNumber = page.Number };
            }
        }
    }
}

(2)VectorDataSearcher

上一篇文章介绍的内容类似,主要做语义搜索,获取TopN个近似内容。

复制代码
public class VectorDataSearcher<TKey> where TKey : notnull
{
    private readonly IVectorStoreRecordCollection<TKey, TextSnippet<TKey>> _vectorStoreRecordCollection;
    private readonly IEmbeddingGenerator<string, Embedding<float>> _embeddingGenerator;

    public VectorDataSearcher(IVectorStoreRecordCollection<TKey, TextSnippet<TKey>> vectorStoreRecordCollection, IEmbeddingGenerator<string, Embedding<float>> embeddingGenerator)
    {
        _vectorStoreRecordCollection = vectorStoreRecordCollection;
        _embeddingGenerator = embeddingGenerator;
    }

    [Description("Get top N text search results from vector store by user's query (N is 1 by default)")]
    [return: Description("Collection of text search result")]
    public async Task<IEnumerable<TextSearchResult>> GetTextSearchResults(string query, int topN = 1)
    {
        var queryEmbedding = await _embeddingGenerator.GenerateEmbeddingVectorAsync(query);
        // Query from vector data store
        var searchOptions = new VectorSearchOptions()
        {
            Top = topN,
            VectorPropertyName = nameof(TextSnippet<TKey>.TextEmbedding)
        };
        var searchResults = await _vectorStoreRecordCollection.VectorizedSearchAsync(queryEmbedding, searchOptions);
        var responseResults = new List<TextSearchResult>();
        await foreach (var result in searchResults.Results)
        {
            responseResults.Add(new TextSearchResult()
            {
                Value = result.Record.Text ?? string.Empty,
                Link = result.Record.ReferenceLink ?? string.Empty,
                Score = result.Score
            });
        }

        return responseResults;
    }
}

(3)UniqueKeyGenerator

这个主要是一个代理,后续我们主要使用Guid作为Key。

复制代码
public sealed class UniqueKeyGenerator<TKey>(Func<TKey> generator)
    where TKey : notnull
{
    /// <summary>
    /// Generate a unique key.
    /// </summary>
    /// <returns>The unique key that was generated.</returns>
    public TKey GenerateKey() => generator();
}

串联实现RAG问答

安装NuGet包:

复制代码
Microsoft.Extensions.AI (preview)
Microsoft.Extensions.Ollama (preivew)
Microsoft.Extensions.AI.OpenAI (preivew)
Microsoft.Extensions.VectorData.Abstractions (preivew)
Microsoft.SemanticKernel.Connectors.Qdrant (preivew)
PdfPig (0.1.9)
Microsoft.Extensions.Configuration (8.0.0)
Microsoft.Extensions.Configuration.Json (8.0.0)

下面我们分解几个核心步骤来实现RAG问答。

Step1. 配置文件appsettings.json:

复制代码
{
  "LLM": {
    "EndPoint": "https://api.siliconflow.cn",
    "ApiKey": "sk-**********************", // Replace with your ApiKey
    "ModelId": "Qwen/Qwen2.5-7B-Instruct"
  },
  "Embeddings": {
    "Ollama": {
      "EndPoint": "http://localhost:11434",
      "ModelId": "bge-m3"
    }
  },
  "VectorStores": {
    "Qdrant": {
      "Host": "edt-dev-server",
      "Port": 6334,
      "ApiKey": "EdisonTalk@2025"
    }
  },
  "RAG": {
    "CollectionName": "oneflower",
    "DataLoadingBatchSize": 10,
    "DataLoadingBetweenBatchDelayInMilliseconds": 1000,
    "PdfFileFolder": "Documents"
  }
}

Step2. 加载配置:

复制代码
var config = new ConfigurationBuilder()
    .AddJsonFile($"appsettings.json")
    .Build();

Step3. 初始化ChatClient、Embedding生成器 以及 VectorStore:

复制代码
# ChatClient
var apiKeyCredential = new ApiKeyCredential(config["LLM:ApiKey"]);
var aiClientOptions = new OpenAIClientOptions();
aiClientOptions.Endpoint = new Uri(config["LLM:EndPoint"]);
var aiClient = new OpenAIClient(apiKeyCredential, aiClientOptions)
    .AsChatClient(config["LLM:ModelId"]);
var chatClient = new ChatClientBuilder(aiClient)
    .UseFunctionInvocation()
    .Build();
# EmbeddingGenerator
var embedingGenerator =
    new OllamaEmbeddingGenerator(new Uri(config["Embeddings:Ollama:EndPoint"]), config["Embeddings:Ollama:ModelId"]);
# VectorStore
var vectorStore = 
    new QdrantVectorStore(new QdrantClient(host: config["VectorStores:Qdrant:Host"], port: int.Parse(config["VectorStores:Qdrant:Port"]), apiKey: config["VectorStores:Qdrant:ApiKey"]));

Step4. 导入PDF文档到VectorStore:

复制代码
var ragConfig = config.GetSection("RAG");
// Get the unique key genrator
var uniqueKeyGenerator = new UniqueKeyGenerator<Guid>(() => Guid.NewGuid());
// Get the collection in qdrant
var ragVectorRecordCollection = vectorStore.GetCollection<Guid, TextSnippet<Guid>>(ragConfig["CollectionName"]);
// Get the PDF loader
var pdfLoader = new PdfDataLoader<Guid>(uniqueKeyGenerator, ragVectorRecordCollection, embedingGenerator);
// Start to load PDF to VectorStore
var pdfFilePath = ragConfig["PdfFileFolder"];
var pdfFiles = Directory.GetFiles(pdfFilePath);
try
{
    foreach (var pdfFile in pdfFiles)
    {
        Console.WriteLine($"[LOG] Start Loading PDF into vector store: {pdfFile}");
        await pdfLoader.LoadPdf(
            pdfFile,
            int.Parse(ragConfig["DataLoadingBatchSize"]),
            int.Parse(ragConfig["DataLoadingBetweenBatchDelayInMilliseconds"]));
        Console.WriteLine($"[LOG] Finished Loading PDF into vector store: {pdfFile}");
    }
    Console.WriteLine($"[LOG] All PDFs loaded into vector store succeed!");
}
catch (Exception ex)
{
    Console.WriteLine($"[ERROR] Failed to load PDFs: {ex.Message}");
    return;
}

Step5. 构建AI对话机器人:

重点关注这里的提示词模板,我们做了几件事情:

(1)给AI设定一个人设:鲜花网站的AI对话机器人,告知其负责的职责。

(2)告诉AI要使用相关工具(向量搜索插件)进行相关背景信息的搜索获取,然后将结果 连同 用户的问题 组成一个新的提示词,最后将这个新的提示词发给大模型进行处理。

(3)告诉AI在输出信息时要把引用的文档信息链接也一同输出。

复制代码
Console.WriteLine("[LOG] Now starting the chatting window for you...");
Console.ForegroundColor = ConsoleColor.Green;
var promptTemplate = """
          你是一个专业的AI聊天机器人,为易速鲜花网站的所有员工提供信息咨询服务。
          请使用下面的提示使用工具从向量数据库中获取相关信息来回答用户提出的问题:
          {{#with (SearchPlugin-GetTextSearchResults question)}}  
            {{#each this}}  
              Value: {{Value}}
              Link: {{Link}}
              Score: {{Score}}
              -----------------
             {{/each}}
            {{/with}}
            
            输出要求:请在回复中引用相关信息的地方包括对相关信息的引用。

            用户问题: {{question}}
            """;
var history = new List<ChatMessage>();
var vectorSearchTool = new VectorDataSearcher<Guid>(ragVectorRecordCollection, embedingGenerator);
var chatOptions = new ChatOptions()
{
    Tools =
    [
      AIFunctionFactory.Create(vectorSearchTool.GetTextSearchResults)
    ]
};
// Prompt the user for a question.
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"助手> 今天有什么可以帮到你的?");
while (true)
{
    // Read the user question.
    Console.ForegroundColor = ConsoleColor.White;
    Console.Write("用户> ");
    var question = Console.ReadLine();
    // Exit the application if the user didn't type anything.
    if (!string.IsNullOrWhiteSpace(question) && question.ToUpper() == "EXIT")
        break;

    var ragPrompt = promptTemplate.Replace("{question}", question);
    history.Add(new ChatMessage(ChatRole.User, ragPrompt));
    Console.ForegroundColor = ConsoleColor.Green;
    Console.Write("助手> ");
    var result = await chatClient.GetResponseAsync(history, chatOptions);
    var response = result.ToString();
    Console.Write(response);
    history.Add(new ChatMessage(ChatRole.Assistant, response));

    Console.WriteLine();
}

调试验证

首先,看看PDF导入中的log显示:

其次,验证下Qdrant中是否新增了导入的PDF文档数据:

最后,和AI机器人对话咨询问题:

问题1及其回复:

问题2及其回复:

更多的问题,就留给你去调戏了。

小结

本文介绍了如何基于Microsoft.Extensions.AI + Microsoft.Extensions.VectorData 一步一步地实现一个RAG(检索增强生成)应用,相信会对你有所帮助。

如果你也是.NET程序员希望参与AI应用的开发,那就快快了解和使用基于Microsoft.Extensioins.AI + Microsoft.Extensions.VectorData 的生态组件库吧。

示例源码

GitHub:点此查看

参考内容

Semantic Kernel 《.NET Sample Demos

推荐内容

Microsoft Learn

eShopSupport

devblogs


作者:周旭龙

出处:https://edisonchou.cnblogs.com

本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文链接。

复制代码
var
相关推荐
Paraverse_徐志斌28 分钟前
RAG架构(检索增强生成)与向量数据库
数据库·ai·llm·embedding·milvus·rag
用户5191495848451 小时前
cURL Kerberos FTP整数溢出漏洞分析与修复
人工智能·aigc
数据智能老司机1 小时前
LLM 提示工程——理解 LLM
gpt·架构·llm
数据智能老司机1 小时前
LLM 提示工程——提示工程入门
gpt·架构·llm
小溪彼岸2 小时前
Claude Code颠覆编程风格的Output Styles
aigc·claude
大模型教程2 小时前
几十行代码搭建顶级RAG系统,UltraRAG 2.0,低代码玩转复杂推理流程
程序员·llm·agent
大模型教程2 小时前
挖到宝了,GitHub 55.1k Strar! LLaMA Factory:大语言模型微调的瑞士军刀
程序员·llm·agent
AI大模型3 小时前
轻松搞定百个大模型微调!LLaMA-Factory:你的AI模型量产神器
程序员·llm·llama
小溪彼岸3 小时前
Hooks才是Claude Code CLI 的革命性更新
aigc·claude
小溪彼岸3 小时前
深入了解Claude Code CLI子代理Subagent
aigc·claude