100 萬條 768 維向量,正確序列化只要 8.6 GB;Bonsai 抽查的 18 個叢集裡,同一批資料卻有 12 個常常膨脹到 15.2 GB——資料本身完全沒有變,多出來的幾 GB 從哪裡來?
向量序列化裡的 Float Bloat
Bonsai 的工程團隊抽查了旗下 18 個向量搜尋叢集,結果讓人意外:「At Bonsai, we sampled 18 diverse vector search clusters across all tiers, and found that 12 out of those 18 contained float bloat.」——18 個裡有 12 個中了同一種毛病,作者 Matt Gross 把它直接命名為「Float Bloat」。乍看是個小地方:向量搜尋系統裡的浮點數字串,長得比它該有的樣子長了一截。但這串多出來的數字,牽出了一條從 embedding 服務、序列化函式庫,一路查到 Python 型別轉換規則的調查。
「Float Bloat」這個名字帶點自嘲——它描述的是一段幾乎每個接觸 embedding 的工程師都寫過的程式碼:把模型吐出來的向量丟進序列化函式,存進資料庫。調查真正的難處也在這裡:症狀太普通了,普通到很容易被當成「浮點數本來就長這樣」而放過。沒有錯誤訊息、沒有效能警告,唯一會露餡的地方,是有人真的去比對「這批向量理論上該佔多少空間」跟「資料庫裡實際佔了多少空間」,這是一件平常很少有人會主動去做的核對工作。
抽查 18 個叢集,12 個在多付錢
文章開頭先把症狀量化:中招的叢集裡,向量實際佔用的空間比它該有的樣子大上一截。這 18 個叢集橫跨所有方案層級(「across all tiers」),文章補了一句「All the way from sandbox through enterprise.」——從最小的 sandbox 一路到 enterprise 都在抽查範圍內。要留意的是,12/18 是這批樣本的彙總數字,文章並沒有給出各層級各自的中招率,因此能說的只有一件事:這個毛病不侷限在某一種規模的叢集裡。至於這截多出來的空間從哪裡來,文章要往下查才會給答案。
含 Float Bloat(12 個)
正常(6 個)
拖曳滑桿看保留位數如何影響體積 · 9~17 位
拿一組具體數字看規模。768 維、100 萬條向量的語料,如果照 Bonsai 觀察到的「widened JSON」格式序列化——每個數字都印到十七位——要吃掉 15.2 GB。同一批資料如果每個數字只印九位,文章稱為「shortest text」,只要 8.6 GB;直接存成二進位格式,更只要 3.1 GB。語料放大十倍到 1000 萬條,三個數字等比例放大成 151.8 GB、86.0 GB、30.7 GB——差距不會因為資料變大而縮小。文章接著換算了這個差距的比例:「~1.8× the shortest-float32 text and ~5× the raw float32 binary」,widened JSON 大約是 shortest text 的 1.8 倍,是 float32 二進位格式的 5 倍,這個倍率不會隨語料變大而縮小。文章估算,把這個問題放到全球規模,「We estimate this problem globally at over 20 Petabytes of unnecessary disk storage overhead.」——這個數字是作者自己粗估出來的,但方向很清楚:多出來的儲存成本已經是系統性規模的浪費。
儲存只是這筆帳的一半。向量搜尋系統經常需要整批搬動向量——建索引、跨節點複寫、把查詢結果連同向量一起回傳給客戶端,每一次搬動,多出來的那幾 GB 都要重新走一次網路。文章開頭那句「doubles the disk and network cost」,講的正是這兩筆同時翻倍的成本,硬碟帳單只是其中一半。而 18 個叢集裡有 12 個中招,樣本橫跨所有方案層級——三分之二是這整批樣本的彙總比例,文章沒有逐層拆開來看。即便如此,這個彙總比例本身就是條線索,指向某個更共通的環節——牽涉範圍超出單一客戶的個別配置。
文章的估算只算了靜態儲存跟單趟傳輸,沒有把索引重建、複本同步這類間接成本算進去——以下屬於本文自己的延伸推論。HNSW、IVF 這類常見的向量索引演算法,資料異動到一定量就得把圖重建一次,重建過程要把整批向量從頭讀過一遍;如果存的是膨脹過的 widened JSON,讀取本身就慢了將近一倍,重建時間也會跟著拉長。多副本架構下,這筆帳還要乘上副本數:主節點寫入一次,每個複本各自再收一次、各自再解析一次。這些成本不在 Bonsai 文章的量測範圍內,本文在此僅提出可能的方向。
會不會是 embedding 服務自己就吐出十七位數?
排查的邏輯很直接:向量的生命週期,第一站就是 embedding 服務。如果連 API 吐出來的原始資料本身就帶著十七位數字,那不管客戶端後面怎麼處理,膨脹都已經發生了——這是排查順序上最合理的第一步,位置最上游,影響面也最大,值得優先排除:如果 OpenAI、Cohere 這些 embedding API 本身回傳的就是十七位精度的字串,問題就出在源頭,跟客戶端寫法無關。Bonsai 的做法是直接去看每一家服務實際吐出來的位元組。
表格裡反覆出現的「shortest-float32」,Bonsai 從頭到尾都當成既有詞彙使用,沒有給過定義。照本文的理解,它指的是能夠原樣還原回同一個 float32 位元組、所需位數最少的十進位字串——這層解釋屬於本文自己的補充,原文並沒有這樣寫。順著這個理解去看表格:字串越接近這個最短長度,代表輸出的精度跟原始資料越貼合,多一位都是浪費。七家橫跨美系雲端、AI 新創到開源生態,樣本涵蓋面已經不小,結果卻整齊到沒有例外:每一家的原生輸出都卡在正確位數上。
這條路排除了服務端:問題不在 embedding 服務本身。「If your stored vectors are seventeen digits long, look at your client because that's probably the problem.」作者把矛頭指向了客戶端自己寫的程式碼——如果向量存進資料庫時是十七位長,該查的是自己這一段序列化邏輯。
問題出在 .tolist() 這一行
服務端排除之後,問題只能出在資料離開 API 之後、寫進磁碟之前的那一段。這一段通常又短又不起眼——沒有網路呼叫,沒有外部依賴,看起來最不需要懷疑,恰恰因為這樣,它才是最少被檢查的地方。文章給出的例子是 Python 裡最常見的路徑:
embedding = my_numpy_vec.tolist() # promotes float32 to float64
json.dumps(embedding)
拖曳下面這條軸,看資料在這四步各自變成什麼樣子:
拖曳把手看資料在管線上每一步變成什麼樣子 · 4 個階段
「九位」跟「十七位」這兩個數字,文章直接把它擺成了一個小節標題:「A float32 has nine digits and a float64 has seventeen.」至於九位是怎麼來的,文章只留了一句說明——「A float32 has a 24-bit mantissa and at most 9 significant digits.」——float32 的尾數是 24 位元,十進位下最多就是九位有效數字。float64 那一端,文章沒有再拆解它的位元組成,只給了「seventeen」這個數字。總之,17 位是雙精度浮點數自己的位數需求,套在一個原本只有九位有效數字的數值上,就變成了純粹的浪費。這也是為什麼「數位數」是一個可靠的排查手法:光看輸出字串有幾位,就能反推資料在哪一步被升位了,這個判斷只倚賴位數本身,跟序列化函式庫的內部實作是兩回事。
這裡有一個容易被忽略的細節:json.dumps 本身沒有做錯任何事。它拿到的是一個貨真價實的 float64 數值,於是照 float64 該有的位數老實印出來,也就是文章講的「seventeen」。文章對這一步的敘述只停在位數上,沒有再往下拆解 Python 內部怎麼決定要印幾位。換句話說,程式在這一步的每個環節都各自「正確」:轉型正確、序列化也正確,唯一的問題是最上游那個 float32 到 float64 的轉換,本來就不該發生。這也是這種 bug 難查的原因:沒有任何一段程式碼是壞的,壞的是兩段各自正確的程式碼接在一起之後的結果。
這種 bug 也很容易在 code review 裡溜過去,因為兩個變動點通常不在同一次改動裡:.tolist() 這一行可能是好幾個月前寫的,當時沒有人特別去想後面接的是不是要交給 json.dumps;序列化那一行往往是後來才加上的,加的人也不會回頭去追前一步回傳的到底是 numpy 的 float32 還是 Python 內建的 float64。兩個決定各自看都合理,合在一起才出問題——這正是這類 bug 很難被單一次 code review 攔下的原因,因為沒有任何一次 review 同時看到問題的兩端;要抓到它,得有人專門去比對「理論容量」跟「實際容量」,而這一步平常沒有人會做。
這不是 Python 獨有的陷阱。任何語言只要把 32 位浮點數轉型成語言預設的浮點型別(多半是 64 位),再拿去序列化,都會踩到同一種問題——差別只在於每個語言的預設路徑好不好踩、修法藏得多深。連原本就做對一半的服務,也可能在這一步失手:OpenAI 自家 SDK 原生就要求 client 端拿壓縮好的 base64 float32 位元組,「OpenAI's own SDK even requests compact base64 float32 bytes, then throws the win away with .tolist().」——已經壓縮好的資料,被同一段程式碼原地放大回十七位數字,等於自己先省了一次,又自己花掉。
校正回九位,能省多少
知道根因之後,修法本身並不神秘。核心動作是把序列化時的輸出位數校正回 float32 真正的精度——只要少印幾位數字,資料格式和 schema 都不用動。以 Python 為例,問題出在 .tolist() 之後直接把結果丟給 json.dumps 這一步,改成逐一用 %.9g 格式化,再兜成 JSON 字串,就可以直接改成:
embedding = [float(f"{value:.9g}") for value in values]
拖曳看三種序列化方式的體積差 · 3 檔
這個修法量到的效果是「~43% smaller, same values, fewer digits」——數值完全沒變,只是少印了不該印的位數。往前一步,乾脆不要用十進位文字傳浮點數,直接上二進位格式,像 base64、Arrow 或 pgvector 原生格式,效果是「~80% smaller: base64 / Arrow / pgvector, exact」。如果拿到的資料本來就是序列化好的位元組,更省事的做法是不要再解回浮點數重新編碼一次,直接原樣往下傳。
三個做法的取捨其實很清楚。校正輸出位數幾乎不用動資料格式,讀寫兩端也不用同步升級,缺點是前面提過的——沒有一個全域開關,得逐一去改每個序列化呼叫點。換成二進位格式省得更多,但要求讀寫雙方都改介面,對已經在跑的系統來說是比較大的變動,通常得排進遷移計畫,分階段才能上線。直接透傳已序列化的位元組省最多力氣,前提是資料中途真的不需要被解析或修改——一旦有任何一段程式碼要讀懂裡面的數值,這條捷徑就走不通了。
這幾個做法裡,校正輸出位數最容易踩坑,因為它沒有預設值可以一次打開——沒有一個開關能修好所有語言的所有呼叫點,得在每個序列化的地方手動指定位數,寫測試的人也很少會去斷言「這個字串該有幾位數」。合理的推測是,這也是為什麼 12/18 這個比例會這麼高:沒有任何一個函式庫的預設行為可以一次修好,問題出在每一次手寫序列化都要重新記得校正位數這件事,忘記一次就多付一次錢。對照本文開頭那組數字:光是把輸出位數修正回九位,還沒換格式,100 萬條向量就能從 15.2 GB 降到 8.6 GB——這還只是三個做法裡最省事的那一個。
跨語言:同一個陷阱,五種踩法
這個陷阱不是 Python 專屬的。真正決定會不會踩雷的,是這個語言的浮點數型別設計裡有沒有一個獨立的 32 位純量型別。JavaScript、Ruby 這一類語言,語言層級根本不存在 float32 這種東西,只要把資料讀進語言的變數,就已經是雙精度了;Java、Rust 這一類語言則相反,語言本身就有 float32(或叫 f32)型別,問題出在某一段程式碼把它轉成雙精度之後才拿去序列化。同一個症狀,背後其實是兩種不同的根因,修法自然也分成兩條路。點一下下面每種語言,看各自的破綻藏在哪裡:
Python · 沒有原生 float32 純量
常見寫法是 json.dumps(vec.tolist())。問題出在 .tolist() 這一步:「.tolist() promotes f32 to a Python float (double)」——Python 內建的 float 本來就是雙精度,轉出來的當下就已經升位了。
修法:逐一用 '%.9g' % x 格式化成字串再兜成 JSON 陣列,不要先 .tolist() 再交給 json.dumps。
JavaScript · 沒有原生 float32 純量
常見寫法是 JSON.stringify([...f32arr])。展開 Float32Array 的當下就出事:「a Float32Array element reads back as float64」——單一元素一讀出來就是雙精度數字。
修法:逐一呼叫 x.toPrecision(9) 再拼成字串。
Ruby · 沒有原生 float32 純量
常見寫法是 JSON.generate(vectors)。這裡連轉型都不用等:「Ruby Float is always 64-bit; no f32 exists」——語言裡從頭到尾就沒有 float32 這個型別。
修法:一樣改成逐一用 '%.9g' % x 格式化。
Java · 有原生 float32,型別轉丟了
常見寫法是先轉成 double 再序列化。修回 float[] 之後,「Jackson emits shortest-float32」——序列化函式庫本身其實會印出正確位數,前提是拿到的輸入沒有先被轉成 double。
修法:全程保留 float[],不要在中途轉型成 double。
Rust · 有原生 f32,巨集把它展開走 f64
常見寫法是 json!(vec_f32)。serde 的 json! 巨集在序列化時「widens during serialize」——巨集展開之後走的是預設的 f64 路徑,跟 vec_f32 宣告的型別無關。
修法:改用 to_string(&vec_f32) 直接對 f32 向量序列化,不讓巨集插手轉型。
把五個語言擺在一起看,規律就浮現了:沒有原生 float32 純量的語言,序列化前都得手動格式化成 9 位有效數字的字串;有原生 float32 的語言,修法要找的是哪一段程式碼把 float32 轉成了 double/f64,把那一段換掉,序列化函式庫本身通常沒有問題。這個兩分法,是把五個語言各自的修法擺在一起才看得出來的規律,屬於本文自己的歸納。也因為修法要看語言,才更能解釋為什麼 18 個叢集裡有 12 個中招:沒有一次版本更新能全部修好,每個語言、每個序列化呼叫點都得自己記得校正一次,責任分散在每一處呼叫點上。
Bonsai 文章本身沒有給出一份現成的自查步驟,但排查邏輯已經寫在前面幾節裡,拼起來就是一份操作清單:挑一條已知向量,分別在三個時間點印出它的十進位字串長度——剛從 embedding API 拿到的原始回應、序列化函式呼叫之後、實際寫進資料庫或送上網路之前。三個時間點的位數理論上都該落在 9 位上下;只要中間某一段突然跳到 17 位,問題就卡在那一段程式碼裡,不用往前後兩端找。這個檢查不需要額外工具,幾行印字串長度的程式碼就能做完,也不需要等到儲存帳單變貴才發現——這是本文從整個排查順序推導出來的操作步驟,Bonsai 原文沒有這樣條列,但邏輯是一致的。
Take-away:下次看到浮點數字串長得比預期長,先數位數再查轉型路徑——十七位長度本身就是 float32 被悄悄升成 float64 的訊號,問題通常就出在自己寫的那一行序列化程式碼裡;換語言不會讓這個陷阱消失,只會換一種踩法。