Re-ranking の理論と実装 — Cross-Encoder と ColBERT で検索精度を引き上げる

検索システムに「Pythonでデータベースに接続する方法」と問い合わせたとき、ベクトル検索で上位に来た結果の中に「Pythonの基本文法」や「データベース設計の原則」のような関連はするが直接的な答えではない文書が混じることがあります。ベクトル検索(Bi-Encoder)は高速ですが、クエリと文書を独立にエンコードするため、両者の細かな関係性を捉えきれないのです。

この問題を解決するのがRe-ranking(リランキング)です。一次検索で取得した候補を、より精密なモデルで並べ替えることで、検索精度を大幅に向上させます。特にCross-Encoderは、クエリと文書を同時に入力して関連度スコアを算出するため、Bi-Encoderよりも遥かに高い精度を達成できます。

Re-rankingを理解することは、以下のような場面で直接役立ちます。

  • RAGパイプラインの精度向上: 検索結果の品質がLLMの回答品質を直接左右します。Re-rankingにより、より関連性の高い文書をコンテキストに含められます
  • 企業内検索システム: 法律文書や技術文書の検索で、キーワード一致だけでは不十分な場面で精度を改善できます
  • 推薦システム: 候補の事前フィルタリング後に、ユーザーの嗜好により合ったアイテムを上位に配置できます

本記事の内容

  • Bi-EncoderとCross-Encoderの構造的な違い
  • なぜCross-Encoderの方が精度が高いか — 相互注意の効果
  • 二段階検索パイプラインの設計
  • ColBERT: 遅延相互作用による速度と精度の両立
  • Pythonでの実装と性能比較
  • 実用上の選択指針

前提知識

この記事を読む前に、以下の記事を読んでおくと理解が深まります。

文埋め込み(Sentence-BERT・SimCSE)
Bi-Encoderの基盤となる文埋め込み手法を理解します
画像なし
埋め込みベクトルと類似度検索の理論とPython実装
ベクトル類似度の基本を理解します
画像なし
ベクトルデータベースの仕組み
一次検索で使われるANN手法を理解します

Bi-Encoder と Cross-Encoder の違い

Bi-Encoder: 独立エンコーディング

Bi-Encoder(双方向エンコーダ)は、クエリ $q$ と文書 $d$ をそれぞれ独立にエンコードし、得られたベクトルの類似度で関連度を算出します。

$$ \text{score}_{\text{bi}}(q, d) = \text{sim}(E_q(q), E_d(d)) $$

ここで $E_q, E_d$ はそれぞれクエリと文書のエンコーダ(多くの場合同一のBERTモデル)で、$\text{sim}$ はコサイン類似度やドット積です。

Bi-Encoderの最大の利点は、文書ベクトルを事前に計算してインデックスに格納できることです。検索時にはクエリベクトルのみを計算し、ANNで高速に近傍を取得します。$N = 10^8$ の文書に対しても、数ミリ秒で検索が完了します。

しかし、クエリと文書が独立にエンコードされるため、両者の間の細かな相互作用(例えば「接続する」という語がクエリの文脈では「データベース接続」を意味するが、文書中では「ネットワーク接続」を意味するなど)を捉えることが困難です。

Cross-Encoder: 同時エンコーディング

Cross-Encoder(交差エンコーダ)は、クエリと文書を連結して1つの入力としてTransformerに入力します。

$$ \text{score}_{\text{cross}}(q, d) = \sigma(\bm{w}^T \cdot \text{BERT}([q; \text{[SEP]}; d]) + b) $$

ここで $[q; \text{[SEP]}; d]$ はクエリと文書をSEPトークンで連結した入力、$\sigma$ はシグモイド関数、$\bm{w}, b$ は分類ヘッドのパラメータです。

Cross-Encoderでは、TransformerのSelf-Attention機構によりクエリの各トークンが文書の全トークンと相互に注意を向けます。これにより、文脈に応じた細やかな意味の対応関係を捉えることができます。

なぜ Cross-Encoder の方が精度が高いか

この精度差を直感的に理解するために、注意機構の違いを図にしましょう。

Bi-Encoder: クエリ内のトークン同士、文書内のトークン同士のSelf-Attentionのみ。クエリと文書の間にはAttentionが存在しない

