Skilore

Embeddings — ベクトル表現・類似度検索・クラスタリング

Embedding はテキスト・画像等のデータを高次元ベクトル空間に射影する技術であり、意味的類似度の計算、検索、分類、クラスタリングなど LLM エコシステムの基盤を支える数学的表現手法である。

84 分で読めます41,632 文字

Embeddings — ベクトル表現・類似度検索・クラスタリング

Embedding はテキスト・画像等のデータを高次元ベクトル空間に射影する技術であり、意味的類似度の計算、検索、分類、クラスタリングなど LLM エコシステムの基盤を支える数学的表現手法である。

この章で学ぶこと

  1. Embedding の数学的基礎 — ベクトル空間、距離関数、次元削減の原理
  2. Embedding モデルの選定と利用 — API・OSS モデルの比較、多言語対応、ファインチューニング
  3. 実践的な応用パターン — セマンティック検索、クラスタリング、異常検知、分類

前提知識

このガイドを読む前に、以下の知識があると理解が深まります:


1. Embedding の基本概念

Embedding の直感的理解
テキスト ベクトル空間
"猫が眠る" ──▶ [0.82, 0.15, -0.33, ...]
"犬が寝る" ──▶ [0.79, 0.18, -0.31, ...] ← 近い
"経済が成長" ──▶ [-0.21, 0.67, 0.44, ...] ← 遠い
y
^ 犬が寝る
| * *猫が眠る
|
|
| *経済が成長
|
└──────────────────▶ x
意味が近い → ベクトルが近い (コサイン類似度が高い)
意味が遠い → ベクトルが遠い (コサイン類似度が低い)

1.1 距離関数

import numpy as np
 
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
    """コサイン類似度: -1 ~ 1 (1に近いほど類似)"""
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
 
def euclidean_distance(a: np.ndarray, b: np.ndarray) -> float:
    """ユークリッド距離: 0 ~ ∞ (0に近いほど類似)"""
    return np.linalg.norm(a - b)
 
def dot_product(a: np.ndarray, b: np.ndarray) -> float:
    """内積: 正規化済みベクトルではコサイン類似度と等価"""
    return np.dot(a, b)
 
# 使用例
vec_cat  = np.array([0.82, 0.15, -0.33])
vec_dog  = np.array([0.79, 0.18, -0.31])
vec_econ = np.array([-0.21, 0.67, 0.44])
 
print(f"猫-犬: {cosine_similarity(vec_cat, vec_dog):.4f}")   # → 0.9987 (高い)
print(f"猫-経済: {cosine_similarity(vec_cat, vec_econ):.4f}") # → -0.2341 (低い)

1.2 距離関数の使い分け

距離関数の選択ガイド
コサイン類似度 (Cosine Similarity)
├── 範囲: -1 ~ 1
├── ベクトルの方向のみを比較 (大きさは無視)
├── テキスト Embedding で最も一般的
└── 推奨: ほとんどの検索・類似度タスク
ユークリッド距離 (L2 Distance)
├── 範囲: 0 ~ ∞
├── ベクトルの大きさも考慮
├── クラスタリングで使用
└── 推奨: 正規化されていないベクトル
内積 (Dot Product / Inner Product)
├── 範囲: -∞ ~ ∞
├── 正規化済みベクトルではコサイン類似度と等価
├── 計算が最も高速
└── 推奨: 正規化済みベクトル (多くの API がデフォルト)
マンハッタン距離 (L1 Distance)
├── 各次元の絶対差の合計
├── 外れ値に対してユークリッドより頑健
└── 推奨: スパースなベクトル

2. Embedding モデルの利用

2.1 OpenAI Embedding API

from openai import OpenAI
 
client = OpenAI()
 
# 単一テキストの埋め込み
response = client.embeddings.create(
    model="text-embedding-3-large",
    input="機械学習とは何ですか?",
    dimensions=1024,  # 次元削減 (3072 → 1024): コスト・速度改善
)
embedding = response.data[0].embedding
print(f"次元数: {len(embedding)}")  # 1024
 
# バッチ処理 (最大2048テキスト)
texts = [
    "Pythonは汎用プログラミング言語です",
    "機械学習は人工知能の一分野です",
    "寿司は日本の伝統的な料理です",
]
response = client.embeddings.create(
    model="text-embedding-3-small",
    input=texts,
)
embeddings = [d.embedding for d in response.data]

2.2 OSS Embedding モデル (Sentence Transformers)

from sentence_transformers import SentenceTransformer
 
# BGE-M3: 多言語対応の高性能OSSモデル
model = SentenceTransformer("BAAI/bge-m3")
 
texts = [
    "機械学習の基礎を学ぶ",
    "ディープラーニング入門",
    "今日のランチは何にする?",
]
 
embeddings = model.encode(texts, normalize_embeddings=True)
print(f"形状: {embeddings.shape}")  # (3, 1024)
 
# 類似度行列
from sentence_transformers.util import cos_sim
similarity_matrix = cos_sim(embeddings, embeddings)
print(similarity_matrix)

2.3 Cohere Embed v3

import cohere
 
co = cohere.Client("YOUR_API_KEY")
 
# input_type が重要: クエリとドキュメントで異なる埋め込みを生成
query_embed = co.embed(
    texts=["日本の人口は?"],
    model="embed-multilingual-v3.0",
    input_type="search_query",       # 検索クエリ用
).embeddings[0]
 
doc_embeds = co.embed(
    texts=[
        "日本の人口は約1億2500万人です。",
        "東京は日本の首都です。",
    ],
    model="embed-multilingual-v3.0",
    input_type="search_document",    # 文書用
).embeddings

