HNSW(階層的ナビゲーション可能小世界グラフ)をスクラッチ実装で完全理解する

数百万件の画像やテキストを、それぞれ数百次元のベクトルに変換したとします。新しいクエリベクトルが来たとき、「このベクトルに最も近いものを上位10件返したい」——これが最近傍探索(Nearest Neighbor Search)です。検索エンジン、レコメンド、画像認識、そして近年の RAG(検索拡張生成)まで、現代の機械学習システムの裏側にはほぼ必ずこの計算が潜んでいます。

ところが素朴にやると、クエリ1本ごとに全データとの距離を計算する必要があります。データが $N$ 件なら $O(N)$。100万件あれば100万回、しかも各距離計算が数百次元の掛け算です。クエリが秒間数千本も飛んでくるサービスでは、これではまったく間に合いません。

そこで登場するのが近似最近傍探索(ANN, Approximate Nearest Neighbor)です。「厳密な答え」を少しだけ諦めるかわりに、桁違いに速く「ほぼ正解」を返す。その中で、現在の事実上の標準になっているのが本記事のテーマ、HNSW(Hierarchical Navigable Small World、階層的ナビゲーション可能小世界グラフ)です。Malkov と Yashunin が 2016 年に提案し(arXiv:1603.09320、IEEE TPAMI 2018)、FAISS・Milvus・Weaviate・pgvector・Qdrant など、ほぼすべてのベクトルデータベースの心臓部として採用されています。

HNSW が活きる場面は身近です。

  • ベクトル検索 / RAG:質問文を埋め込みベクトルにして、巨大な文書ベクトル群から関連文書を瞬時に引く。LLM に渡す「文脈」をミリ秒で集める土台。
  • レコメンド・画像検索:ユーザーや商品の特徴ベクトルから「似ているもの」を即座に返す。数千万アイテムでも実用速度を保つ。

本記事では、HNSW を「ライブラリのブラックボックス」ではなく中身まで理解します。スキップリスト由来の層構造、各層での貪欲探索、そして高 recall の鍵である近傍選択ヒューリスティックを、すべて Python でスクラッチ実装します。2D 点群で探索経路を可視化し、全探索との recall・距離計算回数を実測して、「なぜ速いのに当たるのか」を数値で腹落ちさせましょう。

本記事の内容

  • なぜ全探索 $O(N)$ では足りないのか、ANN とは何を諦める手法なのか
  • NSW グラフと貪欲探索の直感、そこに階層を足す HNSW のアイデア
  • 層割当の指数分布・期待層数・対数計算量の数式的な直感
  • 近傍選択ヒューリスティックが「多様な長距離リンク」を生む仕組み
  • HNSW のスクラッチ実装と、recall・距離計算回数・ef トレードオフの実測

前提・関連記事

この記事は、ベクトルの「近さ」を測る話と、ベクトル検索の全体像を前提にすると理解が深まります。

画像なし
埋め込み類似度:ベクトルの『近さ』をどう測るか
ユークリッド距離・コサイン類似度の基礎。最近傍探索が比べる距離の土台。
画像なし
ベクトルデータベースとFAISS
HNSWが組み込まれる検索基盤。インデックス全体の位置づけ。
画像なし
ハイブリッド検索
ベクトル検索とキーワード検索の組み合わせ。HNSWはこのベクトル側の心臓部。

なぜ全探索ではダメなのか — 次元の呪いとANN

まず、素朴な全探索(brute-force search)の何が問題なのかをはっきりさせましょう。$N$ 個の $d$ 次元ベクトルがあり、クエリ $\bm{q}$ に最も近い $K$ 個を返したいとします。やることは単純で、全データとの距離を計算して小さい順に並べるだけです。計算量は距離計算が $N$ 回、各距離が $d$ 次元なので $O(Nd)$。$N$ が小さければこれで十分ですが、$N$ が100万・1000万と増えると、1本のクエリに線形時間がかかり、現実的な速度を保てません。

「なら木で分割すれば $O(\log N)$ になるのでは?」と思うかもしれません。実際、低次元では kd 木などの木構造が効きます。ところが次元 $d$ が数十を超えると、次元の呪い(curse of dimensionality)によって木構造はほぼ機能しなくなります。高次元空間では、空間を軸で切り分けても「クエリのすぐ隣のセル」が無数に増え、結局ほとんどの枝を調べるはめになるのです。

