RAG完全ガイド:文書を取得してから回答する

1. 概要

LLMは訓練データに含まれているもの、つまり学習時点での知識しか持っていません。あなたの店舗の返品ポリシーや、社内の業務フローについて知ることはありません。このような内容について質問されると、LLMは根拠のない答えを作り出してしまいます。RAG (Retrieval-Augmented Generation) はこの問題を解決します。関連する文書をあらかじめ取得してから、その文書に基づいてLLMに回答させるという手法です。

この記事では、Spring Boot 3とSpring AIを使って、完全なRAGパイプラインを自分で構築していきます。パイプラインには、文書の読み込み、チャンク分割、埋め込み生成、ベクトル検索、出典付きプロンプトの組み立て、そしてテストの実装が含まれます。記事の最後には、よくあるエラーと、RAGを使うべきでない場面についても触れます。

2. RAGの仕組み

RAGは二つの独立した段階に分かれています。

取り込み段階(ingestion) は一度だけ、または定期的に実行します:

  1. 文書を読み込みます(Markdown、PDF、HTMLなど)。
  2. 数百トークン程度の chunk に分割します。
  3. embedding model を呼び出して、各チャンクを数値ベクトルに変換します。
  4. ベクトルとチャンク内容を vector store に保存します。

クエリ段階(query) はユーザーが質問するたびに実行します:

  1. 質問をベクトルに埋め込みます。
  2. ベクトルが最も近いチャンク群を検索します(類似度検索)。
  3. そのチャンク群をプロンプトに組み込みます。これが augment のステップです。
  4. プロンプトをLLMに送信して回答を生成します。これが generate のステップです。

コアの考え方は非常にシンプルです。LLMが文書を「記憶」する必要はなく、プロンプト内で正しい部分を「読む」だけで良いのです。

3. プロジェクトの準備

Spring AI BOMとOpenAI starterを追加します。このstarterはチャットモデルと埋め込みモデルの両方を提供します。

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

次にモデルの設定をします:

spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
spring.ai.openai.chat.options.temperature=0.1
spring.ai.openai.embedding.options.model=text-embedding-3-small

temperature は低めに設定すべきです。RAGの目的は文書に忠実な回答を返すことであり、創造性は必要ありません。

最後に、二つのbeanを宣言します。一つはvector storeで、もう一つは ChatClient です。

@Configuration
public class RagConfig {

    @Bean
    VectorStore vectorStore(EmbeddingModel embeddingModel) {
        return SimpleVectorStore.builder(embeddingModel).build();
    }

    @Bean
    ChatClient chatClient(ChatClient.Builder builder) {
        return builder.build();
    }
}

SimpleVectorStore はすべてのベクトルをメモリに格納します。学習とデモンストレーションに適しています。EmbeddingModel はOpenAI starterから自動的にSpring AIによって設定されます。文書を追加するたびに、storeがこのモデルを呼び出してベクトルを生成します。

注記: SimpleVectorStore はアプリケーションの再起動時にデータを失い、線形検索を使用します。本番環境ではPGvector、Qdrant、Elasticsearchなどを使用すべきです。Spring AIにはそれぞれのstarterがあり、VectorStore インターフェースは変わらないため、以下のコードは修正不要です。

4. 文書の読み込み

src/main/resources/docs/return-policy.md というファイルがあると仮定します:

# 返品ポリシー

お客様は商品受け取り日から30日以内に返品・交換を承ります。
商品はタグ、ラベル、レシートが一式揃っていることが条件です。
返金は5~7営業日以内にお客様の銀行口座にお振込みします。
下着と50%以上割引された商品は返品・交換の対象外です。

4.1. 読み込みとチャンク分割

@Component
public class DocumentIngestor implements ApplicationRunner {

    private final VectorStore vectorStore;
    private final ResourcePatternResolver resolver;

    public DocumentIngestor(VectorStore vectorStore, ResourcePatternResolver resolver) {
        this.vectorStore = vectorStore;
        this.resolver = resolver;
    }