2.4 Google Vertex AI Embedding

from google.cloud import aiplatform
from vertexai.language_models import TextEmbeddingModel
 
# Google の多言語 Embedding モデル
model = TextEmbeddingModel.from_pretrained("text-multilingual-embedding-002")
 
embeddings = model.get_embeddings(
    texts=["機械学習の基礎", "ディープラーニング入門"],
    auto_truncate=True,
)
 
for emb in embeddings:
    print(f"次元数: {len(emb.values)}")  # 768
    print(f"統計: {emb.statistics}")

2.5 Embedding モデルのローカル実行

# ONNX ランタイムで高速推論
from optimum.onnxruntime import ORTModelForFeatureExtraction
from transformers import AutoTokenizer
import numpy as np
 
# ONNX 最適化済みモデルをロード
model = ORTModelForFeatureExtraction.from_pretrained(
    "BAAI/bge-m3",
    export=True,  # 初回は PyTorch → ONNX 変換
)
tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-m3")
 
# 推論
inputs = tokenizer(
    ["機械学習の基礎を学ぶ"],
    padding=True,
    truncation=True,
    max_length=512,
    return_tensors="np",
)
 
outputs = model(**inputs)
# [CLS] トークンの出力を Embedding として使用
embedding = outputs.last_hidden_state[:, 0, :]
embedding = embedding / np.linalg.norm(embedding, axis=1, keepdims=True)
print(f"形状: {embedding.shape}")  # (1, 1024)

3. Embedding モデル比較

3.1 性能・スペック比較

モデル 次元数 最大入力 日本語 MTEB 料金 ライセンス
text-embedding-3-large 3072 8191 tok 64.6 $0.13/1M API
text-embedding-3-small 1536 8191 tok 62.3 $0.02/1M API
Cohere embed-v3 1024 512 tok 64.5 $0.10/1M API
Voyage-3 1024 32000 tok 67.1 $0.06/1M API
BGE-M3 1024 8192 tok 65.0 無料 MIT
multilingual-e5-large 1024 512 tok 61.5 無料 MIT
nomic-embed-text 768 8192 tok 62.4 無料 Apache 2.0

3.2 用途別推奨

用途 推奨モデル 理由
日本語検索 BGE-M3 / Cohere v3 多言語性能最高
低コスト大量処理 text-embedding-3-small 最安 + 十分な品質
最高精度 Voyage-3 / BGE-M3 MTEB 上位
長文書対応 Voyage-3 32K トークン対応
オンプレミス BGE-M3 OSS + 高性能
エッジデバイス nomic-embed-text 軽量 768次元

3.3 Embedding モデル選定フローチャート

Embedding モデル選定フロー
START: 要件確認
├── データをクラウドに送れない?
YES → OSS モデル
├── 多言語 → BGE-M3
├── 軽量 → nomic-embed-text
└── 日本語特化 → multilingual-e5-large
NO ↓
├── 長文書 (8K+ トークン) を扱う?
YES → Voyage-3 (32K 対応)
NO ↓
├── コスト重視?
YES → text-embedding-3-small ($0.02/1M)
NO ↓
├── 日本語重視?
YES → Cohere embed-v3 / BGE-M3
NO ↓
└── 最高精度
→ text-embedding-3-large (dimensions=1024)

4. 実践的な応用パターン

4.1 セマンティック検索

import numpy as np
from openai import OpenAI
 
client = OpenAI()
 
def semantic_search(query: str, documents: list[str], top_k: int = 5) -> list:
    """セマンティック検索の基本実装"""
 
    # 全テキストを一括で埋め込み
    all_texts = [query] + documents
    response = client.embeddings.create(
        model="text-embedding-3-small",
        input=all_texts,
    )
 
    query_vec = np.array(response.data[0].embedding)
    doc_vecs = np.array([d.embedding for d in response.data[1:]])
 
    # コサイン類似度を計算
    similarities = np.dot(doc_vecs, query_vec) / (
        np.linalg.norm(doc_vecs, axis=1) * np.linalg.norm(query_vec)
    )
 
    # 上位 k 件を返す
    top_indices = np.argsort(similarities)[::-1][:top_k]
    return [
        {"text": documents[i], "score": float(similarities[i])}
        for i in top_indices
    ]
 
# 使用例
docs = [
    "Pythonは汎用的なプログラミング言語で、機械学習で広く使われています",
    "JavaScriptはWebブラウザで動作するスクリプト言語です",
    "深層学習はニューラルネットワークを多層にした機械学習手法です",
    "寿司は酢飯と魚介類を組み合わせた日本料理です",
]
results = semantic_search("AIに使われる言語は?", docs, top_k=2)
for r in results:
    print(f"[{r['score']:.4f}] {r['text']}")

4.2 ハイブリッド検索 (ベクトル + キーワード)

import numpy as np
from rank_bm25 import BM25Okapi
import MeCab
 
