TT Lab
はじめる
学ぶ 学習パス コース

LLMエンジニアリング

埋め込み — 意味を座標に変える最も単純な方法

TT Labで続きを見る

一言でいうと

埋め込みは、テキストをベクトルに変えて、距離で比較できるようにすることです。ニューラルネットワークがなくても、TF-IDFだけで、その骨組み、つまりベクトル化 → 正規化 → 内積と、その骨組みが崩れる様子を、すべて観察できます。

なぜ必要なのか

カスタマーサポートの検索ボックスに、2つの報告が入ったとします。1つは、「決済が遅い」で検索すると「決済遅延対応」の文書が出てこないということで、もう1つは、エラーコードE4012で検索すると、的外れな文書が先に出てくるということです。1つ目は表面の文字が違うために起きた失敗で、2つ目は、文字が正確に同じである必要があるのに、意味だけが似たものを持ってきたために起きた失敗です。2つの失敗を区別して直すには、「似ている」を数値でどう定義したかから知る必要があります。

テキストをベクトルに変えておけば、内積や角度で類似度を計算できます。最も単純なベクトルは、単語の出現回数を並べたものです。問題は、よくある単語が支配することです。すべての文書に出てくる単語は、文書を区別するのに何の役にも立たないのに、値は最も大きくなります。

TF-IDFは、この問題に正面から取り組みます。単語の文書内の頻度(TF)に、文書頻度の逆数(IDF)を掛けます。多くの文書に均等に出てくる単語はIDFが低くて抑えられ、特定の文書にしか出てこない単語はIDFが高くて強調されます。

どう動くのか

平滑化を入れたIDFは、通常次のように定義します。scikit-learnのTfidfTransformerがデフォルトで使う式と同じです。

idf(t) = ln((1 + N) / (1 + df(t))) + 1

分母と分子に1を足すのは、すべての語を1回ずつ含む文書がもう1つあると見なすことで、dfが0の語でも割り算が壊れないようにします。最後に1を足すのは、すべての文書に出てくる語の重みが0になって完全に消えてしまうのを防ぐためです。数値で見ると、感覚がつかめます。文書が30件のとき、1つの文書にしか出てこない語は約3.741、3つの文書に出てくる語は約3.048、10の文書に出てくる語は約2.036、すべての文書に出てくる語は、ちょうど1です。まれな語が、よくある語より3、4倍重く扱われます。

ベクトルを作ったあとは、L2正規化を行います。各行を自分の長さで割って長さを1にそろえると、2つのベクトルの内積がそのままコサイン類似度になり、文書の長さの影響がなくなります。正規化しないと、長い文書は、単語が多いというだけで、ほとんどすべてのクエリと内積が大きくなります。

ハッシングトリックは、語彙辞書を維持する代わりに、語をハッシュ関数で固定個数のバケットに割り当てます。語彙がどれだけ増えても次元は固定され、辞書を持ち歩かなくて済みます。代償は2つです。異なる語が同じバケットに入ると区別できず、ハッシュは一方向なので、バケット番号から元の語を取り戻せません。scikit-learnのHashingVectorizerは、デフォルトで2の20乗個のバケットを使い、ハッシュ値に応じて符号を交互に付けて、衝突が片側に積み上がらず、互いに打ち消し合うようにします。バケットを1024個のように小さくすると、衝突が目に見えるほど多くなり、その代償が、最近傍が変わる様子として現れます。

ニューラルネットワークの埋め込みも、骨組みは同じです。違いは、ベクトルの作り方だけです。単語の回数の代わりに、学習されたモデルが文を数百次元の密なベクトルに変えるので、「遅い」と「遅延が大きい」が近くなります。正規化して内積で比べるという残りの手順は、そのままです。

現場で何が問題になるのか

正規化を忘れる。症状は、特定の文書数件が、無関係なクエリの大半で上位に出てくることです。たいていは、最も長い文書たちです。結果のリストで、同じ文書idが繰り返し見えたら、まず行の長さを確認します。

クエリがゼロベクトルになる。クエリの単語がすべてインデックスの語彙の外にあると、クエリベクトルはすべての値が0です。ここで長さで割ると0を0で割ることになり、値がすべてnanになり、numpyは警告を1行だけ残して計算を続けます。nanが混ざったスコアでソートすると、順序が意味を持たなくなるのに、エラーは出ません。長さが0のクエリは、正規化の前に除外して、「結果なし」として返します。

インデックスとクエリのルールが違う。インデックス作成のときは小文字に変換したのに、クエリのときは変換しなかったとか、クエリが来るたびにIDFを再計算したとなると、2つのベクトルは異なる空間にあります。スコアは出るので、気づきにくいです。ニューラルネットワークの埋め込みでは、この問題がもっと大きくなります。モデルが変わるとベクトルを比べられないので、全体をインデックスし直す必要があり、一部のモデルは、クエリと文書に異なる接頭辞を付けるように学習されていて(例: E5系のquery:とpassage:)、それを忘れると、品質が黙って落ちます。

ハッシュが実行ごとに変わる。Python組み込みのhash()は、文字列に対して、プロセスごとに異なるランダムな値を混ぜます。これでバケットを決めると、昨日作ったインデックスと今日来たクエリが、異なるバケットを使います。再現可能なハッシュ(hashlib.md5など)を使う必要があります。

スコアのしきい値をそのまま移す。TF-IDFのコサイン0.3と、ニューラルネットワークの埋め込みのコサイン0.3は、同じ意味ではありません。モデルごとにスコアの分布が違うので、モデルや重み付けの方式を変えたら、しきい値も評価セットで決め直します。

どう確認するのか

行列を作ったら、計算を信じる前に、不変条件から確認します。

import numpy as np

X = np.load("tfidf.npy")
norms = np.linalg.norm(X, axis=1)
print(np.allclose(norms, 1.0))        # every row has length 1
print(np.isnan(X).any())               # no nan anywhere
S = X @ X.T
print(np.allclose(np.diag(S), 1.0))    # self-similarity is 1
np.fill_diagonal(S, -1)
nn = S.argmax(axis=1)
print(np.bincount(nn).max())           # one document as everyone's neighbour?

最後の行が特に役に立ちます。各文書の最近傍を集めて、1つの文書が何回「誰かの最も近い隣」になるかを数えます。1つの文書が特によく選ばれるなら、正規化や重み付けに問題があるというサインです。

ハッシングトリックの損失は、2つの数値で測ります。異なる語の数と、実際に使われたバケットの数を比べると、衝突がどれだけあるかが見え、正確な行列とハッシュ行列の最近傍が何件一致するかを数えると、その衝突が検索結果をどれだけ変えたかが見えます。そして、検索結果を確認するときは、スコアだけを見ず、なぜその文書が出てきたのかを見ます。クエリと文書が共有する語とその重みを出力してみると、たいてい原因が一目でわかります。

次のラボですること

モジュールのラボは2つです。すぐ次のラボは、前の理論で扱ったBPEトークナイザーを土台から実装するもので、そのあとのラボで、この理論の内容を手で計算します。文書30件をトークン化して、文書頻度、IDF、TF-IDF行列をnumpyで自分で作り、コサイン類似度で最近傍とクエリ検索を行い、最後にハッシングトリックで1024次元の行列を作って、最近傍がどれだけ変わるかを測ります。