そこで「厳密解」をきっぱり諦めます。これが ANN の発想です。ANN では検索品質を recall(再現率) で測ります。真の最近傍 $K$ 個のうち、何個を実際に拾えたかの割合です。

$$ \text{recall@}K = \frac{|\,\text{返した上位}K\text{個} \cap \text{真の最近傍}K\text{個}\,|}{K} $$

recall を 0.95 や 0.99 に保ちつつ、距離計算回数を全探索の数十分の一・数百分の一に減らす——これが ANN のゴールです。LSH(局所性鋭敏ハッシュ)や PQ(直積量子化)も同じ目的の手法ですが、本記事の HNSW はグラフ型のアプローチで、高次元・高 recall 領域で特に強いことで知られます。

では、そのグラフ型 ANN の最も素朴な形——NSW グラフ——から見ていきましょう。

小世界グラフと貪欲探索 — NSWの直感

HNSW の H を取った NSW(Navigable Small World、ナビゲーション可能小世界) から始めます。発想はとてもシンプルです。データ点をノード、近い点どうしをエッジでつないだグラフを作り、その上を「目的地に近づく方向へ歩く」だけで最近傍を探します。

イメージは知り合いをたどる伝言ゲームです。「ある人物に伝言を届けたい」とき、自分の知人の中でその人物に一番近そうな人に渡し、その人がまた自分の知人の中で一番近い人に渡す……を繰り返すと、意外なほど少ない手数で届きます。世界中の人が「6人を介せばつながる」という小世界(small world)現象と同じ原理です。

この「一番近い隣へ乗り換え続ける」のが貪欲探索(greedy search)です。手順はこうです。

  1. 適当な開始点(エントリーポイント)から始める。
  2. いまいるノードの隣接点の中で、クエリ $\bm{q}$ に最も近い点を探す。
  3. その点が今より近ければそこへ移動する。今より近い隣がなくなったら停止。

実際に 2D で動かしてみましょう。まず NSW グラフ(後述の HNSW を1層に縮退させたもの)を作り、その上の貪欲探索の経路を描きます。

import numpy as np

rng = np.random.default_rng(7)
data = rng.uniform(0, 10, size=(200, 2))   # 2Dの点群200個

# 単層NSWを後述のHNSW(mL≈0)で構築し、graph[0]だけ使う
# (HNSWクラスは後の節で定義。ここでは概念として貪欲探索だけ示す)
def greedy_search(graph, data, ep, q):
    cur, path = ep, [ep]
    while True:
        best, bd = cur, np.sum((data[cur]-q)**2)
        for e in graph.get(cur, ()):              # 隣接点を調べる
            d = np.sum((data[e]-q)**2)
            if d < bd:                            # より近ければ乗り換え候補
                bd, best = d, e
        if best == cur:                           # もう近づけない→停止
            break
        cur = best; path.append(cur)
    return path

このグラフ上で、右下のクエリに向かって貪欲探索を走らせると、エントリー点からわずか8ホップで真の最近傍にたどり着きました。

単層NSWグラフ上の貪欲探索の経路 近い隣へ次々と乗り換える

オレンジの折れ線が探索の経路です。左上のエントリー点(緑)から出発し、隣接点の中で一番クエリに近い方へ次々と乗り換え、紫の輪(真の最近傍)にぴたりと到達しています。200点のグラフを全部見たわけではなく、たった8回の「近い隣選び」で答えに届いている点に注目してください。これが小世界グラフの威力です。

ただし NSW には弱点があります。エントリー点がクエリから遠いと、最初のうちは「近づく」歩みが小刻みになり、ホップ数が増えてしまいます。グラフが密になるほど各ホップで隣接点を多く調べる必要もあり、データが増えると探索コストが多項対数的(polylogarithmic)にしか落ちません。「最初の大ジャンプ」を効かせて、もっと速く目的地の近くまで飛びたい。この欲求に応えるのが、階層(hierarchy)の導入です。

階層を足す — HNSWの全体像