Cross-Encoder: クエリのトークンと文書のトークンの間にも双方向のAttentionが発生。これを相互注意(cross-attention)と呼びます

情報検索の観点から言えば、Bi-Encoderはクエリと文書をそれぞれ固定長ベクトルに「要約」してから比較するため、要約の過程で失われる情報があります。一方、Cross-Encoderはトークンレベルで直接比較するため、情報の損失が最小限です。

ベンチマークでは、Cross-Encoderは同じ事前学習モデルをベースにしたBi-Encoderに対して、nDCG@10で5〜15ポイントの精度向上を達成することが報告されています。

計算量の比較

精度の代償として、Cross-Encoderは計算量が大幅に増加します。

特性 Bi-Encoder Cross-Encoder
事前計算 文書ベクトルを事前計算可能 不可能(ペアごとに計算)
1ペアの推論 $O(L_q^2 d + L_d^2 d)$ $O((L_q + L_d)^2 d)$
$N$ 文書の検索 ANN: $O(d \log N)$ $O(N (L_q + L_d)^2 d)$
典型的な速度 ~1ms / query ~100ms / document pair

$N = 10^6$ の文書を全てCross-Encoderで評価すると約10万秒かかり、全く実用になりません。そこで、二段階パイプラインが必要になります。

二段階検索パイプライン

Retrieve and Re-rank

二段階検索パイプライン(Retrieve and Re-rank)は、情報検索の標準的なアーキテクチャです。

ステージ1(Retrieve): Bi-Encoder + ANNで高速に上位 $N_1$ 件の候補を取得する。$N_1$ は通常50〜200程度

$$ \mathcal{C} = \text{top-}N_1\left(\text{ANN}(E_q(q), \text{Index})\right) $$

ステージ2(Re-rank): Cross-Encoderで候補 $\mathcal{C}$ を精密に再スコアリングし、上位 $k$ 件を返す

$$ \text{result} = \text{top-}k\left(\{\text{score}_{\text{cross}}(q, d) \mid d \in \mathcal{C}\}\right) $$

全体の計算量は:

$$ O(d \log N) + O(N_1 \cdot (L_q + L_d)^2 \cdot d) $$

$N_1 = 100$ であれば、Cross-Encoderの処理は並列化を含めても数百ミリ秒で完了し、実用的な速度に収まります。

パイプラインの設計上の考慮点

候補数 $N_1$ の選択: $N_1$ が小さすぎると、真に関連する文書がステージ1で漏れる(recall低下)。$N_1$ が大きすぎるとCross-Encoderの計算コストが増加する。一般的には $N_1 = 3k \sim 10k$ が推奨されます。

Recall上限の問題: Re-rankingはステージ1の結果を並べ替えるだけで、ステージ1で取得できなかった文書は回復できません。したがって、ステージ1のrecallが高いことが前提条件です。これが、前回の記事で解説したHybrid Search(BM25 + 密ベクトル)と組み合わせるべき理由です。

二段階パイプラインはBi-EncoderとCross-Encoderの両極端を使い分けますが、この間に位置する「中間的な」アーキテクチャはないのでしょうか。それがColBERTです。

ColBERT: 遅延相互作用

アーキテクチャ

ColBERT(Contextualized Late Interaction over BERT, Khattab & Zaharia, 2020)は、Bi-EncoderとCross-Encoderの中間に位置するアーキテクチャです。

基本的なアイデアは以下の通りです。

  1. クエリと文書を独立にBERTでエンコードする(Bi-Encoderと同じ)
  2. ただし、ベクトルを1つに集約せず、トークンレベルの埋め込みを保持する
  3. 検索時に、トークン間のMaxSim(最大類似度)で関連度を算出する

$$ \text{score}_{\text{ColBERT}}(q, d) = \sum_{i=1}^{L_q} \max_{j=1}^{L_d} \bm{q}_i^T \bm{d}_j $$

ここで $\bm{q}_i \in \mathbb{R}^d$ はクエリの $i$ 番目のトークン埋め込み、$\bm{d}_j \in \mathbb{R}^d$ は文書の $j$ 番目のトークン埋め込みです。

MaxSimの直感的理解

MaxSimを直感的に理解しましょう。クエリの各トークンに対して、文書中で最も意味が近いトークンを見つけ、その類似度を合計します。

