Embeddingで意味を探す:詳細な説明と例

この記事には短い動画があります:YouTubeTikTok

1. 概要

チャットボットに「飛行機がキャンセルされたのですが、予約したホテルの宿泊料金は払い戻してもらえますか?」と質問したとき、RAGシステムは「旅程の中断」について記載されたポリシーセクションを見つける必要があります。質問に元のテキストと100%一致する単語が含まれていなくても、です。この記事では、embeddingの仕組みについて説明します:テキストをベクトルに変換し、意味的な距離で比較し、LLMが元のテキストにさかのぼってアクセスできるように保存する方法です。読み終えると、Javaで簡単な意味検索パイプラインを自分で書き、それが正しく機能することを実証するテストが書けるようになります。

2. Embeddingとは何か

Embeddingモデルはテキストの一片を受け取り、ベクトル(通常は数百から数千の次元を持つ実数の列)を返します。このベクトルは「圧縮されたキーワード」ではなく、多次元空間内のポイントです。意味的に似ているテキストの断片は、この空間で互いに近い位置に配置されます。

public record EmbeddingVector(float[] values) {
    public int dimension() {
        return values.length;
    }
}

EmbeddingVectorは単にfloat[]のラッパーですが、異なるモデル間でfloat[]を誤って混在させるよくあるエラーを避けるのに役立ちます。システムが成長するにつれて、このような混在はとても起きやすいミスです。

2.1. Embeddingを「明確なラベル付きの地図」のように考えてはいけない理由

実際の地図では、経度と緯度の軸があり、「ハノイ」「ダナン」というラベルがあります。Embeddingの場合、個別の次元には「ホテル」「飛行機」「払い戻し」のようなラベルは付いていません。意味は、ベクトル全体のパターンを通じてのみ現れます。そのため、単一の次元を読むだけでは、それが何を表しているかは分かりません。代わりに、全体的なベクトル同士を比較することだけができます。

これが重要なのは、なぜメモリを節約するためにベクトルを切り詰めてはいけないのかを説明するからです(例:1536個の次元から100個だけを保持)。そうするとパターンが破壊され、検索精度が大幅に低下します。

3. 意味的な距離によるマッチング

2つのベクトル間の「近さ」は通常、コサイン類似度で測定されます。1に近いほど意味的に似ています。

public final class VectorMath {

    public static double cosineSimilarity(float[] a, float[] b) {
        if (a.length != b.length) {
            throw new IllegalArgumentException(
                "ベクトル次元の不一致: %d vs %d".formatted(a.length, b.length));
        }
        double dot = 0, normA = 0, normB = 0;
        for (int i = 0; i < a.length; i++) {
            dot += a[i] * b[i];
            normA += a[i] * a[i];
            normB += b[i] * b[i];
        }
        return dot / (Math.sqrt(normA) * Math.sqrt(normB));
    }
}
  • if (a.length != b.length)行:一般的なエラーを防ぎます。異なる2つのモデルのベクトルを比較する場合(例えば一方は768次元、他方は1536次元)、デバッグが難しいランタイムエラーが発生するため、早期にチェックすることが重要です。
  • dot、normA、normB:標準的なコサイン公式に従って、各ベクトルの内積と長さを計算します。

注意: 2つのモデルが同じ次元数を持つ場合、互換性があるということではありません。モデルAとモデルBの両方が1536次元のベクトルを出力しますが、それらの意味空間は異なります。交差比較を行うと、コードがエラーなく実行されても無意味な結果が返されます。

4. 実際のAPI呼び出しなしで練習するためのembeddingモデルのシミュレーション

すぐに実行できる例を示すために、EmbeddingClientのモックを使用します。このモックは「トピック」(旅行、パスワード、請求)に属するキーワード頻度に基づいています。実際には、OpenAI、Cohere、またはオープンソースモデルに置き換えることになりますが、インターフェースは変わりません。

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>
public interface EmbeddingClient {
    EmbeddingVector embed(String text);
}
public class KeywordEmbeddingClient implements EmbeddingClient {

    private static final List<String> TRAVEL_WORDS =
        List.of("飛行便", "キャンセル", "ホテル", "旅程", "空港");
    private static final List<String> PASSWORD_WORDS =
        List.of("パスワード", "ログイン", "アカウント", "リセット", "忘れた");
    private static final List<String> BILLING_WORDS =
        List.of("払い戻し", "支払い", "請求書", "料金", "手数料");

