はじめに
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_6bit・QT_4bit・QT_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を小さくするほど圧縮率が上がります(dはmで割り切れる必要があります)。
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は
dをmで割り切れる必要があります(今回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を使う