「この質問、過去にも似たものがあったはず」— カスタマーサポートで1日に数千件届く問い合わせの中から、意味的に似た質問を瞬時に見つけ出したいとします。キーワード検索では「犬」と「ペット」の関連性を捉えられませんし、「返品したい」と「商品を送り返したい」が同じ意図であることもわかりません。必要なのは、文全体の「意味」をベクトルとして表現し、ベクトル間の距離で意味的な近さを測定する技術です。

上の図がその発想です。クエリと意味が近い文ほど類似度(ベクトルの近さ)が高くなり、語が一致していなくても関連文を取り出せます。「パスワードをリセットしたい」と「ログイン情報を忘れた」は共通語がほぼ無いのに高スコアになる——これがキーワード検索との決定的な違いです。
BERTは強力な言語理解能力を持ちますが、2つの文の類似度を計算するには両方の文を同時にモデルに入力する必要があり(Cross-Encoder)、10,000件の文から類似ペアを見つけるには約5,000万回の推論が必要になります。これでは実用的ではありません。
この問題を解決したのが Sentence-BERT(SBERT, 2019)と SimCSE(2021)です。Sentence-BERTは各文を独立にエンコードして固定長ベクトルを得るSiameseネットワーク構造を導入し、文の比較をコサイン類似度の計算だけで済むようにしました。SimCSEはさらに一歩進み、ドロップアウトマスクの違いだけで正例ペアを生成する巧みな自己対照学習によって、ラベルなしデータだけで高品質な文埋め込みを獲得できることを示しました。
文埋め込みを理解すると、以下のような幅広い応用が可能になります。
- セマンティック検索: 意味的に関連する文書を高速に検索するベクトル検索エンジンの構築
- RAG(Retrieval-Augmented Generation): 大規模言語モデルに外部知識を与えるための文書検索基盤
- 文書クラスタリング: ニュース記事やレビューを意味的なトピックごとに自動分類
- 重複検出: Q&Aサイトにおける類似質問の検出、論文の盗用検出
- 意味的テキスト類似度(STS): 2文間の意味的な近さを0〜5のスコアで評価するタスク
本記事の内容
- BERTの[CLS]トークンをそのまま文ベクトルとして使う場合の問題点
- Cross-Encoderの精度とBi-Encoderの効率のトレードオフ
- Sentence-BERTのSiameseネットワーク設計とプーリング戦略
- 分類目的関数・回帰目的関数・Triplet損失の数学的導出
- SimCSEの教師なし/教師あり対照学習とInfoNCE損失
- アラインメントとユニフォーミティによる埋め込み空間の品質評価
- PyTorchによるSiameseネットワークとSimCSEの実装
- t-SNEによる文埋め込み空間の可視化
前提知識
この記事を読む前に、以下の記事を読んでおくと理解が深まります。
なぜBERTの[CLS]だけでは不十分なのか
BERTの出力と[CLS]トークンの役割
BERTに文を入力すると、各トークンに対応する文脈付きベクトルが出力されます。入力列の先頭に付加される特殊トークン [CLS] の出力ベクトルは、事前学習のNSP(Next Sentence Prediction)タスクにおいて「2つの文が連続しているか否か」の分類に使われます。このため、[CLS]ベクトルが文全体の意味を集約した「文ベクトル」として機能するのではないか、という期待が生まれました。
しかし、実際に[CLS]ベクトルをそのまま文の意味表現として使うと、性能は驚くほど低くなります。Reimers & Gurevych(2019)の実験では、BERTの[CLS]ベクトルによるコサイン類似度は、STS(Semantic Textual Similarity)ベンチマークにおいてGloVeの平均ベクトルよりも劣る結果が報告されています。
なぜこのような直感に反する結果になるのでしょうか。理由は主に2つあります。
1. NSPタスクとの目的の不一致
[CLS]トークンはNSPという二値分類タスクで学習されます。NSPは「文Aの後に文Bが続くかどうか」を判定するタスクであり、文の意味的類似度を測定するタスクとは本質的に異なります。NSPで学習された[CLS]ベクトルは、「2つの文が連続するか否か」の情報は保持していても、「2つの文の意味がどれだけ近いか」を連続的なスケールで表現する能力は持っていません。
2. アニソトロピー(異方性)問題
BERTの出力ベクトル空間には アニソトロピー(anisotropy)と呼ばれる問題があります。これは、すべてのトークンのベクトルがベクトル空間の狭い錐(cone)状の領域に集中してしまう現象です。つまり、任意の2つの文ベクトルのコサイン類似度が常に高い値(0.6〜0.9程度)を取ってしまい、意味的に似ている文と似ていない文を区別できなくなります。
直感的に言えば、BERTの隠れ状態空間は「全員が同じ方向を向いた群衆」のような状態で、個々の違いが埋もれてしまっているのです。

左のように、[CLS]ベクトルは表現空間の狭い錐に固まりがちで、どの2文を比べてもコサイン類似度が高め(0.6〜0.9)に出てしまい、似た文と似ていない文を区別できません。右の均一分布が理想で、意味の差がそのまま角度・距離の差として現れます。
Cross-Encoderの高精度と計算量の壁
BERTで2つの文の類似度を高精度に測定する方法は存在します。それが Cross-Encoder アプローチです。2つの文を [CLS] 文A [SEP] 文B [SEP] という形式で連結し、BERTに同時に入力します。BERTのSelf-Attentionが文A内、文B内、そして文A-文B間のすべてのトークンペアに注意を向けるため、非常にリッチな相互作用を捉えられます。
Cross-Encoderの出力である[CLS]ベクトルを回帰ヘッドに通すと、STS タスクで高い性能を達成できます。しかし、致命的な問題があります。
$N$ 個の文の中から最も類似するペアを見つけたい場合、すべてのペアを列挙する必要があるため、BERTの推論回数は次のようになります。
$$ \binom{N}{2} = \frac{N(N-1)}{2} $$
$N = 10{,}000$ の場合、約5,000万回の推論が必要です。BERTの推論は1回あたり数十ミリ秒かかるため、単一GPUでは数百時間かかる計算になります。
一方、各文を独立にベクトル化できれば(Bi-Encoder)、$N$ 回のBERT推論でベクトルを事前計算し、その後はコサイン類似度の計算(内積)だけでペアの類似度を求められます。内積の計算は行列積で一括処理でき、$N = 10{,}000$ でも1秒未満で完了します。
| アプローチ | 推論回数 | $N=10{,}000$ での計算量 | 事前計算可能 |
|---|---|---|---|
| Cross-Encoder | $O(N^2)$ | 約5,000万回 | 不可 |
| Bi-Encoder | $O(N)$ | 10,000回 | 可能 |

