Skip to content

✍️ 필사 모드: ANN アルゴリズム完全ガイド 2025: HNSW、IVF、Product Quantization、LSH — Vector DB の内部はどう動くか

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに: 「最も似たもの」を探す問題

LLM 時代の必須技術

2022 年の ChatGPT 以降、RAG (Retrieval-Augmented Generation) は LLM の中核手法となった。構造は単純:

  1. 文書を Embedding (ベクトル化)。
  2. ユーザー質問も Embedding。
  3. 最も類似する文書 を検索。
  4. LLM に検索結果をコンテキストとして与える。

この 3 番目が Similarity Search である。数百万〜数十億のベクトルから Top-K の最近接 を探す。

素朴な方法の罠

def naive_knn(query, vectors, k):
    distances = [cosine_distance(query, v) for v in vectors]
    return sorted(enumerate(distances), key=lambda x: x[1])[:k]
  • 10 万ベクトル: 0.1 秒。
  • 100 万: 1 秒 (遅い)。
  • 1 億: 数十秒 (不可)。
  • 1,000 次元 x 1 億: 数分 (破滅)。

実サービスは 数十ミリ秒 を求める。根本的に別のアプローチが必要。

精度を譲る

核心の洞察:

「正確な k 個」ではなく「十分に近い k 個」で足りる。

10% の誤差を許せば 1000 倍速い検索 が可能。これが ANN (Approximate Nearest Neighbor) の本質。

本記事の内容

  • ベクトル類似度の基礎。
  • LSH、IVF、Product Quantization、HNSW、DiskANN、ScaNN。
  • 実戦での選択とパラメータチューニング。

1. ベクトル類似度の基礎

距離関数

L2 (Euclidean) 距離:

d(x, y) = sqrt(sum((x_i - y_i)^2))

Cosine 距離:

cos(x, y) = (x . y) / (|x| * |y|)
distance = 1 - cos(x, y)

内積 (Inner Product):

d(x, y) = -x . y   (大きいほど類似)

正規化されたベクトル

単位長に正規化すると:

  • x . y = cos(x, y) (内積 = Cosine)
  • |x - y|^2 = 2 - 2 * cos(x, y) (L2 と Cosine が等価)

OpenAI や Sentence-BERT が正規化済みベクトルを返すのはこのため。

次元の呪い

高次元では:

  • すべての点がほぼ等距離。
  • 平均と分散の比率が小さくなる。
  • 2D/3D の直感が効かない。

実データは 低次元 manifold 上に分布するので ANN が機能する。

Recall の定義

ANN の品質指標: Recall@k

「真の top-k のうち何個を見つけたか」

  • recall@10 = 見つけた真の top-10 の個数 / 10
  • 1.0 は完全、0.9 は大半の用途で満足、0.5 は不合格。

目標は Recall 0.95 以上 を保ちつつ速度とメモリを最適化。


2. LSH: Locality-Sensitive Hashing

アイデア

LSH は 1998 年に提案された最古級の ANN:

「類似ベクトルを同じハッシュバケットに入れ、クエリ時はそのバケットだけを探す。」

通常のハッシュと逆で、類似入力は同じハッシュ になりやすい。

Random Projection LSH (Cosine 用)

import numpy as np

class CosineLSH:
    def __init__(self, dim, num_hashes=32):
        self.dim = dim
        self.num_hashes = num_hashes
        self.projections = np.random.randn(num_hashes, dim)

    def hash(self, vector):
        signs = np.sign(self.projections @ vector)
        return tuple((signs > 0).astype(int))

衝突確率:

P(hash(x) == hash(y)) = 1 - theta(x,y)/pi

LSH テーブルの構築

from collections import defaultdict

class LSHIndex:
    def __init__(self, dim, num_tables=10, num_hashes_per_table=16):
        self.tables = []
        for _ in range(num_tables):
            lsh = CosineLSH(dim, num_hashes_per_table)
            self.tables.append((lsh, defaultdict(list)))

    def add(self, vector, id):
        for lsh, buckets in self.tables:
            h = lsh.hash(vector)
            buckets[h].append((id, vector))

    def query(self, vector, k):
        candidates = set()
        for lsh, buckets in self.tables:
            h = lsh.hash(vector)
            for id, v in buckets.get(h, []):
                candidates.add((id, tuple(v)))
        scored = [(id, cosine_distance(vector, v)) for id, v in candidates]
        return sorted(scored, key=lambda x: x[1])[:k]

