Microsoft Agent Framework(簡稱 MAF) 已於 4/3 正式發佈 1.0,實際開發體感,仍有不少配套程式庫需使用 prerelease 版本,但它是目前 .NET 開發 AI 應用的官方主流框架,值得花點時間玩一下。

雖然當代 AI 模型可容納的上下文愈來愈長,甚至可 1M Token,但實務上參考來源的資料量常遠大於此,RAG 仍是不可或缺的做法。先前兩分鐘做出 RAG 文件檢索 AI 應用網站有玩過用現成專案模版搭建以 SQLite 資料庫作為向量資料儲存來源的網站,背後有用到 .NET 開發 AI 應用程式之新手指南一文提到的 Microsoft.Extensions.VectorData (簡稱 MEVD),我有件想做很久的事 - 把部落格所有文章塞進向量資料庫,結合向量查詢及 LLM 模型,試著提供比關鍵字更精準的查詢效果。(延伸閱讀:向量資料庫概念科普)

為了方便反覆測試,我先將文章資料轉成以下 Entity 型別轉成 JSON 檔案:

public class Post
{
    [VectorStoreKey]
    public ulong Id { get; set; }
    [JsonIgnore]
    public string FileName => 
        $"[{PubDate:yyyyMMdd-HHmm}]{Regex.Replace(Title, 
            $"[{Regex.Escape(new string(Path.GetInvalidFileNameChars()))}]", "_")}.md";
    [VectorStoreData]
    public string Title { get; set; } = string.Empty;
    public string Slug { get; set; } = string.Empty;
    [VectorStoreData]
    public DateTime PubDate { get; set; }
    [JsonIgnore]
    public string Content { get; set; } = string.Empty;
    [JsonIgnore]
    public string CatgList { get; set; } = string.Empty;
    public string[] Catgs => Regex.Matches(CatgList, 
        @"\[(?<c>.+?)\]").Select(m => m.Groups["c"].Value).ToArray();
    [VectorStoreData]
    public string Markdown { get; set; } = string.Empty;
    [VectorStoreVector(1536, DistanceFunction = DistanceFunction.CosineSimilarity)]
    [JsonConverter(typeof(FloatMemoryBase64Converter))]
    public ReadOnlyMemory<float> ContentEmbedding { get; set; }    

    [VectorStoreData]
    public string Summary { get; set; } = string.Empty;
    [VectorStoreVector(1536, DistanceFunction = DistanceFunction.CosineSimilarity)]
    [JsonConverter(typeof(FloatMemoryBase64Converter))]
    public ReadOnlyMemory<float> SummaryEmbedding { get; set; }    
}

我的部落格文章內文 (Content 屬性) 目前是存成 HTML 格式,前置處理過程我選擇用 ReverseMarkdown 轉成 Markdown (Markdown 屬性),Summary 屬性則為使用 LLM 總結後的較短版本,用以比較原始內文 vs 精簡化版本的向量比對效果。而屬性有以下 MEVD 專用 Attribute:

  1. VectorStoreKeyAttribute:儲存向量資料時需要一個鍵值,支援型別視 VectorStore 而定,以 Qdrant 為例,只能用 ulong 或 Guid。
  2. VectorStoreDataAttribute:將屬性標記為資料,表示該屬性不是鍵值也不是向量,但可以選擇性地關聯一個向量欄位,其中包含此資料的 Embedding,此定義會影響 VectorStore 如何處理該屬性。
  3. VectorStoreVectorAttribute:定義此屬性用於儲存 Embedding 資料。

另外,FloatMemoryBase64Converter 用來將 Embedding 的 ReadOnlyMemory<float> 型別的 JSON 表示格式由 [0.004457849,-0.03937525, ... -0.0073611066, 0.015777944] (1536 個 float 的陣列,約 19,238 字元) 轉成 Base64 編碼 (約 8,214 字元),減少為 42%。

"Base64EmbeddingJson": "JhOSO/F...QIE8", // 長度 8,214
"OrigEmbeddingJson": [0.004457849,-0.03937525, /* 1536 個 */ -0.0073611066, 0.015777944] // 長度 19,238

以下是我本次測試成功的套件版本,Console Application 專案使用 .NET 10,Azure.AI.OpenAI 及 Microsoft.SemanticKernel.Connectors.* 會用到 prerelease 版本。

  <ItemGroup>
    <PackageReference Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
    <PackageReference Include="Microsoft.Extensions.AI" Version="10.5.0" />
    <PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.0" />
    <PackageReference Include="Microsoft.Extensions.VectorData.Abstractions" Version="10.1.0" />
    <PackageReference Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.74.0-preview" />
    <PackageReference Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.74.0-preview" />
  </ItemGroup>