    @Override
    public EmbeddingVector embed(String text) {
        String normalized = text.toLowerCase();
        float travel = score(normalized, TRAVEL_WORDS);
        float password = score(normalized, PASSWORD_WORDS);
        float billing = score(normalized, BILLING_WORDS);
        return new EmbeddingVector(new float[] { travel, password, billing });
    }

    private float score(String text, List<String> keywords) {
        long matches = keywords.stream().filter(text::contains).count();
        return matches / (float) keywords.size();
    }
}

KeywordEmbeddingClientは観測しやすいように3次元(旅行/パスワード/請求)のみを使用しています。実際のembeddingは数百の次元で学習データから学びますが、「意味的に似たテキストの断片はベクトル空間でも近い」という考え方は同じです。

5. LLMが元のテキストにアクセスできるようにメタデータと共にembeddingを保存する

ベクトルはテキストの位置特定に役立つだけで、LLMは引き続きテキストのコンテンツが必要です。回答を生成するために。したがって、ベクトルストア内のすべてのレコードは、embeddingをソースチャンクと関連メタデータにバインドする必要があります。

public record DocumentChunk(
    String chunkId,
    String documentId,
    String text,
    String section,
    LocalDate effectiveDate,
    int version,
    EmbeddingVector embedding
) {}
  • chunkIdとdocumentId:元のドキュメント全体を取得できます。ソースを表示したり、新しいバージョンで古いデータを更新する際に必要です。
  • section、effectiveDate、version:意味マッチングの前にフィルタリングするのに役立ちます。例えば、有効なポリシーのみを検索し、期限切れのバージョンはスキップするなど。
  • text:LLMは「ベクトルを読む」ことができず、実際のテキストが必要だからです。回答を生成するために。必ず一緒に保存してください。
public class InMemoryVectorStore {

    private final List<DocumentChunk> chunks = new ArrayList<>();

    public void add(DocumentChunk chunk) {
        chunks.add(chunk);
    }

    public List<DocumentChunk> findNearest(EmbeddingVector query, int topK) {
        return chunks.stream()
            .sorted(Comparator.comparingDouble(
                (DocumentChunk c) -> -VectorMath.cosineSimilarity(
                    query.values(), c.embedding().values())))
            .limit(topK)
            .toList();
    }
}

findNearestは各チャンクをコサイン類似度で降順にソートします(-符号を使用してComparatorを反転)し、最も類似したtopK結果を取得します。これが、RAGの「retrieval」ステップです。

6. 完全な例:異なる表現でも正確な意味

public class SemanticSearchDemo {

    public static void main(String[] args) {
        EmbeddingClient embedder = new KeywordEmbeddingClient();
        InMemoryVectorStore store = new InMemoryVectorStore();

        store.add(new DocumentChunk(
            "chunk-1", "policy-travel", "飛行便がキャンセルされた場合または旅程が中断された場合の払い戻しポリシー。",
            "Travel Disruption", LocalDate.of(2026, 1, 1), 2,
            embedder.embed("飛行便がキャンセルされた場合または旅程が中断された場合の払い戻しポリシー。")
        ));

        store.add(new DocumentChunk(
            "chunk-2", "policy-account", "パスワードを忘れたときのアカウントのリセット方法のガイド。",
            "Account Recovery", LocalDate.of(2025, 6, 1), 1,
            embedder.embed("パスワードを忘れたときのアカウントのリセット方法のガイド。")
        ));

        String question = "飛行機がキャンセルされたのですが、予約したホテルの宿泊料金は払い戻してもらえますか?";
        EmbeddingVector queryVector = embedder.embed(question);

        List<DocumentChunk> results = store.findNearest(queryVector, 1);
        System.out.println(results.get(0).text());
    }
}

このプログラムを実行すると出力されます:

飛行便がキャンセルされた場合または旅程が中断された場合の払い戻しポリシー。

質問に「ポリシー」や「旅程」という単語は含まれていませんが、質問とテキストの両方が意味空間の「旅行」次元近くに存在するため、システムは正しいチャンクを選択します。パスワードリセットについてのチャンクを完全にスキップします。

