見出し画像

【RAG】Embedding とベクトル検索

ベクトル検索とは何か

第2回では、BM25によるキーワード検索を扱いました。キーワード検索は「クエリと同じ単語を含むチャンク」を見つけるのが得意ですが、表現が違うだけで同じ意味の文書は拾えません。

例えば「認証の設定方法」で検索したとき、BM25は「認証」や「設定」という単語を含むチャンクにヒットします。しかし、「ログインの手順」という表現で書かれたチャンクは、意味は近いのに単語が一致しないためスコアが低くなります。

今回扱うベクトル検索は、キーワードの一致ではなくテキストの「意味」をもとに検索する手法です。
第1回で見たLlamaIndexのデフォルト設定も、実はこのベクトル検索を使っています。RAGにおいて最も一般的な検索手法であり、表現が違っても意味が近ければヒットするのが最大の特徴です。

ベクトル検索を理解するには、まずその基盤になっているEmbeddingの仕組みを知る必要があります。


Embeddingの仕組み

- テキストを数値ベクトルに変換する

Embeddingとは、テキストを数百〜数千次元の数値ベクトル(数値の配列)に変換する技術です。変換にはEmbeddingモデルという専用のモデルを使います。

"認証の設定方法"     → [0.12, -0.34, 0.56, 0.78, ...]  (768次元の数値配列)
"ログインの手順"     → [0.11, -0.33, 0.55, 0.77, ...]  ← 意味が近いので近いベクトル
"天気予報の見方"     → [-0.45, 0.67, -0.12, 0.23, ...]  ← 意味が遠いので遠いベクトル

このベクトルは「意味の座標」のようなものです。地図上の座標が物理的な位置を表すように、Embeddingベクトルはテキストの意味的な位置を表しています。意味が近いテキストは近い座標に、意味が遠いテキストは遠い座標に配置されます。

- Embeddingモデルが学んでいること

Embeddingモデルは、大量のテキストデータから「どの単語・フレーズが近い意味で使われるか」を学習しています。

「認証」と「ログイン」が同じような文脈で使われることを学習データから学んでいるため、この2つの単語を含むテキストは近いベクトルに変換されます。逆に「天気予報」は「認証」と一緒に使われることがほとんどないため、遠いベクトルになります。

重要なのは、Embeddingモデルによって精度が大きく変わるということです。特に日本語は、モデルによって語彙や文脈の捉え方が異なるため、英語以上にモデル選定が重要になります。この点は後のセクションで詳しく触れます。


コサイン類似度 — 意味の「近さ」を測る

2つのベクトルがどれだけ近いかを測る指標がコサイン類似度です。

コサイン類似度 = (A · B) / (|A| × |B|)
  • A · B: ベクトルAとBの内積(各要素を掛けて合計したもの)

  • |A|, |B|: それぞれのベクトルの大きさ(各要素の二乗和の平方根)

結果は -1 から 1 の範囲になります。

  • 1に近い → 意味が似ている

  • 0に近い → 無関係

  • -1に近い → 意味が反対

- コサイン類似度が意味の近さを測れる理由

Embeddingモデルは、「意味が近いテキストは似た方向のベクトルになる」ように学習されています。
つまり、ベクトルの向きがテキストの意味を表しています。

ベクトルの近さを測る方法としてはユークリッド距離(直線距離)もありますが、ユークリッド距離はベクトルの大きさの違いに影響されます。

簡単な例で考えてみます。2次元のベクトルで、AとBは同じ方向を向いていて、Cは別の方向を向いているとします。

A = [1, 2]      ← 「認証の設定方法」
B = [2, 4]      ← 「ログインの手順」(Aと同じ方向、大きさが違う)
C = [2, 0]      ← 「天気予報の見方」(別の方向)

AとBは同じ方向(意味が近い)なので、類似度は高くあるべきです。しかし、

  • ユークリッド距離: A→B = 2.24、A→C = 2.24 → 同じ距離。意味が近いBも遠いCも区別できない

  • コサイン類似度: A→B = 1.0、A→C = 0.45 → 向きの違いを正しく反映

このように、ユークリッド距離は大きさの違いを「距離」として拾ってしまうため、向きが同じ(=意味が近い)ベクトルでも大きさが異なると遠いと判定されてしまいます。コサイン類似度は大きさを無視して向き(角度)だけを比較するため、純粋に意味の近さを測ることができます。

