embeddingバイナリ量子化でVoyage AIの保存コストを1/32にする
Voyage AI embeddingのoutput_dtypeでfloat/int8/binaryを切り替える方法と、8個の浮動小数点値を1バイトに詰めるオフセットバイナリ方式の変換例をまとめます。
バイナリ量子化で何ができるのか
埋め込み(embedding)は通常、32ビットの浮動小数点数の並びとして返ってきます。この精度をより小さいデータ型に変換する処理が量子化です。32ビットを8ビット整数に落とせば保存容量は4分の1に、1ビットまで落とせば32分の1になります。Anthropicは自社の埋め込みモデルを提供しておらず、Claudeのドキュメントが案内するVoyage AIのモデルが、この量子化を output_dtype パラメーターで選べます。
Voyageのドキュメントは、コストが次元数と精度(1つの数値に使うビット数)に比例して増えると説明しています。数百万件のベクトルを抱えるRAGでは、ストレージだけでなく、インデックスをメモリに載せる費用にも直結します。floatのままでは載り切らないコーパスが小さめのインスタンスで動くようになるかどうかが、量子化を検討する出発点です。
1024次元のベクトル100万件を例に、保存サイズを計算すると次のとおりです。
1024次元 × 100万件の保存サイズ
float
約4.1GB
4バイト × 1024 = 4,096バイト / 件
int8 / uint8
約1.0GB
1バイト × 1024 = 1,024バイト / 件
binary / ubinary
約128MB
1024ビット = 128バイト / 件
output_dtypeで指定できる5つの値
output_dtype で選べる値は、既定の float と、低精度の4種類(int8 / uint8 / binary / ubinary)です。Voyageのドキュメントは、量子化に対応する例として voyage-4-large などの新しいモデルを挙げています。使うモデルが output_dtype を受け付けるかは、導入前に1回だけ試して確かめておくと安全です。
| 形式 | 1値あたり | 返ってくるもの |
|---|---|---|
float | 1値あたり32ビット(4バイト) | 返ってくるもの既定値。値の精度も検索精度も最も高い |
int8 / uint8 | 1値あたり8ビット(1バイト) | 返ってくるもの-128〜127(符号付き)または0〜255(符号なし)の整数 |
binary / ubinary | 1値あたり1ビット | 返ってくるもの8個を1つの整数に詰めたリスト。長さは実際の次元数の1/8 |
1024次元をbinaryにすると、返ってくるのは128個の整数です。次元数と整数の個数が一致しなくなるので、ベクトルデータベース側の次元数も、返ってくる整数の個数(元の次元数 ÷ 8)に合わせます。コード側の変更は引数を1つ足すだけです。
import voyageai
vo = voyageai.Client()
documents = ["Sample text 1", "Sample text 2"]
# floatのまま(既定)
r_float = vo.embed(documents, model="voyage-4-large", input_type="document")
# int8に量子化
r_int8 = vo.embed(documents, model="voyage-4-large", input_type="document", output_dtype="int8")
# binaryに量子化(保存サイズは次元数によらず1/32)
r_binary = vo.embed(documents, model="voyage-4-large", input_type="document", output_dtype="binary")量子化後のベクトルは型も値の範囲も変わります。生成側の1行は小さな変更でも、保存先のスキーマとインデックス設定は別途そろえる作業になります。保存先の候補になるベクトルデータベースは、Voyageのドキュメントに6つ挙がっています。Milvus・Qdrant・Weaviate・Elasticsearch・Vespa AI・Turbopufferで、いずれも量子化した埋め込みの保存と検索を直接サポートします。
binaryとubinaryは何が違うのか
どちらも、値が正なら1、0以下なら0という同じビット列を作ります。違いは、8ビットを整数として読むときの解釈だけです。公式の例で、次の8個の値を順に追います。
-0.03955078, 0.006214142, -0.07446289, -0.039001465,
0.0046463013, 0.00030612946, -0.08496094, 0.039947518個のfloatが1つの整数になるまで
- 1
符号でビットに変える
0以下は0、正の値は1です。上の8個は
0, 1, 0, 0, 1, 1, 0, 1になります。 - 2
8ビットを1バイトにパックする
左端を最上位ビットとして並べ、
01001101を作ります。ここまではbinaryもubinaryも共通です。 - 3
整数としての読み方で分かれる
ubinaryは符号なし整数の77です。binaryは符号付き整数で、77から128を引いた-51になります。
binary が使うオフセットバイナリは、符号付き整数を表す方法の1つです。変換するときに128を足し、読み戻すときに128を引きます。Voyageのドキュメントには、-32 を -32 + 128 = 96、つまり 01100000 として表す例と、01010101(85)から128を引いて -43 に戻す例が載っています。
どちらの形式を選ぶか
ubinary
返ってくる整数がそのままビット列の値です。デバッグでビットを目で追うときは、変換を挟まない分だけ素直です。
binary
返ってくる整数に128を足すとビット列に戻ります。ベクトルデータベースが符号付き8ビット整数の列を前提にしているときは、こちらに合わせます。
手元でビット列に戻すときは、format を使えば標準ライブラリだけで済みます。公式ドキュメントのコードは numpy を使いますが、考え方は同じで、binary は128を足してから、ubinary はそのまま2進表記にします。
def bits_of(values, signed):
# signed=True: binary(int8) / False: ubinary(uint8)
return [format(v + 128 if signed else v, "08b") for v in values]
print(bits_of([-51], signed=True)) # ['01001101']
print(bits_of([77], signed=False)) # ['01001101']保存済みのfloatを手元で量子化する
すでにfloatで保存したベクトルがあれば、APIを呼び直さなくても、binaryへの変換は手元でできます。Voyageのドキュメントは、numpy の packbits を使った変換例を載せています。0より大きい値を1、それ以外を0としてビットに詰め、ubinary はそのまま、binary は128を引いて符号付きにします。標準ライブラリだけで書くと次のようになり、公式の8個の値から77と-51が得られます。
def pack_ubinary(values):
out = []
for i in range(0, len(values), 8):
byte = 0
for v in values[i:i + 8]:
byte = (byte << 1) | (1 if v > 0 else 0)
out.append(byte)
return out
vals = [-0.03955078, 0.006214142, -0.07446289, -0.039001465,
0.0046463013, 0.00030612946, -0.08496094, 0.03994751]
ub = pack_ubinary(vals)
print(ub) # [77]
print([u - 128 for u in ub]) # [-51] ← binaryの表現逆向きの変換も公式に例があります。返ってきた整数をビット列に展開すれば、元の符号の並び(正か、0以下か)を取り出せますが、元のfloatの大きさは戻りません。次元数が8の倍数でないと、最後のバイトに詰めるビットが足りなくなります。1024・512・256・2048はすべて8の倍数なので、この記事で扱うVoyageのモデルでは問題になりません。
パックした整数同士の距離を測る
量子化したベクトルを自前で比較する場合、binaryでは「違うビットの数」(ハミング距離)を距離の代わりに使えます。2つのベクトルの整数をXORして、1が立っているビットを数える計算です。binary の値は負数になることがあるので、& 0xFF で下位8ビットだけを取り出してから数えます。下のコードは、ubinary の値と、そこから128を引いた binary の値で同じ距離になることを示しています。int.bit_count() はPython 3.10以降で使えます。
def hamming(a, b):
return sum(((x ^ y) & 0xFF).bit_count() for x, y in zip(a, b))
ub_a, ub_b = [77, 200, 3], [78, 200, 255]
b_a = [x - 128 for x in ub_a] # binary側の表現
b_b = [x - 128 for x in ub_b]
print(hamming(ub_a, ub_b)) # 8
print(hamming(b_a, b_b)) # 8128を引く操作は、8ビットのうち最上位ビットを反転させるだけです。両方のベクトルに同じ反転がかかるので、XORをとると打ち消し合い、距離は変わりません。逆に、片方を binary、もう片方を ubinary で作ってしまうと最上位ビットだけが食い違うため、距離が正しく出ません。同じコレクションの中では形式を揃えます。
次元を減らす操作との組み合わせ
Voyageの新しいモデルは、先頭から必要な次元数だけを切り出せるMatryoshka学習で作られています。ドキュメントの説明では、2048次元のベクトルの先頭256・512・1024個も、それぞれ有効な埋め込みとして使えます。ただし検索品質はわずかに下がります。voyage-4 ファミリーの出力次元は1024(既定)・256・512・2048で、output_dimension で直接指定できます。
量子化と次元削減は削る対象が違います。次元削減はベクトルの長さを短くし、量子化は長さを保ったまま1つ1つの値の精度を落とします。両方を使えば、削減率は掛け算になります。
- 1024次元のfloatを256次元にして、さらにint8にすると、4分の1と4分の1で16分の1(1ベクトル256バイト)
- 同じ256次元をbinaryにすると、128分の1(1ベクトル32バイト)
floatのベクトルを切り詰めたあとは、ノルムを1に揃え直します。Voyageの埋め込みは長さ1に正規化されて返りますが、先頭だけを取り出すと長さが1から外れるためです。公式のコード例でも、切り詰めの直後に embd_normalize を呼んでいます。
binaryにする場合は、この手順が結果を変えません。符号しか残らないので、正の定数で割る再正規化ではビットが1つも変わらないからです。切り詰めの位置も違います。公式の例では、2048次元のfloatから作ったbinaryの先頭64個の整数を取り、512次元分のbinaryとして使っています。binaryは次元数の1/8の長さなので、512次元ぶんが64個の整数に当たります。
2段構成で精度を取り戻す
binaryは1つの値に1ビットしか残さないので、floatより検索精度が下がる前提で設計します。ハミング距離で多めの候補を粗く絞り込み、その候補だけを精密に並べ直す2段構成が、精度とコストの折り合いをつける1つの形です。2段目に渡す候補の数は、返したい件数と、再計算にかけられるコストで決めます。
2段目には2つの選択肢があります。保存してあるfloatの埋め込みで類似度を再計算する方法と、Voyageのrerankモデルに渡す方法です。rerankモデルは、クエリと文書のリストを受け取って関連度の順に並べ直します。ドキュメントには rerank-2.5(精度重視)と rerank-2.5-lite(レイテンシーとコスト重視)が載っています。floatの再計算は追加の保存場所が要り、rerankは追加のAPI呼び出しが要ります。
floatで再計算する場合の類似度には、コサイン類似度とドット積のどちらも使えます。Voyageの埋め込みは長さ1に正規化されて返るため、両者は同値で、ドット積のほうが速く計算できます。ユークリッド距離を使っても、順位はコサイン類似度と変わりません。2段目を自前で書くときは、正規化済みの前提でドット積を選べば足ります。どちらを足すにしても、1段目の量子化とは独立に導入できます。
量子化の導入で先に見る3点
件数の多い側から
量子化の効果が出るのは、ドキュメント側のベクトルです。クエリ側の埋め込みは検索のたびに1件ずつ作る一時的なデータなので、保存コストはほとんど発生しません。
検索品質を測る
float・int8・binaryの検索結果を同じクエリ集合で比べ、上位に残るドキュメントの変化を見ます。結果を見てから、全体に広げるかを決めます。
形式を1つに固定する
binaryとubinary、あるいはfloatが混在するコレクションは、距離計算の前提が崩れます。コレクション単位で
output_dtypeを決めます。
RAGに入れるときのinput_type
RAGでは、保存する文書側に input_type="document"、検索クエリ側に input_type="query" を指定します。量子化を使っても必要な指定で、output_dtype とは独立して併用できます。
組み合わせで1つ注意があります。ビット列同士のハミング距離で検索する構成なら、クエリ側にも文書側と同じ output_dtype を指定して、ベクトルの形をそろえます。floatのクエリで2段目を再計算するなら、文書側のfloatも別に保存しておく必要があります。RAG全体の構築手順はClaude RAGの構築ガイド、検索の取りこぼしを減らす前処理はContextual Retrievalにあります。
まとめ
保存量を4分の1にできれば足りる規模なら、int8 で止めるのが最も手間の小さい選択です。32分の1が必要になるほどベクトルが増えたときに初めて binary に進み、精度の落ち分は2段構成で取り戻します。
API全体の構成はAnthropic API完全ガイドから追えます。