長所: 実装が単純、高速 insert、分散に向く、ストリーミング追加が容易。 短所: Recall が低い (0.7〜0.85)、多数のテーブルが必要、チューニングが難しい。

現代では HNSW/IVF が主流だが、超大規模分散やストリーミング追加では依然有用。


3. IVF: Inverted File Index

アイデア

IVF は FAISS でも定番。

  1. k-means でベクトル空間を Cluster に分割。
  2. 各ベクトルは最も近い Centroid に割り当てられる。
  3. クエリ時は 最も近い Cluster を nprobe 個 だけ検索。

クエリ手順

  1. クエリ q と全 Centroid の距離計算。
  2. 最近 nprobe 個の Cluster 選択。
  3. それらの中のベクトルと正確に距離計算。
  4. Top-k を返す。
class IVFIndex:
    def __init__(self, dim, nlist=100):
        self.nlist = nlist
        self.centroids = None
        self.lists = [[] for _ in range(nlist)]

    def train(self, vectors):
        from sklearn.cluster import KMeans
        kmeans = KMeans(n_clusters=self.nlist)
        kmeans.fit(vectors)
        self.centroids = kmeans.cluster_centers_
        assignments = kmeans.predict(vectors)
        for i, c in enumerate(assignments):
            self.lists[c].append((i, vectors[i]))

    def query(self, q, k=10, nprobe=10):
        centroid_dists = np.linalg.norm(self.centroids - q, axis=1)
        top_clusters = np.argsort(centroid_dists)[:nprobe]
        candidates = []
        for c in top_clusters:
            for id, v in self.lists[c]:
                candidates.append((id, np.linalg.norm(q - v)))
        return sorted(candidates, key=lambda x: x[1])[:k]

パラメータ

  • nlist: Cluster 数、典型的には sqrt(N)
  • nprobe: クエリで探す Cluster 数。増やすほど正確だが遅い。

長所: 線形探索より数十倍高速、単純、FAISS で最適化済み。 短所: Training が必要、追加で再均衡が必要、境界付近のベクトル取りこぼし。


4. Product Quantization: メモリ節約の王

問題

1 億 x 1024 次元 float32 = 400 GB。メモリに載らず、ディスクだと遅い。

PQ のアイデア (Jegou et al., 2011)

  1. 各ベクトルを M 個の subvector に分割 (例: 1024 -> 8 x 128)。
  2. 各 subspace ごとに独立に k-means (例: 256 Centroids)。
  3. 各 subvector を 最近の Centroid インデックス (1 byte) に置き換え。
  4. 1 ベクトル = M バイト
: 1024 * 4 = 4096 byte
PQ:  8 byte
圧縮率: 512

エンコード

class ProductQuantizer:
    def __init__(self, dim, M=8, nbits=8):
        self.dim = dim
        self.M = M
        self.nbits = nbits
        self.dsub = dim // M
        self.K = 2 ** nbits
        self.codebooks = None

    def train(self, vectors):
        from sklearn.cluster import KMeans
        self.codebooks = np.zeros((self.M, self.K, self.dsub))
        for m in range(self.M):
            sub = vectors[:, m*self.dsub:(m+1)*self.dsub]
            kmeans = KMeans(n_clusters=self.K)
            kmeans.fit(sub)
            self.codebooks[m] = kmeans.cluster_centers_

    def encode(self, vectors):
        codes = np.zeros((len(vectors), self.M), dtype=np.uint8)
        for m in range(self.M):
            sub = vectors[:, m*self.dsub:(m+1)*self.dsub]
            dists = np.linalg.norm(
                sub[:, None, :] - self.codebooks[m][None, :, :],
                axis=2
            )
            codes[:, m] = np.argmin(dists, axis=1)
        return codes

Asymmetric Distance Computation (ADC)

  • クエリは 非圧縮 で保持。
  • クエリと各 subspace の全 Centroid の距離を事前計算 -> Lookup Table LUT[M, K]
  • 圧縮ベクトルの距離 = M 回の table 参照の和。
def query_adc(self, q, codes):
    lut = np.zeros((self.M, self.K))
    for m in range(self.M):
        q_sub = q[m*self.dsub:(m+1)*self.dsub]
        for k in range(self.K):
            lut[m, k] = np.sum((q_sub - self.codebooks[m, k])**2)
    distances = np.zeros(len(codes))
    for i, code in enumerate(codes):
        for m in range(self.M):
            distances[i] += lut[m, code[m]]
    return distances