左の図の通り、Cross-Encoderは2文を連結して一緒にBERTへ入れるため精度は高い反面、ペアごとに推論が必要で総当たりは $O(N^2)$、右のグラフのように $N$ が1万でも約5000万回に達します。Bi-Encoderは各文を独立にベクトル化するので $O(N)$ で事前計算でき、検索は内積だけで済みます。
Cross-Encoderは精度が高い一方で、実用的な規模のデータに対してはスケールしません。この「精度と効率のトレードオフ」を解決するのが、次に見るSentence-BERTの設計思想です。
ここまでで、BERTの[CLS]ベクトルが文の意味表現として不十分である理由と、Cross-Encoderの計算量問題を理解しました。これらの課題を同時に解決するのが、Siameseネットワーク構造を用いたSentence-BERTです。
Sentence-BERTのアーキテクチャ
Siameseネットワークの着想
Sentence-BERT(SBERT)の核心的なアイデアは、同じ重みを共有する2つのBERTエンコーダに文Aと文Bをそれぞれ独立に入力し、得られた固定長ベクトル同士を比較するという構造です。このような「同一構造・同一重みの2つのネットワークが並列に動く」アーキテクチャを Siameseネットワーク(シャムネットワーク)と呼びます。
名前の由来はシャム双生児(一卵性双生児)で、2つのネットワークが「双子」のように全く同じパラメータを共有することを表しています。重みを共有することで、文Aと文Bが同じ空間に写像されることが保証され、2つのベクトル間の距離が意味を持つようになります。
Siameseネットワークの全体的な処理の流れは次のとおりです。
- 文Aを共有BERTエンコーダに入力し、トークンレベルの隠れ状態を得る
- プーリング操作で固定長の文ベクトル $\bm{u}$ を得る
- 文Bを同じ BERTエンコーダに入力し、同様にプーリングして文ベクトル $\bm{v}$ を得る
- $\bm{u}$ と $\bm{v}$ を用いて、タスクに応じた目的関数で学習する
推論時には、各文を独立にエンコードして文ベクトルを事前計算できます。新しいクエリが来たときは、そのクエリだけをエンコードし、事前計算済みの全文ベクトルとコサイン類似度を計算すればよいのです。

図のように、重みを共有した2つのBERTに文A・文Bを別々に通し、平均プーリングで文ベクトル $\bm{u},\bm{v}$ を得ます。重みを共有するからこそ両者が同じ空間に写り、$\bm{u},\bm{v}$ 間の距離が意味の近さとして解釈できます。
プーリング戦略 — トークンベクトルから文ベクトルへ
BERTの出力は、入力トークン数 $T$ に対して $T$ 個のベクトル $\bm{h}_1, \bm{h}_2, \dots, \bm{h}_T \in \mathbb{R}^d$ です。これを1つの固定長文ベクトルに集約する操作が プーリング です。Sentence-BERTでは3つの戦略が検討されました。
CLS戦略: [CLS]トークンの出力ベクトルをそのまま文ベクトルとして使います。
$$ \bm{u}_{\text{CLS}} = \bm{h}_{\text{[CLS]}} $$
Mean戦略: 全トークンの出力ベクトルを平均します(パディングトークンはマスクで除外)。
$$ \bm{u}_{\text{mean}} = \frac{1}{T} \sum_{t=1}^{T} \bm{h}_t $$
Max戦略: 各次元について全トークンの最大値を取ります。
$$ (\bm{u}_{\text{max}})_j = \max_{t=1,\dots,T} (\bm{h}_t)_j, \quad j = 1, \dots, d $$
直感的に考えると、Mean戦略が最も安定した性能を発揮しそうです。[CLS]トークンは特定のタスク(NSP)向けに学習されているため一般性に欠け、Max戦略は外れ値に敏感です。一方、平均プーリングはすべてのトークンの情報をまんべんなく取り込むため、文全体の意味をバランスよく捉えることが期待できます。
実際に、Sentence-BERTの論文でもMean戦略が最も高い性能を示しました。以降の議論では、プーリングとしてMean戦略を採用します。