def hybrid_search(
    query: str,
    documents: list[str],
    alpha: float = 0.5,  # 0=キーワードのみ, 1=ベクトルのみ
    top_k: int = 5,
) -> list[dict]:
    """ハイブリッド検索: BM25 + Embedding"""
 
    # 1. BM25 (キーワード検索)
    mecab = MeCab.Tagger("-Owakati")
    tokenized_docs = [mecab.parse(doc).strip().split() for doc in documents]
    tokenized_query = mecab.parse(query).strip().split()
 
    bm25 = BM25Okapi(tokenized_docs)
    bm25_scores = bm25.get_scores(tokenized_query)
    # 正規化 (0-1)
    if bm25_scores.max() > 0:
        bm25_scores = bm25_scores / bm25_scores.max()
 
    # 2. Embedding (セマンティック検索)
    all_texts = [query] + documents
    response = client.embeddings.create(
        model="text-embedding-3-small",
        input=all_texts,
    )
    query_vec = np.array(response.data[0].embedding)
    doc_vecs = np.array([d.embedding for d in response.data[1:]])
 
    cosine_scores = np.dot(doc_vecs, query_vec) / (
        np.linalg.norm(doc_vecs, axis=1) * np.linalg.norm(query_vec)
    )
    # 正規化 (0-1)
    cosine_scores = (cosine_scores + 1) / 2
 
    # 3. スコア統合
    hybrid_scores = alpha * cosine_scores + (1 - alpha) * bm25_scores
 
    # 上位 k 件を返す
    top_indices = np.argsort(hybrid_scores)[::-1][:top_k]
    return [
        {
            "text": documents[i],
            "hybrid_score": float(hybrid_scores[i]),
            "vector_score": float(cosine_scores[i]),
            "bm25_score": float(bm25_scores[i]),
        }
        for i in top_indices
    ]

4.3 クラスタリング

from sklearn.cluster import KMeans
from sklearn.decomposition import PCA
import matplotlib.pyplot as plt
import numpy as np
 
def cluster_texts(texts: list[str], n_clusters: int = 3):
    """テキストをクラスタリング"""
    # Embedding 取得
    embeddings = get_embeddings(texts)  # [N, dim]
 
    # K-means クラスタリング
    kmeans = KMeans(n_clusters=n_clusters, random_state=42)
    labels = kmeans.fit_predict(embeddings)
 
    # PCA で2次元に次元削減して可視化
    pca = PCA(n_components=2)
    reduced = pca.fit_transform(embeddings)
 
    # クラスタごとにグループ化
    clusters = {}
    for text, label in zip(texts, labels):
        clusters.setdefault(int(label), []).append(text)
 
    return clusters
 
# 使用例
texts = [
    "Pythonで機械学習", "TensorFlowの使い方", "PyTorchチュートリアル",
    "東京の観光地", "京都の寺院", "大阪の食べ歩き",
    "確定申告の方法", "住民税の計算", "年末調整の手順",
]
clusters = cluster_texts(texts, n_clusters=3)
for label, items in clusters.items():
    print(f"\nクラスタ {label}:")
    for item in items:
        print(f"  - {item}")

4.4 異常検知

import numpy as np
 
def detect_anomalies(
    reference_texts: list[str],
    test_texts: list[str],
    threshold: float = 0.5,
) -> list[dict]:
    """Embedding ベースの異常検知"""
 
    ref_embeddings = np.array(get_embeddings(reference_texts))
    test_embeddings = np.array(get_embeddings(test_texts))
 
    # 参照テキストの重心 (セントロイド) を計算
    centroid = ref_embeddings.mean(axis=0)
    centroid /= np.linalg.norm(centroid)  # 正規化
 
    results = []
    for text, emb in zip(test_texts, test_embeddings):
        emb_norm = emb / np.linalg.norm(emb)
        similarity = np.dot(centroid, emb_norm)
        is_anomaly = similarity < threshold
        results.append({
            "text": text,
            "similarity": float(similarity),
            "is_anomaly": is_anomaly,
        })
 
    return results

4.5 テキスト分類 (ゼロショット)

def zero_shot_classify(text: str, categories: list[str]) -> dict:
    """Embedding ベースのゼロショット分類"""
 
    # テキストとカテゴリ全てを埋め込み
    all_inputs = [text] + categories
    embeddings = get_embeddings(all_inputs)
 
    text_emb = np.array(embeddings[0])
    cat_embs = np.array(embeddings[1:])
 
    # 各カテゴリとの類似度を計算
    similarities = np.dot(cat_embs, text_emb) / (
        np.linalg.norm(cat_embs, axis=1) * np.linalg.norm(text_emb)
    )
 
    # ソフトマックスで確率に変換
    exp_sim = np.exp(similarities * 10)  # temperature=0.1
    probs = exp_sim / exp_sim.sum()
 
    return {cat: float(prob) for cat, prob in zip(categories, probs)}
 
# 使用例
result = zero_shot_classify(
    "新しいGPUが発表され、AI処理が3倍高速化",
    ["テクノロジー", "スポーツ", "政治", "エンタメ"]
)
# → {'テクノロジー': 0.89, 'スポーツ': 0.03, '政治': 0.04, 'エンタメ': 0.04}

4.6 重複検出 (Deduplication)

import numpy as np
from itertools import combinations
 
def find_near_duplicates(
    texts: list[str],
    threshold: float = 0.95,
) -> list[tuple[int, int, float]]:
    """Embedding ベースの重複検出"""
 
    embeddings = np.array(get_embeddings(texts))
    # 正規化
    norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
    normalized = embeddings / norms
 
    # 類似度行列を計算
    similarity_matrix = np.dot(normalized, normalized.T)
 
    # 閾値以上のペアを抽出
    duplicates = []
    for i, j in combinations(range(len(texts)), 2):
        sim = similarity_matrix[i, j]
        if sim >= threshold:
            duplicates.append((i, j, float(sim)))
 
    return sorted(duplicates, key=lambda x: -x[2])
 
# 使用例
texts = [
    "Pythonは人気のプログラミング言語です",
    "Pythonは広く使われるプログラミング言語です",  # ほぼ重複
    "JavaScriptはWebで使われる言語です",
    "今日は天気が良いです",
]
 
duplicates = find_near_duplicates(texts, threshold=0.9)
for i, j, sim in duplicates:
    print(f"[{sim:.4f}] '{texts[i]}' ≈ '{texts[j]}'")

