Ml-tips

faissの量子化でベクトルを圧縮する(メモリと精度のトレードオフ)

はじめに

faissでベクトルをそのまま持つと、float32なので1次元あたり4バイトです。100次元なら1件400バイト、118万件で約473MBになります。件数が増えるとメモリが効いてきます。

これを削るのが量子化です。ベクトルを粗く符号化してメモリを減らす代わりに、検索精度(recall)を少し落とします。faissには主に2系統あり、この記事ではメモリと精度のトレードオフを実測します。

  • スカラー量子化(SQ):各次元をfloat32(4バイト)→8bit整数(1バイト)などに丸める
  • 積量子化(PQ):ベクトルを複数のサブベクトルに分け、それぞれをコードブックの番号で置き換える

データはANN-Benchmarksが配布するglove-100-angular(GloVeの単語ベクトル約118万件・100次元)を使い、正規化済みとします。正解の最近傍が同梱されているのでrecall@10を評価できます。

import faiss, numpy as np
# xb: (1183514, 100) 検索対象, xq: (10000, 100) クエリ, gt: (10000, 100) 正解近傍index
faiss.normalize_L2(xb); faiss.normalize_L2(xq)
d, K, N = xb.shape[1], 10, xb.shape[0]

# 量子化器の学習用サブサンプル(全件でなくてよい。詳細は注意点)
rng = np.random.RandomState(0)
train_sub = xb[rng.choice(N, 200000, replace=False)]

def recall_at_10(index, nq=1000):
    _, I = index.search(xq[:nq], K)
    return sum(len(set(I[i]) & set(gt[i, :K])) for i in range(nq)) / (nq * K)

def mem_MB(index):
    return faiss.serialize_index(index).nbytes / 1e6   # 実際のシリアライズサイズ

基準:無圧縮(IndexFlatL2)

まず圧縮なしの全探索です。メモリは実際にシリアライズしたバイト数で測ります。

flat = faiss.IndexFlatL2(d); flat.add(xb)
print(f"Flat: recall@10={recall_at_10(flat):.4f}  {mem_MB(flat):.0f} MB  {mem_MB(flat)*1e6/N:.0f} B/vec")
Flat: recall@10=0.9999  473 MB  400 B/vec

recallはほぼ1.0(正確な全探索なので基準)ですが、1件400バイト・全体で473MBです。

スカラー量子化(SQ)

IndexScalarQuantizerは各次元を粗く符号化します。丸め方はQT_8bit(8bit整数)・QT_6bitQT_4bitQT_fp16(16bit浮動小数)などから選べます。8bitなら100次元 × 1バイト=100バイト/件で無圧縮の1/4です。量子化器はtrainで各次元の値域を学習します。

sq = faiss.IndexScalarQuantizer(d, faiss.ScalarQuantizer.QT_8bit)
sq.train(train_sub); sq.add(xb)
print(f"SQ8: recall@10={recall_at_10(sq):.4f}  {mem_MB(sq)*1e6/N:.0f} B/vec")
SQ8: recall@10=0.9803  100 B/vec

ビット数を変えると圧縮率とrecallが変わります。

QT_fp16 : recall@10=0.9993  200 B/vec  (2x)
QT_8bit : recall@10=0.9803  100 B/vec  (4x)
QT_6bit : recall@10=0.9261   75 B/vec  (5.3x)
QT_4bit : recall@10=0.7358   50 B/vec  (8x)

fp16(半精度)はほぼ無損失で2倍、8bitは4倍でもrecall 0.98とほとんど落ちません。4bitまで削るとrecallは0.74まで下がります。SQは「軽く圧縮して精度はほぼ保ちたい」領域で強い選択肢です。

積量子化(PQ)

IndexPQはより攻めた圧縮です。ベクトルをm個のサブベクトルに分割し、各サブベクトルを256個の代表点(コードブック)の番号1バイトで置き換えます。1件あたりmバイトになるので、mを小さくするほど圧縮率が上がります(dmで割り切れる必要があります)。

pq = faiss.IndexPQ(d, 25, 8)   # m=25, 各コードブック 2^8=256点
pq.train(train_sub); pq.add(xb)
print(f"PQ(m=25): recall@10={recall_at_10(pq):.4f}  {mem_MB(pq)*1e6/N:.0f} B/vec")
PQ(m=25): recall@10=0.5603  25 B/vec

m=25なら25バイト/件(無圧縮の1/16)まで縮みますが、recallは0.56まで下がります。mを振るとトレードオフが見えます。

メモリと精度のトレードオフ

SQ(ビット数を変えたもの)・PQ(mを変えたもの)・無圧縮を並べます。

index         recall@10   B/vec   圧縮率
Flat (fp32)    0.9999      400     1x
SQ fp16        0.9993      200     2x
SQ 8bit        0.9803      100     4x
SQ 6bit        0.9261       75     5.3x
SQ 4bit        0.7358       50     8x
PQ m=50        0.8468       50     8x
PQ m=25        0.5603       25    16x
PQ m=20        0.4681       20    20x
PQ m=10        0.2380       10    40x
PQ m=5         0.0791        5    80x

右上の★が無圧縮(ほぼrecall 1.0・400バイト)です。軽い圧縮(2〜4倍)ではSQが強く、fp16はほぼ無損失、8bitでもrecall 0.98を保ちます。一方、メモリを大きく削るならPQが有利です。実際、同じ50バイト/件でも SQ 4bit(recall 0.74)より PQ m=50(recall 0.85)の方が上でした。PQはmを減らせば1件5バイト(80倍圧縮)まで行けますが、recallは急に落ちます。どこまで圧縮するかは、許容できるrecallとメモリ予算しだいです。

注意点

  • PQ・SQはtrainが必要です(コードブックや値域の学習)。学習データは全件でなくサブサンプルで十分です。実際、PQ(m=25)を全件(118万)・200k・50kで学習してもrecallはいずれも0.55〜0.56で変わりませんでした(faissはk-meansの学習点数を内部で上限打ちするので、一定量を超えて渡しても効果がありません)。
  • PQはdmで割り切れる必要があります(今回d=100なのでm=50,25,20,10,5など)。
  • 量子化の主目的はメモリ削減で、全探索での検索速度は必ずしも上がりません。今回の計測ではSQ8は無圧縮より遅く、PQはルックアップテーブルのぶん速い、という結果でした。メモリと速度を両立したい場合は、クラスタ分割(IVF)と組み合わせたIndexIVFPQを使います。

まとめ

  • faissの量子化はベクトルを符号化してメモリを削る手法で、SQ(スカラー)とPQ(積)がある
  • メモリはfaiss.serialize_indexで実測でき、float32は400B/件だった
  • 軽い圧縮(2〜4倍)はSQがほぼ無損失(fp16/8bit)、強い圧縮はPQが有利(同じ50B/件ではPQ m=50がSQ 4bitを上回った)。PQは最大80倍まで削れるがrecallは下がる
  • 量子化はメモリ削減が主目的。速度も両立するならIndexIVFPQを使う

参考リンク