図の3戦略のうち、[CLS]はNSP向けに偏り、Maxは外れ値に敏感です。全トークンをならす平均(Mean)が最もバランスよく、文全体の意味を安定して捉えられます。
ここまでで、Sentence-BERTがどのように各文を固定長ベクトルに変換するかを見ました。次に重要なのは、この文ベクトルの品質を高めるための学習です。Sentence-BERTでは、タスクに応じて3種類の目的関数が使い分けられます。
Sentence-BERTの目的関数
Sentence-BERTの学習データは、文のペア(またはトリプレット)とそのラベルで構成されます。ラベルの種類に応じて、適切な目的関数を選択します。
分類目的関数 — NLIデータセットを活用する
自然言語推論(NLI: Natural Language Inference)のデータセットには、前提文(premise)と仮説文(hypothesis)のペアに「含意(entailment)」「矛盾(contradiction)」「中立(neutral)」の3クラスラベルが付与されています。
まず、前提文と仮説文からそれぞれ文ベクトル $\bm{u}$ と $\bm{v}$ を得ます。次に、この2つのベクトルと要素ごとの差の絶対値 $|\bm{u} – \bm{v}|$ を連結した特徴量を作り、ソフトマックス分類器に通します。
なぜ差の絶対値 $|\bm{u} – \bm{v}|$ を加えるのでしょうか。$\bm{u}$ と $\bm{v}$ だけでは「2つのベクトルがどの程度異なるか」という情報が暗黙的にしか含まれません。差の絶対値を明示的に特徴量として与えることで、分類器がどの次元で2文が異なっているかを直接参照できるようになり、矛盾や含意の判定精度が向上します。
連結特徴量を次のように定義します。
$$ \bm{z} = [\bm{u};\, \bm{v};\, |\bm{u} – \bm{v}|] \in \mathbb{R}^{3d} $$
ここで $[\,;\,]$ はベクトルの連結を表します。$\bm{u}, \bm{v} \in \mathbb{R}^d$ なので、$\bm{z}$ の次元は $3d$ になります。
この $\bm{z}$ を学習可能な重み行列 $\bm{W} \in \mathbb{R}^{k \times 3d}$($k$ はクラス数、NLIでは $k=3$)で線形変換し、ソフトマックスを適用します。
$$ p(y = c \mid \bm{u}, \bm{v}) = \frac{\exp(\bm{W}_c \bm{z})}{\sum_{j=1}^{k} \exp(\bm{W}_j \bm{z})} $$
学習はクロスエントロピー損失を最小化します。
$$ \mathcal{L}_{\text{cls}} = -\sum_{i=1}^{N} \log p(y = y_i \mid \bm{u}_i, \bm{v}_i) $$
この目的関数の重要なポイントは、分類という間接的なタスクを通じてBERTの重みがファインチューニングされ、結果として「含意する文同士は近く、矛盾する文同士は遠い」ような文ベクトル空間が構築されることです。
回帰目的関数 — STS直接最適化
STS(Semantic Textual Similarity)データセットでは、文ペアに対して0〜5の連続的な類似度スコアが付与されています。このスコアを直接予測するように学習するのが回帰目的関数です。
2つの文ベクトル $\bm{u}$ と $\bm{v}$ のコサイン類似度を計算します。
$$ \text{sim}(\bm{u}, \bm{v}) = \frac{\bm{u} \cdot \bm{v}}{\|\bm{u}\| \|\bm{v}\|} $$
コサイン類似度は $[-1, 1]$ の範囲を取ります。これと正規化された正解スコア $s \in [-1, 1]$ との平均二乗誤差(MSE)を最小化します。
$$ \mathcal{L}_{\text{reg}} = \frac{1}{N} \sum_{i=1}^{N} \left( \text{sim}(\bm{u}_i, \bm{v}_i) – s_i \right)^2 $$
この目的関数は、ベクトル空間上のコサイン類似度が人間の判定する類似度スコアに直接対応するよう、文ベクトルを調整します。学習後は、新しい文ペアのコサイン類似度がそのまま類似度のスコアとして使えるため、非常に直感的です。
Triplet目的関数 — アンカー・正例・負例
Triplet損失は、3つの文の組(アンカー $a$、正例 $p$、負例 $n$)を用いて、アンカーと正例の距離が、アンカーと負例の距離よりもマージン $\epsilon$ 以上小さくなるよう学習します。
イメージとしては「アンカー文を中心に、意味的に近い正例は引き寄せ、意味的に遠い負例は押し離す」という操作です。
$$ \mathcal{L}_{\text{triplet}} = \frac{1}{N} \sum_{i=1}^{N} \max\left(0,\; \|\bm{u}_a^{(i)} – \bm{u}_p^{(i)}\| – \|\bm{u}_a^{(i)} – \bm{u}_n^{(i)}\| + \epsilon \right) $$
ここで $\|\cdot\|$ はユークリッド距離、$\epsilon > 0$ はマージンパラメータです。
この損失関数の構造を分解して理解しましょう。$\|\bm{u}_a – \bm{u}_p\|$ はアンカーと正例の距離、$\|\bm{u}_a – \bm{u}_n\|$ はアンカーと負例の距離です。
損失が0になる条件を考えると、次のようになります。
$$ \|\bm{u}_a – \bm{u}_p\| – \|\bm{u}_a – \bm{u}_n\| + \epsilon \leq 0 $$
両辺を整理すると、
$$ \|\bm{u}_a – \bm{u}_n\| \geq \|\bm{u}_a – \bm{u}_p\| + \epsilon $$
つまり、負例はアンカーから「正例までの距離 + マージン $\epsilon$」以上離れている必要があります。マージン $\epsilon$ は「正例と負例の間にどれだけの余裕(ギャップ)を持たせるか」を制御するハイパーパラメータで、Sentence-BERTの論文では $\epsilon = 1$ が使われています。
$\max(0, \cdot)$ の役割は、すでに十分な距離が確保されているトリプレットからは勾配を伝播させないことです。これにより、学習が「まだうまく分離できていないケース」に集中し、効率的に進みます。

3つの目的関数を図にまとめました。分類(左)は $[\bm{u};\bm{v};|\bm{u}-\bm{v}|]$ からNLIの3クラスを当て、回帰(中央)はコサイン類似度を正解スコアにMSEで合わせ、Triplet(右)は正例を近く・負例をマージン以上遠くに配置します。いずれも共有BERTを微調整して「意味の近さ=距離」を作り込みます。
ここまでで、Sentence-BERTの3つの目的関数を見てきました。いずれも共有BERTエンコーダの重みをファインチューニングし、プーリングで得た文ベクトルが意味的類似度を反映するように学習します。しかし、これらの方法にはいずれも ラベル付きデータが必要 という制約があります。NLIデータセットやSTSデータセットの作成にはコストがかかります。次に紹介するSimCSEは、この制約を大胆に緩和するアプローチです。
SimCSEの登場 — ドロップアウトだけで文埋め込みを学習する
教師なし対照学習の発想
SimCSE(Simple Contrastive Learning of Sentence Embeddings, Gao et al., 2021)は、その名の通り「シンプルな対照学習」で文埋め込みを学習するフレームワークです。最も驚くべきは、教師なしSimCSE がラベルなしの文コーパスだけで、Sentence-BERTに匹敵する(場合によっては上回る)性能を達成したことです。
対照学習の基本的な枠組みは「似ているもの同士を近づけ、異なるもの同士を遠ざける」でした。画像の対照学習(SimCLRなど)では、同じ画像にランダムなデータ拡張(回転、切り抜き、色変換など)を適用して正例ペアを作ります。
では、テキストではどうすれば正例ペアを作れるでしょうか。同義語への置換、文の一部の削除、語順の入れ替えなど、さまざまなデータ拡張が考えられます。しかし、テキストは画像と異なり、わずかな変更でも意味が大きく変わる可能性があります。「彼は 無罪 だ」の「無」を削除すれば「彼は罪だ」となり、意味が正反対になってしまいます。
SimCSEが採用したのは、驚くほどシンプルなアイデアです。同じ文をBERTに 2回 入力するだけです。
ドロップアウトによる正例ペア生成
BERTのTransformerブロックには、Self-AttentionやFFN(Feed-Forward Network)の出力にドロップアウトが適用されています。ドロップアウトはランダムにニューロンの一部を無効化する正則化手法です。
重要なのは、ドロップアウトマスクは推論のたびにランダムに生成されるということです。つまり、同じ文を2回入力しても、異なるドロップアウトマスクが適用されるため、わずかに異なる出力ベクトルが得られます。
同じ文 $x_i$ を2回エンコードして得た2つの文ベクトルを $\bm{h}_i$ と $\bm{h}_i’$ とします。
$$ \bm{h}_i = f_{\theta}(x_i, z), \quad \bm{h}_i’ = f_{\theta}(x_i, z’) $$
ここで $f_{\theta}$ はBERTエンコーダ(パラメータ $\theta$)、$z$ と $z’$ は異なるドロップアウトマスクです。$\bm{h}_i$ と $\bm{h}_i’$ は同じ文から生成されたので 正例ペア として扱い、同一バッチ内の他の文から生成されたベクトルを 負例 とします。
この方法が優れている理由は3つあります。
- 意味が変わらない: 同じ文をそのまま入力するので、意味の変化は一切ありません
- 最小限のノイズ: ドロップアウトによる差異はごくわずかで、同じ文の2つの表現は非常に近い位置に写像されます
- 特別なデータ拡張が不要: テキスト固有の拡張手法を設計する必要がありません
Gaoらの論文では、ドロップアウトによる対照学習が、語彙置換やトークン削除などの他のデータ拡張手法をすべて上回ることが実験で示されています。