4.7 レコメンデーション

import numpy as np
 
class EmbeddingRecommender:
    """Embedding ベースのレコメンデーションエンジン"""
 
    def __init__(self):
        self.items: list[dict] = []
        self.embeddings: np.ndarray | None = None
 
    def add_items(self, items: list[dict]):
        """アイテムを追加 (title, description, metadata)"""
        self.items = items
        texts = [f"{item['title']}: {item['description']}" for item in items]
        self.embeddings = np.array(get_embeddings(texts))
        # 正規化
        norms = np.linalg.norm(self.embeddings, axis=1, keepdims=True)
        self.embeddings = self.embeddings / norms
 
    def recommend_by_text(self, query: str, top_k: int = 5) -> list[dict]:
        """テキストクエリに基づくレコメンド"""
        query_emb = np.array(get_embeddings([query])[0])
        query_emb = query_emb / np.linalg.norm(query_emb)
 
        scores = np.dot(self.embeddings, query_emb)
        top_indices = np.argsort(scores)[::-1][:top_k]
 
        return [
            {**self.items[i], "score": float(scores[i])}
            for i in top_indices
        ]
 
    def recommend_similar(self, item_index: int, top_k: int = 5) -> list[dict]:
        """類似アイテムのレコメンド"""
        scores = np.dot(self.embeddings, self.embeddings[item_index])
        top_indices = np.argsort(scores)[::-1][1:top_k+1]  # 自分自身を除外
 
        return [
            {**self.items[i], "score": float(scores[i])}
            for i in top_indices
        ]
 
    def recommend_by_history(
        self, viewed_indices: list[int], top_k: int = 5
    ) -> list[dict]:
        """閲覧履歴に基づくレコメンド"""
        # 閲覧アイテムの平均ベクトル
        viewed_embs = self.embeddings[viewed_indices]
        profile = viewed_embs.mean(axis=0)
        profile = profile / np.linalg.norm(profile)
 
        scores = np.dot(self.embeddings, profile)
 
        # 既に閲覧したアイテムを除外
        for idx in viewed_indices:
            scores[idx] = -1
 
        top_indices = np.argsort(scores)[::-1][:top_k]
        return [
            {**self.items[i], "score": float(scores[i])}
            for i in top_indices
        ]

5. 次元削減とパフォーマンス最適化

Embedding 最適化のトレードオフ
次元数 精度 ストレージ 検索速度
3072 最高 ×3 遅い
1536 高い ×1.5 普通
1024 良好 ×1 速い ← 推奨
512 中程度 ×0.5 最速
256 低下 ×0.25 最速
推奨: 1024次元がコスパ最適
理由: 精度低下が1-2%に対し、ストレージ・速度が大幅改善
Matryoshka Embedding:
text-embedding-3 は任意の次元数に切り詰め可能
dimensions パラメータで指定するだけ

5.1 Matryoshka Representation Learning (MRL)

from openai import OpenAI
 
client = OpenAI()
 
# 同じテキストを異なる次元数で埋め込み
text = "機械学習の基礎を学ぶ"
 
for dim in [256, 512, 1024, 3072]:
    response = client.embeddings.create(
        model="text-embedding-3-large",
        input=text,
        dimensions=dim,
    )
    emb = response.data[0].embedding
    print(f"次元数: {dim:4d}, メモリ: {dim * 4:>5d} bytes/vector")
 
# 出力:
# 次元数:  256, メモリ:  1024 bytes/vector
# 次元数:  512, メモリ:  2048 bytes/vector
# 次元数: 1024, メモリ:  4096 bytes/vector
# 次元数: 3072, メモリ: 12288 bytes/vector
 
# MRL の利点:
# - 同一モデルで精度 vs コストを柔軟に調整
# - 先頭の次元ほど重要な情報を保持
# - 段階的な検索 (粗い→細かい) が可能

5.2 バッチ処理と並列化

import asyncio
from openai import AsyncOpenAI
 
async def batch_embed(texts: list[str], batch_size: int = 100) -> list:
    """大量テキストの効率的な埋め込み"""
    client = AsyncOpenAI()
    all_embeddings = []
 
    # バッチに分割
    batches = [texts[i:i+batch_size] for i in range(0, len(texts), batch_size)]
 
    # 並列実行 (レート制限に注意)
    semaphore = asyncio.Semaphore(5)  # 同時5リクエストまで
 
    async def embed_batch(batch):
        async with semaphore:
            response = await client.embeddings.create(
                model="text-embedding-3-small",
                input=batch,
            )
            return [d.embedding for d in response.data]
 
    results = await asyncio.gather(*[embed_batch(b) for b in batches])
 
    for batch_result in results:
        all_embeddings.extend(batch_result)
 
    return all_embeddings

5.3 Embedding のキャッシュ戦略

import hashlib
import json
import sqlite3
import numpy as np
from typing import Optional
 