ベクトルA("認証の設定方法")   → ある方向を向いている
ベクトルB("ログインの手順")   → Aとほぼ同じ方向 → コサイン類似度 ≈ 0.92
ベクトルC("天気予報の見方")   → AとBとは全然違う方向 → コサイン類似度 ≈ 0.05

結果が -1〜1 の範囲に収まるためスコアの比較もしやすく、ほとんどのベクトル検索システムでコサイン類似度が採用されています。


ベクトル検索の流れ

ここまでの要素を使って、ベクトル検索がどのように動くかを整理します。

- インデックス構築時(事前処理)

1. 各チャンクをEmbeddingモデルでベクトルに変換する
2. ベクトルをベクトルDB(またはメモリ上の配列)に保存する

- 検索時

1. ユーザーのクエリをEmbeddingモデルでベクトルに変換する
2. インデックス内のすべてのチャンクベクトルとコサイン類似度を計算する
3. スコアが高い上位k件を返す

第1回で見たLlamaIndexのコードでは、この処理が VectorStoreIndex.from_documents() と query_engine.query() の裏側で自動的に行われていました。

from llama_index.core import SimpleDirectoryReader, VectorStoreIndex

# 1. ドキュメントを読み込む
documents = SimpleDirectoryReader("./docs").load_data()

# 2. インデックスを作成する(チャンク分割 → Embedding → 保存を内部でやっている)
index = VectorStoreIndex.from_documents(documents)

# 3. 質問する(検索 → コンテキスト付与 → LLM生成を内部でやっている)
query_engine = index.as_query_engine()
response = query_engine.query("〇〇について教えて")

print(response)


- LangChainでベクトル検索を使う

LangChainでベクトル検索を実装すると、以下のようになります。

from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma

# Embeddingモデルを指定
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")

# チャンクをベクトル化してChromaDBに保存
vectorstore = Chroma.from_documents(chunks, embeddings)

# 検索
results = vectorstore.similarity_search("認証の設定方法", k=5)

3行で動きます。LlamaIndexと同じく、Embedding → 保存 → 検索がフレームワークに抽象化されています。

※ここではOpenAIのEmbeddingモデルとChromaDBを使っていますが、どちらも差し替え可能です。EmbeddingモデルをHuggingFaceのローカルモデルに変えたり、ベクトルDBをFAISSやpgvectorに変えても、similarity_search() の呼び方は変わりません。目的や環境に合わせて選べるのがLangChainの統一的なインターフェースの強みです。


- フレームワークを使わずに自分で書く

フレームワークを使わずにベクトル検索を組み立てることもできます。Embeddingの取得部分だけが違い、ベクトルを得た後の検索処理は共通です。

from openai import OpenAI
import numpy as np

client = OpenAI()

# チャンクを一括でベクトル化
response = client.embeddings.create(input=chunk_texts, model="text-embedding-3-large")
chunk_vecs = np.array([item.embedding for item in sorted(response.data, key=lambda x: x.index)])

# クエリをベクトル化
response = client.embeddings.create(input=["認証の設定方法"], model="text-embedding-3-large")
query_vec = np.array(response.data[0].embedding)

# コサイン類似度で検索
def cosine_similarity(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

scores = [cosine_similarity(query_vec, cv) for cv in chunk_vecs]
top_k = np.argsort(scores)[-5:][::-1]

for i in top_k:
    print(f"スコア: {scores[i]:.3f} | {chunk_texts[i][:80]}...")

ベクトルを得るまでがAPI呼び出しに変わるだけで、コサイン類似度の計算以降はまったく同じコードです。


- sentence-transformersで書く場合

API課金なしでローカル完結させたい場合は、sentence-transformersが使えます。HuggingFaceのモデルをローカルで動かすためのライブラリです。

from sentence_transformers import SentenceTransformer
import numpy as np

# Embeddingモデルのロード(初回はモデルをダウンロード)
model = SentenceTransformer("all-MiniLM-L6-v2")

# チャンクを一括でベクトル化
chunk_vecs = model.encode(chunk_texts)  # → (チャンク数, 384) のNumPy配列

# クエリをベクトル化
query_vec = model.encode("認証の設定方法")

# コサイン類似度で検索
def cosine_similarity(a, b):
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))