図の通り、SimCSEの正例ペアは「同じ文をBERTに2回通すだけ」で作ります。Transformer内部のドロップアウトが毎回違うマスクを引くため、ほぼ同じだが微妙に異なる2つのベクトルが正例になります。意味を壊さずに最小限のノイズを与えられるのが、テキスト拡張より優れた点です。
教師ありSimCSE — NLIデータを活用する
教師ありSimCSEでは、NLI(Natural Language Inference)データセットのラベルを利用して、より質の高い正例・負例ペアを構成します。
NLIデータセットでは、前提文に対して「含意(entailment)」「矛盾(contradiction)」「中立(neutral)」の関係にある仮説文がラベル付けされています。教師ありSimCSEは次のようにペアを構成します。
- 正例: 含意関係にあるペア(「犬が走っている」→「動物が動いている」)
- ハード負例: 矛盾関係にあるペア(「犬が走っている」→「犬は静かに眠っている」)
矛盾関係のペアが特に有効な理由は、語彙的には重複が多い(同じ単語が含まれる)にもかかわらず意味が異なるため、モデルが表面的な単語の一致ではなく深い意味理解を学習せざるを得ないからです。このような「難しい負例」をハード負例(hard negative)と呼びます。
次のセクションでは、教師なしSimCSEと教師ありSimCSEの目的関数を数学的に定式化します。
SimCSEの数学的定式化
NT-Xent損失(InfoNCE)
SimCSEの目的関数は NT-Xent損失(Normalized Temperature-scaled Cross Entropy Loss)、別名 InfoNCE損失 に基づいています。バッチサイズ $N$ のミニバッチにおいて、$i$ 番目の文の損失は次のように定義されます。
教師なしSimCSEでは、同じ文を2回エンコードした $(\bm{h}_i, \bm{h}_i’)$ が正例ペア、同一バッチ内の他の文ベクトルが負例です。$i$ 番目のサンプルに対する損失は次の式で表されます。
$$ \ell_i = -\log \frac{\exp(\text{sim}(\bm{h}_i, \bm{h}_i’) / \tau)}{\sum_{j=1}^{N} \exp(\text{sim}(\bm{h}_i, \bm{h}_j’) / \tau)} $$
ここで $\text{sim}(\bm{a}, \bm{b}) = \frac{\bm{a} \cdot \bm{b}}{\|\bm{a}\| \|\bm{b}\|}$ はコサイン類似度、$\tau > 0$ は温度パラメータです。
この式の構造を丁寧に読み解きましょう。分子は正例ペア $(\bm{h}_i, \bm{h}_i’)$ のコサイン類似度を温度 $\tau$ で割ってから指数関数に入れたものです。分母は、$i$ 番目の文ベクトル $\bm{h}_i$ と全候補ベクトル $\bm{h}_1′, \bm{h}_2′, \dots, \bm{h}_N’$ との類似度の指数関数の総和です。
負の対数を取ることで、$\ell_i$ を最小化することは「正例のスコアを分母全体に対して最大化する」ことと等価になります。つまり、$\bm{h}_i$ と $\bm{h}_i’$ のコサイン類似度を高めつつ、$\bm{h}_i$ と他の文ベクトルとのコサイン類似度を低くするよう学習が進みます。
バッチ全体の損失は、各サンプルの損失の平均です。
$$ \mathcal{L}_{\text{unsup}} = \frac{1}{N} \sum_{i=1}^{N} \ell_i $$
温度パラメータ $\tau$ の役割
温度パラメータ $\tau$ は、対照学習において非常に重要な役割を果たします。$\tau$ の値が学習にどのような影響を与えるか、直感的に理解しましょう。
$\tau$ が大きい場合(例: $\tau = 1.0$)、コサイン類似度を $\tau$ で割った値は小さくなり、指数関数の出力は全体的に均されます。すべての候補が似たようなスコアを持ち、正例と負例の区別が曖昧になります。結果として、学習のシグナルが弱まり、モデルの学習が遅くなります。
$\tau$ が小さい場合(例: $\tau = 0.01$)、コサイン類似度の微小な差が指数関数によって極端に増幅されます。最も類似度が高い候補にほぼすべての確率質量が集中し、それ以外の候補からの学習シグナルが消失します。また、学習が不安定になりやすくなります。
SimCSEの論文では $\tau = 0.05$ が最適とされており、これは対照学習全般で広く使われる値です。数学的には、$\tau$ はソフトマックスの「シャープさ」を制御しています。
$$ \tau \to 0: \quad \text{ソフトマックス} \to \text{ハードマックス(argmax)} $$
$$ \tau \to \infty: \quad \text{ソフトマックス} \to \text{一様分布} $$
教師ありSimCSEの損失関数
教師ありSimCSEでは、NLIデータセットの含意ペアを正例、矛盾ペアをハード負例として利用します。$i$ 番目のサンプルに対して、前提文 $x_i$、含意仮説文 $x_i^+$、矛盾仮説文 $x_i^-$ の3つ組を入力とします。
$$ \ell_i^{\text{sup}} = -\log \frac{\exp(\text{sim}(\bm{h}_i, \bm{h}_i^+) / \tau)}{\sum_{j=1}^{N} \left[ \exp(\text{sim}(\bm{h}_i, \bm{h}_j^+) / \tau) + \exp(\text{sim}(\bm{h}_i, \bm{h}_j^-) / \tau) \right]} $$
教師なし版との違いを確認しましょう。分子は含意ペアの類似度であり、分母には含意ペアの類似度 と 矛盾ペアの類似度の両方が含まれています。矛盾ペアは語彙的に類似しているため「騙されやすい」負例であり、これを明示的に分母に加えることで、モデルは表面的な類似に惑わされない深い意味理解を強いられます。
アラインメントとユニフォーミティ — 埋め込み空間の品質指標
Wang & Isola(2020)は、良い表現学習が満たすべき2つの性質を定式化しました。SimCSEの論文ではこの枠組みを用いて文埋め込みの品質を評価しています。
アラインメント(alignment)は、正例ペアのベクトルがどれだけ近いかを測ります。
$$ \ell_{\text{align}} = \mathbb{E}_{(x, x^+) \sim p_{\text{pos}}} \left[ \|\bm{h}_x – \bm{h}_{x^+}\|^2 \right] $$
正例ペアの距離の期待値が小さいほど、意味的に似た文が近い位置に埋め込まれていることを意味します。アラインメントが小さいということは、「似ているものは近い」という基本的な要求が満たされているということです。
ユニフォーミティ(uniformity)は、文ベクトル全体が超球面上にどれだけ均一に分布しているかを測ります。
$$ \ell_{\text{uniform}} = \log \mathbb{E}_{(x, y) \stackrel{\text{i.i.d.}}{\sim} p_{\text{data}}} \left[ e^{-2\|\bm{h}_x – \bm{h}_y\|^2} \right] $$
この値が小さいほど、ベクトルが超球面上に均一に分布していることを意味します。ユニフォーミティが低いということは、埋め込み空間の情報容量が最大限に活用されているということです。
理想的な文埋め込みは、アラインメントが低く(正例が近い)、かつユニフォーミティも低い(全体が均一に分布) 状態です。先述したBERTの[CLS]ベクトルのアニソトロピー問題は、ユニフォーミティが極端に悪い状態に対応します。SimCSEの対照学習は、アラインメントとユニフォーミティの両方を同時に改善する効果があることが実験的に示されています。

