Vector Sets 成為 Redis 的一部分
原文由 Salvatore Sanfilippo 于 發布,訂閱此部落格
昨天我們終於把 Vector Sets 合併進了 Redis,你可以在這裡找到詳細說明你會得到什麼的 README:
https://github.com/redis/redis/blob/unstable/modules/vector-sets/README.md
簡單來說,這個新資料結構的目標,就是打造一種類似 Set 的新資料型別,有點像 Sorted Sets,但差別在於 score 不再是一個純量,而是一個向量。你可以用 Redis 一貫的方式新增、移除元素,不必操心其他事,只需要關心 Redis 所實作的這個抽象資料結構本身的特性,就能去查詢與某個查詢向量(或是集合中某個既有元素所對應的向量)相似的元素,等等。不過這些晚點再細說,先來點背景:
從 README 的路徑本身就能看出,實作是放在「modules」底下,但實際上 Vector Sets 並不是一個模組,而是 Redis 核心的一部分。事情是這樣的,一開始我是把它當成模組來開發,後來我建議即便如此,實作上還是繼續使用 modules API,藉此推動 Redis 內部的模組化,這樣就能兼得兩者的好處:從 Redis 8 開始,每個 Redis 執行個體都會把 Vector Sets 當成原生資料型別,同時核心與實作之間也有清晰的邊界
Redis 睽違已久的首個全新主要資料型別
我想 Redis 上一個重大的資料結構應該是 Streams,也是我開發的。我離職了、又回來了,中間還發生了分叉事件,結果看起來,要在 Redis 裡引入新資料型別的重擔還是落在我身上 :D 我得說:我樂意接受,因為我不只喜歡寫程式,也非常喜歡設計,而且我有種感覺,向量和向量相似度在概念上非常單純,所以它們值得擁有一個非常簡單的 API。這就是我試圖做到的事。Vector Sets 目前還是 beta 功能,但我可以告訴你一件事,我保證你 3 分鐘就能學會它的 API。
我決定,實作向量相似度的一個基本要求,就是從頭重新實作 HNSW(你可以在 hnsw.c 看到我的實作),因為那將會是我的核心資料結構,我不想隨便從 GitHub 抓一段程式碼就交差了事。不過,當我開始閱讀相關論文後,我逐漸發現還有幾個環節是缺漏的。
所以,就像我過去做 HyperLogLog 時必須補上一些缺口一樣(在這裡:https://antirez.com/news/75),這次也出現了一些新的演算法挑戰。特別是我想要做到兩件事:
- 真正刪除節點。在 Vector Sets 裡,你可以用 VADD 新增元素,用 VREM 移除元素。而我希望記憶體能盡快被回收。
- 確保在刪除元素時,HNSW 圖的連通性依然能夠維持。
所以這讓我的實作跟其他 HNSW 實作有了一些差異。我沒有使用墓碑(tombstone)刪除,而是在節點被刪除的當下就實際將它從圖中解除連結,並用其他潛在的合適鄰居重新連結回去。為了做到這點,我的實作被設計成強制要求連結必須是相互的,這不再是一個盡力而為的性質:這反過來也大幅改變了插入時需要做的事。
我在 HNSW 上做的另一個修改,是支援用謂詞函式(predicate function)來掃描圖,讓你可以查詢符合特定條件式的節點。這需要以某種方式修改貪婪式的圖掃描演算法:既要收集可能需要拜訪的節點,也要收集結果集,同時還要在查詢的選擇性過高時具備提早停止的條件。當然,我們可不想觸發完整的圖掃描。
除了對 HNSW 的修改之外,我還想要幾個更務實、更顯而易見的功能:
- 對所有向量相似度請求進行執行緒化。沒錯,這在 Redis 的世界裡是新鮮事,但儘管我一向認為單執行緒、無共享(shared nothing)在整體上是個好設計,我覺得向量是個例外。它們很慢,比 Redis 所建模的其他資料結構慢上許多。額外收穫是,當我在實作具執行緒能力的 VSIM(執行向量相似度查詢的指令)時,我還發現只要用一點技巧,就能把寫入操作拆成讀的一半與寫的一半兩個部分,讓候選鄰居的蒐集在背景執行,而實際的插入則在前景完成。不過這種拆分並非預設行為,你需要用 VADD 的 CAS 選項來強制啟用。
- 我想要支援量化,甚至把它設為預設值。所以 Vector Sets 同時提供了 8 位元量化和二進位量化。也支援用隨機投影(random projection)來做降維。不過,儘管我很喜歡 RP 和二進位量化,對我而言真正的「殺手級」還是 int8 量化。它們超級快,只佔 FP32 25% 的記憶體,而且對於大多數透過嵌入式 AI 模型產生的向量來說,結果幾乎與完整向量一模一樣。
順帶一提,我相信最終的成果是一個非常快的實作。舉例來說,在我的機器上,用一個包含 300 萬個項目、每個有 300 個分量的 Vector Set,我在筆電上每秒可以跑 5 到 6 萬次 VSIM(取前 10 筆)查詢。不過我還是鼓勵你自己跑跑基準測試。
另外請注意,Vector Sets 在磁碟上是以圖的形式序列化儲存的,所以當 Redis 重新啟動後把它們載回記憶體時,你不需要再付出一次插入的時間成本:每載入一百萬個元素只需要幾秒鐘,而不是像重新加回記憶體中的 HNSW 那樣需要好幾分鐘。
資料結構,而非索引
到目前為止我說的都是比較底層的東西。但對我來說,Vector Sets 最有趣的部分是它的資料模型以及支撐它的 API。許多資料庫把向量相似度當成一種索引來提供,但在 Redis 這裡,東西就是資料結構:這次也不例外。你可以像這樣加入資料:
VADD mykey FP32 …blob of data… item1諸如此類。所以如果你想,你可以擁有很多小的 Vector Set,每個 key 一個。這裡重要的一點是,如果你把向量分散到 N 個不同的 key(透過對要插入的項目做雜湊或類似方式來決定要用哪個 key),那麼你可以把對不同 key 的多次 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"如果我從不同的 key 和執行個體拿到幾個這樣的結果,我只要按分數排序(其中 1 代表完全相同,0 代表相反的向量)就大功告成了。
所以,我的感覺是 Vector Sets 可以被組合成不同的模式,來處理分散在不同執行個體中的大量向量(它們會消耗不少 RAM)等等。還有一個有趣的點是,拆分可以讓寫入呈線性擴展,因為每個子集都會打到特定的 key,而多個插入可以平行進行。
跟往常一樣,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。如果你發現 bug,請告訴我 :)
隨機一篇部落格
留言
登入後參與討論