昨天看了一輪常見的向量資料庫,其中 Rust 開發強調效能且支援 Docker 執行的 Qdrant 深得我心。這篇就來練習用 C# 寫入向量資料到 Qdrant 並進行向量相似性搜索。

開始前需要對 Embedding、向量相似性等有基本概念,還不清楚的同學推薦前幾天的向量資料庫概念科普影片,而這次會借用之前使用 OpenAI Embedding 模型比對文字相似性範例,主要不同在於上回我們是自己寫演算法,所有向量兩兩相比暴力搜尋,用餘弦相似性找出最相近的資料,實務上要面對成千上萬甚至億級資料,得改用 HNSW 等演算法才符合效率要求。有了向量資料庫,我們只需寫入向量內容,至於如何有效率儲存及查詢,閃開讓專業的來準沒錯。

第一步先建立一個 Docker 容器跑 Qdrant 服務。

Qdrant 的說明文件完整,對初學者十分友善。我選擇用 Docker Compose 執行,官方文件有 docker-compose.yml 範例:(註:其中用到 configs,需要 Docker Engine v25+、Docker Desktop v4.26+ 版本)

services:
  qdrant:
    image: qdrant/qdrant:latest
    restart: always
    container_name: qdrant
    ports:
      - 6333:6333
      - 6334:6334
    expose:
      - 6333
      - 6334
      - 6335
    configs:
      - source: qdrant_config
        target: /qdrant/config/production.yaml
    volumes:
      - ./qdrant_data:/qdrant_data

configs:
  qdrant_config:
    content: |
      log_level: INFO        

執行 docker-compose up -d,啟動後由 http://localhost:6333/dashboard 可進入管理介面,可檢視存入的資料,並有 Console 可以測試查詢:

Qdrant 有提供 C# 程式庫 - Qdrant.Client,在專案 dotnet add package Qdrant.Client 從 NuGet 安裝後用 var client = new QdrantClient("localhost"); 建立客戶端就可以開始玩了。(提醒:實務應用需啟用 API Key 認證及 TLS 加密傳輸)

我的示範程式如下:

using Azure;
using Azure.AI.OpenAI;
using Qdrant.Client;
using Qdrant.Client.Grpc;
using GrpcCond = Qdrant.Client.Grpc.Conditions;

var keywd = args.Any() ? args[0] : "向量";

#region OpenAI Embedding API
// 加密版環境變數 https://blog.darkthread.net/blog/secure-apikey-for-console-app/
var endPoint = ProtectedEnvironmentVariables.Get("SK_EndPoint", true);
var apiKey = ProtectedEnvironmentVariables.Get("SK_ApiKey", true);
AzureKeyCredential credentials = new(apiKey);
OpenAIClient openAIClient = new(new Uri(endPoint), credentials);
Func<string, float[]> GetEmbedding = (text) =>
{
    EmbeddingsOptions embeddingOptions = new()
    {
        DeploymentName = "embedding",
        Input = { text },
    };
    return openAIClient.GetEmbeddings(embeddingOptions).Value.Data[0].Embedding.ToArray();
};
#endregion

// 實務上應使用 API Key 驗證,此處省略
var client = new QdrantClient("localhost");

var colName = "text_embedding";

// 若集合不存在,則建立集合
var chkCol = await client.CollectionExistsAsync(colName);
if (!chkCol)
{
    await client.CreateCollectionAsync(colName,
        new VectorParams { Size = 1536, Distance = Distance.Cosine });
}