HNSW の核心は、NSW グラフを何層も重ねることです。直感は地図のズームと同じです。広域地図(上層)でおおまかに目的地の方角へ一気に移動し、詳細地図(下層)で番地まで詰める。上層は点が少なく長距離のリンクでつながり、下層は点が密で短距離のリンクでつながります。

HNSWの概念模式図 上層から降りながら各層で貪欲に最近傍へ近づく

上の模式図がHNSWの探索の全体像です。最上層(第2層)のエントリーポイントから始め、その層で貪欲にクエリへ近づきます。これ以上近づけなくなったら、いまいる点を入口にして1つ下の層へ降ります。降りた先ではより多くの点と短いリンクがあるので、さらに細かく近づけます。これを第0層まで繰り返し、最下層で最終的な最近傍候補を集めます。

ポイントは2つです。第一に、上層の長距離リンクで一気にクエリ付近へワープできること。NSW の「最初の小刻みな歩み」が解消されます。第二に、各点がどの層まで存在するかをランダムに決めること。ほとんどの点は第0層にしかいませんが、ごく一部の点だけが上層にも顔を出し、それが「広域の道しるべ」になります。

この「上の層ほど指数的にスカスカ」という構造、実は古典的なデータ構造に瓜二つです。次節でその正体——スキップリスト——を見ます。

スキップリストとの対応 — 層割当の指数分布

論文自身が述べているとおり、HNSW はスキップリスト(skip list)の連結リストを近接グラフに置き換えたものと見なせます。スキップリストは、ソート済み連結リストの上に「飛び石」の階層を重ねたデータ構造です。上の階層ほど要素がまばらで、検索時は上から降りながら一気にジャンプして目的の値へ近づきます。

スキップリストとHNSWの対応

上の図のように、スキップリストは「1次元の順序」の上を上層の長いジャンプで近づくのに対し、HNSW は「一般の距離空間」を上層の長距離リンクで近づきます。やっていることは同じで、違うのは舞台が数直線か高次元空間かだけです。

では、各点をどの層まで置くか。HNSW は次の式で点 $i$ の最上位層 $l_i$ を決めます。$U$ は $(0,1)$ 上の一様乱数です。

$$ \begin{equation} l_i = \left\lfloor -\ln(U) \cdot m_L \right\rfloor, \qquad U \sim \text{Uniform}(0,1) \end{equation} $$

$-\ln(U)$ は平均1の指数分布に従う乱数です。これに正規化係数 $m_L$ を掛けて切り捨てると、層 $l$ が選ばれる確率は層が上がるほど指数的に小さくなります。具体的に、$l \ge k$ となる確率を計算してみましょう。$-\ln(U) \cdot m_L \ge k$ すなわち $U \le e^{-k/m_L}$ なので、

$$ P(l \ge k) = P\!\left(U \le e^{-k/m_L}\right) = e^{-k/m_L} $$

ここで論文は $m_L = 1/\ln M$ と選びます($M$ は1点あたりのリンク数、後述)。これを代入すると、

$$ P(l \ge k) = e^{-k \ln M} = \left(\frac{1}{M}\right)^{k} $$

きれいな形になりました。1つ上の層に到達する確率は $1/M$。スキップリストでいう「確率 $p = 1/M$ で1段上がる」と完全に同じです。したがって、ある層がちょうど最上位になる確率は

$$ P(l = k) = P(l \ge k) – P(l \ge k+1) = \left(1 – \frac{1}{M}\right)\left(\frac{1}{M}\right)^{k} $$

幾何分布です。実際に乱数を10万個振って、理論と一致するか確かめます。

import numpy as np, math

def layer_dist(M, N=100000, seed=1):
    rng = np.random.default_rng(seed)
    mL = 1.0 / math.log(M)
    U = rng.random(N)
    levels = np.floor(-np.log(U) * mL).astype(int)   # 式(1)
    return levels

for M in [8, 16]:
    lv = layer_dist(M)
    frac = [(lv == k).mean() for k in range(lv.max()+1)]
    print(f"M={M}: 各層の割合 =", [round(f, 4) for f in frac[:5]])

HNSWの層割当は指数分布 各層で確率1/Mに減衰

