在C#中基於Semantic Kernel的檢索增強生成(RAG)實踐

dax.net發表於2024-10-19

Semantic Kernel簡介

玩過大語言模型(LLM)的都知道OpenAI,然後微軟Azure也提供了OpenAI的服務:Azure OpenAI,只需要申請到API Key,就可以使用這些AI服務。使用方式可以是透過線上Web頁面直接與AI聊天,也可以呼叫AI的API服務,將AI的能力整合到自己的應用程式中。不過這些服務都是線上提供的,都會需要根據token計費,所以不僅需要依賴網際網路,而且在使用上會有一定成本。於是,就出現了像Ollama這樣的本地大語言模型服務,只要你的電腦足夠強悍,應用場景允許的情況下,使用本地大語言模型也是一個不錯的選擇。

既然有這麼多AI服務可以選擇,那如果在我的應用程式中需要能夠很方便地對接不同的AI服務,應該怎麼做呢?這就是Semantic Kernel的基本功能,它是一個基於大語言模型開發應用程式的框架,可以讓你的應用程式更加方便地整合大語言模型。Semantic Kernel可用於輕鬆生成 AI 代理並將最新的 AI 模型整合到 C#、Python 或 Java 程式碼庫中。因此,它雖然在.NET AI生態中扮演著非常重要的角色,但它是支援多程式語言跨平臺的應用開發套件。

Semantic Kernel主要包含以下這些核心概念:

  1. 連線(Connection):與外部 AI 服務和資料來源互動,比如在應用程式中實現Open AI和Ollama的無縫整合
  2. 外掛(Plugins):封裝應用程式可以使用的功能,比如增強提示詞功能,為大語言模型提供更多的上下文資訊
  3. 規劃器(Planner):根據使用者行為編排執行計劃和策略
  4. 記憶體(Memory):抽象並簡化 AI 應用程式的上下文管理,比如文字向量(Text Embedding)的儲存等

有關Semantic Kernel的具體介紹可以參考微軟官方文件

演練:透過Semantic Kernel使用Microsoft Azure OpenAI Service

話不多說,直接實操。這個演練的目的,就是使用部署在Azure上的gpt-4o大語言模型來實現一個簡單的問答系統。

微軟於2024年10月21日終止面向個人使用者的Azure OpenAI服務,企業使用者仍能繼續使用。參考:https://finance.sina.com.cn/roll/2024-10-18/doc-incsymyx4982064.shtml

在Azure中部署大語言模型

登入Azure Portal,新建一個Azure AI service,然後點選Go to Azure OpenAI Studio,進入OpenAI Studio:

在C#中基於Semantic Kernel的檢索增強生成(RAG)實踐

進入後,在左側側邊欄的共享資源部分,選擇部署標籤頁,然後在模型部署頁面,點選部署模型按鈕,在下拉的選單中,選擇部署基本模型

在C#中基於Semantic Kernel的檢索增強生成(RAG)實踐

選擇模型對話方塊中,選擇gpt-4o,然後點選確認按鈕:

在C#中基於Semantic Kernel的檢索增強生成(RAG)實踐

在彈出的對話方塊部署模型 gpt-4o中,給模型取個名字,然後直接點選部署按鈕,如果希望對模型版本、安全性等做一些設定,也可以點選自定義按鈕展開選項。

在C#中基於Semantic Kernel的檢索增強生成(RAG)實踐

部署成功後,就可以在模型部署頁面的列表中看到已部署模型的版本以及狀態:

在C#中基於Semantic Kernel的檢索增強生成(RAG)實踐

點選新部署的模型的名稱,進入模型詳細資訊頁面,在頁面的終結點部分,把目標URI金鑰複製下來,待會要用。目標URI只需要複製主機名部分即可,比如https://qingy-m2e0gbl3-eastus.openai.azure.com這樣:

在C#中基於Semantic Kernel的檢索增強生成(RAG)實踐

在C#中使用Semantic Kernel實現問答應用

首先建立一個控制檯應用程式,然後新增Microsoft.SemanticKernel NuGet包的引用:

$ dotnet new console --name ChatApp
$ dotnet add package Microsoft.SemanticKernel

然後編輯Program.cs檔案,加入下面的程式碼:

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using System.Text;

var apikey = Environment.GetEnvironmentVariable("azureopenaiapikey")!;

// 初始化Semantic Kernel
var kernel = Kernel.CreateBuilder()
    .AddAzureOpenAIChatCompletion(
        "gpt-4", 
        "https://qingy-m2e0gbl3-eastus.openai.azure.com", 
        apikey)
    .Build();

// 建立一個對話完成服務以及對話歷史物件,用來儲存對話歷史,以便後續為大模型
// 提供對話上下文資訊。
var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();
var chat = new ChatHistory("你是一個AI助手,幫助人們查詢資訊和回答問題");
StringBuilder chatResponse = new();

while (true)
{
    Console.Write("請輸入問題>> ");

    // 將使用者輸入的問題新增到對話中
    chat.AddUserMessage(Console.ReadLine()!);

    chatResponse.Clear();

    // 獲取大語言模型的反饋,並將結果逐字輸出
    await foreach (var message in
                   chatCompletionService.GetStreamingChatMessageContentsAsync(chat))
    {
        // 輸出當前獲取的結果字串
        Console.Write(message);

        // 將輸出內容新增到臨時變數中
        chatResponse.Append(message.Content);
    }

    Console.WriteLine();

    // 在進入下一次問答之前,將當前回答結果新增到對話歷史中,為大語言模型提供問答上下文
    chat.AddAssistantMessage(chatResponse.ToString());

    Console.WriteLine();
}

在上面的程式碼中,需要將你的API Key和終結點URI配置進去,為了安全性,這裡我使用環境變數儲存API Key,然後由程式讀入。為了讓大語言模型能夠了解在一次對話中,我和它之間都討論了什麼內容,在程式碼中,使用一個StringBuilder臨時儲存了當前對話的應答結果,然後將這個結果又透過Semantic Kernel的AddAssistantMessage方法加回到對話中,以便在下一次對話中,大語言模型能夠知道我們在聊什麼話題。

比如下面的例子中,在第二次提問時我問到“有那幾次遷徙?”,AI能知道我是在說人類歷史上的大遷徙,然後將我想要的答案列舉出來:

在C#中基於Semantic Kernel的檢索增強生成(RAG)實踐

到這裡,一個簡單的基於gpt-4o的問答應用就完成了,它的工作流程大致如下:

在C#中基於Semantic Kernel的檢索增強生成(RAG)實踐

AI能回答所有的問題嗎?

由於這裡使用的gpt-4o大語言模型是在今年5月份釋出的,而大語言模型都是基於現有資料經過訓練得到的,所以,它應該不會知道5月份以後的事情,遇到這樣的問題,AI只能回答不知道,或者給出一個比較離譜的答案:

在C#中基於Semantic Kernel的檢索增強生成(RAG)實踐

你或許會想,那我將這些知識或者新聞文章下載下來,然後基於上面的程式碼,將這些資訊先新增到對話歷史中,讓大語言模型能夠了解上下文,這樣回答問題的時候準確率不是提高了嗎?這個思路是對的,可以在進行問答之前,將新聞的文字資訊新增到對話歷史中:

chat.AddUserMessage("這是一些額外的資訊:" + await File.ReadAllTextAsync("input.txt"));

但是這樣做,會造成下面的異常資訊:

在C#中基於Semantic Kernel的檢索增強生成(RAG)實踐

這個問題其實就跟大語言模型的Context Window有關。當今所有的大語言模型在一次資料處理上都有一定的限制,這個限制就是Context Window,在這個例子中,我們的模型一次最多處理12萬8千個token(token是大語言模型的資料處理單元,它可以是一個片語,一個單詞或者是一個字元),而我們卻輸入了147,845個token,於是就報錯了。很明顯,我們應該減少傳入的資料量,但這樣又沒辦法把完整的新聞文章資訊傳送給大語言模型。此時就要用到“檢索增強生成(RAG)”。

Semantic Kernel的檢索增強生成(RAG)實踐