scores = [cosine_similarity(query_vec, cv) for cv in chunk_vecs]
top_k = np.argsort(scores)[-5:][::-1]

for i in top_k:
    print(f"スコア: {scores[i]:.3f} | {chunk_texts[i][:80]}...")

LangChain版との対応を整理すると以下のようになります。

画像

どちらの方法でも、フレームワークが1行で隠していた処理を自分でステップごとに書く形になります。手間は増えますが、ステップの間に処理を挟めるようになります。

コードの流れに沿って見ると、

1. チャンクをベクトル化      → chunk_vecs
2. クエリをベクトル化        → query_vec
                            ← ここに挟める(例: メタデータフィルタリング)
3. コサイン類似度を計算      → scores
                            ← ここに挟める(例: 閾値フィルタ、デバッグ出力)
4. 上位k件を取得            → top_k
  • メタデータフィルタリング(2→3の間): 類似度を計算する前に、対象チャンクを絞り込める。例えば .py ファイルのチャンクだけに限定してからスコアを計算すれば、ドキュメントがノイズとして混ざるのを防げる

  • 閾値フィルタ(3→4の間): scores が数値として手元にあるので、「上位5件だがスコアが0.3以下のものは除外する」といったフィルタを挟める

  • デバッグ(3の時点): 全チャンクのスコアが見えるので、「なぜこのチャンクが上位に来たのか」「上位と下位でスコアにどれだけ差があるか」を確認できる

といった処理を挟むことができるようになります。

LangChainの similarity_search() はデフォルトではスコアを返さないため、こうした調整をしたい場合は中身を自分で書くほうがやりやすいです。


- ベクトルDBについて

上の例ではベクトルをNumPy配列としてメモリに持ち、全チャンクとの類似度を総当たりで計算しています。チャンク数が数千程度ならこれで十分な速度が出ます。

しかし、チャンク数が数万〜数十万になると総当たりでは遅くなるため、ベクトルDBが必要になります。

ベクトルDBは、近似最近傍探索(ANN: Approximate Nearest Neighbor)というアルゴリズムを使って、すべてのベクトルと比較しなくても近いベクトルを高速に見つけられるようにしたデータベースです。

代表的なものとして以下のようなものが挙げられます。

  • FAISS(Meta製): ライブラリとして使う。ローカルで高速

  • pgvector: PostgreSQLの拡張。既存のPostgreSQLにベクトル検索を追加できる

  • Pinecone: クラウドサービス。運用の手間が少ない

  • ChromaDB: 開発・プロトタイプ向け。セットアップが簡単

開発ではpgvectorを使いました。既にPostgreSQLをデータベースとして使っていたため、同じDB上でベクトル検索もできるのが運用面で合理的でした。


ベクトル検索の弱点

ベクトル検索は「意味が近い」で検索できる強力な手法ですが、弱点もあります。

- 固有名詞・識別子に弱い

第1回でも触れましたが、ベクトル検索は意味を持たない固有の文字列を正確にヒットさせるのが苦手です。

例えば「契約書No.2024-0381の内容を教えて」と検索すると、ベクトル検索は「契約書」の意味に反応して契約書関連のチャンク全般にヒットします。「No.2024-0381」という番号をピンポイントで含むチャンクが上位に来るとは限りません。

社内ツール名、プロジェクトコード、関数名なども同様です。
これらはEmbeddingモデルにとっては「意味的に区別しにくい文字列」なので、ベクトルの距離に差がつきにくいのです。


- コードの検索に弱い

変数名や関数名は意味的な情報が薄く、Embeddingモデルがうまく捉えられないことが多いです。setup_authentication と「認証の設定」が意味的に近いと判定できるかどうかは、モデルの学習データ次第です。


- Embeddingモデルの品質に依存する

同じテキストでも、使うEmbeddingモデルによってベクトルの品質(=検索精度)が大きく変わります。特に日本語では、英語向けに最適化されたモデルだと精度が大幅に落ちることがあります。


ハイブリッド検索 — キーワード検索とベクトル検索を組み合わせる

ここまでの内容を整理すると、

  • キーワード検索(第2回): キーワードの一致で検索。固有名詞や識別子に強いが、表現の揺れに弱い

  • ベクトル検索(今回): 意味の近さで検索。表現が違っても拾えるが、固有名詞や識別子に弱い

これら2種類の検索方法は、互いの弱点を補えることがわかります。
この2つを組み合わせるのがハイブリッド検索です。


