Vector Sets 已成為 Redis 的一部分
昨天我們終於將 Vector Sets(向量集合) 合併到 Redis 中,你可以在這裡找到詳細說明所有功能的 README:
https://github.com/redis/redis/blob/unstable/modules/vector-sets/README.md
簡而言之,這個新資料結構的目標是建立一種類似 Set 的新資料型別,類似於 Sorted Sets(有序集合),差別在於分數不再是純量,而是向量,你可以用 Redis 一貫的方式新增與移除元素,無需關心 Redis 所實作的抽象資料結構特性以外的任何事,還能查詢與給定查詢向量(或集合中某個元素所關聯的向量)相似的元素,諸如此類。不過這些稍後再詳談,先說點背景:
從 README 本身的路徑就可以看出,實作是放在「modules」底下,但實際上,Vector Sets 並不是模組,而是 Redis 核心的一部分,事情是這樣的:我一開始是將它們當作模組來開發,後來我建議即便如此,實作上仍應繼續使用模組 API,以促進 Redis 內部的模組化,如此一來就能兼具兩種優點:從 Redis 8 開始的每個 Redis 執行個體都會將 Vector Sets 作為原生資料型別,同時核心與實作之間也有清晰的界線
Redis 睽違已久的首個全新主要資料型別
我想 Redis 上一個重大的資料結構應該是 Streams(串流),也是由我開發的。我曾離職、後來又回歸,中間還發生了分支事件,但看起來在 Redis 引入新資料型別的重擔仍落在我身上 :D 我必須說:我樂於承擔,因為我不只喜歡寫程式,也非常喜歡設計,而且我有一種感覺,向量以及向量相似度在概念上非常單純,所以它們值得擁有一個非常簡潔的 API。這就是我嘗試做到的事。Vector Sets 目前仍是 Beta 功能,但我可以告訴你一件事,我保證你能在 3 分鐘內學會這個 API。
我決定,實作向量相似度的一項基本要求,就是從頭開始重新實作 HNSWs(階層式可導航小世界圖)(你可以在 hnsw.c 中看到我的實作),因為那將是我的核心資料結構,而我不想隨便從 GitHub 抓一段程式碼就將就著用。然而,當我開始閱讀相關論文時,我開始意識到還有幾個環節是缺失的。
因此,就像我過去處理 HyperLogLog(基數估計) 時必須填補一些缺口(見此:https://antirez.com/news/75)一樣,這裡也出現了一些新的演算法挑戰。特別是我想要達成兩件事:
- 能夠真正刪除節點。在 Vector Sets 中,你可以用 VADD 新增元素,用 VREM 移除元素。而我希望記憶體能盡快被回收。
- 我希望確保在刪除元素的過程中,HNSW 圖的連通性得以維持。
因此,這導致我的實作與其他 HNSW 實作有一些差異。我沒有使用墓碑式刪除,而是在節點被刪除的當下就實際將其解除連結,並將其重新連結至其他可能合適的鄰居。為此,我的實作被設計為強制要求連結必須是相互的,這不再是一種盡力而為的特性:這反過來也大幅改變了插入時所需執行的操作。
我對 HNSW 做的另一項修改,是支援使用述詞函式掃描圖的能力,讓你可以查詢符合特定運算式的節點。這需要在某種程度上修改貪婪式圖掃描演算法:以收集可能需要造訪的節點,並收集結果集,同時也要有提早終止條件,以防查詢的選擇性過高。當然,我們不希望觸發全圖掃描。
除了對 HNSW 的修改之外,我還想要幾個更務實、更直觀的功能:
- 對所有向量相似度請求進行執行緒化。是的,這在 Redis 的世界裡是新鮮事,但儘管我認為單執行緒與無共享在整體上是很好的設計,我覺得向量是特例。它們很慢,比 Redis 所建模的其他資料結構慢上許多。額外的一點是,當我在實作具執行緒化的 VSIM(執行向量相似度查詢的指令)時,我還發現透過一些技巧,你可以將寫入操作的讀取半部與寫入半部拆成兩部分,讓候選鄰居的收集在背景執行,而實際的插入則在前景執行。不過這種拆分並非預設行為,你需要使用 VADD 的 CAS 選項來強制啟用。
- 我希望支援量化,甚至將其設為預設值。因此 Vector Sets 同時提供了 8 位元量化與二進位量化。還支援透過隨機投影來降低維度。然而,儘管我很喜歡隨機投影和二進位量化,現實是對我而言真正的「殺手級」是 int8 量化。它們速度超快,只占用 FP32 25% 的記憶體,而且對於大多數透過嵌入式 AI 模型產生的向量,其結果與完整向量幾乎一致。
順帶一提,我認為最終成果是一個非常快速的實作。舉例來說,在我的機器上,對於一個包含 300 萬個項目、每個項目 300 個維度的向量集合,我在筆電上每秒可執行 5 萬至 6 萬次 VSIM(取前 10 個項目)。不過我鼓勵你自行進行基準測試。
另外請注意,Vector Sets 在磁碟上是以圖的形式序列化儲存,因此當它們在 Redis 重新啟動後被重新載入記憶體時,你無需再次付出插入時間:載入每百萬個元素只需數秒,而不像重新加入記憶體中的 HNSW 那樣需要數分鐘。
資料結構,而非索引
到目前為止我所說的都是關於底層的東西。但對我而言,Vector Sets 最有趣的部分是其資料模型以及支援它的 API。許多資料庫將向量相似度作為一種索引來提供,但在 Redis 中,事物就是資料結構:這次也不例外。你可以像這樣新增資料:
VADD mykey FP32 …blob of data… item1依此類推。因此如果你願意,可以擁有多個小型的向量集合,每個鍵一個。而這裡重要的一點是,如果你將向量分散到 N 個不同的鍵中(透過雜湊你要插入的項目或類似方式來選擇要用哪個鍵),那麼你可以將針對不同鍵的多個 VSIM 呼叫結果合併為單一回應:
VSIM word_embeddings_int8 ele "banana" WITHSCORES COUNT 4
1) "banana"
2) "0.9997616112232208"
3) "bananas"
4) "0.8758847117424011"
5) "pineapple"
6) "0.8288004100322723"
7) "mango"
8) "0.8179697692394257"如果我從不同的鍵與執行個體取得一些這類結果,我只需依分數排序(其中 1 代表完全相同,0 代表相反向量)即可。
因此,我的感覺是 Vector Sets 可以組合成不同的模式,以處理將大量向量(它們會消耗相當多的 RAM)分散到不同執行個體等情境。此外,有趣的是,切分可以線性地擴展寫入能力,因為每個子集都會命中特定的鍵,而多個插入可以並行執行。
一如往常,Redis 社群很可能會發掘出許多目前尚未顯而易見的使用模式。
篩選功能的運作方式
談到篩選,如果說執行緒化在 Redis 中並不常見,那更別提 JSON 了!但這一次,我第一次找到一個充分的理由,直接在 Redis API 中面向使用者公開 JSON:
VGETATTR word_embeddings_int8 banana
{"len": 6}所以基本上,透過 VSETATTR / VGETATTR(以及在 VADD 中新增項目時直接設定 JSON 屬性的等效選項),你可以為你想要的項目關聯一個字串。
然後你就可以做到像這樣的事:
VSIM word_embeddings_int8 ele "banana" FILTER ".len == 3"
1) "yam"
2) "pea"
3) "fig"
4) "rum"
5) "ube"
6) "oat"
7) "nut"
8) "gum"
9) "soy"
10) "pua"篩選運算式並不是一種程式語言,而是你在高階程式語言的 if() 陳述式中會寫的內容,包含 &&、||、所有常見的運算子等等(不過我敢說我們之後還會再加入一些)。
好了,細節都在文件中,那裡還有記憶體用量範例、針對特定功能的深入討論等等。我希望很快就能擴充文件。就目前而言,我真的(真的!)希望你會喜歡 Vector Sets。如果你發現錯誤,請告訴我 :)
隨機一篇部落格