棒グラフ(実測)が赤い理論曲線 $(1-1/M)(1/M)^k$ にぴたりと重なっています。$M=8$ なら層が1つ上がるごとに割合が約 $1/8$ に、$M=16$ なら約 $1/16$ に減ります。つまり大半の点は第0層にしかおらず、第1層に約 $1/M$、第2層に約 $1/M^2$……という指数的なピラミッドになります。

この指数分布が、なぜ対数計算量につながるのか。次に期待層数とホップ数の見積もりを通して、その直感を掴みましょう。

なぜO(log N)で済むのか — 期待層数とホップ数

HNSW が速い理由は、層数も各層のホップ数も $O(\log N)$ で抑えられることにあります。順に見積もります。

まず最上位層の数。$N$ 個の点それぞれが確率 $1/M^k$ で層 $k$ 以上に到達するので、層 $k$ に存在する点数の期待値は $N \cdot M^{-k}$ です。最上層は「期待で点が1個以下になる層」あたりで打ち止めになるので、$N \cdot M^{-L} \approx 1$、すなわち

$$ L \approx \log_M N = \frac{\ln N}{\ln M} $$

層数は $N$ に対して対数的にしか増えません。これが第一の $\log$ です。たとえば $M=16$、$N=10^9$ でも $L \approx \log_{16}10^9 \approx 7.5$ 層程度に収まります。

次に各層でのホップ数。$m_L = 1/\ln M$ という選択には、論文が指摘する絶妙なバランスがあります。隣り合う層の間で、各点が持つ近傍の平均1個だけが両方の層に共通するように設計されているのです。これにより「上の層で進んだ分、下の層では平均して1ホップ程度の距離まで詰まっている」状態が保たれ、各層で必要なホップ数が定数オーダーに抑えられます。$m_L$ を小さくしすぎると層が浅くなって1層あたりのホップが増え、大きくしすぎると層が深くなりすぎる。その最適点が $1/\ln M$ です。

層数 $O(\log N)$ × 各層の定数ホップ × 1ホップあたり最大 $M$ 個の隣接調査をかけ合わせると、検索全体の距離計算は

$$ O\!\left(M \cdot \log N\right) $$

つまり $N$ に対して対数オーダーです。全探索の $O(N)$ と比べると、$N$ が大きいほど差は劇的に開きます。

この対数スケーリングを実測で確かめましょう。データ数 $N$ を1000から3万2000まで増やし、クエリ1本あたりの距離計算回数を測ります。

import numpy as np, math
# HNSWクラスは次節で定義。ここでは結果のみ示す
Ns  = [1000, 2000, 4000, 8000, 16000, 32000]
# 実測した距離計算回数(D=16, M=12, ef=64)
dcs = [576, 694, 796, 886, 963, 1026]
for N, dc in zip(Ns, dcs):
    print(f"N={N:6d}: 距離計算 {dc:5d}回  (全探索なら {N}回)")

HNSWの距離計算回数の対数スケーリング

両対数グラフで、HNSW(紫)の距離計算回数はほぼ直線状の対数曲線(緑の点線 $O(\log N)$)に沿って増え、全探索の $O(N)$(赤い破線)から大きく下に離れていきます。$N$ を32倍にしても距離計算は約576回から約1026回へ2倍弱しか増えていません。一方、全探索なら32倍そのまま増えます。recall はこの間ずっと 0.98 以上を保っており、「速くてもちゃんと当たる」ことが確認できます。

理屈と直感が揃ったところで、いよいよ HNSW を一から実装します。

HNSWのスクラッチ実装 — 探索

実装は2つに分かれます。探索(あるクエリの近傍を探す)構築(点を1つずつ挿入してグラフを育てる)です。まず探索の中核、特定の層内での貪欲探索を一般化した SEARCH-LAYER から作ります。

NSW の貪欲探索は「最も近い隣が1つ」を追っていましたが、HNSW では recall を上げるために、探索中に有望な候補を $ef$ 個まで保持します。$ef$(探索の幅)が大きいほど多くの道を同時に探り、取りこぼしが減ります。アルゴリズムは2つの優先度つきキューを使います。

  • 候補キュー $C$:これから展開する点を、クエリに近い順(最小ヒープ)で持つ。
  • 結果キュー $W$:現時点で見つかった最良の $ef$ 個を、遠い順(最大ヒープ)で持つ。