良い埋め込み(左)は正例ペアが近く(赤線が短い=アラインメント低)、全体が球面上に広く散らばります(ユニフォーミティ低)。悪い埋め込み(右)はアニソトロピーで一か所に固まり、空間を活かせていません。対照学習はこの両方を同時に改善します。
これらの指標はあくまで分析ツールであり、直接最適化するわけではありません。しかし、異なる手法の埋め込み品質を定量的に比較する上で非常に有用です。
ここまでで理論的な枠組みが揃いました。次のセクションでは、これらの概念をPyTorchで実装し、実際に文埋め込みを学習・可視化してみましょう。
PyTorchで実装するSentence-BERTのSiameseネットワーク
モデル定義
まず、Sentence-BERTのSiameseネットワーク構造をPyTorchで実装します。BERTエンコーダに平均プーリングを適用して文ベクトルを得る基本的な構造です。
import torch
import torch.nn as nn
import torch.nn.functional as F
from transformers import BertModel, BertTokenizer
class SentenceBERT(nn.Module):
"""Sentence-BERT: Siameseネットワークによる文埋め込みモデル"""
def __init__(self, model_name="bert-base-uncased"):
super().__init__()
self.bert = BertModel.from_pretrained(model_name)
self.hidden_size = self.bert.config.hidden_size
def mean_pooling(self, token_embeddings, attention_mask):
"""パディングを除外した平均プーリング"""
# attention_maskを拡張して埋め込み次元に合わせる
mask_expanded = attention_mask.unsqueeze(-1).expand(
token_embeddings.size()
).float()
# マスクされたトークンを0にして合計
sum_embeddings = torch.sum(token_embeddings * mask_expanded, dim=1)
# マスクの合計(有効トークン数)で割る
sum_mask = torch.clamp(mask_expanded.sum(dim=1), min=1e-9)
return sum_embeddings / sum_mask
def encode(self, input_ids, attention_mask):
"""文をエンコードして固定長ベクトルを返す"""
outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
token_embeddings = outputs.last_hidden_state
sentence_embedding = self.mean_pooling(token_embeddings, attention_mask)
# L2正規化(コサイン類似度計算の効率化)
sentence_embedding = F.normalize(sentence_embedding, p=2, dim=1)
return sentence_embedding
def forward(self, input_ids_a, attention_mask_a, input_ids_b, attention_mask_b):
"""2つの文をエンコードして文ベクトルのペアを返す"""
u = self.encode(input_ids_a, attention_mask_a)
v = self.encode(input_ids_b, attention_mask_b)
return u, v
このコードのポイントを補足します。mean_pooling メソッドでは attention_mask を使ってパディングトークンを除外しています。BERTへの入力は固定長にパディングされるため、パディング部分のベクトルを平均に含めてしまうと、短い文ほどパディングの影響を強く受けてしまいます。torch.clamp(sum_mask, min=1e-9) はゼロ除算を防ぐための安全措置です。また、encode の最後に L2正規化を行うことで、コサイン類似度の計算が単純な内積で済むようになります。
分類目的関数による学習
NLIデータセットを用いた分類目的関数の学習ループを実装します。
import torch.optim as optim
class SBERTClassificationHead(nn.Module):
"""NLI分類のための分類ヘッド"""
def __init__(self, hidden_size, num_classes=3):
super().__init__()
# [u; v; |u-v|] を入力とするため、次元は3倍
self.classifier = nn.Linear(hidden_size * 3, num_classes)
def forward(self, u, v):
# 要素ごとの差の絶対値を計算
diff = torch.abs(u - v)
# 連結特徴量 [u; v; |u-v|]
combined = torch.cat([u, v, diff], dim=1)
logits = self.classifier(combined)
return logits
def train_sbert_classification(model, head, dataloader, epochs=3, lr=2e-5):
"""Sentence-BERTを分類目的関数で学習"""
optimizer = optim.AdamW(
list(model.parameters()) + list(head.parameters()), lr=lr
)
criterion = nn.CrossEntropyLoss()
model.train()
head.train()
for epoch in range(epochs):
total_loss = 0
for batch in dataloader:
optimizer.zero_grad()
# 文ペアをエンコード
u, v = model(
batch["input_ids_a"], batch["attention_mask_a"],
batch["input_ids_b"], batch["attention_mask_b"]
)
# 分類
logits = head(u, v)
loss = criterion(logits, batch["labels"])
loss.backward()
optimizer.step()
total_loss += loss.item()
avg_loss = total_loss / len(dataloader)
print(f"Epoch {epoch+1}/{epochs}, Loss: {avg_loss:.4f}")
学習が進むにつれて損失が減少すれば、BERTエンコーダがNLIタスクに適した文ベクトルを生成するようにファインチューニングされていることを意味します。分類目的関数の場合、含意関係にある文ペアは近い位置に、矛盾関係にある文ペアは遠い位置に配置される文ベクトル空間が形成されます。
次に、ラベルなしデータだけで文埋め込みを学習するSimCSEの実装に進みましょう。
PyTorchで実装するSimCSEの対照学習
教師なしSimCSEのモデル
SimCSEのモデル構造自体はSentence-BERTと同じですが、学習方法が大きく異なります。同じ文を2回エンコードすることで、ドロップアウトマスクの違いから正例ペアを自動生成します。
class SimCSE(nn.Module):
"""SimCSE: ドロップアウト対照学習による文埋め込みモデル"""
def __init__(self, model_name="bert-base-uncased", temperature=0.05):
super().__init__()
self.bert = BertModel.from_pretrained(model_name)
self.temperature = temperature
def mean_pooling(self, token_embeddings, attention_mask):
"""パディングを除外した平均プーリング"""
mask_expanded = attention_mask.unsqueeze(-1).expand(
token_embeddings.size()
).float()
sum_embeddings = torch.sum(token_embeddings * mask_expanded, dim=1)
sum_mask = torch.clamp(mask_expanded.sum(dim=1), min=1e-9)
return sum_embeddings / sum_mask
def encode(self, input_ids, attention_mask):
"""文をエンコード(推論時はdropoffでドロップアウト無効)"""
outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)
sentence_embedding = self.mean_pooling(
outputs.last_hidden_state, attention_mask
)
return sentence_embedding
def forward(self, input_ids, attention_mask):
"""同じ入力を2回エンコード(異なるドロップアウトマスク)"""
# 1回目のエンコード
h1 = self.encode(input_ids, attention_mask)
# 2回目のエンコード(ドロップアウトマスクが異なる)
h2 = self.encode(input_ids, attention_mask)
return h1, h2
ここで注目すべきは、forward メソッドで同じ input_ids と attention_mask を2回 self.encode に渡しているだけだという点です。特別なデータ拡張は一切行っていません。BERTの内部にあるドロップアウト層が、呼び出しごとに異なるランダムマスクを生成するため、同じ入力に対して微妙に異なる出力が得られます。
InfoNCE損失の実装
教師なしSimCSEのInfoNCE損失を実装します。
def simcse_unsup_loss(h1, h2, temperature=0.05):
"""
教師なしSimCSEのInfoNCE損失
h1, h2: (batch_size, hidden_size) - 同じ文の2つの表現
"""
batch_size = h1.size(0)
# コサイン類似度行列を計算
# h1_norm, h2_normは (batch_size, hidden_size)
h1_norm = F.normalize(h1, p=2, dim=1)
h2_norm = F.normalize(h2, p=2, dim=1)
# 類似度行列: (batch_size, batch_size)
# sim[i][j] = cos_sim(h1[i], h2[j])
sim_matrix = torch.mm(h1_norm, h2_norm.t()) / temperature
# 正例は対角要素(i番目の文の2つの表現)
labels = torch.arange(batch_size, device=h1.device)
# クロスエントロピー損失(ソフトマックス + 負の対数尤度)
loss = F.cross_entropy(sim_matrix, labels)
return loss
この実装を数式と対応させて理解しましょう。sim_matrix[i][j] は $\text{sim}(\bm{h}_i, \bm{h}_j’) / \tau$ に対応します。labels は [0, 1, 2, ..., N-1] で、$i$ 番目のサンプルの正解ラベルが $i$(対角要素)であることを示します。F.cross_entropy は内部でソフトマックスと負の対数尤度を計算するため、まさに InfoNCE 損失の定義式そのものです。
学習ループ
def train_simcse(model, sentences, tokenizer, epochs=1,
batch_size=64, lr=3e-5, max_length=32):
"""教師なしSimCSEの学習"""
optimizer = optim.AdamW(model.parameters(), lr=lr)
model.train()
for epoch in range(epochs):
total_loss = 0
n_batches = 0
# ミニバッチに分割
for i in range(0, len(sentences), batch_size):
batch_sents = sentences[i:i + batch_size]
# トークナイズ
encoded = tokenizer(
batch_sents, padding=True, truncation=True,
max_length=max_length, return_tensors="pt"
)
input_ids = encoded["input_ids"].to(model.bert.device)
attention_mask = encoded["attention_mask"].to(model.bert.device)
optimizer.zero_grad()
# 同じ入力を2回エンコード
h1, h2 = model(input_ids, attention_mask)
loss = simcse_unsup_loss(h1, h2, model.temperature)
loss.backward()
optimizer.step()
total_loss += loss.item()
n_batches += 1
avg_loss = total_loss / n_batches
print(f"Epoch {epoch+1}/{epochs}, Loss: {avg_loss:.4f}")
学習が進むと、InfoNCE損失が減少していきます。これは「同じ文の2つの表現(正例ペア)のコサイン類似度が高まり、異なる文の表現との類似度が下がっている」ことを意味します。結果として、意味的に近い文はベクトル空間上でも近くに配置され、意味的に異なる文は遠くに配置される文埋め込み空間が形成されます。
ここまでの実装で、文埋め込みモデルの学習方法を理解しました。次に、学習された文埋め込みの品質を可視化によって確認しましょう。
文埋め込みの可視化 — t-SNEによるクラスタリングの確認
学習された文埋め込みが本当に意味的な類似性を捉えているかを、t-SNEで2次元に可視化して確認します。ここでは、事前学習済みのsentence-transformersモデルを使い、異なるトピックの文がベクトル空間上でどのように分布するかを観察します。
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.font_manager as _fm
for _c in ['Hiragino Sans','Yu Gothic','Noto Sans CJK JP','IPAexGothic','Meiryo']:
if any(_c==_f.name for _f in _fm.fontManager.ttflist):
plt.rcParams['font.family']=_c; break
plt.rcParams['axes.unicode_minus']=False
from sklearn.manifold import TSNE
from sentence_transformers import SentenceTransformer
# 事前学習済みモデルのロード
model = SentenceTransformer("all-MiniLM-L6-v2")
# 3つのトピックから文を用意
sentences_science = [
"The speed of light in vacuum is approximately 3e8 m/s.",
"Quantum mechanics describes the behavior of subatomic particles.",
"Einstein's theory of relativity revolutionized physics.",
"The periodic table organizes chemical elements by atomic number.",
"DNA carries the genetic instructions for all living organisms.",
"Gravity is the force that attracts objects toward each other.",
"Photosynthesis converts sunlight into chemical energy.",
"The universe is expanding at an accelerating rate.",
]
sentences_sports = [
"The soccer World Cup is held every four years.",
"Basketball requires excellent hand-eye coordination.",
"Tennis players compete on grass, clay, and hard courts.",
"The Olympic Games bring together athletes from around the world.",
"Marathon runners train for months to build endurance.",
"Baseball has been a popular sport in Japan for over a century.",
"Swimming is both a competitive sport and recreational activity.",
"Football stadiums can hold tens of thousands of spectators.",
]
sentences_cooking = [
"Sushi is a traditional Japanese dish made with vinegared rice.",
"Baking bread requires flour, water, yeast, and salt.",
"Italian pasta comes in hundreds of different shapes.",
"A wok is essential for high-heat stir-frying techniques.",
"Chocolate is made from roasted and ground cacao beans.",
"Fermentation is used to make yogurt, cheese, and kimchi.",
"Spices like cumin and turmeric add depth to curries.",
"French cuisine emphasizes technique and fresh ingredients.",
]
all_sentences = sentences_science + sentences_sports + sentences_cooking
labels = (["科学"] * 8 + ["スポーツ"] * 8 + ["料理"] * 8)
colors = (["#00bcd4"] * 8 + ["#ff9800"] * 8 + ["#4caf50"] * 8)
# 文埋め込みを計算
embeddings = model.encode(all_sentences)
# t-SNEで2次元に次元削減
tsne = TSNE(n_components=2, random_state=42, perplexity=8)
embeddings_2d = tsne.fit_transform(embeddings)
# 可視化
plt.figure(figsize=(10, 8))
for label, color in [("科学", "#00bcd4"), ("スポーツ", "#ff9800"),
("料理", "#4caf50")]:
mask = np.array(labels) == label
plt.scatter(
embeddings_2d[mask, 0], embeddings_2d[mask, 1],
c=color, label=label, s=100, alpha=0.8, edgecolors="white"
)
plt.title("文埋め込みのt-SNE可視化", fontsize=14)
plt.xlabel("t-SNE 次元1")
plt.ylabel("t-SNE 次元2")
plt.legend(fontsize=12)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