class EmbeddingCache:
    """SQLite ベースの Embedding キャッシュ"""
 
    def __init__(self, db_path: str = "embedding_cache.db", model: str = "text-embedding-3-small"):
        self.model = model
        self.conn = sqlite3.connect(db_path)
        self.conn.execute("""
            CREATE TABLE IF NOT EXISTS embeddings (
                text_hash TEXT PRIMARY KEY,
                model TEXT,
                embedding BLOB,
                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
            )
        """)
 
    def _hash(self, text: str) -> str:
        return hashlib.sha256(f"{self.model}:{text}".encode()).hexdigest()
 
    def get(self, text: str) -> Optional[list[float]]:
        """キャッシュから Embedding を取得"""
        row = self.conn.execute(
            "SELECT embedding FROM embeddings WHERE text_hash = ?",
            (self._hash(text),),
        ).fetchone()
        if row:
            return json.loads(row[0])
        return None
 
    def put(self, text: str, embedding: list[float]):
        """Embedding をキャッシュに保存"""
        self.conn.execute(
            "INSERT OR REPLACE INTO embeddings (text_hash, model, embedding) VALUES (?, ?, ?)",
            (self._hash(text), self.model, json.dumps(embedding)),
        )
        self.conn.commit()
 
    def get_or_create(self, texts: list[str]) -> list[list[float]]:
        """キャッシュミスのテキストのみ API を呼び出し"""
        results = [None] * len(texts)
        uncached_indices = []
 
        for i, text in enumerate(texts):
            cached = self.get(text)
            if cached:
                results[i] = cached
            else:
                uncached_indices.append(i)
 
        if uncached_indices:
            uncached_texts = [texts[i] for i in uncached_indices]
            response = client.embeddings.create(
                model=self.model, input=uncached_texts
            )
            for idx, emb_data in zip(uncached_indices, response.data):
                results[idx] = emb_data.embedding
                self.put(texts[idx], emb_data.embedding)
 
        return results
 
# 使用例
cache = EmbeddingCache()
embeddings = cache.get_or_create(["Hello", "World", "Hello"])  # "Hello" は1回だけAPI呼び出し

6. Embedding のファインチューニング

6.1 Sentence Transformers でのファインチューニング

from sentence_transformers import (
    SentenceTransformer,
    InputExample,
    losses,
)
from torch.utils.data import DataLoader
 
# ベースモデルをロード
model = SentenceTransformer("BAAI/bge-m3")
 
# 訓練データの準備 (正例ペア)
train_examples = [
    InputExample(texts=["Pythonの使い方", "Pythonプログラミング入門"], label=0.9),
    InputExample(texts=["Pythonの使い方", "Java言語の基礎"], label=0.3),
    InputExample(texts=["機械学習とは", "ディープラーニングの基礎"], label=0.8),
    InputExample(texts=["機械学習とは", "今日の天気"], label=0.05),
    # 1000+ ペアを推奨
]
 
train_dataloader = DataLoader(train_examples, shuffle=True, batch_size=16)
 
# CosineSimilarity Loss (回帰型)
train_loss = losses.CosineSimilarityLoss(model)
 
# 訓練
model.fit(
    train_objectives=[(train_dataloader, train_loss)],
    epochs=3,
    warmup_steps=100,
    output_path="./finetuned-embedding",
)
 
# ファインチューニング済みモデルの利用
finetuned = SentenceTransformer("./finetuned-embedding")
embeddings = finetuned.encode(["テスト文書"])

6.2 対比学習 (Contrastive Learning) によるファインチューニング

from sentence_transformers import InputExample, losses
from sentence_transformers.evaluation import InformationRetrievalEvaluator
 
# Triplet Loss (アンカー, 正例, 負例)
triplet_examples = [
    InputExample(texts=[
        "Pythonでのデータ分析",     # アンカー
        "pandas を使ったデータ処理",  # 正例 (類似)
        "JavaScriptフレームワーク",   # 負例 (非類似)
    ]),
    # ...
]
 
triplet_loader = DataLoader(triplet_examples, shuffle=True, batch_size=16)
triplet_loss = losses.TripletLoss(model, distance_metric=losses.TripletDistanceMetric.COSINE)
 
# Multiple Negatives Ranking Loss (大規模データに最適)
mnrl_examples = [
    InputExample(texts=["クエリ1", "関連文書1"]),
    InputExample(texts=["クエリ2", "関連文書2"]),
    # バッチ内の他ペアが自動的に負例になる
]
 
mnrl_loader = DataLoader(mnrl_examples, shuffle=True, batch_size=32)
mnrl_loss = losses.MultipleNegativesRankingLoss(model)
 
# 評価器の設定
evaluator = InformationRetrievalEvaluator(
    queries={"q1": "Python データ分析", "q2": "機械学習 入門"},
    corpus={"d1": "pandasの使い方...", "d2": "scikit-learnチュートリアル..."},
    relevant_docs={"q1": {"d1"}, "q2": {"d2"}},
)
 
model.fit(
    train_objectives=[(mnrl_loader, mnrl_loss)],
    evaluator=evaluator,
    epochs=5,
    evaluation_steps=500,
    output_path="./finetuned-retrieval",
)

7. チャンク分割戦略

テキストチャンク分割の戦略
1. 固定長分割 (Fixed Size)
├── 実装が最も簡単
├── 文の途中で切れるリスク
└── 推奨チャンクサイズ: 256-512 トークン
2. セマンティック分割 (Semantic Chunking)
├── 文/段落境界で分割
├── Embedding の類似度が急変する点で分割
└── 意味の一貫性が高い
3. オーバーラップ分割 (Sliding Window)
├── チャンク間に重複区間を設定
├── 境界付近の情報損失を軽減
└── 推奨オーバーラップ: 50-100 トークン
4. 再帰的分割 (Recursive)
├── LangChain の RecursiveCharacterTextSplitter
├── 段落 → 文 → 単語の順で階層的に分割
└── 最も汎用的で品質が良い
5. ドキュメント構造ベース
├── Markdown: ヘッダーで分割
├── HTML: タグ構造で分割
└── コード: 関数/クラス単位で分割
from langchain.text_splitter import RecursiveCharacterTextSplitter
 
# 再帰的分割 (最も一般的)
splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,        # トークン数ではなく文字数
    chunk_overlap=50,      # オーバーラップ文字数
    separators=["\n\n", "\n", "。", "、", " ", ""],  # 分割優先順
    length_function=len,
)
 