C から最も近い候補を取り出し、その隣接点を見て、W の中の最遠点より近ければ CW に追加します。C の最近候補が W の最遠点より遠くなったら、もう改善の見込みがないので打ち切ります。

import heapq, numpy as np

def search_layer(graph, data, q, eps, ef, lc):
    """層lcで、入口epsからクエリqに近いef個を貪欲探索で集める。"""
    visited = set(eps)
    C = [(np.sum((data[e]-q)**2), e) for e in eps]   # 最小ヒープ(距離,点)
    heapq.heapify(C)
    W = [(-d, e) for d, e in C]                       # 最大ヒープ(-距離,点)
    heapq.heapify(W)
    while C:
        d_c, c = heapq.heappop(C)                     # 最も近い候補
        if d_c > -W[0][0]:                            # W中の最遠より遠い
            break                                     # → もう改善しない
        for e in graph[lc].get(c, ()):               # cの隣接を展開
            if e in visited:
                continue
            visited.add(e)
            d_e = np.sum((data[e]-q)**2)
            if d_e < -W[0][0] or len(W) < ef:
                heapq.heappush(C, (d_e, e))
                heapq.heappush(W, (-d_e, e))
                if len(W) > ef:
                    heapq.heappop(W)                  # 最遠を1つ捨てる
    return [e for _, e in W]

距離はユークリッド距離の二乗で比べています。平方根は単調変換なので大小関係は変わらず、計算を1つ省けるからです。W のサイズを常に $ef$ 以下に保つことで、メモリと比較回数を抑えています。

完全な検索(K-NN-SEARCH)は、この search_layer上層では $ef=1$、最下層だけ $ef$で呼びます。上層は「とにかく速くクエリ付近まで降りる」のが目的なので幅1で十分、最下層で初めて幅を広げて精度を稼ぐ、という役割分担です。クラス全体は構築の節でまとめて示しますが、検索本体はこうです。

def search(self, q, K, ef):
    q = np.asarray(q, float)
    ep = self.ep
    for lc in range(self.max_level, 0, -1):          # 上層は幅1で降下
        W = self._search_layer(q, [ep], ef=1, lc=lc)
        ep = min(W, key=lambda n: self._distq(q, n))
    W = self._search_layer(q, [ep], ef=ef, lc=0)     # 最下層だけ幅ef
    return sorted(W, key=lambda n: self._distq(q, n))[:K]

この「上は幅1・下だけ幅 $ef$」が、速度と精度の両立の鍵です。実際にどれくらいの点しか見ないのか、探索が触れた点を可視化してみましょう。

探索が触れた点と最終候補集合W

灰色が探索中に距離を計算した「訪問済み」の点、水色が最終的に W に残った候補です。250点のうち触れたのは24点だけ。全体の1割弱しか見ずに、クエリ(赤星)周辺の点群を正しく拾い、真の最近傍(紫の輪)も候補に含まれています。「ほとんど見ないのに当たる」ことが視覚的にわかります。

探索ができたので、次はこのグラフをどう作るかです。構築こそ HNSW の品質を決める核心で、近傍選択ヒューリスティックが登場します。

HNSWのスクラッチ実装 — 構築と挿入

グラフは点を1つずつ挿入して育てます。点 $q$ を挿入する手順(論文の Algorithm 1 INSERT)はこうです。

  1. 式(1)で $q$ の最上位層 $l$ をサイコロで決める。
  2. 既存のエントリーポイントから、$l$ より上の層を $ef=1$ で降りて、$q$ の入口を $l$ 層の近くまで運ぶ。
  3. $l$ 層から第0層まで、各層で $efConstruction$ 幅の探索を行い、見つかった候補から $M$ 個の近傍を選んで双方向リンクを張る。
  4. リンクを張った相手の次数が上限 $M_{\max}$(第0層は $M_{\max 0}=2M$)を超えたら、近傍選択で刈り込む
  5. $l$ が現在の最上層より高ければ、エントリーポイントを $q$ に更新する。