このグラフでは、3つのトピック(科学・スポーツ・料理)の文が明確に分離したクラスタを形成しています(上図は同じ構造を合成データで再現したものです。t-SNEの配置は実行ごとに回転・反転しますが、クラスタが分かれること自体は安定します)。各トピック内の文どうしが近くに集まり、トピック間は大きく離れていることから、意味的な類似性がベクトル空間上の距離として適切に表現されているとわかります。
特に注目すべきは、「The periodic table organizes chemical elements」と「DNA carries the genetic instructions」のように、キーワードが全く異なる文でも同じ Science クラスタに属している点です。これは文埋め込みモデルが単語の表面的な一致ではなく、文レベルの意味的なトピックを捉えていることを示しています。
コサイン類似度によるペアワイズ比較
t-SNEの可視化に加えて、具体的な文ペア間のコサイン類似度を数値で確認しましょう。
import numpy as np
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("all-MiniLM-L6-v2")
# 比較する文のペア
pairs = [
("I love playing soccer with my friends.",
"Football is my favorite team sport."),
("I love playing soccer with my friends.",
"The recipe calls for two cups of flour."),
("How do I reset my password?",
"I forgot my login credentials."),
("How do I reset my password?",
"The weather is nice today."),
]
print("=== Cosine Similarity Between Sentence Pairs ===\n")
for s1, s2 in pairs:
emb1 = model.encode([s1])
emb2 = model.encode([s2])
# コサイン類似度 = 正規化ベクトルの内積
cos_sim = np.dot(emb1[0], emb2[0]) / (
np.linalg.norm(emb1[0]) * np.linalg.norm(emb2[0])
)
print(f"Sentence 1: {s1}")
print(f"Sentence 2: {s2}")
print(f"Cosine Similarity: {cos_sim:.4f}\n")
出力結果の典型的な傾向として、意味的に近い文ペア(「サッカーが好き」と「フットボールが好きなチームスポーツ」)では0.6〜0.8程度の高い類似度が得られ、意味的に無関係な文ペア(「サッカーが好き」と「小麦粉2カップ」)では0.0〜0.2程度の低い類似度が得られます。特に「パスワードをリセットしたい」と「ログイン資格情報を忘れた」のペアは、共通する単語がほとんどないにもかかわらず高い類似度を示します。これは、文埋め込みが語彙的な一致ではなく意味的な一致を捉えていることの証拠です。
アラインメントとユニフォーミティの計測
先ほど理論的に紹介したアラインメントとユニフォーミティの指標を、実際に計算してみましょう。異なるモデルの埋め込み品質を定量的に比較します。
import numpy as np
from sentence_transformers import SentenceTransformer
def compute_alignment(embeddings_a, embeddings_b):
"""
アラインメント: 正例ペア間の平均二乗距離
値が小さいほど良い(正例が近い)
"""
diff = embeddings_a - embeddings_b
distances_sq = np.sum(diff ** 2, axis=1)
return np.mean(distances_sq)
def compute_uniformity(embeddings, t=2.0):
"""
ユニフォーミティ: ベクトルの分布の均一性
値が小さいほど良い(均一に分布)
"""
# 全ペア間の二乗距離を計算
n = len(embeddings)
total = 0
count = 0
for i in range(n):
for j in range(i + 1, n):
diff = embeddings[i] - embeddings[j]
dist_sq = np.sum(diff ** 2)
total += np.exp(-t * dist_sq)
count += 1
return np.log(total / count)
# 正例ペア(意味的に類似した文)
positive_pairs = [
("A dog is running in the park.", "A puppy plays outside."),
("The cat sleeps on the sofa.", "A kitten is resting on the couch."),
("She is cooking dinner.", "A woman prepares a meal."),
("The car is driving fast.", "A vehicle speeds down the road."),
("He reads a book every night.", "A man enjoys reading before bed."),
("The sun sets over the ocean.", "The sunset paints the sea golden."),
("Children play in the garden.", "Kids are having fun outdoors."),
("It is raining heavily today.", "There is a strong downpour."),
]
sentences_a = [p[0] for p in positive_pairs]
sentences_b = [p[1] for p in positive_pairs]
model = SentenceTransformer("all-MiniLM-L6-v2")
emb_a = model.encode(sentences_a, normalize_embeddings=True)
emb_b = model.encode(sentences_b, normalize_embeddings=True)
all_emb = np.vstack([emb_a, emb_b])
alignment = compute_alignment(emb_a, emb_b)
uniformity = compute_uniformity(all_emb)
print(f"Alignment (lower is better): {alignment:.4f}")
print(f"Uniformity (lower is better): {uniformity:.4f}")
ファインチューニング済みの文埋め込みモデル(all-MiniLM-L6-v2)では、アラインメントが比較的低く(正例ペアが近い)、ユニフォーミティも低い(全体が均一に分布)値が得られます。対照的に、BERTの[CLS]ベクトルをそのまま使った場合は、アニソトロピー問題によりユニフォーミティが非常に悪い値を示します。これは全ベクトルが狭い領域に集中しているためです。
SimCSEの対照学習が効果的な理由は、InfoNCE損失が暗黙的にアラインメント(正例を近づける項)とユニフォーミティ(負例を遠ざけて空間を均一にする項)の両方を同時に最適化しているからです。
実用Tips — sentence-transformersライブラリの活用
基本的な使い方
実務で文埋め込みを使う場合、ゼロからモデルを学習する必要はほとんどありません。Hugging Faceの sentence-transformers ライブラリには、大規模データセットで事前学習・ファインチューニング済みのモデルが多数公開されています。
from sentence_transformers import SentenceTransformer, util
# モデルのロード
model = SentenceTransformer("all-MiniLM-L6-v2")
# 単一文のエンコード
embedding = model.encode("This is a sample sentence.")
print(f"Embedding shape: {embedding.shape}")
# => (384,) — 384次元の文ベクトル
# 複数文の一括エンコード
sentences = [
"The weather is lovely today.",
"It's so sunny outside!",
"He drove to the stadium.",
]
embeddings = model.encode(sentences)
print(f"Embeddings shape: {embeddings.shape}")
# => (3, 384) — 3文 x 384次元
# 全ペアのコサイン類似度を計算
cosine_scores = util.cos_sim(embeddings, embeddings)
print("\nCosine Similarity Matrix:")
for i in range(len(sentences)):
for j in range(len(sentences)):
print(f" [{i}][{j}] = {cosine_scores[i][j]:.4f}", end="")
print()
all-MiniLM-L6-v2 は、384次元の文ベクトルを出力する軽量かつ高性能なモデルです。6層のTransformerをベースにしており、768次元のBERT-baseと比べて推論が2倍以上高速でありながら、STS ベンチマークでの性能は同等レベルを維持しています。出力される類似度行列では、対角成分が1.0(自分自身との類似度)で、天気に関する1文目と2文目の類似度が高く、スタジアムに関する3文目との類似度が低くなることが確認できます。
セマンティック検索の実装
文埋め込みの最も典型的な応用であるセマンティック検索を実装します。
from sentence_transformers import SentenceTransformer, util
import torch
model = SentenceTransformer("all-MiniLM-L6-v2")
# 検索対象の文書コーパス
corpus = [
"Python is a high-level programming language.",
"Machine learning models learn patterns from data.",
"The Eiffel Tower is located in Paris, France.",
"Neural networks are inspired by biological neurons.",
"Tokyo is the capital city of Japan.",
"Deep learning requires large amounts of training data.",
"The Great Wall of China is visible from space.",
"Natural language processing analyzes human language.",
"Mount Fuji is the highest mountain in Japan.",
"Backpropagation is used to train neural networks.",
]
# コーパスのベクトルを事前計算
corpus_embeddings = model.encode(corpus, convert_to_tensor=True)
# クエリ
query = "How do neural networks learn?"
query_embedding = model.encode(query, convert_to_tensor=True)
# コサイン類似度でランキング
cos_scores = util.cos_sim(query_embedding, corpus_embeddings)[0]
top_results = torch.topk(cos_scores, k=3)
print(f"Query: {query}\n")
print("Top 3 most similar sentences:")
for score, idx in zip(top_results[0], top_results[1]):
print(f" (Score: {score:.4f}) {corpus[idx]}")
クエリ「How do neural networks learn?」に対して、「Backpropagation is used to train neural networks」「Neural networks are inspired by biological neurons」「Machine learning models learn patterns from data」といった意味的に関連する文が上位にランクされます。キーワード検索では「neural networks learn」という完全一致がなければヒットしない文も、セマンティック検索では意味の近さに基づいて発見できます。
この例では10文のコーパスですが、実際のRAGシステムでは数百万件の文書を対象とします。その場合は FAISS や Milvus などのベクトルデータベースを用いて近似最近傍探索を行い、ミリ秒単位で検索を完了させます。
モデルの選択指針
sentence-transformers には数百のモデルが公開されています。用途に応じた選択の目安を整理します。
| モデル名 | 次元 | 速度 | 性能 | 用途 |
|---|---|---|---|---|
| all-MiniLM-L6-v2 | 384 | 高速 | 良好 | 汎用(推奨デフォルト) |
| all-mpnet-base-v2 | 768 | 中速 | 高い | 精度重視 |
| paraphrase-MiniLM-L6-v2 | 384 | 高速 | 良好 | 言い換え検出 |
| multi-qa-MiniLM-L6-cos-v1 | 384 | 高速 | 良好 | 質問応答検索 |
| multilingual-e5-large | 1024 | 低速 | 非常に高い | 多言語対応 |
日本語を扱う場合は、多言語対応モデル(multilingual-e5-large 等)の利用が必要です。英語のみのモデルは日本語の文埋め込みに適しません。
Sentence-BERTとSimCSEの比較
ここまでで2つのアプローチを詳しく見てきました。両者の特徴を整理しましょう。
| 項目 | Sentence-BERT | SimCSE(教師なし) | SimCSE(教師あり) |
|---|---|---|---|
| ラベル付きデータ | 必要(NLI/STS) | 不要 | 必要(NLI) |
| データ拡張 | なし | ドロップアウト | なし |
| 正例ペアの構成 | NLIの含意ペア | 同一文の2回エンコード | NLIの含意ペア |
| 負例の構成 | バッチ内の他ペア | バッチ内の他文 | NLIの矛盾ペア + バッチ内 |
| 目的関数 | 分類/回帰/Triplet | InfoNCE | InfoNCE + ハード負例 |
| STS-B性能 | 高い | Sentence-BERTに匹敵 | 最高 |
| 学習の簡便さ | 中程度 | 非常に簡単 | 中程度 |