chunks = splitter.split_text(long_document)
 
# セマンティック分割 (Embedding 類似度ベース)
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
 
semantic_splitter = SemanticChunker(
    OpenAIEmbeddings(model="text-embedding-3-small"),
    breakpoint_threshold_type="percentile",
    breakpoint_threshold_amount=90,  # 類似度の低い箇所で分割
)
 
semantic_chunks = semantic_splitter.split_text(long_document)

8. トラブルシューティング

8.1 よくある問題と解決策

Embedding のトラブルシューティング
問題 1: 検索精度が低い
├── 原因 1: チャンクサイズが不適切
└── 解決: 256-512 トークンに調整
├── 原因 2: クエリとドキュメントの形式が異なる
└── 解決: e5系は "query:" "passage:" プレフィックス
├── 原因 3: モデルの言語対応が弱い
└── 解決: 多言語モデル (BGE-M3) に変更
└── 原因 4: ドメイン特化用語が多い
└── 解決: ファインチューニングを検討
問題 2: 速度が遅い
├── 原因 1: 毎回 API を呼んでいる
└── 解決: キャッシュ層を追加
├── 原因 2: バッチ処理していない
└── 解決: 100件/バッチで一括処理
└── 原因 3: 次元数が大きすぎる
└── 解決: 1024次元に削減
問題 3: コストが高い
├── 原因 1: 大きいモデルを使っている
└── 解決: text-embedding-3-small に変更
├── 原因 2: 重複テキストを再計算している
└── 解決: ハッシュベースのキャッシュ
└── 原因 3: 不必要に長いテキストを埋め込んでいる
└── 解決: チャンク分割で最適長に
問題 4: 類似度スコアが直感と合わない
├── 原因 1: 距離関数の選択ミス
└── 解決: コサイン類似度を使用
└── 原因 2: 正規化されていない
└── 解決: normalize_embeddings=True

8.2 Embedding の品質検証

import numpy as np
from itertools import combinations
 
def validate_embeddings(
    test_pairs: list[tuple[str, str, float]],
    model_name: str = "text-embedding-3-small",
) -> dict:
    """Embedding モデルの品質検証
 
    test_pairs: [(text_a, text_b, expected_similarity), ...]
    expected_similarity: 0.0 (無関係) ~ 1.0 (同義)
    """
    texts_a = [p[0] for p in test_pairs]
    texts_b = [p[1] for p in test_pairs]
    expected = [p[2] for p in test_pairs]
 
    embs_a = np.array(get_embeddings(texts_a, model=model_name))
    embs_b = np.array(get_embeddings(texts_b, model=model_name))
 
    # コサイン類似度を計算
    actual = []
    for a, b in zip(embs_a, embs_b):
        sim = np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
        actual.append(float(sim))
 
    # 相関係数 (期待値との相関)
    from scipy.stats import spearmanr
    correlation, p_value = spearmanr(expected, actual)
 
    # 分類精度 (閾値0.5で正/負を判別)
    correct = sum(
        1 for e, a in zip(expected, actual)
        if (e >= 0.5 and a >= 0.5) or (e < 0.5 and a < 0.5)
    )
    accuracy = correct / len(test_pairs)
 
    return {
        "model": model_name,
        "spearman_correlation": correlation,
        "p_value": p_value,
        "classification_accuracy": accuracy,
        "mean_actual_similarity": np.mean(actual),
    }

9. アンチパターン

アンチパターン 1: Embedding モデルの混在

# NG: インデックス時と検索時で異なるモデルを使用
index_embeddings = openai_embed(documents)    # text-embedding-3-large
query_embedding = cohere_embed(query)          # embed-v3
# → ベクトル空間が異なるため、類似度計算が無意味
 
# OK: 同一モデルで統一
index_embeddings = openai_embed(documents, model="text-embedding-3-small")
query_embedding = openai_embed(query, model="text-embedding-3-small")

アンチパターン 2: 巨大テキストの直接埋め込み

# NG: 10万文字のドキュメントをそのまま埋め込み
embedding = embed(huge_document)  # 情報が圧縮されすぎて精度低下
 
# OK: 適切にチャンク分割してから埋め込み
chunks = split_text(huge_document, chunk_size=512)
chunk_embeddings = [embed(chunk) for chunk in chunks]
# → 各チャンクの意味が保持される

アンチパターン 3: キャッシュなしの大量処理

# NG: 同じテキストを何度も API に送信
for query in user_queries:  # 多くは過去のクエリと同一
    embedding = embed(query)  # 毎回 API 呼び出し → コスト膨大
 
# OK: キャッシュを使用
cache = EmbeddingCache()
for query in user_queries:
    embedding = cache.get_or_create([query])[0]

アンチパターン 4: Embedding の可視化なしでの運用

# NG: Embedding の分布を確認せずにデプロイ
# → 想定外のクラスタリング結果やバイアスに気づかない
 
# OK: 定期的に可視化して品質を確認
from sklearn.manifold import TSNE
import matplotlib.pyplot as plt
 
def visualize_embeddings(embeddings, labels, title="Embedding Space"):
    tsne = TSNE(n_components=2, random_state=42, perplexity=30)
    reduced = tsne.fit_transform(np.array(embeddings))
 
    plt.figure(figsize=(10, 8))
    for label in set(labels):
        mask = [l == label for l in labels]
        points = reduced[mask]
        plt.scatter(points[:, 0], points[:, 1], label=label, alpha=0.6)
    plt.legend()
    plt.title(title)
    plt.savefig("embedding_visualization.png")

実践演習

演習1: 基本的な実装

