サイトアイコンmaita tomoya dev io

#84 ベクトル検索とRAG入門 — 仕組みから実装例まで、具体例で学ぶAI検索の基礎

はじめに

ChatGPTやClaudeなどのLLM(大規模言語モデル)を業務で使おうとすると、必ずぶつかるのが「LLMは社内のドキュメントや最新情報を知らない」という壁だ。この壁を乗り越える手法として一気に普及したのがRAG(Retrieval-Augmented Generation)であり、その中核を支えるのがベクトル検索である。

本記事では、ベクトル検索とRAGの仕組みを、身近な具体例とコードサンプルを交えながら解説する。読み終わる頃には、公式ドキュメントを読む準備が整い、自分で最小のRAGシステムを組み立てられる状態を目指す。


なぜ「普通の検索」ではダメなのか

キーワード検索の限界

まず、従来のキーワード検索(全文検索)の例を見てみよう。

社内FAQに以下の文書があったとする。

「有給休暇の申請は、前日までに上長へSlackで連絡してください」

ここでユーザーが「休みを取るにはどうすればいい?」と検索した場合、**「休み」「取る」**というキーワードは文書中に一切登場しない。全文検索(LIKE句やElasticsearchのmatchクエリ)ではヒットしない可能性が高い。

検索クエリ文書中の語一致
休み有給休暇
取る申請
どうすれば連絡してください

人間なら「同じこと聞いてるな」と分かるが、キーワード検索は文字の一致しか見ないので無力である。

ベクトル検索が解決すること

ベクトル検索は「意味が近いものを探す」検索手法だ。文章を数百〜数千次元の数値ベクトル(座標)に変換し、座標が近いもの=意味が近いものとして扱う。

先ほどの例なら、「休みを取る」と「有給休暇の申請」は意味的に近いので、ベクトル空間上でも近い位置に配置される。結果、キーワードが一致しなくてもヒットする。


ベクトルと埋め込み(Embedding)の具体例

「ベクトルにする」ってどういうこと?

言葉だけだとピンと来ないので、2次元の超シンプルな例で考えてみる。各単語に(動物度, 食べ物度)という2つの軸で点数を付けるとしよう。

単語動物度食べ物度
0.950.05
0.930.07
りんご0.020.90
バナナ0.030.88
寿司0.100.95

これを2次元平面にプロットすると、「犬」と「猫」は近い場所に、「りんご」と「バナナ」と「寿司」も近い場所に集まる。一方、「犬」と「寿司」は遠い。

軸は2つだけじゃない——実際は「何百〜何千の軸」を使う

ここまでは話を簡単にするため「動物度」「食べ物度」の2軸だけで説明してきた。ただ、現実の単語はもっと多様な性質を持っている。たとえば「鶏肉」は動物でもあり食べ物でもある、「アイボ」は生き物のように見える家電だ——こうした微妙な違いを、たった2軸では表現しきれない。

そこで実際のEmbeddingモデルは、軸を数百〜数千個用意して、それぞれの単語に対してすべての軸の値を決めていく。OpenAIのtext-embedding-3-small1536軸(=1536次元)text-embedding-3-large3072軸といった具合だ。

イメージとしては、先ほどの表をそのまま横にぐーんと伸ばす感じになる。

単語次元1次元2次元3...次元1536
-0.01230.0456-0.0789...0.0234
-0.01180.0442-0.0801...0.0251
寿司0.0567-0.02340.0912...-0.0456

主要なモデルの次元数を比較するとこうなる。

モデル次元数
text-embedding-3-small1536
text-embedding-3-large3072
text-embedding-ada-002(旧)1536
multilingual-e5-large(OSS)1024

ただし、各軸に「動物度」のような名前は付いていない

例え話と現実には大きな違いがひとつある。さきほどの2軸の例では、人間が「動物度」「食べ物度」と意味のある名前を付けた。しかし実際のEmbeddingモデルでは、1536個の軸がそれぞれ何を表しているか、人間には分からない

モデルがやっているのはシンプルで、「似た文脈で使われる語は近い座標に置く」というルールだけを守って、大量のテキストから自動的に座標を決めている。結果、各軸は「動物度」のようにきれいに分かれず、いろんな意味要素が絡み合った抽象的な軸になる。

