Claude Media
embeddingバイナリ量子化でVoyage AIの保存コストを1/32にする

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値あたり返ってくるもの
float1値あたり32ビット(4バイト)返ってくるもの既定値。値の精度も検索精度も最も高い
int8 / uint81値あたり8ビット(1バイト)返ってくるもの-128〜127(符号付き)または0〜255(符号なし)の整数
binary / ubinary1値あたり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.03994751
手順

8個のfloatが1つの整数になるまで

  1. 1

    符号でビットに変える

    0以下は0、正の値は1です。上の8個は 0, 1, 0, 0, 1, 1, 0, 1 になります。

  2. 2

    8ビットを1バイトにパックする

    左端を最上位ビットとして並べ、01001101 を作ります。ここまでは binary も ubinary も共通です。

  3. 3

    整数としての読み方で分かれる

    ubinary は符号なし整数の77です。binary は符号付き整数で、77から128を引いた-51になります。

binary が使うオフセットバイナリは、符号付き整数を表す方法の1つです。変換するときに128を足し、読み戻すときに128を引きます。Voyageのドキュメントには、-32 を -32 + 128 = 96、つまり 01100000 として表す例と、01010101(85)から128を引いて -43 に戻す例が載っています。

くらべる

どちらの形式を選ぶか

uint8で受け取る

ubinary

返ってくる整数がそのままビット列の値です。デバッグでビットを目で追うときは、変換を挟まない分だけ素直です。

int8で受け取る

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))    # 8

128を引く操作は、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完全ガイドから追えます。

この記事を共有:XはてブLinkedIn