// 若集合內無資料,則新增資料
var count = await client.CountAsync(colName);
if (count == 0)
{
    var texts = new string[] {
    "詩歌\t我達達的馬蹄是美麗的錯誤 我不是歸人,是個過客",
    "詩歌\t如何讓你遇見我 在我最美麗的時刻 為這 我已在佛前 求了五百年 求他讓我們結一段塵緣",
    "詩歌\t我們還能不能再見面 我在佛前苦苦求了幾千年 願意用幾世換我們一世情缘 希望可以感動上天",
    "醫療\t我們吃下碳水化合物,消化後將轉為葡萄糖進入血液,血糖上升會刺激胰臟 β 細胞分泌「胰島素」,讓血糖進入細胞給細胞利用,並將多餘血糖合成肝醣儲存到肝臟及肌肉",
    "醫療\t當血糖被消耗,血糖濃度會下降,此時胰臟 α 細胞會分泌「升糖素」,分解肝醣釋出葡萄糖以及糖質新生(把體內其他原料轉變為葡萄糖)提高血糖濃度",
    "資訊\t內嵌是一種特殊的數據表示格式,可供機器學習模型和演算法輕鬆使用。內嵌是文字片段語意意義的資訊密集表示法。",
    "資訊\t每個內嵌都是浮點數的向量,因此,在向量空間中兩個內嵌之間的距離與原始格式兩個輸入之間的語意相似度相互關聯。",
    };

    ulong i = 0;
    var points = texts.Select(t =>
    {
        var p = t.Split('\t');
        var catg = p[0];
        var text = p[1];
        return new PointStruct
        {
            Id = (ulong)i++,
            Vectors = GetEmbedding(text),
            Payload =
            {
                ["catg"] = catg,
                ["text"] = text
            }
        };
    }).ToList();
    var updateResult = await client.UpsertAsync(colName, points);
}

// 將查詢語句轉換為向量
var queryVector = GetEmbedding(keywd);

Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"# 查詢:{keywd}");
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"(向量相似度, TOP 5)");
Console.ResetColor();

Func<string, string> ellipsis = (text) => text.Length > 32 ? text.Substring(0, 30) + "..." : text;

var top5 = await client.SearchAsync(
  colName,
  queryVector,
  limit: 5);

int rank = 1;
foreach (var p in top5)
{
    Console.WriteLine($"{rank++}. {p.Score:n4} {p.Payload["catg"].StringValue} {ellipsis(p.Payload["text"].StringValue)}");
}

Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"(向量相似度 + 限定詩歌類, TOP 3)");
Console.ResetColor();

var top3poem = await client.SearchAsync(
  colName,
  queryVector,
  filter: Conditions.MatchText("catg", "詩歌"),
  limit: 3);
rank = 1;
foreach (var p in top3poem)
{
    Console.WriteLine($"{rank++}. {p.Score:n4} {ellipsis(p.Payload["text"].StringValue)}");
}

程式呼叫 OpenAI text-embedding-ada-002 模型將文字向量化,並使用先前分享的環境變數無腦加密技巧保存 Azure API Key。建立 QdrantClient 後先檢查是否已建立名為 text_embedding 的集合,若無則新增集合,指定向量維度 1536 (OpenAI 規格)、用餘弦函數計算距離;再來檢查集合中是否有資料,若無則將上回範例的七段文字向量化存入。為了測試屬性篩選,我為每段文字加了類別,在新增資料點時,與內文一起作為 Payload 資料寫入。

測試部分則將查詢語句向量化當成參數傳入 .Search(),找出最相近的五筆內容,顯示其 Score (愈相似分數愈高),並由 Payload 取出類別及文字內容顯示。最後在查詢時限定類別為詩歌的篩選功能,完成整段測試。

以下是胡亂測試結果:

第一句問消化機制,前兩筆有命中,但其他無關內容也算得出 Score,可以排名,當資料有限時,硬抓前幾名有可能前幾名會混入無關內容,一般需配合 BM25、TF-IDF 等演算法讓結果更精確。

第三個測試很有趣,查「起司牛肉堡」,向量比對結果「我達達的馬蹄」排名在「血糖濃度理論」之前,猜想是"牛"與"馬"的語意相似性推了一把。

由此可知,向量比對可捕捉相近語意的特性,雖彌補了關鍵字必須文字吻合才會生效的侷限性,但也容易因此混入無關內容。故在實作 RAG 時,如何提高搜索精準度是門學問。

A C# tutorial for Qdrant to create collection, store embedding vectors and query.


Comments

# by Hank

使用 Cohere Rerank 是否能改善

# by Jeffrey

to Hank,感謝分享。Cohere Rerank 應該也是加入 BM25 提升準確度。

# by ChihKang

桯式呼叫 >> 程式呼叫

# by Jeffrey

to ChihKang, 謝,已校正。

Post a comment