つまり:

  • 2軸の例: 人間が軸の意味を定義 → どの軸が何を表すか分かる
  • 実際の1536軸: モデルが勝手に軸を学習 → 軸の意味は分からない。でも、ベクトル同士の距離は意味的な近さを正しく表す

「各軸の意味は分からなくても、ベクトル全体としては意味を捉えている」——この感覚が掴めれば十分だ。次元数が多いほど微妙なニュアンスを表現できるが、その分ストレージと計算コストも増えるので、実務では精度とコストのバランスで選ぶことになる(小〜中規模なら1536次元のsmallで十分なことが多い)。


単語ベクトル — すべての出発点

ここまで「文章を数値ベクトルにする」という話をしてきたが、実はこの考え方には**単語ベクトル(Word Embedding)**というルーツがある。現代のEmbeddingモデルを理解する上で欠かせない、とても面白い話なので少し寄り道しよう。

きっかけは2013年のWord2Vec

2013年、GoogleのTomas Mikolov氏らが発表したWord2Vecという論文が、自然言語処理の世界を一変させた。それまで「単語」はコンピュータから見れば単なる文字列、あるいは「ID:42」のような識別子でしかなかった。ところがWord2Vecは、単語を数百次元のベクトルに変換し、そのベクトル同士の関係が意味を表すことを示したのである。

Word2Vecの学習方法はシンプルで、「ある単語の周辺に出てくる単語を予測する」というタスクをニューラルネットで解かせる、というものだった。すると副産物として、単語ごとに「意味を捉えたベクトル」が得られる。

「王様 - 男性 + 女性 = 女王」の衝撃

Word2Vecで一番有名な例が、このベクトル演算だ。

vec("king") - vec("man") + vec("woman") ≒ vec("queen")

日本語で言えば「王様から男性成分を引いて女性成分を足すと女王になる」という、まるで代数のような計算が成立する。

なぜこんなことが起きるのか。ベクトル空間の中で、単語たちが次のように配置されているからだ。

  男性                    女性
    |                       |
  king -----(性別の差)----> queen
    |                       |
  man ------(性別の差)----> woman

つまり、「性別を表す方向」「王族らしさを表す方向」といった意味の軸が、ベクトル空間の中に自然に現れる。人間が「性別軸」と名前を付けたわけではないのに、モデルが大量のテキストから自動的にこの構造を学んだのだ。

他にも、こんな演算が成立することが報告された。

演算近いベクトル
Paris - France + JapanTokyo
walked - walking + swimmingswam
bigger - big + smallsmaller

国と首都の関係、動詞の過去形・進行形の関係、形容詞の比較級といった言語的な関係性が、そのままベクトルの引き算の方向として表現される。これが「単語の意味をベクトルで捉えている」という主張の強力な証拠になった。

類似単語を取り出してみる

Word2Vecで「dog」に近い単語を取り出すと、こんな結果が得られる(あくまでイメージ)。

単語dogとの類似度
dogs0.87
puppy0.82
cat0.75
pet0.70
horse0.65
car0.12

同義語(dogs, puppy)上位概念(pet)、**同じカテゴリ(cat, horse)**がきれいに上位に来て、無関係な「car」は下に沈む。これこそがベクトル検索の原点だ。

Word2Vecの後継たち

Word2Vec以降、単語ベクトルの世界はどんどん進化していった。

モデル特徴
Word2Vec2013周辺語予測による単語ベクトル。ベクトル演算が成立
GloVe2014単語共起行列からベクトルを学習(Stanford)
fastText2016部分文字列も学習 → 未知語・活用形に強い(Facebook)
ELMo2018文脈によって変わる単語ベクトル
BERT2018Transformer登場。双方向の文脈を取り込む
OpenAI Embeddings2022-文章全体をまとめてベクトル化

初期の単語ベクトルの限界

Word2VecやGloVeには重大な弱点があった。それは一つの単語に一つのベクトルしか割り当てられないことだ。

たとえば「bank」は「銀行」と「川岸」の両方の意味を持つ同綴異義語だが、Word2Vecではどちらの意味でも同じベクトルが返ってくる。「I went to the bank to deposit money」でも「I sat on the bank of the river」でも、全く同じベクトルだ。

bank (銀行) ─┐
              ├── 同じベクトル ⚠️