其實,並不一定非要把整篇新聞文章發給大語言模型,可以換個思路:只需要在新聞文章中摘出跟提問相關的內容傳送給大語言模型就可以了,這樣就可以大大減小需要傳送到大語言模型的token數量。所以,這裡就出現了額外的一些步驟:

  1. 對大量的文件進行預處理,將文字資訊量化並儲存下來(Text Embedding)
  2. 在提出新問題時,根據問題語義,從儲存的文字量化資訊(Embeddings)中,找到與問題相關的資訊
  3. 將這些資訊傳送給大語言模型,並從大語言模型獲得應答
  4. 將結果反饋給呼叫方

流程大致如下:

在C#中基於Semantic Kernel的檢索增強生成(RAG)實踐

虛線灰色框中就是檢索增強生成(RAG)相關流程,這裡就不針對每個標號一一說明了,能夠理解上面所述的4個大的步驟,就很好理解這張圖中的整體流程。下面我們直接使用Semantic Kernel,透過RAG來增強模型應答。

首先,在Azure OpenAI Studio中,按照上文的步驟,部署一個text-embedding-3-small的模型,同樣將終結點URI和API Key記錄下來,然後,在專案中新增Microsoft.SemanticKernel.Plugins.Memory NuGet包的引用,因為我們打算先使用基於記憶體的文字向量資料庫來執行我們的程式碼。Semantic Kernel支援多種向量資料庫,比如Sqlite,Azure AI Search,Chroma,Milvus,Pinecone,Qdrant,Weaviate等等。在新增引用的時候,需要使用--prerelease引數,因為Microsoft.SemanticKernel.Plugins.Memory包目前還處於alpha階段。

將上面的程式碼改成下面的形式:

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using System.Text;
using Microsoft.SemanticKernel.Connectors.AzureOpenAI;
using Microsoft.SemanticKernel.Memory;
using Microsoft.SemanticKernel.Text;

#pragma warning disable SKEXP0010, SKEXP0001, SKEXP0050

const string CollectionName = "LatestNews";

var apikey = Environment.GetEnvironmentVariable("azureopenaiapikey")!;

// 初始化Semantic Kernel
var kernel = Kernel.CreateBuilder()
    .AddAzureOpenAIChatCompletion(
        "gpt-4", 
        "https://qingy-m2e0gbl3-eastus.openai.azure.com", 
        apikey)
    .Build();

// 建立文字向量生成服務
var textEmbeddingGenerationService = new AzureOpenAITextEmbeddingGenerationService(
    "text-embedding-3-small",
    "https://qingy-m2e0gbl3-eastus.openai.azure.com",
    apikey);

// 建立用於儲存文字向量的記憶體向量資料庫
var memory = new MemoryBuilder()
    .WithMemoryStore(new VolatileMemoryStore())
    .WithTextEmbeddingGeneration(textEmbeddingGenerationService)
    .Build();

// 從外部檔案以Markdown格式讀入內容,然後根據語義產生多個段落
var markdownContent = await File.ReadAllTextAsync(@"input.md");
var paragraphs =
    TextChunker.SplitMarkdownParagraphs(
        TextChunker.SplitMarkDownLines(markdownContent.Replace("\r\n", " "), 128),
        64);

// 將各個段落進行量化並儲存到向量資料庫
for (var i = 0; i < paragraphs.Count; i++)
{
    await memory.SaveInformationAsync(CollectionName, paragraphs[i], $"paragraph{i}");
}

// 建立一個對話完成服務以及對話歷史物件,用來儲存對話歷史,以便後續為大模型
// 提供對話上下文資訊。
var chatCompletionService = kernel.GetRequiredService<IChatCompletionService>();
var chat = new ChatHistory("你是一個AI助手,幫助人們查詢資訊和回答問題");
StringBuilder additionalInfo = new();
StringBuilder chatResponse = new();