ここで2種類のパラメータが出てきます。$efConstruction$ は構築時の探索幅で、大きいほど良いグラフになりますが構築が遅くなります。$M$ は1点あたりのリンク数で、論文は $5 \sim 48$ を推奨し、高 recall・高次元ほど大きめが良いとしています。第0層だけ $M_{\max 0}=2M$ と上限を倍にするのは、最下層の接続性が検索品質を強く左右するためで、論文のシミュレーションでも $2M$ が良いバランスとされています。

ここまでの探索・構築をまとめたクラスが次です。

import heapq, math, numpy as np

class HNSW:
    def __init__(self, M=8, efConstruction=32, mL=None, heuristic=True, seed=0):
        self.M = M
        self.Mmax, self.Mmax0 = M, 2*M           # 上限次数(第0層は2M)
        self.efC = efConstruction
        self.mL = mL if mL is not None else 1.0/math.log(M)
        self.heuristic = heuristic
        self.rng = np.random.default_rng(seed)
        self.data, self.levels, self.graph = [], [], []
        self.ep, self.max_level = None, -1
        self.dist_count = 0                       # 距離計算回数の計測用

    def _distq(self, q, b):
        self.dist_count += 1
        d = q - self.data[b]
        return float(np.dot(d, d))                # 二乗ユークリッド

    def _random_level(self):
        return int(-math.log(self.rng.random()) * self.mL)   # 式(1)

挿入の本体は次のとおりです。上層を $ef=1$ で降り、$l$ 層以下で近傍を選んで張る、という流れがそのままコードになっています。

    def add(self, vec):
        q = np.asarray(vec, float)
        idx = len(self.data); self.data.append(q)
        l = self._random_level(); self.levels.append(l)
        while len(self.graph) <= l: self.graph.append({})
        for lc in range(l+1): self.graph[lc][idx] = set()
        if self.ep is None:                       # 最初の点
            self.ep, self.max_level = idx, l; return
        ep, L = self.ep, self.max_level
        for lc in range(L, l, -1):                # l より上は幅1で降下
            W = self._search_layer(q, [ep], ef=1, lc=lc)
            ep = min(W, key=lambda n: self._distq(q, n))
        for lc in range(min(L, l), -1, -1):       # l 以下で近傍接続
            W = self._search_layer(q, [ep], ef=self.efC, lc=lc)
            neighbors = self._select_neighbors(q, W, self.M, lc)
            for e in neighbors:                   # 双方向リンク
                self.graph[lc][idx].add(e); self.graph[lc][e].add(idx)
            Mmax = self.Mmax0 if lc == 0 else self.Mmax
            for e in neighbors:                   # 次数超過なら刈り込み
                if len(self.graph[lc][e]) > Mmax:
                    newc = self._select_neighbors(self.data[e],
                                list(self.graph[lc][e]), Mmax, lc)
                    self.graph[lc][e] = set(newc)
            ep = min(W, key=lambda n: self._distq(q, n))
        if l > self.max_level: self.ep, self.max_level = idx, l

_search_layer は前節の search_layer をメソッド化したもの(距離計算を _distq 経由にして回数を数えるだけ)です。残るは _select_neighbors——どの $M$ 個を近傍に選ぶか。ここが HNSW の品質を分ける最重要パーツなので、節を改めます。

このコードで300点の2Dデータを構築し、全層の構造を描いてみます。

多層HNSWの全層構造 上層ほど点が少なく長距離リンク

左から第3層(1点)、第2層(4点)、第1層(34点)、第0層(300点)です。式(1)の指数分布どおり、上層ほど点が激減し、残った少数の点が長距離リンクで結ばれています。第0層は全点が密に短距離でつながり、上層は「広域の幹線道路」、下層は「住宅街の細道」という役割分担がはっきり見えます。300→34→4→1 という比率は、ほぼ $1/M$($M=6$ なので約 $1/6$)ごとの減衰になっています。

近傍選択ヒューリスティック — 多様な方向に橋を架ける

近傍を選ぶいちばん素朴な方法は「候補のうち最も近い $M$ 個を選ぶ」です(論文の Algorithm 3, SELECT-NEIGHBORS-SIMPLE)。ところがこれには落とし穴があります。点が密集したクラスタの近くにいると、最も近い $M$ 個が全部同じクラスタに吸い込まれてしまうのです。すると、その点からは「ある一方向の塊」にしかリンクが張れず、別の方向のクラスタへは橋がかかりません。グラフが分断され、貪欲探索が局所最適にハマって recall が落ちます。