bank (川岸) ─┘

これだと「お金を銀行に預けたい」と検索したときに、川の記事がヒットしてしまう可能性がある。

文脈を読むモデルの登場

この問題を解決したのがELMoやBERTに代表される、文脈依存の単語ベクトルだ。同じ「bank」でも、前後の文脈に応じて異なるベクトルを返してくれる。

「I went to the bank to deposit money」 → bankのベクトル = [0.12, -0.45, ...]
「I sat on the bank of the river」      → bankのベクトル = [0.67, 0.22, ...]

前後の単語を見てベクトルを動的に調整するこの仕組みは、Transformerというアーキテクチャの登場によって実現した。そしてこのTransformerの系譜から、後にGPTやBERT、そしてOpenAIのEmbeddingモデルが生まれていく。

「単語」から「文章」へ

ここが重要なポイントだ。現代のEmbeddingモデル(OpenAIのtext-embedding-3など)は、単語1つではなく文章全体を1つのベクトルにまとめてくれる。

  • Word2Vecの時代: 「犬」→ 1本のベクトル / 「可愛い」→ 1本のベクトル / 文章をベクトル化したければ平均を取るなどの工夫が必要
  • 現代: 「うちの犬はとても可愛い」という文全体を、文脈込みで1本のベクトルにしてくれる

これによって、RAGのように文章単位で意味を比較することが自然にできるようになった。Word2Vecが示した「意味をベクトルで表す」という発想を、文・段落・ドキュメントへとスケールアップしたのが、今のEmbeddingモデルというわけだ。

歴史を押さえると、この後に出てくるOpenAI APIのEmbeddingが何をやっているのか、腹落ちしやすくなるはずだ。


実際に動かしてみる

OpenAIのEmbedding APIを使ってみる

実際にOpenAIのtext-embedding-3-smallモデルで「犬」をベクトル化してみる。

from openai import OpenAI

client = OpenAI()

response = client.embeddings.create(
    model="text-embedding-3-small",
    input="犬"
)

vector = response.data[0].embedding
print(f"次元数: {len(vector)}")
print(f"先頭10個: {vector[:10]}")

実行結果(例):

次元数: 1536
先頭10個: [-0.0123, 0.0456, -0.0789, 0.0234, ...]

1536個の数字の羅列が返ってくる。人間には意味不明だが、モデルにとっては「犬」という概念を表す座標だ。

類似度の計算 — コサイン類似度

2つのベクトルがどれだけ近いかを測る代表的な指標がコサイン類似度である。2つのベクトルのなす角度の余弦(cos θ)で、1に近いほど似ている0は無関係-1は正反対を意味する。

import numpy as np

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

# 3つの文章をベクトル化
texts = [
    "休みを取るにはどうすればいいですか",
    "有給休暇の申請は前日までに上長へSlackで連絡してください",
    "今日のランチは何を食べようかな"
]
vectors = [
    client.embeddings.create(model="text-embedding-3-small", input=t).data[0].embedding
    for t in texts
]

print(f"質問 vs FAQ:   {cosine_similarity(vectors[0], vectors[1]):.3f}")
print(f"質問 vs ランチ: {cosine_similarity(vectors[0], vectors[2]):.3f}")

実行結果(例):

質問 vs FAQ:   0.612
質問 vs ランチ: 0.198

キーワードが一致していないのに、「休みを取る質問」と「有給休暇のFAQ」は高い類似度が出ている。これがベクトル検索の威力だ。


RAG(Retrieval-Augmented Generation)とは

何を解決するのか

LLMには次のような弱点がある。

  1. 知識が古い — 学習データのカットオフ以降の情報を知らない
  2. 社内情報を知らない — 非公開のドキュメントや顧客情報は当然学習していない
  3. ハルシネーション — それっぽい嘘を自信満々に生成することがある

これらを解決するために、質問に関連する文書を検索して、その内容をプロンプトに同梱してLLMに答えさせる手法がRAGである。

RAGの3ステップ

[1. Indexing(事前準備)]
  社内文書 → 分割 → Embedding → ベクトルDBに保存

[2. Retrieval(検索)]
  ユーザーの質問 → Embedding → ベクトルDBで類似文書を検索

