LLM アプリ開発 #7 埋め込みとベクトル検索

読了 5分

第6回で Claude を外部機能とつなぎました。ところがよくある要望が一つあります。「うちの会社の文書を見て答えて」というものです。Claude は私たちの文書を知らないので、質問に関連する文書を先に見つけて一緒に渡す必要があります。この「関連文書を見つける」核心の技術が埋め込みとベクトル検索です。今回その土台を作り、次回 RAG として仕上げます。

埋め込みとは #

埋め込みは、テキストを数値のリスト、つまりベクトルに変えることです。単なる変換ではなく、意味が近いテキストは近いベクトルになるように作られた変換です。たとえば「子犬」と「愛犬」は近いベクトルになり、「子犬」と「株式市場」は遠く離れたベクトルになります。

この性質のおかげで、ベクトル間の距離を測れば意味の類似度を測れます。キーワードが正確に重ならなくても、「返金の方法」で検索して「決済キャンセルの手順」を扱った文書を見つけられます。単語ではなく意味で探すからです。

注記
Claude はテキストを生成するモデルで、埋め込みは作りません。埋め込みは専用の埋め込みモデルで得ます。下の例は、別途キーなしでローカルで動く sentence-transformers を使います。実際のサービスでは、品質の高いホスティング埋め込み API(例: Voyage AI)を使うこともあります。どちらにせよ「テキストを入れるとベクトルが出る」という使い方は同じです。

テキストをベクトルに変える #

sentence-transformers で、文をいくつかベクトルに変えてみます。

embed.py
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")

texts = [
    "犬を散歩させる方法",
    "愛犬の運動の大切さ",
    "今日の株式市場の動向",
]

vectors = model.encode(texts)
print(vectors.shape)  # (3, 384) — 文3つ、それぞれ384次元のベクトル

各文が384個の数値からなるベクトルになりました。次元数(ここでは384)はモデルごとに異なりますが、同じモデルで作ったベクトルどうしは同じ次元なので、互いに距離を測れます。

ベクトルで類似度を測る #

二つのベクトルがどれだけ似ているかは、ふつうコサイン類似度で測ります。値が1に近いほど意味が似ており、0に近いほど無関係です。

similarity.py
import numpy as np

def cosine_similarity(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

dog_walk, dog_exercise, stocks = vectors

print(cosine_similarity(dog_walk, dog_exercise))  # 高い(どちらも犬)
print(cosine_similarity(dog_walk, stocks))        # 低い(無関係)

「犬の散歩」と「愛犬の運動」は単語がほとんど重ならないのに、類似度が高く出ます。意味が近いからです。一方「犬の散歩」と「株の動向」は低く出ます。これがキーワード検索と違う点です。

ベクトル検索を作る #

文書をいくつか先にベクトルへ変えておけば、質問が来たときに質問もベクトルへ変えて、最も似た文書を見つけられます。これがベクトル検索です。

vector_search.py
import numpy as np
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")

documents = [
    "返金は購入後7日以内にマイページから申請できます。",
    "配送は注文後ふつう2〜3日かかります。",
    "会員ランクは累計購入額に応じて自動で上がります。",
]
doc_vectors = model.encode(documents)

def search(query: str, top_k: int = 1):
    q = model.encode([query])[0]
    scores = doc_vectors @ q  # 正規化されていれば内積がそのままコサイン類似度
    ranked = np.argsort(scores)[::-1][:top_k]
    return [(documents[i], float(scores[i])) for i in ranked]

print(search("お金を返してもらうにはどうすれば?"))
# 返金の文書が最も似た文書として出る

「お金を返してもらうには」という質問に「返金」という単語がないのに、意味が最も近い返金の文書を見つけます。これが LLM アプリで関連文書を見つける基本の原理です。

埋め込みモデルを選ぶ #

埋め込みモデルはいくつもあり、選ぶときに見る基準がいくつかあります。

  • 次元数 — ベクトルの長さです。次元が高いとより細かい意味の差を捉えますが、保存容量と計算コストが増えます。上で使った all-MiniLM-L6-v2 は384次元で、軽くて速いほうです。
  • 言語 — 日本語の文書を扱うなら、日本語をうまく扱うモデルか多言語モデルを選ぶ必要があります。英語中心に学習したモデルは、日本語の類似度が不正確になることがあります。
  • ローカルとホスティング — ローカルモデルはキーなしで無料で動きますが、品質と速度に限界があります。ホスティング API はコストがかかりますが、品質が高く長い文書に強いです。

肝心なのは、一度選んだモデルを一貫して使うことです。下のつまずきで見るとおり、文書と質問を同じモデルで埋め込んでこそ比較できます。ですからモデルを変えると、保存しておいた文書ベクトルもすべて作り直す必要があります。最初は軽いモデルで始め、品質が足りなければより良いモデルへ移りますが、移る際には全体の再埋め込みが必要になる点を念頭に置きます。

よくつまずくところ #

  • 検索モデルと保存モデルが違う — 文書を埋め込んだモデルと、質問を埋め込むモデルが違うと、ベクトルを比較できません。同じモデルで一貫して作る必要があります。
  • 正規化の前提を取り違える — 上の例は、モデルが正規化されたベクトルを返す前提で内積を類似度に使いました。そうでないモデルなら、コサイン類似度の式をそのまま使います。
  • 毎回埋め込みを作り直す — 文書の埋め込みは一度作って保存し、再利用します。検索のたびに全文書を埋め込み直すと、遅くて高くつきます。

まとめ #

今回は、意味で文書を見つける埋め込みとベクトル検索を扱いました。

  • 埋め込みはテキストをベクトルに変え、意味が似ていればベクトルも近くなるようにします。
  • ベクトル間のコサイン類似度で、意味の類似度を測ります。
  • 文書を先に埋め込んでおき、質問に最も近いものを見つけるのがベクトル検索で、規模が大きくなればベクトルデータベースを使います。

これで質問に関連する文書を見つけられます。次回の「LLM アプリ開発 #8 RAG パイプラインの構築」では、こうして見つけた文書を Claude に渡し、私たちの文書に基づいて答えさせる RAG を仕上げます。

X