- ハイブリッド検索の流れ

1. ベクトル検索で上位k件を取得 → スコアを正規化
2. キーワード検索で上位k件を取得 → スコアを正規化
3. 両方のスコアを重み付けで統合
4. 統合スコアの上位k件を最終結果とする


- スコアの正規化が必要な理由

キーワード検索のスコアとベクトル検索のスコアは尺度がまったく違います

  • キーワード検索のスコア(BM25の場合): 0〜数十の範囲(チャンク数やクエリの単語数に依存)

  • ベクトル検索のスコア(コサイン類似度の場合): -1〜1の範囲

これらをそのまま足し合わせると、スコアの大きいほう(キーワード検索)に結果が引きずられてしまいます。そのため、まず両方のスコアを0〜1の範囲に正規化する必要があります。

def normalize(scores):
    min_s, max_s = min(scores), max(scores)
    if max_s == min_s:
        return [0.0] * len(scores)
    return [(s - min_s) / (max_s - min_s) for s in scores]


- 重み付けが必要な理由

正規化して尺度を揃えたら、単純に足し合わせるだけでもハイブリッド検索にはなります。しかしそれだと、キーワード検索とベクトル検索が常に50:50の比率で統合されてしまいます。

データの特性によって、どちらの検索を重視すべきかは異なります。例えば、関数名やクラス名の完全一致が重要なコードベースならキーワード検索の比率を上げたいですし、表現の揺れが多いマニュアルならベクトル検索の比率を上げたいです。この調整のために重み(α)を導入します。


- 重み付けの統合

正規化したスコアをαで重み付けして統合します。

最終スコア = α × キーワード検索スコア(正規化済) + (1 - α) × ベクトル検索スコア(正規化済)

α はキーワード検索の重みです。例えば α = 0.4 なら「キーワード検索を4割、ベクトル検索を6割」で統合するという意味です。

これをコードにすると以下のようになります。

import numpy as np

def hybrid_search(query, chunks, bm25_index, embedding_model, chunk_vecs, k=5, alpha=0.4):
    # キーワード検索スコア(BM25にはトークン化が必要。第2回参照)
    bm25_scores = bm25_index.get_scores(query.lower().split())
    bm25_norm = normalize(bm25_scores)
    
    # ベクトル検索スコア
    query_vec = embedding_model.encode(query)
    vec_scores = [cosine_similarity(query_vec, cv) for cv in chunk_vecs]
    vec_norm = normalize(vec_scores)
    
    # 統合
    final_scores = [alpha * b + (1 - alpha) * v for b, v in zip(bm25_norm, vec_norm)]
    
    # 上位k件
    top_k = np.argsort(final_scores)[-k:][::-1]
    return [(chunks[i], final_scores[i]) for i in top_k]


- αの決め方

α に正解はありません。まずは 0.3〜0.5 程度から始めて、第4回で扱う評価指標(Recall@k等)を見ながら調整するのが実用的です。

データの特性によって適切な値は変わります。

  • 固有名詞が多いデータ(製品カタログ、契約書など)→ αを大きく(キーワード検索寄り)

  • 自然言語の説明が多いデータ(マニュアル、ガイドなど)→ αを小さく(ベクトル検索寄り)

開発では、固有名詞の検索精度がベクトル検索だけでは不十分だったため、キーワード検索(BM25)を組み合わせたハイブリッド検索に切り替えました。これにより、社内固有の略語や製品名もちゃんとヒットするようになりました。

※なお、今回扱ったのはスコアベースの統合方法ですが、他にも順位ベースの手法としてRRF(Reciprocal Rank Fusion)があります。RRFはスコアではなく各検索結果の順位だけを使って統合するため、正規化やαの調整が不要というメリットがあります。一方で、スコアの差(1位と2位が僅差なのか大差なのか)の情報が失われるため、スコアベースの統合より精度が落ちるケースがあります。


Embeddingモデルの選定

ベクトル検索の精度はEmbeddingモデルの品質に直結します。
特に日本語を扱う場合、モデル選定は非常に重要です。

- 主な選択肢

画像

※日本語対応は目安であり、実データで必ず比較する必要あり。


- 日本語での注意点