そこで論文は SELECT-NEIGHBORS-HEURISTIC(Algorithm 4) を導入します。アイデアは「すでに選んだ近傍に近すぎる候補は、冗長だから採らない」。具体的には、候補を近い順に見ていき、いま見ている候補 $e$ が、すでに採用した近傍のどれかに、クエリへの距離より近いなら、それは「もう張ってある方向」なので捨てます。これにより、同じ塊に固まらず、四方八方へバランス良くリンクが伸びます。

    def _select_neighbors(self, q, C, M, lc):
        qvec = q if isinstance(q, np.ndarray) else self.data[q]
        dq = lambda n: self._distq(qvec, n)
        if not self.heuristic:
            return sorted(C, key=dq)[:M]          # 単純: 近いM個(alg.3)
        R = []                                     # ヒューリスティック(alg.4)
        for e in sorted(set(C), key=dq):           # 近い順に吟味
            if len(R) >= M: break
            de_q = dq(e)
            # 既採用の近傍より e に近い相手がいれば冗長 → 捨てる
            redundant = any(self._dist(e, r) < de_q for r in R)
            if not redundant:
                R.append(e)
        return R

_dist(e, r) は採用済み近傍 $r$ と候補 $e$ の距離です。「$e$ がクエリよりも既存近傍 $r$ に近い」なら、$r$ を経由して $e$ 方向はもうカバーできているとみなして $e$ を採らない、というのが判定の核心です。この一行が、Delaunay グラフ(各点の「縄張り」が隣接するように結ぶ、最近傍探索に理想的なグラフ)を近似する効果を持ちます。

効果を2Dで可視化します。クエリの右側に密集クラスタ、他の方向にぽつぽつと点を置き、$M=4$ で選んでみます。

近傍選択ヒューリスティック 単純選択との比較 多様な方向へ橋を架ける

左の単純選択では、選ばれた4本のリンクのうち3本が右上の密集クラスタに吸い込まれ、左方向はほぼ手薄です。右のヒューリスティックでは、クラスタからは代表1点だけを採り、残りを左上・左下・右の異なる方向へ振り分けています。同じ塊に固まらず四方へ橋を架ける——この多様性が、グラフ全体の連結性と高 recall を支えます。

では、この多様性が recall にどれだけ効くのか。クラスタ構造を持つ高次元データで、ヒューリスティックの有無を比較します。

import numpy as np
# 20クラスタ・32次元・5000点(後述の評価データ)で構築・評価
# ヒューリスティック有無それぞれでrecall@10を計測した結果(実測値)
efs = [10, 20, 40, 80, 160]
heur   = [0.922, 0.977, 0.991, 0.991, 0.991]   # ヒューリスティック
simple = [0.840, 0.870, 0.879, 0.889, 0.890]   # 単純(近いM個)
for ef, h, s in zip(efs, heur, simple):
    print(f"ef={ef:3d}: ヒューリスティック {h:.3f} / 単純 {s:.3f}")

ヒューリスティック有無のrecall比較

ヒューリスティック(青)は $ef$ を上げると recall がぐんぐん伸び、$ef=40$ で 0.99 に到達します。一方、単純選択(オレンジ)は $ef$ をいくら増やしても 0.89 あたりで頭打ちです。グラフが一方向に偏って張られているため、$ef$ を広げても「届かない領域」が残るのです。クラスタ構造のあるデータで高 recall を狙うほど、ヒューリスティックの差が効いてきます。

実装が出そろいました。最後に、HNSW 全体の性能を全探索と比べて測り、パラメータの効き方を確かめましょう。

実測 — recall・距離計算回数・efトレードオフ

評価用に、20個のクラスタからなる32次元・5000点のデータを作り、200本のクエリで recall@10 と距離計算回数を測ります。比較対象は全探索(5000回の距離計算で必ず recall=1.0)です。

import numpy as np
def brute(data, q, K):                            # 全探索(正解)
    return list(np.argsort(np.sum((data-q)**2, axis=1))[:K])

