入門 · レッスン 8 / 10

エンベディングと類似検索

エンベディングベクトルとは何か、コサイン類似度の手計算、22 Mパラメータの文エンコーダを20文に対してローカルで実行、numpyによる最近傍検索、そしてキーワード索引より意味の検索に強い理由

入門Hugging Face35 分更新 2026年11月13日無料期間 2027年1月31日

このレッスンで学ぶこと

  • エンベディングとは何か、ベクトル間の距離が意味の類似性の代わりになる理由を説明できる
  • 3次元ベクトルでコサイン類似度を手計算し、ベクトルの長さで割る理由を述べられる
  • all-MiniLM-L6-v2で文をエンコードし、1回の行列積で最近傍検索を実行できる
  • 意味検索と転置キーワード索引を比較し、それぞれが勝つ場面を述べられる

前提レッスン: PyTorchで作る最初のニューラルネットワーク

エンベディング(埋め込み)とは、テキスト、画像、その他何でも、ある対象を表す数値のリストで、意味の似たものが近いリストになるように配置されたものです。この1つの考え方が、意味検索、推薦、文書のクラスタリング、重複除去、そして検索拡張生成の検索側を支えています。本レッスンは、「文書を埋め込んでベクトルを検索する」という話を聞いたことがあり、その数値が何なのか、「近い」はどう測るのか、それらに対する検索は20行のnumpyでどう書けるのかを知りたい人のためのものです。すべてノートPCのCPUで、すでにキャッシュ済みのモデルを使って動きます。

one-hotから密なベクトルへ

レッスン07のネットワークは、ピクセル値を入力としていました。テキストにはそのような自然な数値がありません。素朴な符号化では、語彙の各単語に非常に長いベクトルの中の固有の位置を与え、その位置を1、それ以外を0にします。このone-hotベクトルは類似性には役に立ちません。「cat」と「kitten」は「cat」と「carburettor」と同じだけ離れています。異なる単語のどの組も直交しているからです。

エンベディングは、長い疎なベクトルを短い密なベクトル、典型的には数百個の数値に置き換えます。幾何が使われ方を反映するように学習されたものです。似た文脈に現れる単語や文は学習中に引き寄せられ、そうでないものは引き離されます。個々の次元に決まった意味はありません。重要なのはベクトル同士の相対的な位置です。

コサイン類似度の手計算

たまたま「毛深さ」「ペットらしさ」「乗り物らしさ」を意味する3次元のおもちゃのエンベディングを想像してください。3つの項目に下のベクトルを与えます。問題は、2つのベクトルをどうやって1つの類似度の数値に変えるかです。

import numpy as np

def cosine(u: np.ndarray, v: np.ndarray) -> float:
    return float(u @ v / (np.linalg.norm(u) * np.linalg.norm(v)))

cat = np.array([0.9, 0.8, 0.1])     # (furry, pet, vehicle)
dog = np.array([0.8, 0.9, 0.0])
car = np.array([0.0, 0.1, 0.9])

print(f"cat . dog = {cat @ dog:.2f}, |cat| = {np.linalg.norm(cat):.3f}, |dog| = {np.linalg.norm(dog):.3f}")
print(f"cos(cat, dog) = {cosine(cat, dog):.3f}")
print(f"cos(cat, car) = {cosine(cat, car):.3f}")
print(f"cos(cat, 3*cat) = {cosine(cat, 3 * cat):.3f}   # scale does not matter")
cat . dog = 1.44, |cat| = 1.208, |dog| = 1.204
cos(cat, dog) = 0.990
cos(cat, car) = 0.155
cos(cat, 3*cat) = 1.000   # scale does not matter

一度は手で計算を追ってください。catとdogの内積は0.9 x 0.8 + 0.8 x 0.9 + 0.1 x 0.0 = 0.72 + 0.72 + 0 = 1.44です。catの長さは0.81 + 0.64 + 0.01 = 1.46の平方根で、1.208です。内積を両方の長さで割ると1.44 / (1.208 x 1.204) = 0.990になります。結果は2つのベクトルのなす角のコサインです。同じ方向を向いていれば1、直交していれば0、逆向きなら負になります。

長さで割ることこそが、生の内積ではなくコサインを使う理由です。最後の行がその理由を示しています。catのすべての数値を3倍しても方向は変わらないので、自分自身との類似度は1のままです。実際のエンベディングでは、ベクトルの長さは文の長さや単語の頻度など意味とはあまり関係のないものに左右されがちで、コサインはそれを取り除きます。実務ではほとんどのシステムが、索引作成時に一度だけすべてのベクトルを長さ1に正規化します。その後は内積とコサインが同じ数値になり、検索は1回の行列積になります。