以下の要件を満たすコードを実装してください。

要件:

  • 入力データの検証を行うこと
  • エラーハンドリングを適切に実装すること
  • テストコードも作成すること
# 演習1: 基本実装のテンプレート
class Exercise1:
    """基本的な実装パターンの演習"""
 
    def __init__(self):
        self.data = []
 
    def validate_input(self, value):
        """入力値の検証"""
        if value is None:
            raise ValueError("入力値がNoneです")
        return True
 
    def process(self, value):
        """データ処理のメインロジック"""
        self.validate_input(value)
        self.data.append(value)
        return self.data
 
    def get_results(self):
        """処理結果の取得"""
        return {
            'count': len(self.data),
            'data': self.data
        }
 
# テスト
def test_exercise1():
    ex = Exercise1()
    assert ex.process(1) == [1]
    assert ex.process(2) == [1, 2]
    assert ex.get_results()['count'] == 2
 
    try:
        ex.process(None)
        assert False, "例外が発生するべき"
    except ValueError:
        pass
 
    print("全テスト合格!")
 
test_exercise1()

演習2: 応用パターン

基本実装を拡張して、以下の機能を追加してください。

# 演習2: 応用パターン
from typing import List, Dict, Optional
from datetime import datetime
 
class AdvancedExercise:
    """応用パターンの演習"""
 
    def __init__(self, max_size: int = 100):
        self._items: List[Dict] = []
        self._max_size = max_size
        self._created_at = datetime.now()
 
    def add(self, key: str, value: any) -> bool:
        """アイテムの追加(サイズ制限付き)"""
        if len(self._items) >= self._max_size:
            return False
        self._items.append({
            'key': key,
            'value': value,
            'timestamp': datetime.now().isoformat()
        })
        return True
 
    def find(self, key: str) -> Optional[Dict]:
        """キーによる検索"""
        for item in reversed(self._items):
            if item['key'] == key:
                return item
        return None
 
    def remove(self, key: str) -> bool:
        """キーによる削除"""
        for i, item in enumerate(self._items):
            if item['key'] == key:
                self._items.pop(i)
                return True
        return False
 
    def stats(self) -> Dict:
        """統計情報"""
        return {
            'total_items': len(self._items),
            'max_size': self._max_size,
            'usage_percent': len(self._items) / self._max_size * 100,
            'uptime': str(datetime.now() - self._created_at)
        }
 
# テスト
def test_advanced():
    ex = AdvancedExercise(max_size=3)
    assert ex.add("a", 1) == True
    assert ex.add("b", 2) == True
    assert ex.add("c", 3) == True
    assert ex.add("d", 4) == False  # サイズ制限
    assert ex.find("b")['value'] == 2
    assert ex.remove("b") == True
    assert ex.find("b") is None
    stats = ex.stats()
    assert stats['total_items'] == 2
    print("応用テスト全合格!")
 
test_advanced()

演習3: パフォーマンス最適化

以下のコードのパフォーマンスを改善してください。

# 演習3: パフォーマンス最適化
import time
from functools import lru_cache
 
# 最適化前(O(n^2))
def slow_search(data: list, target: int) -> int:
    """非効率な検索"""
    for i in range(len(data)):
        for j in range(i + 1, len(data)):
            if data[i] + data[j] == target:
                return (i, j)
    return (-1, -1)
 
# 最適化後(O(n))
def fast_search(data: list, target: int) -> tuple:
    """ハッシュマップを使った効率的な検索"""
    seen = {}
    for i, num in enumerate(data):
        complement = target - num
        if complement in seen:
            return (seen[complement], i)
        seen[num] = i
    return (-1, -1)
 
# ベンチマーク
def benchmark():
    import random
    data = list(range(5000))
    random.shuffle(data)
    target = data[100] + data[4000]
 
    start = time.time()
    result1 = slow_search(data, target)
    slow_time = time.time() - start
 
    start = time.time()
    result2 = fast_search(data, target)
    fast_time = time.time() - start
 
    print(f"非効率版: {slow_time:.4f}秒")
    print(f"効率版:   {fast_time:.6f}秒")
    print(f"高速化率: {slow_time/fast_time:.0f}倍")
 
benchmark()

ポイント:

  • アルゴリズムの計算量を意識する
  • 適切なデータ構造を選択する
  • ベンチマークで効果を測定する

設計判断ガイド

選択基準マトリクス

技術選択を行う際の判断基準を以下にまとめます。

判断基準 重視する場合 妥協できる場合
パフォーマンス リアルタイム処理、大規模データ 管理画面、バッチ処理
保守性 長期運用、チーム開発 プロトタイプ、短期プロジェクト
スケーラビリティ 成長が見込まれるサービス 社内ツール、固定ユーザー
セキュリティ 個人情報、金融データ 公開データ、社内利用
開発速度 MVP、市場投入スピード 品質重視、ミッションクリティカル

アーキテクチャパターンの選択

アーキテクチャ選択フロー
① チーム規模は?
├─ 小規模(1-5人)→ モノリス
└─ 大規模(10人+)→ ②へ
② デプロイ頻度は?
├─ 週1回以下 → モノリス + モジュール分割
└─ 毎日/複数回 → ③へ
③ チーム間の独立性は?
├─ 高い → マイクロサービス
└─ 中程度 → モジュラーモノリス

トレードオフの分析

技術的な判断には必ずトレードオフが伴います。以下の観点で分析を行いましょう:

1. 短期 vs 長期のコスト

  • 短期的に速い方法が長期的には技術的負債になることがある
  • 逆に、過剰な設計は短期的なコストが高く、プロジェクトの遅延を招く