def evaluate(h, data, queries, K, ef):
    recs, dcs = [], []
    for q in queries:
        truth = set(brute(data, q, K))
        h.dist_count = 0
        got = set(h.search(q, K, ef))
        recs.append(len(truth & got) / K)         # recall@K
        dcs.append(h.dist_count)                   # 距離計算回数
    return np.mean(recs), np.mean(dcs)

$M=12$、$efConstruction=80$ で構築し、検索時の $ef$ を10から320まで振った結果がこちらです。

# 実測結果(M=12, N=5000, K=10)
efs   = [10, 20, 40, 80, 160, 320]
recall= [0.942, 0.965, 0.970, 0.970, 0.975, 0.975]
dist  = [160, 219, 289, 363, 475, 975]            # 全探索は常に5000回

efを振ったrecallと距離計算回数のトレードオフ

左図:$ef$ を上げると recall が向上し、$ef=160$ で 0.975 に達して頭打ちになります。右図:その recall を、クエリあたりの距離計算回数に対してプロットしたものです。recall 0.94 をわずか160回の距離計算で達成しています。全探索の5000回(赤い破線)と比べて約31分の1です。recall を 0.97 まで上げても約363回、全探索の14分の1で済みます。「少し精度を譲るだけで、計算量が桁違いに減る」という ANN の旨味がそのまま数値に出ています。

最後に、$M$ を振ったときの効き方を見ます。$M$ はリンク数=メモリ消費に直結し、recall の到達点も左右します。

# M=4,8,16,32 それぞれで ef を振り、距離計算回数 vs recall を描く
for M in [4, 8, 16, 32]:
    h = HNSW(M=M, efConstruction=max(64, 4*M), heuristic=True, seed=6)
    for v in data: h.add(v)
    # ef=10..320 で evaluate(...) を回してプロット

パラメータMを振った効果 recallと距離計算

同じ距離計算回数で比べると、$M$ が大きいほど高い recall に到達できます(曲線が上にある)。ただし $M$ を増やすとリンクが増えてメモリ消費が比例して増え、1ホップで調べる隣接点も増えます。論文が「低 recall・低次元なら小さい $M$、高 recall・高次元なら大きい $M$」を推奨し、$M=5 \sim 48$ を目安とするのはこのトレードオフのためです。実務では $M=16$ 前後を起点に、要求 recall とメモリ予算で調整するのが定石です。

まとめ

本記事では、近似最近傍探索の事実上の標準である HNSW を、理論からスクラッチ実装まで一気通貫で解説しました。

  • 全探索 $O(N)$ の限界:高次元では木構造も次元の呪いで効かず、recall を少し諦めて高速化する ANN が必要になる。
  • NSW と貪欲探索:近い点どうしをつないだ小世界グラフ上を「近い隣へ乗り換え続ける」だけで最近傍に届く。
  • 階層(H)の導入:層割当を確率 $1/M$ の指数分布($m_L=1/\ln M$、スキップリストと同型)にし、上層の長距離リンクで一気にクエリ付近へワープ。期待層数・各層ホップ数とも $O(\log N)$ で、検索は $O(M\log N)$。
  • 近傍選択ヒューリスティック:「既存近傍に近すぎる候補は捨てる」ことで多様な方向へ橋を架け、Delaunay グラフを近似。クラスタデータの高 recall を支える。
  • 実測:recall 0.94 を全探索の約31分の1の距離計算で達成。$ef$ は recall-速度、$M$ は recall-メモリのつまみ。

HNSW を理解すると、FAISS や各種ベクトルデータベースで $M$・$efConstruction$・$ef$ をなぜそう設定するのかが腹落ちします。リンク数を増やせばメモリと引き換えに高 recall、探索幅 $ef$ を上げれば速度と引き換えに精度——どのつまみが何を動かすかが、もうブラックボックスではありません。

次のステップとして、HNSW が組み込まれる検索基盤の全体像や、メモリをさらに圧縮する量子化系の手法と組み合わせる発展を見てみてください。HNSW と直積量子化(PQ)を組み合わせた索引や、局所性鋭敏ハッシュ(LSH)との比較は、ANN を実運用に乗せるうえで自然な続きになります。