LLMが正しく回答するために必要な証拠:RAGの詳細説明とコード例

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

1. 概要

LLMがどれほど強力であっても、あなたの企業の内部ドキュメントの内容を自動的には知ることができません — それが受け取るのはinputに入力されたものだけです。この記事はRetrieval-Augmented Generation (RAG)の仕組みを説明します。なぜそれが必要なのか、retrievalがどのように機能するのか、そして回答の品質がテキスト生成前の検索ステップにほぼ完全に依存する理由を解説します。読み終わると、RAGシステムのデータフローが明確に理解でき、Javaでシンプルなretrievalパイプラインを自分で書けるようになります。

2. 問題:LLMは機密データについて「記憶」を持たない

モデルは公開データセットで訓練され、その時点でフリーズされています。先週あなたの企業が発布した新しい払い戻しポリシーは知りません。ましてや何か内部契約の内容など知るはずもありません。

機密情報を提供する方法は2つあります:

  1. プロンプトに直接入れ込む — ドキュメントが小さくてcontextウィンドウに収まる場合のみ実現可能です。
  2. 検索(retrieval) — ドキュメント集合が大きい場合、アプリケーションは最も関連性の高い数段落のみを選択してからプロンプトに入力します。
// 方法1:全ドキュメントを詰め込む — 短いテキストの場合のみOK
String prompt = """
    以下のポリシーに基づいて、顧客の質問に答えてください。
    ポリシー: %s
    質問: %s
    """.formatted(fullPolicyText, userQuestion);

このアプローチはシンプルですがスケーラブルではありません。contextウィンドウにはトークンの上限があり、関係のないテキストを詰め込めば詰め込むほど、モデルは「迷子になり」やすく(lost in the middle)なります。ドキュメント集合が数千ページある場合、retrieval を使う必要があります。

3. RAGとは、そしてその動作フロー

RAG (Retrieval-Augmented Generation)は問題を2つの独立したステップに分割します:

  • Retrieval:質問に関連した正しい文章段落を見つけます。
  • Generation:LLMはこれらの段落を使用して、ソースの引用付きで回答を生成します。
ユーザーの質問
      │
      ▼
[Embeddingモデル] ── 質問をベクトルに変換
      │
      ▼
[ベクトルデータベース] ── 最も意味的に類似した文章段落を見つける
      │
      ▼
[元の段落] ── 原文をプロンプトに入力
      │
      ▼
[LLM] ── 回答とソース引用を生成

重要なポイント:LLMはベクトルデータベースに直接「参照」しません。受け取るのは、retrievalが完了した後にアプリケーションが提供するプレーンテキスト(plain text)のみです。ベクトルデータベースは単なる検索補助ツールであり、モデルが読む「知識」を保存する場所ではありません。

3.1. Embedding — 質問を数値に変換

Embeddingモデルはテキスト段落を実数ベクトル(例:1536次元)に変換します。意味が似た2つのテキスト段落は、その空間で互いに近いベクトルを生成します。

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>
@Service
public class PolicyEmbeddingService {

    private final EmbeddingModel embeddingModel;

    public PolicyEmbeddingService(EmbeddingModel embeddingModel) {
        this.embeddingModel = embeddingModel;
    }

    public float[] embed(String text) {
        EmbeddingResponse response = embeddingModel.embedForResponse(List.of(text));
        return response.getResult().getOutput();
    }
}

embed()はテキスト段落を受け取り(例:質問内容「ホテルのキャンセルポリシーはどうなっていますか?」)、その文の意味を表すベクトルであるfloat[]配列を返します。これがベクトルデータベースに既に保存されているベクトルと照合されるものです。

3.2. ベクトルデータベース — 最も意味的に類似した文章段落を見つける

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>
@Service
public class PolicyRetrievalService {

    private final VectorStore vectorStore;

    public PolicyRetrievalService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    public void indexPolicy(String policyId, String content) {
        Document document = new Document(content, Map.of("policyId", policyId));
        vectorStore.add(List.of(document));
    }

    public List<Document> search(String question) {
        SearchRequest request = SearchRequest.query(question).withTopK(3);
        return vectorStore.similaritySearch(request);
    }
}
  • indexPolicy()はポリシー段落をベクトルデータベースに保存し、メタデータ(policyId)を付加して、後でこのテキスト段落がどのドキュメントに属しているかを知ることができるようにします。
  • search()はユーザーの質問を受け取り、内部で自動的にembeddingを行い、最も意味的に類似した3つの段落(topK(3))を見つけます。
search("ホテルのキャンセルポリシーはどうなっていますか?")の結果:
1. "お客様は24時間前までキャンセル無料で..."
2. "返金不可(non-refundable)の予約の場合..."
3. "5人以上のグループ予約にはキャンセル料が適用されます..."

3.3. Generation — LLMが証拠に基づいて回答

@Service
public class PolicyAnswerService {

    private final ChatClient chatClient;
    private final PolicyRetrievalService retrievalService;

    public PolicyAnswerService(ChatClient.Builder builder,
                                PolicyRetrievalService retrievalService) {
        this.chatClient = builder.build();
        this.retrievalService = retrievalService;
    }

    public String answer(String question) {
        List<Document> passages = retrievalService.search(question);

        String context = passages.stream()
            .map(Document::getContent)
            .collect(Collectors.joining("\n---\n"));

        String prompt = """
            以下のテキスト段落のみに基づいて回答してください。情報が不足している場合は、明確に「わかりません」と述べてください。
            テキスト段落:
            %s

            質問: %s
            """.formatted(context, question);

        return chatClient.prompt(prompt).call().content();
    }
}