実際の文エンコーダ

以下で使うモデルはsentence-transformers/all-MiniLM-L6-v2です。22 Mパラメータの6層のtransformerエンコーダで、文を384次元のベクトルに写像します。2021年にSentence-Transformersプロジェクトから、重みと学習コードを公開した上でApache-2.0ライセンスでリリースされたので、オープンウェイトにとどまらずオープンソースです。トークナイザは最大512トークンを受け付けますが、モデルは128ワードピースの入力で学習されており、デフォルトでは256で切り詰めるので、文や短い段落向けのモデルであって、文書全体向けではありません。ノートPCのCPUで毎秒数千文をエンコードできるほど小さく、だからこそ多くのチュートリアルに登場し、Field Guideもオフライン利用のためにキャッシュしています。

sentencetexttokeniserword piecestransformer6 layers, 22 Mmean pool+ normalisevector384 floats, |v| = 1indexing, once per sentencequery timequerysame encoderq vector(384,)scores = E @ qE is (20, 384)argsorttop knearestsentencesstored vectors E
文がベクトルになる仕組みと、クエリが保存済みベクトルと照合される仕組み。点線の左側はすべて、索引作成時に文ごとに1回だけ行われます。

トークナイザは、固定語彙のワードピースにテキストを分割します。transformerは各ピースを文脈を考慮したベクトルに変えます(その仕組みはtransformersハブの主題です)。平均プーリングはそれらのピースのベクトルを平均して文全体の1つのベクトルにし、正規化はそれを単位長に揃えます。下の20文は本レッスンのために書いたもので、互いに無関係な10の主題について2文ずつの10組になっているので、検索結果の良し悪しを自分で判断できます。

import numpy as np
from sentence_transformers import SentenceTransformer

sentences = [
    "The cat slept on the warm windowsill all afternoon.",
    "A kitten chased a ball of wool across the kitchen floor.",
    "Our dog barks whenever the postman arrives.",
    "The puppy dug a hole under the garden fence.",
    "Train services from Leeds were cancelled because of flooding.",
    "The 07:42 to Manchester is running twelve minutes late.",
    "Interest rates were held at 4 percent by the central bank.",
    "Inflation fell for the third month in a row.",
    "The recipe needs two eggs, flour and a pinch of salt.",
    "Whisk the batter until no lumps remain, then rest it for an hour.",
    "The goalkeeper saved a penalty in the final minute.",
    "The match ended two all after extra time.",
    "Jupiter is the largest planet in the solar system.",
    "The probe sent back images of Saturn's rings.",
    "Replace the brake pads when they are thinner than three millimetres.",
    "The mechanic changed the oil and rotated the tyres.",
    "She learned to play the cello at the age of nine.",
    "The orchestra tuned up before the symphony began.",
    "Back up your files before upgrading the operating system.",
    "The laptop battery lasts about six hours on a full charge.",
]

model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2", device="cpu")
emb = model.encode(sentences, normalize_embeddings=True)   # unit length, so dot = cosine
print("embeddings:", emb.shape, emb.dtype)
print("first five values of sentence 0:", np.round(emb[0, :5], 3))
print("norm of sentence 0:", round(float(np.linalg.norm(emb[0])), 3))
embeddings: (20, 384) float32
first five values of sentence 0: [ 0.105  0.053  0.017  0.148 -0.028]
norm of sentence 0: 1.0

20文が、小さな浮動小数点数からなる20 x 384の行列になりました。個々の値には名前を付けられるような意味はありません。モデルをローカルキャッシュから初めて読み込むには1、2秒かかり、その後は20文のエンコードに数ミリ秒しかかかりません。モデルがキャッシュされていない場合、SentenceTransformer(...)はHugging Face Hubから約90 MBをダウンロードします。その管理方法はHugging Faceプラットフォームページで説明しています。

numpyによる最近傍検索

単位長のベクトルなら、クエリと保存済みの全文との類似度は1回の行列ベクトル積です。結果を並べ替えれば最近傍が得られます。

import numpy as np
from sentence_transformers import SentenceTransformer