例えば、クエリが「Python データベース 接続」の場合: – 「Python」→ 文書中の「Python」や「スクリプト」と最も類似するトークンを見つける – 「データベース」→ 文書中の「DB」や「MySQL」と最も類似するトークンを見つける – 「接続」→ 文書中の「コネクション」や「接続」と最も類似するトークンを見つける

これにより、Bi-Encoderのような情報の圧縮損失を避けつつ、Cross-Encoderほどの計算コストはかかりません。

計算量の比較

ColBERTの特徴は、文書のトークン埋め込みを事前に計算してインデックスに保存できることです。

特性 Bi-Encoder ColBERT Cross-Encoder
文書の事前計算 1ベクトル/文書 $L_d$ ベクトル/文書 不可能
ストレージ $Nd$ $N L_d d$
スコア計算 $O(d)$ $O(L_q L_d d)$ $O((L_q+L_d)^2 d)$
精度 ★★★ ★★★★ ★★★★★

ColBERTのストレージは Bi-Encoder の $L_d$ 倍(例: 128倍)になりますが、$d$ を小さくする(例: 768→128に投影)ことで実用的な範囲に収めています。ColBERTv2では、残差圧縮によりストレージをさらに削減しています。

では次に、これらの手法をPythonで実装して精度を比較しましょう。

Pythonによる実装

Bi-Encoder、Cross-Encoder、ColBERTのシミュレーション

実際のBERTモデルの代わりに、ランダムな埋め込みベクトルを使って各アーキテクチャの特性をシミュレーションします。

import numpy as np
import matplotlib.pyplot as plt

np.random.seed(42)

class BiEncoderSim:
    """Bi-Encoderのシミュレーション。"""

    def __init__(self, dim=128):
        self.dim = dim

    def encode_query(self, query_tokens):
        """クエリをトークン埋め込みの平均で1ベクトルに集約する。"""
        return np.mean(query_tokens, axis=0)

    def encode_doc(self, doc_tokens):
        """文書をトークン埋め込みの平均で1ベクトルに集約する。"""
        return np.mean(doc_tokens, axis=0)

    def score(self, query_tokens, doc_tokens):
        q_vec = self.encode_query(query_tokens)
        d_vec = self.encode_doc(doc_tokens)
        # コサイン類似度
        return np.dot(q_vec, d_vec) / (np.linalg.norm(q_vec) * np.linalg.norm(d_vec) + 1e-10)


class CrossEncoderSim:
    """Cross-Encoderのシミュレーション。
    トークン間の全ペア相互作用を考慮する。"""

    def __init__(self, dim=128):
        self.dim = dim

    def score(self, query_tokens, doc_tokens):
        # 全トークン間の類似度行列
        sim_matrix = query_tokens @ doc_tokens.T
        norms_q = np.linalg.norm(query_tokens, axis=1, keepdims=True)
        norms_d = np.linalg.norm(doc_tokens, axis=1, keepdims=True)
        sim_matrix = sim_matrix / (norms_q @ norms_d.T + 1e-10)

        # 全ペアの平均(相互注意の近似)
        return np.mean(sim_matrix)


class ColBERTSim:
    """ColBERT(MaxSim)のシミュレーション。"""

    def __init__(self, dim=128):
        self.dim = dim

    def score(self, query_tokens, doc_tokens):
        # 類似度行列
        sim_matrix = query_tokens @ doc_tokens.T
        norms_q = np.linalg.norm(query_tokens, axis=1, keepdims=True)
        norms_d = np.linalg.norm(doc_tokens, axis=1, keepdims=True)
        sim_matrix = sim_matrix / (norms_q @ norms_d.T + 1e-10)

        # MaxSim: クエリの各トークンに対する最大類似度の和
        max_sims = np.max(sim_matrix, axis=1)  # (L_q,)
        return np.sum(max_sims)


def generate_synthetic_data(n_docs=200, n_relevant=20, dim=128, q_len=5, d_len=30):
    """関連文書と非関連文書を含む合成データを生成する。"""
    # クエリ
    query_tokens = np.random.randn(q_len, dim) * 0.5

    docs = []
    labels = []

    for i in range(n_docs):
        if i < n_relevant:
            # 関連文書: クエリトークンに類似したトークンを含む
            doc = np.random.randn(d_len, dim) * 0.5
            # ランダムな位置にクエリに類似するトークンを埋め込む
            for j in range(min(q_len, d_len)):
                pos = np.random.randint(0, d_len)
                doc[pos] = query_tokens[j] + np.random.randn(dim) * 0.1
            docs.append(doc)
            labels.append(1)
        else:
            # 非関連文書
            doc = np.random.randn(d_len, dim) * 0.5
            docs.append(doc)
            labels.append(0)

    return query_tokens, docs, np.array(labels)