[3. Generation(生成)]
  検索結果 + 質問 → LLMに投げる → 回答を得る

図で表すと以下のようになる。

ユーザー質問
    ↓
[Embedding化]
    ↓
[ベクトルDB検索] ←─── 社内文書(事前にEmbedding済み)
    ↓
関連文書TOP-K
    ↓
[プロンプト組み立て]
  「以下の文書を参考に質問に答えて: {文書} 質問: {質問}」
    ↓
  [LLM]
    ↓
  回答

最小のRAGを実装してみる

実際に動く最小構成のRAGを組み立ててみよう。ここではベクトルDBを使わず、Pythonのリストとnumpyだけで実装する(理解優先のため)。

ステップ1: 社内文書をEmbedding化

from openai import OpenAI
import numpy as np

client = OpenAI()

# 仮の社内FAQデータ
documents = [
    "有給休暇の申請は、前日までに上長へSlackで連絡してください。",
    "経費精算は毎月25日までに会計システムに入力してください。",
    "リモートワークは週3日まで可能です。事前に上長の承認が必要です。",
    "社内研修は毎週水曜日の15時からZoomで開催されます。",
    "健康診断は年1回、10月に実施されます。予約は9月中に行ってください。"
]

def embed(text: str) -> list[float]:
    """テキストをベクトル化する"""
    resp = client.embeddings.create(model="text-embedding-3-small", input=text)
    return resp.data[0].embedding

# 文書を全部ベクトル化してメモリに保存
doc_vectors = [embed(d) for d in documents]

ステップ2: 質問から関連文書を検索

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

def retrieve(question: str, top_k: int = 2) -> list[str]:
    """質問に近い文書をTOP-K件返す"""
    q_vec = embed(question)
    scored = [(cosine_similarity(q_vec, dv), doc) for dv, doc in zip(doc_vectors, documents)]
    scored.sort(key=lambda x: x[0], reverse=True)
    return [doc for _, doc in scored[:top_k]]

# 実行
question = "休みを取りたいんだけど、どうすればいい?"
relevant_docs = retrieve(question, top_k=2)
for i, d in enumerate(relevant_docs, 1):
    print(f"{i}. {d}")

実行結果(例):

1. 有給休暇の申請は、前日までに上長へSlackで連絡してください。
2. リモートワークは週3日まで可能です。事前に上長の承認が必要です。

「休み」「取る」といった単語は文書に含まれていないのに、意味が近いため正しく有給休暇のFAQがTOPに来ている。

ステップ3: LLMに答えさせる

def answer(question: str) -> str:
    docs = retrieve(question, top_k=2)
    context = "\n".join(f"- {d}" for d in docs)

    prompt = f"""以下の社内ドキュメントを参考に、質問に答えてください。
ドキュメントに書かれていないことは「分かりません」と答えてください。

# ドキュメント
{context}

# 質問
{question}
"""
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}]
    )
    return resp.choices[0].message.content

print(answer("休みを取りたいんだけど、どうすればいい?"))

実行結果(例):

有給休暇を取得する場合は、前日までに上長へSlackで連絡してください。

これで最小のRAGが完成した。わずか数十行のコードで、社内ドキュメントに基づいて答えるAIアシスタントが動く。


実務で使うベクトルDB

上の例はメモリ上のリストで済ませたが、実務では数万〜数千万件の文書を扱うためベクトルDBを使う。代表的な選択肢は以下の通り。

ベクトルDB特徴こんな時に
PineconeフルマネージドSaaS。運用ほぼ不要運用工数を減らしたい
WeaviateOSS + SaaS。ハイブリッド検索が強いセルフホストも視野
QdrantOSS。Rust実装で高速性能重視・オンプレ運用
Chroma軽量OSS。ローカル開発に便利プロトタイピング
pgvectorPostgreSQLの拡張。既存DBと統合既にPostgresを使っている
Elasticsearch全文検索+ベクトル検索のハイブリッドキーワード検索も必要

pgvectorでの例

既存のPostgreSQLがある現場ではpgvectorが手軽だ。

-- 拡張を有効化
CREATE EXTENSION IF NOT EXISTS vector;

-- テーブル作成(1536次元のベクトル列)
CREATE TABLE documents (
  id SERIAL PRIMARY KEY,
  content TEXT,
  embedding vector(1536)
);