sentences = [
    "The cat slept on the warm windowsill all afternoon.",
    "A kitten chased a ball of wool across the kitchen floor.",
    "Our dog barks whenever the postman arrives.",
    "The puppy dug a hole under the garden fence.",
    "Train services from Leeds were cancelled because of flooding.",
    "The 07:42 to Manchester is running twelve minutes late.",
    "Interest rates were held at 4 percent by the central bank.",
    "Inflation fell for the third month in a row.",
    "The recipe needs two eggs, flour and a pinch of salt.",
    "Whisk the batter until no lumps remain, then rest it for an hour.",
    "The goalkeeper saved a penalty in the final minute.",
    "The match ended two all after extra time.",
    "Jupiter is the largest planet in the solar system.",
    "The probe sent back images of Saturn's rings.",
    "Replace the brake pads when they are thinner than three millimetres.",
    "The mechanic changed the oil and rotated the tyres.",
    "She learned to play the cello at the age of nine.",
    "The orchestra tuned up before the symphony began.",
    "Back up your files before upgrading the operating system.",
    "The laptop battery lasts about six hours on a full charge.",
]
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2", device="cpu")
emb = model.encode(sentences, normalize_embeddings=True)

def search(query: str, k: int = 3) -> None:
    q = model.encode([query], normalize_embeddings=True)[0]
    scores = emb @ q                          # one cosine per stored sentence
    top = np.argsort(-scores)[:k]
    print(f"\nquery: {query}")
    for i in top:
        print(f"  {scores[i]:.3f}  {sentences[i]}")

search("my pet is asleep in the sun")
search("delayed railway journey")
search("how do I make pancakes")
query: my pet is asleep in the sun
  0.490  The cat slept on the warm windowsill all afternoon.
  0.355  Our dog barks whenever the postman arrives.
  0.171  The puppy dug a hole under the garden fence.

query: delayed railway journey
  0.363  The 07:42 to Manchester is running twelve minutes late.
  0.359  Train services from Leeds were cancelled because of flooding.
  0.160  Whisk the batter until no lumps remain, then rest it for an hour.

query: how do I make pancakes
  0.523  The recipe needs two eggs, flour and a pinch of salt.
  0.325  Whisk the batter until no lumps remain, then rest it for an hour.
  0.104  Replace the brake pads when they are thinner than three millimetres.

3つのクエリのどれも、最良の一致と内容語を共有していません。「pet」と「asleep」が「cat」と「slept」を見つけました。「railway」が「train」と「the 07:42 to Manchester」を見つけました。「pancakes」が卵、小麦粉、生地を見つけました。これがモデルの学習された能力であり、スコアはどれくらい確信してよいかを教えてくれます。上位の一致は0.35から0.52あたり、各クエリの3番目の結果は0.1から0.17あたりで別の主題です。この小さな集合では0.3付近のしきい値で当たりと外れを分けられますが、適切なしきい値はモデルとコレクションに依存するので、自分のデータで確認しなければなりません。

絶対値がおもちゃの例より低いのは、実際のエンベディングは意味を384次元に分散させており、同じ主題の2つの文でもほとんどの次元では異なるからです。スコアはクエリ内で比較し、モデル間で比較しないでください。

20行に対するnp.argsortは一瞬です。100万行でも1クエリは100万 x 384の行列ベクトル積にすぎず、ノートPCで約0.1秒です。数千万行になると、少しの再現率と引き換えに速度を得る近似最近傍索引に移行します。RAGパイプラインのチェックリストがそのステップを扱い、小さなRAGプロジェクトはまさにこのモデルで完全な検索パイプラインを構築します。

転置索引による同じ検索

エンベディング以前の検索は転置索引でした。すべての単語について、それを含む文書のリストを持つものです。下のブロックは同じ20文に対して転置索引を構築し、「the」が何にでも一致しないよう少数のストップワードを除外した上で、同じ3つのクエリともう1つを実行します。

import re
from collections import defaultdict

sentences = [
    "The cat slept on the warm windowsill all afternoon.",
    "A kitten chased a ball of wool across the kitchen floor.",
    "Our dog barks whenever the postman arrives.",
    "The puppy dug a hole under the garden fence.",
    "Train services from Leeds were cancelled because of flooding.",
    "The 07:42 to Manchester is running twelve minutes late.",
    "Interest rates were held at 4 percent by the central bank.",
    "Inflation fell for the third month in a row.",
    "The recipe needs two eggs, flour and a pinch of salt.",
    "Whisk the batter until no lumps remain, then rest it for an hour.",
    "The goalkeeper saved a penalty in the final minute.",
    "The match ended two all after extra time.",
    "Jupiter is the largest planet in the solar system.",
    "The probe sent back images of Saturn's rings.",
    "Replace the brake pads when they are thinner than three millimetres.",
    "The mechanic changed the oil and rotated the tyres.",
    "She learned to play the cello at the age of nine.",
    "The orchestra tuned up before the symphony began.",
    "Back up your files before upgrading the operating system.",
    "The laptop battery lasts about six hours on a full charge.",
]

