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

大家好,我是Edison。

之前我们了解 Microsoft.Extensions.AI 和 Microsoft.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
相关推荐
怕浪猫1 小时前
小程序成长计划上新:10 亿 Token 现在能调 DeepSeek-v4.1-flash了
人工智能·aigc·ai编程
stormzhangV5 小时前
未来十年的建议
人工智能·aigc·agent
孟健8 小时前
我用Grok Bot本地剪口播视频,3段提示词搞定气口切除、字幕与封面
人工智能·python·aigc
小碗细面8 小时前
Magpie 实战教程:一个本地网关,让 Claude Code 跑 Kimi、Codex 跑 DeepSeek
aigc·ai编程
threerocks12 小时前
2027 年最赚钱的 AI 创业方向
aigc·openai·ai编程
lyai00713 小时前
读完 DeepSeek Harness 源码,我给自己画了一张「最小 CLI Agent」施工图
llm·agent
西安小哥15 小时前
大模型 Agent 做日常任务,卡住的从来不是模型能力
aigc
goehou15 小时前
缓存该删还是该改,LLM 服务里的双写问题
redis·缓存·llm·一致性·系统设计
又过二十1 天前
我把 5.5 万 Star的AI生成PPT项目ppt-master改造成了桌面应用
aigc·ppt
孟健1 天前
我把9个AI助理迁到了Grok Bot,连远程服务器都省了
人工智能·aigc·ai编程