while (true)
{
    Console.Write("請輸入問題>> ");
    var question = Console.ReadLine()!;
    additionalInfo.Clear();

    // 從向量資料庫中找到跟提問最為相近的3條資訊,將其新增到對話歷史中
    await foreach (var hit in memory.SearchAsync(CollectionName, question, limit: 3))
    {
        additionalInfo.AppendLine(hit.Metadata.Text);
    }
    var contextLinesToRemove = -1;
    if (additionalInfo.Length != 0)
    {
        additionalInfo.Insert(0, "以下是一些附加資訊:");
        contextLinesToRemove = chat.Count;
        chat.AddUserMessage(additionalInfo.ToString());
    }

    // 將使用者輸入的問題新增到對話中
    chat.AddUserMessage(question);

    chatResponse.Clear();
    // 獲取大語言模型的反饋,並將結果逐字輸出
    await foreach (var message in
                   chatCompletionService.GetStreamingChatMessageContentsAsync(chat))
    {
        // 輸出當前獲取的結果字串
        Console.Write(message);

        // 將輸出內容新增到臨時變數中
        chatResponse.Append(message.Content);
    }

    Console.WriteLine();

    // 在進入下一次問答之前,將當前回答結果新增到對話歷史中,為大語言模型提供問答上下文
    chat.AddAssistantMessage(chatResponse.ToString());
    
    // 將當次問題相關的內容從對話歷史中移除
    if (contextLinesToRemove >= 0) chat.RemoveAt(contextLinesToRemove);

    Console.WriteLine();
}

重新執行程式,然後提出同樣的問題,可以看到,現在的答案就正確了:

在C#中基於Semantic Kernel的檢索增強生成(RAG)實踐

現在看看向量資料庫中到底有什麼。新新增一個對Microsoft.SemanticKernel.Connectors.Sqlite NuGet包的引用,然後,將上面程式碼的:

.WithMemoryStore(new VolatileMemoryStore())

改為:

.WithMemoryStore(await SqliteMemoryStore.ConnectAsync("vectors.db"))

重新執行程式,執行成功後,在bin\Debug\net8.0目錄下,可以找到vectors.db檔案,用Sqlite檢視工具(我用的是SQLiteStudio)開啟資料庫檔案,可以看到下面的表和資料:

在C#中基於Semantic Kernel的檢索增強生成(RAG)實踐

Metadata欄位儲存的就是每個段落的原始資料資訊,而Embedding欄位則是文字向量,其實它就是一系列的浮點值,代表著文字之間在語義上的距離

使用基於Ollama的本地大語言模型

Semantic Kernel現在已經可以支援Ollama本地大語言模型了,雖然它目前也還是預覽版。可以在專案中透過新增Microsoft.SemanticKernel.Connectors.Ollama NuGet包來體驗。建議安裝最新版本的Ollama,然後,下載兩個大語言模型,一個是Chat Completion型別的,另一個是Text Embedding型別的。我選擇了llama3.2:3bmxbai-embed-large這兩個模型:

在C#中基於Semantic Kernel的檢索增強生成(RAG)實踐

程式碼上只需要將Azure OpenAI替換為Ollama即可:

// 初始化Semantic Kernel
var kernel = Kernel.CreateBuilder()
    .AddOllamaChatCompletion(
        "llama3.2:3b", 
        new Uri("http://localhost:11434"))
    .Build();

// 建立文字向量生成服務
var textEmbeddingGenerationService = new OllamaTextEmbeddingGenerationService(
    "mxbai-embed-large:latest", 
    new Uri("http://localhost:11434"));

總結

透過本文的介紹,應該可以對Semantic Kernel、RAG以及在C#中的應用有一定的瞭解,雖然沒有涉及原理性的內容,但基本已經可以在應用層面上提供一定的參考價值。Semantic Kernel雖然有些Plugins還處於預覽階段,但透過本文的介紹,我們已經可以看到它的強大功能,比如,允許我們很方便地接入各種流行的向量資料庫,也允許我們很方便地切換到不同的AI大語言模型服務,在AI的應用整合上,Semantic Kernel發揮著重要的作用。

參考

本文部分內容參考了微軟官方文件《Demystifying Retrieval Augmented Generation with .NET》,程式碼也部分參考了文中內容。文章介紹得更為詳細,建議有興趣的讀者移步閱讀。

相關文章