    @Override
    public void run(ApplicationArguments args) throws IOException {
        // chunkSize=300トークン、minChunkSizeChars=100、minChunkLengthToEmbed=5、
        // maxNumChunks=10000、keepSeparator=true
        var splitter = new TokenTextSplitter(300, 100, 5, 10_000, true);

        for (Resource file : resolver.getResources("classpath:docs/*.md")) {
            var reader = new TextReader(file);
            reader.getCustomMetadata().put("source", file.getFilename());

            List<Document> chunks = splitter.apply(reader.get());
            vectorStore.add(chunks);
            System.out.printf("%sを読み込み: %dチャンク%n", file.getFilename(), chunks.size());
        }
    }
}

上記のコードは以下の処理を行います:

  • ApplicationRunner はコンテキスト起動後に一度だけ実行されるため、文書は最初のリクエストの前に必ず読み込まれます。
  • TextReader がファイルを Document として読み込みます。メタデータの source にファイル名を設定して、後の出典記載ステップで使用します。
  • TokenTextSplitter はテキストを約300トークンのチャンクに分割します。メタデータは各チャンクにコピーされます。
  • vectorStore.add(...) は各チャンクに対してembedding modelを呼び出し、ベクトルを保存します。

起動時に以下のような出力が表示されます:

return-policy.mdを読み込み: 1チャンク
shipping.mdを読み込み: 3チャンク

4.2. チャンクサイズの選択

チャンクサイズは回答品質に直接影響します:

  • チャンクが大きすぎる場合: 一つのチャンクが複数のトピックを含み、ベクトルが「希釈」され、正確なマッチングが難しくなります。プロンプトもトークン数が増えます。
  • チャンクが小さすぎる場合: 文が文脈を失います。例えば「30日以内」が一つのチャンクに、「返品・交換」が別のチャンクに分かれてしまうかもしれません。

散文的なテキストの場合、200~500トークンが合理的な出発点です。FAQ や目次など構造化されたドキュメントの場合は、各チャンクが一つの完全な考えになるように見出しごとに分割すべきです。

5. 関連文書の取得

取得ステップは単一の呼び出しで十分です:

List<Document> docs = vectorStore.similaritySearch(
        SearchRequest.builder()
                .query("どのくらい返品できますか?")
                .topK(4)
                .similarityThreshold(0.5)
                .build());
  • query: 質問です。Vector storeは読み込み時と同じモデルを使用して質問を自動的に埋め込みます。
  • topK(4): 最大4つの最も近いチャンクを取得します。
  • similarityThreshold(0.5): コサイン類似度が0.5以下のチャンクをフィルタリングします。しきい値を設定しない場合、storeは質問と無関係であっても常にtopK個の結果を返します。

すべてのケースに最適なしきい値はありません。数十の実際の質問でスコアをログに出力し、正しい結果とノイズを分離するしきい値を選ぶべきです。

6. プロンプトの組み立てと回答生成

6.1. プロンプトビルダー

プロンプト組み立ての部分を純粋な関数に抽出すると、テストが容易になります:

public final class PromptBuilder {

    private static final String TEMPLATE = """
            あなたは顧客サポート担当者です。
            以下のコンテキストのみに基づいて回答してください。
            コンテキストに情報がない場合は、以下の文言で返答してください:
            「その情報はドキュメントに見つかりません。」
            回答の最後に、[return-policy.md] のような形式で出典を記載してください。

            コンテキスト:
            {context}

            質問:{question}
            """;

    private PromptBuilder() {}

    public static String build(String question, List<Document> docs) {
        String context = docs.stream()
                .map(d -> "[" + d.getMetadata().getOrDefault("source", "unknown") + "]\n" + d.getText())
                .collect(Collectors.joining("\n---\n"));
        return TEMPLATE.replace("{context}", context)
                       .replace("{question}", question);
    }
}

