vatt'ghern jaskier's ballads

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 是這批樣本的彙總數字,文章並沒有給出各層級各自的中招率,因此能說的只有一件事:這個毛病不侷限在某一種規模的叢集裡。至於這截多出來的空間從哪裡來,文章要往下查才會給答案。

12/18 66.7%

含 Float Bloat(12 個)

正常(6 個)

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.」——中招的比例是三分之二。

拖曳滑桿看保留位數如何影響體積 · 9~17 位

17 位
8 12 16 GB(100 萬條向量) 9 11 13 15 17 保留的有效位數 9 位 · 8.6 GB(shortest text,實測) 17 位 · 15.2 GB(widened JSON,實測)
推算容量:15.2 GB 每個數值約 19.8 bytes
9 位與 17 位兩端是 Bonsai 對同一批 100 萬條、768 維向量的實測值——float32 真正精度的 shortest text 8.6 GB、widened JSON 15.2 GB;中間數值為線性內插所得。

拿一組具體數字看規模。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 的做法是直接去看每一家服務實際吐出來的位元組。

服務原生輸出精度
OpenAIshortest-float32
Voyageshortest-float32
Cohereshortest-float32
Jinashortest-float32
Googleshortest-float32
AWSshortest-float32
Hugging Faceshortest-float32
「We surveyed OpenAI, Voyage, Cohere, Jina, Google, AWS, and Huggingface inference endpoints on float32 models. Every native wire we could sample emits shortest-float32 decimals.」七家全部原生輸出都是正確位數的 float32,沒有一家在源頭就寫出十七位數字。

表格裡反覆出現的「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 檔

現況
15.2 GB widened JSON 基準 8.6 GB %.9g 文字修正 -43% 3.1 GB 二進位格式 -80%
widened JSON:15.2 GB——現況,把 float32 硬轉成 float64 再印出十七位數字。
三個數字都來自同一批 100 萬條、768 維向量:15.2 GB(widened JSON)、8.6 GB(shortest text)、3.1 GB(float32 二進位格式)。

這個修法量到的效果是「~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 JavaScript Ruby Java Rust (點左側任一語言)

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 純量的語言(Python、JavaScript、Ruby)都在序列化前手動格式化位數;有原生 float32 的語言(Java、Rust)修的是型別轉換路徑,格式化字串維持原樣。

把五個語言擺在一起看,規律就浮現了:沒有原生 float32 純量的語言,序列化前都得手動格式化成 9 位有效數字的字串;有原生 float32 的語言,修法要找的是哪一段程式碼把 float32 轉成了 double/f64,把那一段換掉,序列化函式庫本身通常沒有問題。這個兩分法,是把五個語言各自的修法擺在一起才看得出來的規律,屬於本文自己的歸納。也因為修法要看語言,才更能解釋為什麼 18 個叢集裡有 12 個中招:沒有一次版本更新能全部修好,每個語言、每個序列化呼叫點都得自己記得校正一次,責任分散在每一處呼叫點上。

Bonsai 文章本身沒有給出一份現成的自查步驟,但排查邏輯已經寫在前面幾節裡,拼起來就是一份操作清單:挑一條已知向量,分別在三個時間點印出它的十進位字串長度——剛從 embedding API 拿到的原始回應、序列化函式呼叫之後、實際寫進資料庫或送上網路之前。三個時間點的位數理論上都該落在 9 位上下;只要中間某一段突然跳到 17 位,問題就卡在那一段程式碼裡,不用往前後兩端找。這個檢查不需要額外工具,幾行印字串長度的程式碼就能做完,也不需要等到儲存帳單變貴才發現——這是本文從整個排查順序推導出來的操作步驟,Bonsai 原文沒有這樣條列,但邏輯是一致的。

Take-away:下次看到浮點數字串長得比預期長,先數位數再查轉型路徑——十七位長度本身就是 float32 被悄悄升成 float64 的訊號,問題通常就出在自己寫的那一行序列化程式碼裡;換語言不會讓這個陷阱消失,只會換一種踩法。