図の通り、教師なしSimCSEはラベル不要で最も手軽、教師ありSimCSEはNLIの矛盾ペアをハード負例に使って最高性能、という住み分けです。実務では事前学習済みモデルや教師なしSimCSEを土台に、ドメインデータで追加学習する流れが一般的です。
教師なしSimCSEの最も大きな利点は、ラベルなしの文コーパスだけで学習できることです。ドメイン固有のデータ(医療文書、法律文書、技術文書など)に対して、ラベル付けのコストをかけずに文埋め込みモデルを適応させることができます。
一方、教師ありSimCSEは、NLIデータセットのハード負例を活用することで最も高い性能を達成します。ハード負例によって「語彙的には似ているが意味が異なる」ケースを正しく区別する能力が鍛えられるためです。
実務では、まず教師なしSimCSEまたは事前学習済みの sentence-transformers モデルをベースラインとして使い、ドメイン固有のデータで追加学習する戦略が一般的です。
まとめ
本記事では、文レベルの意味表現を効率的に獲得するための手法として、Sentence-BERTとSimCSEを解説しました。
- BERTの[CLS]ベクトルは文埋め込みとして不十分である — アニソトロピー問題とNSPタスクとの目的の不一致が原因
- Cross-Encoderは高精度だが $O(N^2)$ の計算量が実用上のボトルネック — Bi-Encoder(Sentence-BERT)は $O(N)$ で事前計算が可能
- Sentence-BERTはSiameseネットワーク構造で文ペアを独立にエンコードし、分類・回帰・Tripletの3種類の目的関数で学習する
- SimCSEの教師なし手法はドロップアウトマスクの違いだけで正例ペアを生成し、ラベルなしデータで高品質な文埋め込みを獲得する
- InfoNCE損失は温度パラメータ $\tau$ によってソフトマックスのシャープさを制御し、アラインメントとユニフォーミティの両方を暗黙的に改善する
- 教師ありSimCSEはNLIの矛盾ペアをハード負例として活用し、最高性能を達成する
文埋め込みは、セマンティック検索やRAGなど、現代のNLPアプリケーションの基盤技術です。今後は、Bi-EncoderとCross-Encoderを組み合わせたリランキング戦略や、ドメイン適応のための追加学習手法も重要なトピックになります。
次のステップとして、以下の記事も参考にしてください。