2. 一貫性 vs 柔軟性

  • 統一された技術スタックは学習コストが低い
  • 多様な技術の採用は適材適所が可能だが、運用コストが増加

3. 抽象化のレベル

  • 高い抽象化は再利用性が高いが、デバッグが困難になる場合がある
  • 低い抽象化は直感的だが、コードの重複が発生しやすい
# 設計判断の記録テンプレート
class ArchitectureDecisionRecord:
    """ADR (Architecture Decision Record) の作成"""
 
    def __init__(self, title: str):
        self.title = title
        self.context = ""
        self.decision = ""
        self.consequences = []
        self.alternatives = []
 
    def set_context(self, context: str):
        """背景と課題の記述"""
        self.context = context
        return self
 
    def set_decision(self, decision: str):
        """決定内容の記述"""
        self.decision = decision
        return self
 
    def add_consequence(self, consequence: str, positive: bool = True):
        """結果の追加"""
        self.consequences.append({
            'description': consequence,
            'type': 'positive' if positive else 'negative'
        })
        return self
 
    def add_alternative(self, name: str, reason_rejected: str):
        """却下した代替案の追加"""
        self.alternatives.append({
            'name': name,
            'reason_rejected': reason_rejected
        })
        return self
 
    def to_markdown(self) -> str:
        """Markdown形式で出力"""
        md = f"# ADR: {self.title}\n\n"
        md += f"## 背景\n{self.context}\n\n"
        md += f"## 決定\n{self.decision}\n\n"
        md += "## 結果\n"
        for c in self.consequences:
            icon = "✅" if c['type'] == 'positive' else "⚠️"
            md += f"- {icon} {c['description']}\n"
        md += "\n## 却下した代替案\n"
        for a in self.alternatives:
            md += f"- **{a['name']}**: {a['reason_rejected']}\n"
        return md

10. FAQ

Q1: Embedding の次元数はどう選ぶべき?

RAG や検索用途では 1024 次元が精度とコストのバランスが良い。 大規模データ (数百万件以上) では 256-512 次元に削減してストレージ・速度を優先。 精度が最重要なら 1536-3072 次元を使い、ANN インデックス (HNSW等) で速度を補う。

Q2: Embedding モデルのファインチューニングは効果的か?

ドメイン固有の用語や概念が多い場合 (医療、法律、特定業界)、ファインチューニングで 5-15% の精度向上が期待できる。 Sentence Transformers の SentenceTransformerTrainer で対比学習が可能。 ただし、汎用用途では最新のプリトレインモデルの方が良い場合も多い。

Q3: 日本語 Embedding で特に注意する点は?

トークナイザの日本語対応品質が性能に直結する。 BGE-M3、Cohere embed-v3、multilingual-e5 は日本語で高性能。 日本語特化モデル (intfloat/multilingual-e5-large 等) は JSTS/JSICK ベンチマークで評価する。 「クエリ」と「ドキュメント」で異なるプレフィックスを付けるモデル (e5系) では、この使い分けが精度に大きく影響する。

Q4: Embedding モデルを変更する際の注意点は?

モデル変更時は全ベクトルの再計算が必要。異なるモデルのベクトル空間は互換性がない。 段階的な移行: 新旧モデルを並行稼働し、品質比較した上で切り替える。 バージョン管理: モデル名とバージョンをメタデータに記録しておく。

Q5: Sparse Embedding と Dense Embedding の違いは?

Dense Embedding (本章): 全次元に値が入る (1024次元に1024個の値)。意味的類似度に強い。 Sparse Embedding (BM25, SPLADE等): ほとんどの次元が0。キーワード一致に強い。 ハイブリッド検索: 両方を組み合わせると最高の検索精度が得られる。 BGE-M3 は Dense + Sparse の両方を生成できる唯一のモデルの一つ。


FAQ

Q1: このトピックを学ぶ上で最も重要なポイントは何ですか?

実践的な経験を積むことが最も重要です。理論だけでなく、実際にコードを書いて動作を確認することで理解が深まります。

Q2: 初心者がよく陥る間違いは何ですか?

基礎を飛ばして応用に進むことです。このガイドで説明している基本概念をしっかり理解してから、次のステップに進むことをお勧めします。

Q3: 実務ではどのように活用されていますか?

このトピックの知識は、日常的な開発業務で頻繁に活用されます。特にコードレビューやアーキテクチャ設計の際に重要になります。


まとめ

項目 推奨
API 推奨モデル text-embedding-3-small (コスパ) / Voyage-3 (精度)
OSS 推奨モデル BGE-M3 (多言語) / nomic-embed-text (軽量)
日本語推奨 BGE-M3 / Cohere embed-v3
推奨次元数 1024 (バランス型)
距離関数 コサイン類似度 (正規化済みなら内積と等価)
バッチサイズ 100-500 テキスト/リクエスト
チャンクサイズ 256-512 トークン (オーバーラップ 50-100)
キャッシュ 必須 (SQLite / Redis)
主要用途 検索、分類、クラスタリング、異常検知、RAG

次に読むべきガイド


参考文献

  1. Muennighoff et al., "MTEB: Massive Text Embedding Benchmark," EACL 2023
  2. OpenAI, "Embeddings Guide," https://platform.openai.com/docs/guides/embeddings
  3. Xiao et al., "C-Pack: Packaged Resources To Advance General Chinese Embedding," arXiv:2309.07597, 2023
  4. Sentence Transformers, "Documentation," https://www.sbert.net/
  5. Kusupati et al., "Matryoshka Representation Learning," NeurIPS 2022
  6. Karpukhin et al., "Dense Passage Retrieval for Open-Domain Question Answering," EMNLP 2020