7. テストによる検証

class SemanticSearchTest {

    private final EmbeddingClient embedder = new KeywordEmbeddingClient();
    private final InMemoryVectorStore store = new InMemoryVectorStore();

    @BeforeEach
    void setUp() {
        store.add(new DocumentChunk(
            "chunk-1", "policy-travel",
            "飛行便がキャンセルされた場合または旅程が中断された場合の払い戻しポリシー。",
            "Travel Disruption", LocalDate.of(2026, 1, 1), 2,
            embedder.embed("飛行便がキャンセルされた場合または旅程が中断された場合の払い戻しポリシー。")
        ));
        store.add(new DocumentChunk(
            "chunk-2", "policy-account",
            "パスワードを忘れたときのアカウントのリセット方法のガイド。",
            "Account Recovery", LocalDate.of(2025, 6, 1), 1,
            embedder.embed("パスワードを忘れたときのアカウントのリセット方法のガイド。")
        ));
    }

    @Test
    void shouldMatchTravelPolicyEvenWithoutSharedWords() {
        String question = "飛行機がキャンセルされたのですが、予約したホテルの宿泊料金は払い戻してもらえますか?";
        EmbeddingVector queryVector = embedder.embed(question);

        List<DocumentChunk> results = store.findNearest(queryVector, 1);

        assertThat(results)
            .hasSize(1)
            .first()
            .extracting(DocumentChunk::documentId)
            .isEqualTo("policy-travel");
    }

    @Test
    void shouldRejectComparingDifferentDimensions() {
        float[] a = { 1f, 0f, 0f };
        float[] b = { 1f, 0f };

        assertThatThrownBy(() -> VectorMath.cosineSimilarity(a, b))
            .isInstanceOf(IllegalArgumentException.class)
            .hasMessageContaining("次元の不一致");
    }
}

最初のテストは中核的な動作を確認します:異なる表現の質問でも正しいチャンクが見つかること。2番目のテストは、互換性のない2つのベクトルを誤って比較しないようにします。このエラーは、embeddingモデルを変更したが古いデータをre-embedするのを忘れた場合に起きやすく、例外が何も報告されないため危険です。

8. よくあるエラーと使用すべきでない場合

  • embeddingモデルを変更したが、古いデータをre-embedしない:古いベクトルと新しいベクトルが同じ空間にありません。マッチングは静かに間違った結果を返します。例外が何も報告されません。

  • チャンクが長すぎるか短すぎる:長いチャンクはベクトルを「混ぜ」、複数の意味を含め、精度を低下させます。短すぎるチャンクはコンテキストを失います。通常、200~500トークンが合理的な開始点です。ドメインに応じた微調整が必要です。

  • メタデータの保存を忘れる:ベクトルだけを保存してdocumentId/versionを保存しない場合、ポリシーが変更されたときに正しいドキュメントを削除または更新できません。

  • 正確なマッチングが必要なデータに対してembeddingを使用する:正確な注文コードORD-2026-00123を検索する必要がある場合、全文検索または正確なキー索引の方が、embeddingより高速かつ正確です。embeddingは意味による検索に優れており、正確な文字列マッチングには優れていません。

注意: 小さなデータセット(数百チャンク)の場合、上記のInMemoryVectorStoreで十分です。数百万のチャンクにスケールするとき、専用のベクトルデータベース(pgvector、Pinecone、Milvus…)が必要です。各ベクトルの順序付き比較を避けるため、近似最近傍検索(ANN)インデックスをサポートしています。

9. 結論

Embeddingはテキストをベクトルに変換し、マシンがキーワードマッチングではなく「意味」を比較できるようにします。これはすべてのRAGシステムの基礎です。覚えるべき重要なポイント:クエリとドキュメントは同じ互換性のあるエンコーダーを使用する必要があります、各ベクトルは元のテキストにトレースバックするためのメタデータを持つ必要があります、同じ次元数を持つことは、2つのモデルの互換性を保証しません。上記のJavaの例から、KeywordEmbeddingClientを実際のembeddingモデルに置き換えることができます(Spring AI経由またはAPI直接呼び出し)。ただし、コサイン類似度、ベクトルストア、メタデータなどの他の部分はすべて変わりません。

ragembeddingvector-searchspring-aillm