-- 類似検索(<=> はコサイン距離演算子)
SELECT content, embedding <=> '[0.012, ...]'::vector AS distance
FROM documents
ORDER BY embedding <=> '[0.012, ...]'::vector
LIMIT 5;

距離が小さい=類似度が高い。インデックス(HNSWやIVFFlat)を張れば数百万件でも高速に検索できる。


RAGの精度を上げる実践テクニック

最小RAGが動いたら、次は精度を上げるフェーズだ。実務でよく使われる工夫を紹介する。

1. チャンク分割(Chunking)

長いドキュメントを丸ごとEmbeddingすると、ノイズが多くて類似度が出にくい。そのため適切なサイズに分割する。

  • 目安:500〜1000文字程度(日本語なら300〜500字)
  • オーバーラップを設ける(例:50文字かぶせる)ことで、文の切れ目で情報が失われるのを防ぐ
def chunk_text(text: str, size: int = 500, overlap: int = 50) -> list[str]:
    chunks = []
    start = 0
    while start < len(text):
        chunks.append(text[start:start + size])
        start += size - overlap
    return chunks

2. ハイブリッド検索

ベクトル検索は意味的類似には強いが、固有名詞や型番など「完全一致してほしいもの」には弱い。そこでBM25(キーワード検索) + ベクトル検索を組み合わせ、スコアを合成する手法が広く使われる。

3. リランキング(Reranking)

ベクトル検索でTOP-20件取ってきて、それをより精度の高いモデル(Cross-Encoder)で再スコアリングしてTOP-3に絞る。Cohere Rerankなどが有名。

4. メタデータフィルタ

文書に「部署」「作成日」などメタデータを付けておき、検索時に絞り込む。

# 例: 人事部の文書かつ2025年以降
filter = {"department": "HR", "created_at": {"$gte": "2025-01-01"}}

5. 質問の書き換え(Query Rewriting)

ユーザーの質問をそのまま検索するのではなく、LLMに「検索に適した形」に書き換えさせてから検索する。会話履歴を踏まえた質問の明確化にも有効。


よくあるハマりどころ

実際に筆者が手を動かして遭遇した注意点をまとめる。

1. 同じモデルで埋め込むこと

インデックス作成時と検索時で、必ず同じEmbeddingモデルを使うtext-embedding-3-smalltext-embedding-3-largeはベクトル空間が別物なので、混ぜると類似度が意味を持たなくなる。

2. 日本語の扱い

日本語は単語の境界が曖昧なため、英語向けのEmbeddingモデルだと精度が落ちることがある。OpenAIのtext-embedding-3シリーズは多言語対応で日本語も強いが、用途によっては多言語E5BGE-M3などのOSSモデルも検討に値する。

3. コスト

Embedding APIは安いが、大量の文書を毎回Embeddingし直すと馬鹿にならない。ドキュメントのハッシュを取って、変更があった時だけ再Embeddingする仕組みを入れるとよい。

4. セキュリティ

社内文書をOpenAI APIに送る場合、契約や社内規程で外部送信が許される内容かを必ず確認する。機密度が高い場合はAzure OpenAIの企業契約や、オンプレのOSSモデル(Llama系+OSS Embedding)を検討する。


まとめ

  • ベクトル検索は文章を高次元の数値ベクトルに変換し、意味が近いものを探す検索手法。キーワードが一致しなくてもヒットする。
  • RAGは「検索で関連文書を取得 → LLMに同梱して答えさせる」パターン。LLMの知識不足とハルシネーションを同時に緩和できる。
  • 最小RAGはEmbedding API + numpy + LLM APIの3点で数十行で作れる。まず動かしてみるのが理解への近道。
  • 実務投入にはベクトルDB、チャンク分割、ハイブリッド検索、リランキングなどの工夫を積み重ねる。
  • 同じEmbeddingモデルを使う、日本語モデルを選ぶ、コスト管理、社外送信の可否——この4点は最初に押さえておきたい注意点。

まずは本記事のコードをコピペして自分の環境で動かし、手持ちのドキュメントを食わせてみてほしい。「意味で検索できる」感覚を掴めれば、公式ドキュメントやベクトルDBの比較記事がぐっと読みやすくなるはずだ。


参考リンク