「以下のテキスト段落のみに基づいて」という指示に注目 — これはhallucination(幻覚)を減らすための重要な制約です。モデルは訓練データから知識を作り出す代わりに、証拠(evidence)に密接に従うように求められます。

注意: 「contextのみに基づく」という明確な制約がない場合、モデルは背景知識をretrievalされたコンテンツと混ぜる可能性があり、聞こえは合理的でも実際のドキュメントと乖離した回答につながります。

4. retrievalが悪いと回答も悪い理由

これは見落とされやすい部分です:LLMは受け取ったものを解釈することのみ得意で、その情報が正しいか十分かどうかを自動的にチェックする方法がありません。

一般的な2つの状況:

状況1 — retrievalが古いドキュメントを取得する。 ベクトルデータベースが古いポリシーバージョンのindexをまだ保持している場合(更新/削除されていない場合)、システムは古いテキスト段落を返します。LLMはその段落を読んで完全に確信を持って回答します — しかしそれは誤った確信です。

状況2 — retrievalが重要なコンテキストを欠いている。 元のドキュメントの構造が次のようだと仮定します:

第5条 キャンセルポリシー
お客様はチェックイン24時間前まで無料でキャンセルできます。

第5.1条 例外
正月の予約の場合、無料キャンセルは適用されません。
キャンセル料は予約金の50%です。

chunking ステップが「第5条」と「第5.1条」を独立した2つのチャンクに分割し、retrieval が「第5条」チャンクのみを取得した場合(質問が「正月」または「例外」を含まないため)、LLMは「24時間前までキャンセル無料」と答えます — これは非常に合理的に聞こえますが、重要な例外条件を見落としています。

@Test
void chunking_exceptionMissing_LLMOmitsCondition() {
    // 例外部分を欠いた1つのチャンクのみを返すretrievalをシミュレート
    List<Document> incompletePassages = List.of(
        new Document("お客様はチェックイン24時間前まで無料でキャンセルできます。")
    );

    String answer = fakeAnswerService.answerFromPassages(
        "正月に予約した部屋をキャンセルしたいのですが、料金はかかりますか?",
        incompletePassages
    );

    // 正月の例外に関する証拠が不足しているため、回答は誤りです
    assertThat(answer).doesNotContain("50%");
}

このテストはLLMのバグを捉えることではなく、問題がretrievalにあり、モデルの推論能力にはないことを示しています。十分な証拠(両方のチャンク)を提供すれば、中程度のモデルでも正しい回答が得られます。

5. Chunking:retrieval の「適切な粒度」のための鍵

上の例で不適切なchunkingが結果をもたらすことを示しているため、RAGを構築する際に最も投資する必要がある場所です。

@Service
public class PolicyChunkingService {

    public List<String> splitByHeading(String document) {
        // 「第X条」または「第X.Y条」のパターンで分割して、親子関係のコンテキストを保持
        String[] rawChunks = document.split("(?=第\\d+)");
        return Arrays.stream(rawChunks)
            .map(String::trim)
            .filter(chunk -> !chunk.isBlank())
            .toList();
    }
}

固定文字数で分割する代わりに(例:500文字ごとに切ると、条項と例外の間で切られやすい)、この関数は意味境界(「第X条」)で分割します。このアプローチにより、各チャンクは情報の完全な単位を保持でき、主要ルールとその例外を分離するリスクが減少します。

@Test
void splitByHeading_keepMainRuleAndExceptionTogether() {
    String document = """
        第5条 キャンセルポリシー
        お客様は24時間前まで無料でキャンセルできます。

        第5.1条 例外
        正月期間は無料キャンセルは適用されず、キャンセル料は50%です。
        """;

    List<String> chunks = chunkingService.splitByHeading(document);

    assertThat(chunks).hasSize(2);
    assertThat(chunks.get(1)).contains("例外", "50%");
}

注意: 見出しによるchunkingは、ドキュメントが明確な構造を持つ場合にのみ有効です(契約書、規約、技術文書)。自由形式のテキスト(メール、チャットログ)では、異なる戦略が必要です — 例えば段落ごとにオーバーラップ付きで分割するか、隣接する文間の類似性に基づいたセマンティックchunkingを使用します。

6. RAGを使わない方がいい場合

RAGは万能なソリューションではありません:

  • データセットが小さく固定的:ドキュメント全体が数千トークンのみの場合、プロンプトに直接入力する方がシンプルで、完全なretrievalパイプラインを構築するよりもエラーが少なくなります。
  • 複雑な多段階推論が必要な質問で、答えが単一のテキスト段落に直接含まれていない — RAGは「質問に似た段落」を探し、「推論された答えを含む段落」を探しません。
  • リアルタイムで秒単位で変更されるデータ(株価、リアルタイム在庫):ベクトルデータベースを継続的に再indexするのではなく、APIを直接呼び出す(function calling)方が良いです。

7. まとめ

LLMはあなたの機密データを「知る」ことはありません — それは提供された証拠を解釈することに優れているだけです。RAGはretrieval(正しいテキスト段落を見つける)をgeneration(回答を生成する)から分離することでこの問題を解決し、embeddingモデルとベクトルデータベースが橋渡しとなります。最終的な回答の品質は、データをindexしてchunkすることがどれほど良くできるかにほぼ完全に依存します — 強力なLLMでも悪いretrievalステップを救うことはできません。RAGを構築している場合は、より大きなモデルに切り替えることを考える前に、chunkingと検索の正確性/エラー率をチェックする時間をかけてください。

ragllmvector-databasespring-aijava