我先使用 InMemoryVectorStore 測試,將字串屬性轉成 ReadOnlyMemory<float> 有兩種做法,第一種建立 VectorStore 時指定 EmbeddingGenerator,呼叫 UpsertAsync() 或 SearchAsync() 時自動將字串轉向量 參考,由於我想一次轉好向量儲存起來,未來切換不同 VectorStore 時不需每次算算,故程式會預先算好 Embedding 一起存入 JSON。

以下為包含建立 InMemoryVectorStore、讀入 JSON、建立摘要及 Embedding、將物件寫入 VectorStoreCollection,輸入搜尋文字,分別比對 ContentEmbedding、SummaryEmbedding 找出最相關的文章... 等一連串作業:(順便得到統計數字,從 2004-01-10 到 2026-04-25,我已經寫了 4025 篇文章,廢話真不是普通的多啊~~~)

using Microsoft.Extensions.AI;
using Microsoft.SemanticKernel.Connectors.InMemory;
using Azure.AI.OpenAI;
using System.ClientModel;
using BlogRag;
using Microsoft.Extensions.VectorData;
using System.Text.Json;
using System.Text.Encodings.Web;
using Microsoft.SemanticKernel.Connectors.Qdrant;
using Qdrant.Client;

var endPoint = Environment.GetEnvironmentVariable("AOAI_EndPoint") ??
    throw new InvalidOperationException("AOAI_EndPoint environment variable is not set.");
var key = Environment.GetEnvironmentVariable("AOAI_Key") ??
    throw new InvalidOperationException("AOAI_Key environment variable is not set.");
var chatDepName = "gpt-5.2-chat";

// 建立 AOAI LLM 模型
var azureOpenAIClient = new AzureOpenAIClient(new Uri(endPoint), new ApiKeyCredential(key));
var chatClient = azureOpenAIClient.GetChatClient(chatDepName);

// 設定 Embedding 模型(以 Azure OpenAI 為例)
var embGen = azureOpenAIClient.GetEmbeddingClient("text-embedding-3-small").AsIEmbeddingGenerator();

// 建立 In-Memory Vector Store
VectorStore vectorStore;
vectorStore = new InMemoryVectorStore(
    // 指定 Embedding Generator,可在 Upsert 時自動生成向量,或在 Search 時自動將查詢轉成向量
    new InMemoryVectorStoreOptions() { EmbeddingGenerator = embGen }
);

// 建立 VectorStore
var collection = vectorStore.GetCollection<ulong, Post>("blogposts");
await collection.EnsureCollectionExistsAsync();