STOP = {"the", "a", "an", "is", "in", "on", "of", "to", "and", "my", "our", "i", "do", "how"}

def tokens(text: str) -> set[str]:
    return set(re.findall(r"[a-z0-9]+", text.lower())) - STOP

index: dict[str, set[int]] = defaultdict(set)
for i, s in enumerate(sentences):
    for t in tokens(s):
        index[t].add(i)
print("distinct terms:", len(index), " postings for 'train':", sorted(index["train"]))

def keyword_search(query: str, k: int = 3) -> None:
    hits: dict[int, int] = defaultdict(int)
    for t in tokens(query):
        for i in index.get(t, ()):
            hits[i] += 1
    ranked = sorted(hits.items(), key=lambda kv: -kv[1])[:k]
    print(f"\nquery: {query}")
    if not ranked:
        print("  (no sentence shares a word with the query)")
    for i, n in ranked:
        print(f"  {n} shared term(s)  {sentences[i]}")

keyword_search("my pet is asleep in the sun")
keyword_search("delayed railway journey")
keyword_search("how do I make pancakes")
keyword_search("worn brake pads")
distinct terms: 132  postings for 'train': [4]

query: my pet is asleep in the sun
  (no sentence shares a word with the query)

query: delayed railway journey
  (no sentence shares a word with the query)

query: how do I make pancakes
  (no sentence shares a word with the query)

query: worn brake pads
  2 shared term(s)  Replace the brake pads when they are thinner than three millimetres.

言い換えた3つのクエリは何も返しません。それらの単語がコレクションに1つも現れないからです。これが語彙の不一致問題であり、自然言語のクエリでエンベディングが主流になった理由です。4つ目のクエリは裏面を示しています。「brake pads」は正確な用語で、索引はそれを定数時間で確実に見つけ、部品番号、エラーコード、人名でも同じことをします。エンベディングモデルはまさにそこが弱点です。珍しいトークンはモデルがほとんど手がかりを持たないワードピースに分割され、2つの異なる部品番号が近くに配置されることがあります。

転置索引(BM25とその仲間) エンベディング検索
言い換えを見つける できない できる
正確な識別子、コード、名前 信頼できる 信頼できない
索引作成コスト 極小、モデル不要 文書ごとに1回の順伝播
一致の説明 できる、共有した語 できない
新しいドメインでの調整 ほとんど不要 ときに必要、モデルのファインチューニングが要ることも

本番システムではたいてい両方を実行し、2つの順位付きリストを統合します。これをハイブリッド検索と呼びます。統合と、その後によく続く再ランキングのステップは検索ハブで扱います。

演習

  1. まだ扱われていない主題について自分で5文をリストに追加し、単語を共有せずにそれらを見つけるはずのクエリを書いてください。上位3件のスコアと、0.3のしきい値で機能したかどうかを報告してください。
  2. 検索ブロックのnormalize_embeddings=TrueをFalseに置き換え、scores = emb @ qをノルムを使ってコサインを明示的に計算するように変更してください。順位が同じであることを確認し、次に20個の生ベクトルのノルムを表示して、どれだけばらつくか見てください。
  3. キーワード索引を、共有した語の数を文中の語の数で割った値でスコア付けするよう拡張し、短い正確な一致が長い部分一致より上位に来るようにしてください。「worn brake pads」と「oil change」を実行し、同じ2つのクエリのエンベディング結果と比較してください。

まとめ

  • エンベディングは、似た入力が近くに配置されるように学習された密なベクトルです。次元に個別の意味はなく、相対的な位置だけが意味を持ちます。
  • コサイン類似度は内積を両方の長さで割ったものです。ベクトルのスケールを無視し、単位長に正規化した後は単なる内積になります。
  • all-MiniLM-L6-v2(22 Mパラメータ、384次元、Apache-2.0)はCPUで数ミリ秒で文をエンコードし、クエリと単語を共有しない言い換えを見つけます。
  • 正規化済みベクトルに対する最近傍検索は、1回の行列ベクトル積とargsortです。近似索引が必要になるのは数百万行からです。
  • キーワード索引は言い換えに弱い一方、正確な識別子には強く、一致の理由を説明できます。ほとんどのシステムは両方を使います。

さらに読む