距離計算が M 回の参照と加算で済む。原次元 dim の乗算の代わりに M (通常 1/64〜1/128) の操作。

IVF-PQ

FAISS の有名な組合せ:

  1. IVF で Cluster に絞る。
  2. 各 Cluster 内を PQ で圧縮保持。
  3. クエリ時は nprobe Cluster を ADC で高速スキャン。

結果: 100 億ベクトルを数 GB に収め、ミリ秒で検索。Meta 規模の画像検索はこの方式。

import faiss
d = 128; nlist = 1024; m = 8; bits = 8
quantizer = faiss.IndexFlatL2(d)
index = faiss.IndexIVFPQ(quantizer, d, nlist, m, bits)
index.train(training_vectors)
index.add(vectors)
index.nprobe = 32
distances, indices = index.search(queries, k=10)

5. HNSW: グラフベースの革命

アイデア

HNSW (Hierarchical Navigable Small World) は 2016 年発表 (Malkov と Yashunin)。現在の 最高性能 ANN とされ、Pinecone、Weaviate、Qdrant、Milvus、pgvector の既定エンジン。

  1. ベクトルを Graph で接続。
  2. 各ノードは近傍 M 個と Edge。
  3. クエリ時に Graph を辿って最近接を探す。

Greedy Search と Local Minimum

単純な Greedy 探索は Local Minimum に落ちる。近傍は皆遠いが、全体にはもっと近いノードがある場合。

HNSW の多層構造

Layer 2 (sparse): [A --- E --- I]
                   |     |     |
Layer 1:          [A B D E G I J]
                   | | | | | | |
Layer 0 (dense):  [A B C D E F G H I J K L]
  • Layer 0: 全ベクトル、密な接続。
  • 上位: ランダム部分集合、疎な接続。「高速道路」。

検索手順

  1. 最上位 Layer から開始。
  2. Greedy でその層の最近接を探す。
  3. 1 層下へ。
  4. Layer 0 まで繰り返し、最終 top-k を返す。

Skip list に似た構造。上位 = 大域移動、下位 = 詳細探索。

挿入

  1. ランダム Layer 選択: L = floor(-log(uniform(0,1)) * mL)
  2. 最上位から Greedy 探索で位置特定。
  3. 各 Layer で最近接 M 個と接続。
  4. 近傍数が M_max を超えたら heuristic pruning。

主要パラメータ

  • M parameter: 各ノードの最大近傍数 (既定 16)。
  • ef_construction: 挿入時探索幅 (既定 200)。
  • ef_search: クエリ時探索幅。

擬似コード

class HNSW:
    def __init__(self, M=16, ef_construction=200):
        self.M = M
        self.M_max0 = M * 2
        self.ef_construction = ef_construction
        self.mL = 1.0 / np.log(M)
        self.nodes = {}
        self.layers = defaultdict(dict)
        self.entry_point = None
        self.max_layer = -1

    def search_layer(self, q, entry, ef, layer):
        visited = {entry}
        candidates = [(distance(q, self.nodes[entry]), entry)]
        results = [(-distance(q, self.nodes[entry]), entry)]
        while candidates:
            dist, curr = heapq.heappop(candidates)
            if -results[0][0] < dist:
                break
            for neighbor in self.layers[layer].get(curr, []):
                if neighbor in visited:
                    continue
                visited.add(neighbor)
                d = distance(q, self.nodes[neighbor])
                if d < -results[0][0] or len(results) < ef:
                    heapq.heappush(candidates, (d, neighbor))
                    heapq.heappush(results, (-d, neighbor))
                    if len(results) > ef:
                        heapq.heappop(results)
        return sorted([(-d, n) for d, n in results])

性能

  • Recall 0.95 で線形探索の 100〜1000 倍高速。
  • メモリは元データの 1.5〜2 倍。
  • 挿入は検索より遅い。
  • 削除は難しい (tombstone + 再構築)。

短所

  • メモリ前提 (ディスクは不得手)。
  • 修正に弱い。
  • パラメータに敏感。

6. DiskANN: ディスクベースの ANN

動機

10 億 x 768 次元 = 3TB。RAM に載らない。

Microsoft Research の DiskANN (2019) は SSD で動作 し、1 台のサーバで 数十億ベクトル を処理。

核心アイデア

  1. Vamana グラフ: 単一層で多様な Edge (alpha > 1)。
  2. SSD アクセスを最小化 (数十回の random read)。
  3. 次ノードの Prefetching。