日本語を扱う場合、Embeddingモデルの選定は英語以上に重要です。
日本語には以下のような難しさがあります。

  • 表記揺れ: 「サーバー」「サーバ」「server」が同じものを指す

  • 敬語・丁寧語: 「設定してください」「設定する」「設定の仕方」が同じ意味

  • 漢字・ひらがなの混在: 「認証」「にんしょう」の対応

英語向けに最適化されたモデルは、これらの日本語特有のパターンを十分に学習していないことがあります。日本語を主に扱うなら、日本語に特化したモデル(Ruri、GLuCoSE-base-jaなど)や、多言語対応モデル(multilingual-e5-large)を候補に含めて比較することをおすすめします。


- モデル選定の進め方

  1. 候補を2〜3個選ぶ

  2. 自分のデータでテストクエリを用意する(最低20〜30問)

  3. 各モデルで検索結果を比較する(第4回のRecall@k等を使う)

  4. 精度・速度・コストのバランスで決める

ベンチマーク上のスコアが高くても、自分のデータで良い結果が出るとは限りません。必ず実際のデータで試してから決めてください。


さらに精度を上げるには

ハイブリッド検索で多くのケースはカバーできますが、さらに高い精度を目指すための手法もあります。
※ここでは参考までに軽く触れるにとどめます。


- クエリ拡張

ユーザーのクエリを言い換えたり、複数のバリエーションを生成して検索し、結果を統合する手法です。LLMにクエリを渡して「この質問を別の表現で3通り言い換えて」と頼むだけで実装できます。

例えば「認証の設定方法」というクエリに対して、

  • 「ログインの設定手順」

  • 「authenticationの構成方法」

  • 「認証機能のセットアップ」

のようなバリエーションを生成し、それぞれで検索した結果を統合します。1つのクエリでは拾えなかったチャンクが、別の表現で見つかることがあります。


- HyDE(Hypothetical Document Embeddings)

クエリから仮の回答文をLLMに生成させ、その回答文でベクトル検索する手法です。

クエリ(「認証の設定方法は?」)は短く、文書とは文体が異なるため、ベクトルの類似度が低くなりがちです。一方、LLMが生成した仮の回答文(「認証を設定するには、まずconfig.yamlで...」)は実際の文書に近い文体になるため、ベクトル検索の精度が上がります。

LLM呼び出しが1回増えるだけなので、実装コストは低いです。


- Reranking(リランキング)

ハイブリッド検索で取得した上位k件を、別のモデルで再スコアリングする手法です。

1. ハイブリッド検索で上位20件を取得(粗い検索)
2. Rerankモデルで20件を再スコアリング(精密な判定)
3. 再スコアリング後の上位5件を最終結果とする

Rerankモデル(Cross-Encoderとも呼ばれる)は、クエリとチャンクのペアを同時に入力して関連性を判定します。Embeddingモデルがクエリとチャンクを別々にベクトル化するのに対し、Rerankモデルはペアで評価するため、より精密な判定が可能です。その分計算コストが高いため、全チャンクに適用するのではなく、検索結果の上位に絞って使うのが一般的です。


- メタデータフィルタリング

検索の前にメタデータで対象を絞り込む手法です。第2回で扱ったメタデータ(ファイルパス、ファイルタイプ、見出し階層など)を使って、検索対象を事前に限定します。

例えば「Pythonの認証コードを教えて」というクエリなら、.py ファイルのチャンクだけに絞ってから検索することで、Markdownのドキュメントがノイズとして混ざるのを防げます。

検索精度の改善というよりノイズの除去ですが、効果は大きく実装も比較的簡単です。


まとめ

  • Embeddingはテキストを「意味の座標」(数値ベクトル)に変換する技術。意味が近いテキストは近いベクトルになる

  • ベクトル検索はコサイン類似度を使い、意味的に近いチャンクを見つける。RAGにおける最も一般的な検索手法

  • ただし、固有名詞やコードの検索には弱い。キーワード検索と組み合わせたハイブリッド検索で互いの弱点を補える

  • ハイブリッド検索ではスコアの正規化と重み付けがポイント。αの値はデータの特性と評価指標で調整する

  • 日本語を扱うならEmbeddingモデルの選定が検索精度を大きく左右する。必ず実データで比較して選ぶ

  • 次回は、検索の質を定量的に測る「検索精度を測る」を扱います


いいなと思ったら応援しよう!

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
【RAG】Embedding とベクトル検索|y
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1