var jsonOptions = new JsonSerializerOptions { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
// 使用 LLM 生成摘要,限定長度不要超過 8K Token,並保持由作者陳述語氣
Func<string, Task<string>> Summerize = async (content) =>
{
    var messages = new List<OpenAI.Chat.ChatMessage> {
        new OpenAI.Chat.SystemChatMessage("You are a helpful assistant that summarizes blog posts."),
        new OpenAI.Chat.UserChatMessage(
            $"""
            Concentrate and summarize the following blog post in zh-tw and keep it less than 8K tokens. 
            Write in author's voice, make it comprehensive without skipping any essentials, 
            only content, no extra wording:
            {content}
            """)
    };
    var resp = await chatClient.CompleteChatAsync(messages);
    return resp.Value.Content[0].Text;
};
// 生成 Embedding 的 Func,因 Embedding 模型有 8K Token 限制,若超過限制則先生成摘要再轉 Embedding
Func<string, Task<ReadOnlyMemory<float>>> EmbedContent = async (content) =>
{
    try
    {
        return await embGen.GenerateVectorAsync(content);
    }
    catch (Exception ex)
    {
        // TODO:Embedding 模型有 8K 限制,良好做法應 Chunking,此處僅轉成 8K- 摘要粗暴搞定
        if (ex.Message.Contains("maximum context length"))
        {
            var summary = await Summerize(content);
            return await embGen.GenerateVectorAsync(summary);
        }
        else throw;
    }
};
// 逐一讀取 JSON,產生摘要及向量,更新至 VectorStore
var sw = System.Diagnostics.Stopwatch.StartNew();
int count = 0;
foreach (var file in Directory.GetFiles("..\\Posts", "*.json"))
{
    var json = await File.ReadAllTextAsync(file);
    var post = System.Text.Json.JsonSerializer.Deserialize<Post>(json);
    if (post is null) continue;
    if (string.IsNullOrEmpty(post.Summary)) // 使用 LLM 生成摘要
    {
        Console.WriteLine($"Generating summary for {Path.GetFileName(file)}...");
        post.Summary = await Summerize(post.Markdown);
        Console.WriteLine($" - Summary reduced content from {post.Markdown.Length:n0} to {post.Summary.Length:n0} characters.");
        File.WriteAllText(file, System.Text.Json.JsonSerializer.Serialize(post, jsonOptions));
    }
    if (post.SummaryEmbedding.IsEmpty) // 生成摘要向量
    {
        Console.WriteLine($"Generating embedding for summary of {file}...");
        post.SummaryEmbedding = await embGen.GenerateVectorAsync(post.Summary);
        File.WriteAllText(file, System.Text.Json.JsonSerializer.Serialize(post, jsonOptions));
    }
    if (post.ContentEmbedding.IsEmpty) // 生成內容向量
    {
        Console.WriteLine($"Generating embedding for {file}...");
        post.ContentEmbedding = await EmbedContent(post.Markdown);
        File.WriteAllText(file, System.Text.Json.JsonSerializer.Serialize(post, jsonOptions));
    }
    // 將物件上傳到 VectorStore
    await collection.UpsertAsync(post);
    count++;
}
sw.Stop();
Print($"載入 {count:n0} 篇文章,耗時:{sw.Elapsed.TotalSeconds:F2} 秒", ConsoleColor.Green);

// 每次查詢分別用 ContentEmbedding 和 SummaryEmbedding 搜索,看哪個效果好
var dictSrchMethod = new Dictionary<string, VectorSearchOptions<Post>>()
{
    { "ContentEmbedding", new VectorSearchOptions<Post>() { VectorProperty = p => p.ContentEmbedding } },
    { "SummaryEmbedding", new VectorSearchOptions<Post>() { VectorProperty = p => p.SummaryEmbedding } }
};

while (true)
{
    // 使用者輸入搜索關鍵字
    Console.Write("請輸入搜索關鍵字:");
    var query = Console.ReadLine()!;
    if (query == "/exit") break;
    if (string.IsNullOrWhiteSpace(query)) continue;
    // 預先將查詢轉成向量
    var queryEmbedding = await embGen.GenerateVectorAsync(query);

    VectorSearchOptions<Post> searchOptions;
    foreach (var kv in dictSrchMethod)
    {
        searchOptions = kv.Value; // 切換不同向量屬性進行搜索
        Console.WriteLine($"使用 {kv.Key} 進行向量搜索...", ConsoleColor.Cyan);
        // 如有設 EmbeddingGenerator,SearchAsync 會自動將 query 字串轉成向量
        var results = collection.SearchAsync(queryEmbedding, 10, searchOptions);
        // 輸出結果
        await foreach (var result in results)
        {
            Print($"[{result.Score:F4}] {result.Record.Title}", ConsoleColor.Yellow);
        }
    }
}

void Print(string msg, ConsoleColor? color = null)
{
    if (color.HasValue) Console.ForegroundColor = color.Value;
    Console.WriteLine(msg);
    Console.ResetColor();
}

簡單實測一下,由於我是用整篇文章計算 Embedding,丟入一段文字找相關文章最能展現向量相似度搜尋的威力。例如:我分別丟入一段 「AI 影響就業」以及「量子電腦威脅密碼學」的描述,搜尋結果的相關度極佳,這是傳統全文檢索很難辦到的:

但反過來,若只丟關鍵字,用向量相似度搜尋的結果就不盡理想,畢竟關鍵字只佔內文或摘要的一小部分,很難影響整個 Embedding 使其具備特徵。

最後這個測試完美展現傳統全文檢索難以實現的效果,我輸入一段日文「自分の足で長距離レースを完走する」(中文意思是「用雙腳完成長距離賽事」),用另一種語言又換句話說的方式,仍能成功查到馬拉松文:

值得一提的是,MEVD 提供一個抽象層,將實作與介面隔離得很好,在使用 InMemoryVectorStore 測試成功後,我們只需修改以下這幾行,就能改用之前研究過的 Qdrant 向量資料庫作為向量儲存提供者:

/*
var vectorStore = new InMemoryVectorStore(
    new InMemoryVectorStoreOptions() { EmbeddingGenerator = embGen }
);
*/
var qdrantClient = new QdrantClient("localhost");
var vectorStore = new QdrantVectorStore(qdrantClient, ownsClient: true, new() {
    EmbeddingGenerator = embGen,
    HasNamedVectors = true
});

就這麼簡單!

不過,向量相似度有其侷限性,相似度最高未必是最相關的內容,實務上常需結合 TFIDF/BM25 等關鍵字匹配演算法較能獲得理想結果,這部分留待下一篇再來研究。

Explores building a RAG blog search using MAF and MEVD. Demonstrates embedding full posts and summaries into a vector store to achieve semantic, cross-language search far beyond traditional keyword retrieval.


Comments

Be the first to post a comment

Post a comment