テンプレートに三つの重要なポイントがあります:

  1. 「以下のコンテキストのみに基づいて回答してください」 によってLLMを文書に限定し、無根拠な回答を減らします。
  2. 固定された拒否文言 はLLMが文書に情報がない場合の正当な対応策を提供します。
  3. 各チャンクに出典名が付随 しているため、LLMは出典を記載でき、ユーザーは検証できます。

ここでは String.formatted() の代わりに replace() を使用しています。理由は、ドキュメント内容に % 文字(例:「50%割引」)が含まれる可能性があり、formatted() はこれに遭遇すると UnknownFormatConversionException を投げるからです。

6.2. RagService

@Service
public class RagService {

    static final String NOT_FOUND = "その情報はドキュメントに見つかりません。";

    private final VectorStore vectorStore;
    private final ChatClient chatClient;

    public RagService(VectorStore vectorStore, ChatClient chatClient) {
        this.vectorStore = vectorStore;
        this.chatClient = chatClient;
    }

    public String answer(String question) {
        List<Document> docs = vectorStore.similaritySearch(
                SearchRequest.builder()
                        .query(question)
                        .topK(4)
                        .similarityThreshold(0.5)
                        .build());

        if (docs == null || docs.isEmpty()) {
            return NOT_FOUND;
        }

        return chatClient.prompt()
                .user(PromptBuilder.build(question, docs))
                .call()
                .content();
    }
}

チャンクが見つからない場合、serviceは即座に拒否文言を返し、LLMを呼び出しません。このアプローチは費用を節約でき、LLMが一般知識から無根拠な回答を作成する可能性を完全に排除します。

最後にcontrollerです:

@RestController
public class AskController {

    private final RagService ragService;

    public AskController(RagService ragService) {
        this.ragService = ragService;
    }

    @GetMapping("/ask")
    public String ask(@RequestParam String q) {
        return ragService.answer(q);
    }
}

APIを呼び出してテストしてみます:

curl -G "http://localhost:8080/ask" --data-urlencode "q=どのくらい返品できますか?"
curl -G "http://localhost:8080/ask" --data-urlencode "q=ラップトップは販売していますか?"
商品受け取り日から30日以内に返品・交換を承ります。タグ、ラベル、レシートが一式揃っていることが条件です。返金は5~7営業日以内にお客様の銀行口座にお振込みします。[return-policy.md]
その情報はドキュメントに見つかりません。

最初の回答は文書に忠実で、出典が記載されています。二番目の回答は一致するドキュメントがないため、システムは無根拠な回答を生成する代わりに正しく拒否しています。

7. テストの実装

実際のLLMをunit testで呼び出すべきではありません。遅い、費用がかかる、結果が不安定だからです。代わりに、決定的な部分をテストしましょう。プロンプトビルダーと、ドキュメントがない場合の拒否ロジックです。

class RagServiceTest {

    @Test
    void promptContainsContextSourceAndQuestion() {
        var doc = new Document("30日以内に返品・交換を承ります。", Map.of("source", "return-policy.md"));

        String prompt = PromptBuilder.build("返品期限は?", List.of(doc));

        assertThat(prompt)
                .contains("[return-policy.md]")
                .contains("30日以内に返品・交換を承ります。")
                .contains("質問:返品期限は?")
                .doesNotContain("{context}", "{question}");
    }

    @Test
    void percentSignInDocumentDoesNotBreakPrompt() {
        var doc = new Document("50%以上割引された商品は返品対象外です。", Map.of("source", "return-policy.md"));

        assertThat(PromptBuilder.build("セール品は?", List.of(doc)))
                .contains("50%以上");
    }

    @Test
    void returnsNotFoundWithoutCallingLlmWhenNoDocuments() {
        VectorStore vectorStore = mock(VectorStore.class);
        ChatClient chatClient = mock(ChatClient.class);
        when(vectorStore.similaritySearch(any(SearchRequest.class))).thenReturn(List.of());

        String answer = new RagService(vectorStore, chatClient).answer("ラップトップを販売していますか?");

        assertThat(answer).isEqualTo(RagService.NOT_FOUND);
        verifyNoInteractions(chatClient);
    }
}

