在自然語言處理領域,有一個常見且重要的任務就是文本相似度搜索。文本相似度搜索是指根據用戶輸入的一段文本,從數據庫中找出與之最相似或最相關的一段或多段文本。它可以應用在很多場景中,例如問答系統、推薦系統、搜索引擎等。
比如,當用戶在知乎上提出一個問題時,系統就可以從知乎上已有的回答中找出與該問題最匹配或最有價值的回答,並展示給用戶。
要實現類似高效的搜索,我們需要使用一些特殊的數據結構和算法。其中,向量相似度搜索是一種在大規模數據搜索中表現優秀的算法。而Redis作爲一種高性能的鍵值數據庫,也可以幫助我們實現向量相似度搜索。今天我們就來體驗一把,還請幫忙點個關注👇
在開始學習如何使用Redis實現向量相似度搜索之前,需要了解向量及向量相似度搜索的基本知識和原理,以便更好地理解後面的內容。
什麼是向量?
向量是數學、物理學和工程科學等多個自然科學中的基本概念,它是一個具有方向和長度的量,用於描述問題,如空間幾何、力學、信號處理等。在計算機科學中,向量被用於表示數據,如文本、圖像或音頻。此外,向量還代表AI模型對文本、圖像、音頻、視頻等非結構化數據的印象。
向量相似度搜索的基本原理
向量相似度搜索的基本原理是通過將數據集中的每個元素映射爲向量,並使用特定相似度計算算法,如基於餘弦相似度的、基於歐氏相似度或基於Jaccard相似度等算法,找到與查詢向量最相似的向量。
Redis實現向量相似度搜索
瞭解原理後,我們開始來實現如何使用Redis實現向量相似度搜索。Redis允許我們在FT.SEARCH命令中使用向量相似度查詢。使我們可以加載、索引和查詢作爲Redis哈希或JSON文檔中字段存儲的向量。
//相關文檔地址
https://redis.io/docs/interact/search-and-query/search/vectors
1、Redis Search安裝
關於Redis Search的安裝和使用,此處不再贅述,如果您對此不熟悉,可以參考上一篇文章:
C#+Redis Search:如何用Redis實現高性能全文搜索
2、創建向量索引庫
這裏我們使用NRedisStack和StackExchange.Redis兩個庫來與Redis進行交互操作。
//創建一個Redis連接 static ConnectionMultiplexer mux = ConnectionMultiplexer.Connect("localhost"); //獲取一個Redis數據庫 static IDatabase db = mux.GetDatabase(); //創建一個RediSearch客戶端 static SearchCommands ft = new SearchCommands(db, null);
在進行向量搜索之前,首先需要定義並創建索引,並指定相似性算法。
public static async Task CreateIndexAsync() { await ft.CreateAsync(indexName, new FTCreateParams() .On(IndexDataType.HASH) .Prefix(prefix), new Schema() .AddTagField("tag") .AddTextField("content") .AddVectorField("vector", VectorField.VectorAlgo.HNSW, new Dictionary<string, object>() { ["TYPE"] = "FLOAT32", ["DIM"] = 2, ["DISTANCE_METRIC"] = "COSINE" })); }
這段代碼的意思是:
- 使用了一個異步方法 ft.CreateAsync 來創建索引。它接受三個參數:索引名稱 indexName,一個 FTCreateParams 對象和一個 Schema 對象;
- FTCreateParams 類提供了一些參數選項,用於指定索引的參數。這裏使用 .On(IndexDataType.HASH) 方法來指定索引數據類型爲哈希,並使用 .Prefix(prefix) 方法來指定索引數據的前綴;
- Schema 類用於定義索引中的字段和字段類型。這裏定義了一個標籤字段(tag field)用於區分過慮數據。定義了一個文本字段(text field)用於存儲原始數據,以及一個向量字段(vector field)用於存儲經原始數據轉化後的向量數據;
- 使用了 VectorField.VectorAlgo.HNSW 來指定向量算法爲 HNSW(Hierarchical Navigable Small World)。還傳遞了一個字典對象,用於設置向量字段的參數。其中,鍵爲字符串類型,值爲對象類型。
目前Redis支持兩種相似度算法:
HNSW分層導航小世界算法,使用小世界網絡構建索引,具有快速查詢速度和小內存佔用,時間複雜度爲O(logn),適用於大規模索引。
FLAT暴力算法,它對所有的鍵值對進行掃描,然後根據鍵值對的距離計算出最短路徑,時間複雜度爲O(n),其中n是鍵值對的數量。這種算法時間複雜度非常高,只適用於小規模的索引。
3、添加向量到索引庫
索引創建後,我們將數據添加到索引中。
public async Task SetAsync(string docId, string prefix, string tag, string content, float[] vector) { await db.HashSetAsync($"{prefix}{docId}", new HashEntry[] { new HashEntry ("tag", tag), new HashEntry ("content", content), new HashEntry ("vector", vector.SelectMany(BitConverter.GetBytes).ToArray()) }); }
SetAsync方法用於將一個具有指定文檔ID、前綴、標籤、內容及內容的向量存儲到索引庫中。並使用SelectMany()方法和BitConverter.GetBytes()方法將向量轉換爲一個字節數組。
4、向量搜索
Redis 支持兩種類型的向量查詢:KNN查詢和Range查詢,也可以將兩種查詢混合使用。
KNN 查詢
KNN 查詢用於在給定查詢向量的情況下查找前 N 個最相似的向量。
public async IAsyncEnumerable<(string Content, double Score)> SearchAsync(float[] vector, int limit) { var query = new Query($"*=>[KNN {limit} @vector $vector AS score]") .AddParam("vector", vector.SelectMany(BitConverter.GetBytes).ToArray()) .SetSortBy("score") .ReturnFields("content", "score") .Limit(0, limit) .Dialect(2); var result = await ft.SearchAsync(indexName, query).ConfigureAwait(false); foreach (var document in result.Documents) { yield return (document["content"],Convert.ToDouble(document["score"])); } }
這段代碼的意思是:
- 創建一個查詢對象 query,並設置查詢條件。查詢條件包括:
- "*=>[KNN {limit} @vector $vector AS score]":使用KNN算法進行向量相似度搜索,限制結果數量爲limit,使用給定的向量vector作爲查詢向量,將查詢結果按照相似度得分進行排序;
- AddParam("vector", vector.SelectMany(BitConverter.GetBytes).ToArray()):將浮點數數組轉換爲字節數組,並將其作爲查詢參數傳遞給查詢;
- SetSortBy("score"):按照相似度得分對結果進行排序;
- ReturnFields("content", "score"):將content和score兩個字段從結果集中返回;
- Limit(0, limit):限制結果集的起始位置爲0,結果數量爲limit;
- Dialect(2):設置查詢方言爲2,即Redis默認的查詢語言Redis Protocol;
- 調用異步搜索方法 ft.SearchAsync(indexName, query),並等待搜索結果;
- 遍歷搜索結果集 result.Documents,將每個文檔轉換爲 (string Content, double Score) 元組,並通過 yield 語句進行迭代返回。
Range 查詢:
Range查詢提供了一種根據 Redis 中的向量字段與基於某些預定義閾值(半徑)的查詢向量之間的距離來過濾結果的方法。類似於 NUMERIC 和 GEO 子句,可以在查詢中多次出現,特別是可以和 KNN 進行混合搜索。
public static async IAsyncEnumerable<(string Tag, string Content, double Score)> SearchAsync(string tag, float[] vector, int limit) { var query = new Query($"(@tag:{tag})=>[KNN {limit} @vector $vector AS score]") .AddParam("vector", vector.SelectMany(BitConverter.GetBytes).ToArray()) .SetSortBy("score") .ReturnFields("tag", "content", "score") .Limit(0, limit) .Dialect(2); var result = await ft.SearchAsync(indexName, query).ConfigureAwait(false); foreach (var document in result.Documents) { yield return (document["tag"], document["content"], Convert.ToDouble(document["score"])); } }
這段代碼使用了KNN和Range混合查詢,與上一段代碼相比,新增了@tag參數,將限制結果僅包含給定標籤的內容。這樣做可以增加查詢的準確性,提高查詢效率。
5、從索引庫中刪除向量
public async Task DeleteAsync(string docId, string prefix) { await db.KeyDeleteAsync($"{prefix}{docId}"); }
這個方法通過刪除與指定向量相關聯的哈希緩存鍵,來實現從索引庫中刪除指定向量數據。
6、刪除向量索引庫
public async Task DropIndexAsync()
{
await ft.DropIndexAsync(indexName, true);
}
這個方法 await ft.DropIndexAsync接受兩個參數: indexName 和 true 。indexName 表示索引庫的名稱, true 表示在刪除索引時是否刪除索引文件。
7、查詢索引庫信息
public async Task<InfoResult> InfoAsync() { return await ft.InfoAsync(indexName); }
通過 await ft.InfoAsync(indexName) 方法,我們可以獲取到指定索引庫的大小,文檔數量等相關索引庫信息。
完整 Demo 如下:
using NRedisStack; using NRedisStack.Search; using NRedisStack.Search.DataTypes; using NRedisStack.Search.Literals.Enums; using StackExchange.Redis; using static NRedisStack.Search.Schema; namespace RedisVectorExample { class Program { //創建一個Redis連接 static ConnectionMultiplexer mux = ConnectionMultiplexer.Connect("localhost"); //獲取一個Redis數據庫 static IDatabase db = mux.GetDatabase(); //創建一個RediSearch客戶端 static SearchCommands ft = new SearchCommands(db, null); //索引名稱 static string indexName = "test:index"; //索引前綴 static string prefix = "test:data"; static async Task Main(string[] args) { //創建一個向量的索引 await CreateIndexAsync(); //添加一些向量到索引中 await SetAsync("1", "A", "測試數據A1", new float[] { 0.1f, 0.2f }); await SetAsync("2", "A", "測試數據A2", new float[] { 0.3f, 0.4f }); await SetAsync("3", "B", "測試數據B1", new float[] { 0.5f, 0.6f }); await SetAsync("4", "C", "測試數據C1", new float[] { 0.7f, 0.8f }); //刪除一個向量 await DeleteAsync("4"); //KUN搜索 await foreach (var (Content, Score) in SearchAsync(new float[] { 0.1f, 0.2f }, 2)) { Console.WriteLine($"內容:{Content},相似度得分:{Score}"); } //混合 await foreach (var (Tag, Content, Score) in SearchAsync("A", new float[] { 0.1f, 0.2f }, 2)) { Console.WriteLine($"標籤:{Tag},內容:{Content},相似度得分:{Score}"); } //檢查索引是否存在 var info = await InfoAsync(); if (info != null) await DropIndexAsync(); //存在則刪除索引 } public static async Task CreateIndexAsync() { await ft.CreateAsync(indexName, new FTCreateParams() .On(IndexDataType.HASH) .Prefix(prefix), new Schema() .AddTagField("tag") .AddTextField("content") .AddVectorField("vector", VectorField.VectorAlgo.HNSW, new Dictionary<string, object>() { ["TYPE"] = "FLOAT32", ["DIM"] = 2, ["DISTANCE_METRIC"] = "COSINE" })); } public static async Task SetAsync(string docId, string tag, string content, float[] vector) { await db.HashSetAsync($"{prefix}{docId}", new HashEntry[] { new HashEntry ("tag", tag), new HashEntry ("content", content), new HashEntry ("vector", vector.SelectMany(BitConverter.GetBytes).ToArray()) }); } public static async Task DeleteAsync(string docId) { await db.KeyDeleteAsync($"{prefix}{docId}"); } public static async Task DropIndexAsync() { await ft.DropIndexAsync(indexName, true); } public static async Task<InfoResult> InfoAsync() { return await ft.InfoAsync(indexName); } public static async IAsyncEnumerable<(string Content, double Score)> SearchAsync(float[] vector, int limit) { var query = new Query($"*=>[KNN {limit} @vector $vector AS score]") .AddParam("vector", vector.SelectMany(BitConverter.GetBytes).ToArray()) .SetSortBy("score") .ReturnFields("content", "score") .Limit(0, limit) .Dialect(2); var result = await ft.SearchAsync(indexName, query).ConfigureAwait(false); foreach (var document in result.Documents) { yield return (document["content"], Convert.ToDouble(document["score"])); } } public static async IAsyncEnumerable<(string Tag, string Content, double Score)> SearchAsync(string tag, float[] vector, int limit) { var query = new Query($"(@tag:{tag})=>[KNN {limit} @vector $vector AS score]") .AddParam("vector", vector.SelectMany(BitConverter.GetBytes).ToArray()) .SetSortBy("score") .ReturnFields("tag", "content", "score") .Limit(0, limit) .Dialect(2); var result = await ft.SearchAsync(indexName, query).ConfigureAwait(false); foreach (var document in result.Documents) { yield return (document["tag"], document["content"], Convert.ToDouble(document["score"])); } } } }
篇幅原因先到這裏,下一篇我們接着探討如何利用ChatGPT Embeddings技術提取文本向量,並基於Redis實現文本相似度匹配。相比傳統方法,這種方式能夠更好地保留文本的語義和情感信息,從而更準確地反映文本的實質性內容。