# データ生成
query, docs, labels = generate_synthetic_data(n_docs=200, n_relevant=20)

# 各手法でスコアリング
bi_enc = BiEncoderSim()
cross_enc = CrossEncoderSim()
colbert = ColBERTSim()

scores_bi = [bi_enc.score(query, doc) for doc in docs]
scores_cross = [cross_enc.score(query, doc) for doc in docs]
scores_colbert = [colbert.score(query, doc) for doc in docs]

# Recall@k の計算
def recall_at_k(scores, labels, k):
    top_k_indices = np.argsort(scores)[::-1][:k]
    relevant_in_top_k = np.sum(labels[top_k_indices])
    total_relevant = np.sum(labels)
    return relevant_in_top_k / total_relevant

k_values = [5, 10, 20, 50, 100]
recall_bi = [recall_at_k(scores_bi, labels, k) for k in k_values]
recall_cross = [recall_at_k(scores_cross, labels, k) for k in k_values]
recall_colbert = [recall_at_k(scores_colbert, labels, k) for k in k_values]

# 可視化
fig, axes = plt.subplots(1, 3, figsize=(18, 5))

# 左: スコア分布
for ax_idx, (scores, name, color) in enumerate([
    (scores_bi, 'Bi-Encoder', '#00d4ff'),
    (scores_cross, 'Cross-Encoder', '#ffa726'),
    (scores_colbert, 'ColBERT (MaxSim)', '#66bb6a'),
]):
    relevant_scores = [s for s, l in zip(scores, labels) if l == 1]
    irrelevant_scores = [s for s, l in zip(scores, labels) if l == 0]

    axes[0].hist(relevant_scores, bins=20, alpha=0.3, color=color, label=f'{name} (relevant)')

axes[0].set_xlabel('Score')
axes[0].set_ylabel('Count')
axes[0].set_title('Score Distribution (Relevant Documents)')
axes[0].legend(fontsize=8)
axes[0].grid(True, alpha=0.3)

# 中央: Recall@k
axes[1].plot(k_values, recall_bi, 'o-', label='Bi-Encoder', color='#00d4ff', markersize=8)
axes[1].plot(k_values, recall_cross, 's-', label='Cross-Encoder', color='#ffa726', markersize=8)
axes[1].plot(k_values, recall_colbert, '^-', label='ColBERT', color='#66bb6a', markersize=8)
axes[1].set_xlabel('k')
axes[1].set_ylabel('Recall@k')
axes[1].set_title('Recall@k Comparison')
axes[1].legend()
axes[1].grid(True, alpha=0.3)

# 右: 二段階パイプラインのシミュレーション
# Stage 1: Bi-Encoderで上位N1件を取得
# Stage 2: Cross-Encoderで再ランキング
n1_values = [10, 20, 30, 50, 100, 200]
rerank_recalls = []

for n1 in n1_values:
    # Stage 1
    top_n1_indices = np.argsort(scores_bi)[::-1][:n1]

    # Stage 2: Cross-Encoderで再スコアリング
    rerank_scores = [(idx, cross_enc.score(query, docs[idx])) for idx in top_n1_indices]
    rerank_scores.sort(key=lambda x: x[1], reverse=True)

    # Top-10のRecall
    top_10_indices = [idx for idx, _ in rerank_scores[:10]]
    recall = sum(labels[idx] for idx in top_10_indices) / min(20, 10)
    rerank_recalls.append(recall)

axes[2].plot(n1_values, rerank_recalls, 'o-', color='#ab47bc', markersize=8,
             label='Bi-Encoder + Cross-Encoder rerank')
axes[2].axhline(y=recall_at_k(scores_bi, labels, 10), color='#00d4ff',
                linestyle='--', alpha=0.7, label='Bi-Encoder only @10')