構成

  • ベクトルは SSD。
  • 圧縮グラフはメモリ (ノード ID のみ)。
  • クエリ時はメモリでグラフを辿り、SSD からバッチでベクトル取得。

性能

  • 1 億ベクトル: 8GB RAM + SSD、Recall 0.95 @ 10ms。
  • 10 億: 64GB RAM + SSD。

メモリは元の 1/50 程度。Microsoft Bing 検索、Milvus の DiskANN 統合などで利用。


7. ScaNN: Google の回答

Google の ScaNN (2020) は PQ を改良。Anisotropic Quantization で量子化誤差を内積ベースで最適化。Asymmetric Hashing と組み合わせ、Recall と速度で最高水準。Google 検索、YouTube 推薦、Google Photos で使用。TensorFlow で OSS 公開。


8. Vector DB 比較

DBエンジン特徴向く用途
PineconeHNSW + 独自最適化Managed、Serverlessプロダクション
WeaviateHNSWSchema、GraphQL意味検索
QdrantHNSWRust、filter 強力filter + ベクトル
MilvusIVF/HNSW/DiskANNGPU 対応大規模
pgvectorIVF-Flat、HNSWPostgreSQL 拡張SQL 統合
ElasticsearchHNSW全文 + ベクトルhybrid 検索
FAISS全アルゴリズムライブラリ研究、カスタム
ChromaHNSW開発者向けプロトタイプ、RAG
Redis StackHNSW、FlatRedis + ベクトル低遅延

pgvector

CREATE EXTENSION vector;

CREATE TABLE items (
  id bigserial PRIMARY KEY,
  content text,
  embedding vector(1536)
);

CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

SELECT id, content, embedding <=> '[0.1,0.2,...]' AS distance
FROM items
ORDER BY embedding <=> '[0.1,0.2,...]'
LIMIT 10;

数百万規模までは十分。数億規模で専用 DB に差をつけられる。

Qdrant

from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue

client.search(
    collection_name="my_collection",
    query_vector=embedding,
    query_filter=Filter(
        must=[FieldCondition(key="category", match=MatchValue(value="tech"))]
    ),
    limit=10,
)

9. パラメータチューニング

HNSW

M parameter:

  • 低 (8〜16): メモリ節約、低次元 (128 以下) 向き。
  • 中 (16〜32): 一般。
  • 高 (32〜64): 高次元、高 Recall。

ef_construction:

  • 100〜200: 高速ビルド。
  • 400〜800: 高 Recall。
  • 1000+: 最高品質、遅いビルド。

ef_search:

  • 50〜100: 高速、Recall 低め。
  • 100〜200: 均衡。
  • 500〜1000: 最高 Recall、遅い。

レシピ

高 Recall (0.99 以上):

m = 32
ef_construction = 400
ef_search = 200

均衡 (Recall 0.95):

m = 16
ef_construction = 200
ef_search = 100

大規模、メモリ制約:

m = 8
ef_construction = 100
ef_search = 50
# + Product Quantization

次元削減

1536 次元 (OpenAI ada-002) は重い。PCA で 784 次元へ、Matryoshka Embeddings、より小さいモデル (384 次元 Sentence-BERT) など。


10. 実戦シナリオと落とし穴

RAG パイプライン

  • 100 万文書、100ms 以内、高 Recall。
  • 推奨: Qdrant または Pinecone、HNSW、M=32、ef_search=100、メタデータ filter 併用。

画像類似検索

  • 1 億画像、top-100 を Re-ranking、メモリ制約。
  • 推奨: Milvus + IVF-PQ、GPU、Recall 0.9 + 精密 Re-ranking。

リアルタイム推薦

  • ユーザー + アイテム Embedding、10ms 以内、頻繁更新。
  • 推奨: Redis Stack + HNSW、または FAISS in-memory。

落とし穴 1: Cold Start

HNSW はビルドが遅い。100 万ベクトルで数十分。バッチ挿入、バックグラウンドビルド + Swap。

落とし穴 2: 削除・更新

Tombstone + 定期再構築。削除が多ければ Annoy や Swap 方式を検討。

落とし穴 3: Filter の複雑さ

  • Post-filter: 結果が少なすぎる。
  • Pre-filter: HNSW グラフが部分集合で探索不能。
  • Integrated (Qdrant 方式): 最も効率的だが実装が複雑。

落とし穴 4: 次元の呪い

2000 次元超は ANN の効果が落ちる。256〜768 次元推奨。

落とし穴 5: Recall 測定ミス

自分のデータ・クエリで測ること:

def measure_recall(index, queries, exact_answers, k=10):
    correct = 0
    total = 0
    for q, exact in zip(queries, exact_answers):
        approx = index.search(q, k)
        correct += len(set(approx) & set(exact))
        total += k
    return correct / total

クイズ

Q1. なぜ「正確な K-NN」でなく「近似 K-NN」なのか?

A. 速度と精度のトレードオフが極端だから。正確な K-NN は O(N*d) で、1 億 x 1024 次元では 1 クエリに数分。ANN は数% の Recall を譲って 1000 倍速を得る。類似度の「正解」は主観的で、Recall 0.95 ならユーザーは違いを感じない。加えて top-100 を ANN で取り、そこに Cross-encoder などで Re-ranking をかければ精度も戻る。「十分に良く、速い」が ANN の哲学。

Q2. Product Quantization はなぜ 500 倍のメモリ節約ができるか?

A. 空間を M 個の subspace に分割して独立に量子化する。1024 次元 float32 (4096 byte) を 8 subvector + 256 Centroids で各 1 byte、計 8 byte に圧縮 (512 倍)。Recall が保たれる理由は 256^8 = 2^64 通りのコード組合せがデータ分布を十分被覆するため。ADC により、クエリは非圧縮のまま M 回の Lookup Table 参照で距離が計算できる。IVF-PQ は Cluster 絞込みと組合せ、大規模検索の事実上の標準。

Q3. HNSW が多層構造を使う理由と各層の役割は?

A. Greedy 探索の Local Minimum を避けるため。ノードは指数分布で Layer が決まり、大半が Layer 0、極少数が上位に入る。検索は最上位から始めて (大域的に正しい近傍へ高速移動)、層を降りながら粒度を細かくし、最後に Layer 0 で top-k を確定する。Skip list に近い構造で、O(log N) 相当の計算量と現行最高水準の Recall-Latency を実現する。

Q4. IVF の nprobe はどうトレードオフを決めるか?

A. nprobe はクエリ時に実際に探す Cluster 数。nprobe=1 は最速だが境界を取りこぼす (Recall 0.6)、nprobe=10 で 0.85、nprobe=32 で 0.95、nprobe=nlist は全探索。チューニングは自分のデータで Recall を測り、目標値 (例 0.95) に達する最小の nprobe を選ぶ。nlist=1000 なら nprobe=32 付近が一般的なスイートスポット。

Q5. HNSW を選ばないほうが良い場面は?

A. (1) メモリが極端に制約 -> IVF-PQ。(2) ディスクベース必須 -> DiskANN。(3) 挿入削除が頻繁 -> Annoy や定期再構築。(4) GPU 環境 -> FAISS IVF-PQ。(5) 10 万以下の小規模 -> 単純な Brute Force。(6) 大量ストリーミング挿入 -> LSH。(7) Filter 主導 -> Qdrant の pre-filter。(8) 既存 SQL スタック活用 -> pgvector。HNSW は強力な既定だが、限界を知ってこそ拡張時に正しい選択ができる。


おわりに

まとめ

  1. Exact -> Approximate: 速度のための必然の選択。
  2. LSH: ハッシュベース、ストリーミング向き。
  3. IVF: Cluster ベース、nprobe でチューニング。
  4. Product Quantization: メモリ 500 倍節約。
  5. IVF-PQ: 大規模の標準。
  6. HNSW: Graph ベース、現行最高の Recall-Latency。
  7. DiskANN: ディスクベース超大規模。
  8. ScaNN: Google の Anisotropic Quantization。

選択の要約

規模メモリ推奨
10 万未満余裕Brute force
10 万〜1000 万余裕HNSW
1000 万〜1 億余裕HNSW or IVF-PQ
1 億〜100 億制約IVF-PQ or DiskANN
100 億超制約DiskANN、ScaNN 分散

RAG 時代における重要性

ChatGPT 以降、ほぼ全ての LLM アプリが RAG を使い、その性能とコストはベクトル検索に大きく依存する。遅い検索は UX を損ない、低い Recall は幻覚を招き、巨大なメモリはインフラコストを爆発させる。ANN を理解することで、適切な Vector DB を選び、パラメータを調整し、障害を推定し、将来の最適化機会を見通せる。


参考資料

현재 단락 (1/363)

2022 年の ChatGPT 以降、**RAG (Retrieval-Augmented Generation)** は LLM の中核手法となった。構造は単純:

작성 글자: 0원문 글자: 12,487작성 단락: 0/363