三番目のテストは二つのことを検証します。まず、返される回答が拒否文言であること。次に、ChatClient が全く呼び出されないこと。もし誰かが isEmpty() チェックを誤って削除すれば、テストは即座に失敗するでしょう。

検索品質を評価するには、integration testを別途作成できます。期待される出典ファイル名を付けた質問セットを準備し、そのファイルが topK 結果に含まれているかを確認します。このテストスイートは毎回のビルドではなく、定期的に実行されるべきです。

8. QuestionAnswerAdvisorによる簡潔化

各ステップを理解したら、Spring AIの組み込みadvisorを使用できます。このadvisorは検索とプロンプト組み立てをカプセル化します。依存関係を追加する必要があります:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
String answer = chatClient.prompt()
        .advisors(QuestionAnswerAdvisor.builder(vectorStore)
                .searchRequest(SearchRequest.builder().topK(4).similarityThreshold(0.5).build())
                .build())
        .user("どのくらい返品できますか?")
        .call()
        .content();

Advisorは質問を自動的に埋め込み、チャンクを検索し、デフォルトテンプレートに従ってプロンプトに挿入します。便利ですが、出典形式と早期拒否ロジックの制御が難しくなります。したがって、セクション6の自作アプローチは、そうしたニーズがある場合は保持する価値があります。

9. よくあるエラーと、RAGを使うべきでない場面

Embedding modelを変更しても、データを再読み込みしない。 異なるモデルのベクトルは相互に比較できません。モデルを変更するたびに、すべてのドキュメントを再度埋め込みする必要があります。

similarityThreshold を設定しない。 Vector storeは常にtopK個の結果を返します。その場合LLMはノイズ混じりのコンテキストを受け取り、確信を持った誤った回答をします。

永続的なstoreで毎回起動時にドキュメントを再読み込みする。 PGvectorの場合、上記のApplicationRunner はデプロイするたびに重複を作成します。各チャンクに固定IDを割り当てるべきです。例えば、内容とファイル名のハッシュ値、または読み込み前にsourceでフィルタして削除します。

プロンプトに大量のチャンクを詰め込む。 topK を20に増やしても、回答が良くなるわけではありません。LLMは長いコンテキストの中の情報を見落とす傾向があり、トークンコストも増えます。

ドキュメント内容を無条件に信頼する。 ドキュメントがユーザー提供の場合、「以前の指示をすべて無視せよ」といったprompt injectionを含むかもしれません。どのデータソースをstoreへの読み込み対象として許可するか、管理する必要があります。

注記: RAGがすべての問題に適しているわけではありません。「9月に返品された注文の数は?」のように、データ全体を集約する必要がある質問はSQLクエリに変換すべきです。Similarity searchは数個のチャンクだけを返すため、集計や合計計算はできません。ドキュメント全体がわずか数ページなら、複雑なRAGパイプラインより、プロンプトに直接含める方が通常、シンプルで正確です。

10. まとめ

RAGは知識をモデルから分離します。ドキュメントはvector storeに存在し、LLMは各質問で必要な部分だけを読みます。この記事では、二つの段階を完全に実装しました:文書の取り込み(読み込み、チャンク分割、埋め込み、保存)とクエリ(しきい値付き検索、出典付きプロンプト組み立て、LLM呼び出し)です。プロンプトと拒否ロジックのテストも実装しました。

この基盤から、以下の方向へ進めます:SimpleVectorStore をPGvectorに置き換える、見出しに基づいてチャンク分割する、メタデータでフィルタリング(例:特定部門のドキュメントのみ検索)する、またはrerank stepを追加してプロンプト前に結果を再ソートするなどです。

ragspring-aillmvector-storespring-boot