axes[2].axhline(y=recall_at_k(scores_cross, labels, 10), color='#ffa726',
                linestyle='--', alpha=0.7, label='Cross-Encoder only @10')
axes[2].set_xlabel('Stage 1 Candidates (N1)')
axes[2].set_ylabel('Recall@10 after Reranking')
axes[2].set_title('Two-Stage Pipeline: Effect of N1')
axes[2].legend(fontsize=8)
axes[2].grid(True, alpha=0.3)

plt.tight_layout()
plt.savefig('reranking_comparison.png', dpi=150, bbox_inches='tight')
plt.show()

左のグラフでは、各手法で関連文書がどのようなスコア分布を持つかを示しています。Cross-Encoderが最も関連文書を高スコアに集中させており、分離能力の高さが確認できます。

中央のグラフでは、Recall@kの比較を行っています。Cross-Encoderが全てのkで最高のrecallを達成し、ColBERTがそれに次ぎ、Bi-Encoderが最も低い結果になっています。これはトークンレベルの相互作用を考慮できるかどうかの差を反映しています。

右のグラフが実用的に最も重要です。Bi-Encoderの上位N1件をCross-Encoderでre-rankした結果、N1=50程度でCross-Encoder単体と同等のrecallに到達しています。これは、二段階パイプラインがCross-Encoderの精度とBi-Encoderの速度を両立できることを実証しています。

実用上の選択指針

どの手法を使うべきか

状況 推奨手法 理由
レイテンシ < 50ms Bi-Encoder のみ Cross-Encoder が間に合わない
レイテンシ < 500ms Bi-Encoder + Cross-Encoder 二段階パイプラインの標準構成
精度最優先 Hybrid Search + Cross-Encoder BM25でrecall補完
ストレージ制約あり Bi-Encoder + Cross-Encoder ColBERTはストレージ大
更新頻度高い Bi-Encoder + Cross-Encoder ColBERTはインデックス再構築が重い

代表的なCross-Encoderモデル

モデル ベースモデル 用途
cross-encoder/ms-marco-MiniLM-L-6-v2 MiniLM 高速、英語検索
cross-encoder/ms-marco-MiniLM-L-12-v2 MiniLM 精度重視、英語検索
BAAI/bge-reranker-v2-m3 XLM-RoBERTa 多言語対応
Cohere Rerank 独自 API経由、多言語

RAGパイプラインとの統合

典型的な高精度RAGパイプラインは以下の構成です。

  1. チャンキング: 文書をセマンティックチャンクに分割
  2. インデキシング: Bi-Encoderで埋め込み、ベクトルDBに格納
  3. 検索(Stage 1): BM25 + 密ベクトルのハイブリッド検索で上位50件を取得
  4. Re-ranking(Stage 2): Cross-Encoderで上位50件を再スコアリング
  5. コンテキスト構築: Re-rank後の上位5〜10件をLLMのプロンプトに含める

ステージ3のHybrid Searchにより、キーワード一致と意味的類似度の両方でrecallを最大化し、ステージ4のRe-rankingで精度(precision)を引き上げます。

まとめ

本記事では、Re-rankingの理論と実装を解説しました。

  • Bi-Encoderはクエリと文書を独立にエンコードするため高速だが、トークンレベルの相互作用を捉えられず精度に限界がある
  • Cross-Encoderはクエリと文書を同時にTransformerに入力し、Self-Attentionで相互注意を実現するため高精度だが、$O(N)$ のペア評価が必要で大規模検索には不向き
  • 二段階パイプライン(Bi-Encoder → Cross-Encoder)により、速度と精度を実用的なレベルで両立できる。候補数 $N_1 = 50 \sim 100$ が一般的
  • ColBERTはトークンレベル埋め込みのMaxSimにより、Bi-EncoderとCross-Encoderの中間的な速度・精度特性を持つ
  • RAGパイプラインでは、Hybrid Search + Re-rankingが高精度の標準構成

次のステップとして、以下の記事も参考にしてください。

画像なし
RAGの仕組みとPython実装
Re-rankingが組み込まれるRAGパイプラインの全体像を理解します
画像なし
Hybrid Search — 密ベクトル検索とBM25の融合
Re-rankingの前段として使われるHybrid Searchを理解します
画像なし
ベクトルデータベースの仕組み
一次検索で使われるANNインデックスの理論を理解します