【朗報】Spring AIを使えば
Javaエンジニアでも生成AIと遊べるぞ

導入

  • 生成AIの盛り上がりがヤバい
  • 調べるとPythonの情報ばかりが出てきてヤバい
  • Javaエンジニアであるオレたちの明日がヤバい
  • Spring AI「やあ」
  • 哀れなオレたち「オマエなら必ず来てくれると信じてたぞ!!!」
  • というわけでオレたちの救世主 Spring AI を紹介します。

自己紹介

本日お話しすること

  1. OpenAI APIを用いたテキスト生成の基本
  2. Spring AIとOpenAI APIの組み合わせ方
  3. ベクトルデータベースとRAG(Retrieval-Augmented Generation)
  4. MCP(Model Context Protocol)で外部データソースを操作

発表資料・コード例のURL

バージョンなど

  • Java 21
  • Spring Boot 3.4.1
  • Spring AI 1.0.0-M5
  • OpenAI API
    • 使用しているモデルは次の通り
      • テキスト生成: gpt-4o-mini
      • 埋め込み: text-embedding-3-small

OpenAI APIを用いたテキスト生成の基本

生成AIによるテキスト生成について

  • 生成AIは入力されたテキストの続きをそれっぽく生成する
    • 人「こんにちは。」
      AI「こんにちは!何かお手伝いできることはありますか?」←それっぽい
  • プロンプト = 入力されたテキスト
  • トークン = テキストを意味的な単位(単語や記号、文字など)で分割したもの
    • gpt-4o-miniでトークン化した例
      Spring Boot
      [30099, 25087, 4344, 103923, 128194, 8574, 3393]

テキスト生成するAPI

  • Create chat completion
  • APIを使うためには
    • アカウントを作成
    • Credit balanceを追加(プリペイド)
    • APIキーを作成する

最初のテキスト生成

curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4o-mini",
    "messages": [
      {
        "role": "user",
        "content": "こんにちは。"
      }
    ]
  }'
{
  "id": "chatcmpl-ArKzNcOaW2qqrwpDzSbtgLJX9wg6T",
  "object": "chat.completion",
  "created": 1737275429,
  "model": "gpt-4o-mini-2024-07-18",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "こんにちは!何かお手伝いできることはありますか?",
        "refusal": null
      },
      "logprobs": null,
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 9,
    "completion_tokens": 16,
    "total_tokens": 25,
    "prompt_tokens_details": {
      "cached_tokens": 0,
      "audio_tokens": 0
    },
    "completion_tokens_details": {
      "reasoning_tokens": 0,
      "audio_tokens": 0,
      "accepted_prediction_tokens": 0,
      "rejected_prediction_tokens": 0
    }
  },
  "service_tier": "default",
  "system_fingerprint": "fp_72ed7ab54c"
}

複数のmessageで会話を表現できる

curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4o-mini",
    "messages": [
      { "role": "user"     , "content": "1から10の中で数字を2つ挙げて。" },
      { "role": "assistant", "content": "2と5です。" },
      { "role": "user"     , "content": "それらの合計は?" }
    ]
  }' -s | jq -r ".choices[0].message.content"
2と5の合計は7です。

システムプロンプトで生成AIに指示を与える

curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4o-mini",
    "messages": [
      { "role": "system", "content": "あなたは令和に生きる侍です。武を重んじ忠義を尽くす性格です。話し言葉も侍を意識してください。" },
      { "role": "user"  , "content": "こんにちは。" }
    ]
  }' -s | jq -r ".choices[0].message.content"
こんにちは、御仁。今日は如何な様子でござるか?何かお手伝いできることがあれば、遠慮なきよう仰せください。

参考: コスト計算

  • gpt-4o-miniは100万トークンあたり
  • 最初のテキスト生成結果より
      "usage": {
        "prompt_tokens": 9,
        "completion_tokens": 16,
        "total_tokens": 25,
    
    ((9 × $0.15) + (16 × $0.65)) / 100万 = 0.001833円 ($1 = 156円)

参考: 『吾輩は猫である』が出力された場合のコスト

夏目漱石『吾輩は猫である』(青空文庫)

  • 文字数: 347,704
  • トークン数: 296,711
  • (296,711 × $0.65) / 100万 = $0.193 = 30円
  • モデルによって価格は異なる
    • gpt-4oだと463円

Spring AIとOpenAI APIの組み合わせ方

Spring AI

  • AIアプリケーション開発の基盤となる機能を提供する
    • LangChainやLlamaIndexといった著名なPythonプロジェクトからインスピレーションを得ているが、それらのクローンではない
  • 提供される機能
    • チャットや埋め込みのクライアント
    • 構造化された出力をJavaオブジェクトへマッピング
    • ベクトルデータベースのサポート

チャットクライアントの構築

@Bean
ChatClient chatClient(ChatClient.Builder builder) {
    String defaultSystem = """
            あなたは令和に生きる侍です。武を重んじ忠義を尽くす性格です。
            話し言葉も侍を意識してください。""";
    return builder
        .defaultSystem(defaultSystem)
        .defaultAdvisors(advisor1, advisor2, advisor3)
        .build();
}
  • ビルダーをインジェクションして構築する
  • 必要に応じてシステムプロンプトやAdvisorのデフォルト値を設定する

チャットクライアントでテキスト生成

@PostMapping
String generate(@RequestParam String query) {
    return chatClient.prompt()
        .user(query)
        .call()
        .content();
}

ストリーム(Server-Sent Events)

@PostMapping(path = "stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
Flux<String> stream(@RequestParam String query) {
    return chatClient.prompt()
        .user(query)
        .stream()
        .content();
}
  • callメソッドの代わりにstreamメソッドをコールする
  • Flux<T>を取得できるのでtext/event-streamで返せばSSEできる

システムプロンプト

@PostMapping("with_system_prompt")
String generateWithSystemPrompt(@RequestParam String query) {
    String system = """
            あなたはJava言語のエキスパートです。Javaに関する質問へ自信満々に回答してください。
            回答するときは語尾を「じゃば」にしてください。""";
    return chatClient.prompt()
        .system(system)
        .user(query)
        .call()
        .content();
}
  • ChatClient構築時にデフォルトのシステムプロンプトを設定できるが、アドホックな指定もできる

マルチモーダル

@PostMapping("multimodal")
String generateMultimodal(@RequestParam String query, @RequestParam URL image) {
    return chatClient.prompt()
        .user(prompt -> prompt
            .text(query)
            .media(MimeType.valueOf("image/jpeg"), image))
        .call()
        .content();
}
  • マルチモーダル = テキスト、画像、音声など複数の入力モードを処理するやつ

構造化された出力

record MainChatacters(String hero, String heroine, List<String> subCharacters) {
}
@PostMapping
MainChatacters post(@RequestParam String query) {
    return chatClient.prompt()
        .user(query)
        .call()
        .entity(MainChatacters.class);
}
  • 生成AIがJSONスキーマに従って出力してくれるやつ

関数呼び出し

@PostMapping
public String post(@RequestParam String query) {
    return chatClient.prompt().user(query)
        .function("resolveGitHubAccount")
        .call().content();
}
  • Function callingを使える
  • functionメソッドで関数のbean名を渡すだけ
    • またはFunctionCallbackを使用して明示的に関数の定義を渡す

関数呼び出し

@Component
@Description("名前をもとにしてGitHubのアカウント名を返す関数。")
public class ResolveGitHubAccount implements Function<User, GitHubAccount> {
    public record User(String name) {}
    public record GitHubAccount(String accountName) {}

    @Override
    public GitHubAccount apply(User user) {
        return new GitHubAccount(switch (user.name()) {
            case "うらがみ" -> "backpaper0";
            default -> "unknown";
        });
    }
}

Advisor

  • 処理をインターセプトして前後処理を差し込んだり入出力を加工できる仕組み
  • FQCNはorg.springframework.ai.chat.client.advisor.api.Advisor
  • org.springframework.aop.Advisorとは継承関係にない、まったくの別物

会話の履歴

public ChatController(ChatClient.Builder builder, ChatMemory chatMemory) {
    this.chatClient = builder
        .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory))
        .build();
}
  • MessageChatMemoryAdvisorがクエリーのコンテキストとして会話の履歴を付与したり、クエリーと生成されたテキストを会話の履歴として保存してくれる
  • ChatMemoryは会話の履歴を登録・取得するインターフェース

会話の履歴

@PostMapping("{conversationId}")
public String post(@RequestParam String query, @PathVariable String conversationId) {
    return chatClient.prompt()
        .user(query)
        .advisors(advisor -> advisor.param("chat_memory_conversation_id", conversationId))
        .call()
        .content();
}
  • "chat_memory_conversation_id"パラメーターで会話を指定する

チャットクライアントのデモ

チャットクライアントのまとめ

  • ChatClientでテキスト生成できる
    • インスタンスはビルダーで構築する
    • マルチモーダルにも対応している
  • Advisorで処理を拡張できる
    • 実装クラスのひとつに会話の履歴を扱うMessageChatMemoryAdvisorがある

ベクトルデータベースとRAG
(Retrieval-Augmented Generation)

RAGの概要

  • RAG(Retrieval-Augmented Generation)は生成AIが「事前に学んだ知識」だけでなく「外部の情報」を取り込んで回答を生成する仕組み
  • 具体的には、まず質問に関連する情報をデータベースや検索エンジンから探し出し、その情報を元に回答を作る
  • 例えるなら「本を読んで得た知識だけで話す」生成AIが「必要に応じて本棚から新しい本を取り出して確認しながら話す」ようになる感じ

ベクトル検索の概要

  • テキストや画像などのデータを数値ベクトルに変換し、ベクトル間の類似度を計算することで意味的に近いものを検索する技術
  • 数値ベクトルへの変換には「埋め込みモデル」というものが使われる

Spring AIでRAG

  • RAGのためのAdvisorが2つある
    • QuestionAnswerAdvisor
      • ベクトルデータベースを検索した結果をプロンプトに埋め込んでテキスト生成するだけの単純なRAGができる
    • RetrievalAugmentationAdvisor
      • クエリーの変換や拡張、ルーティングといったRAGの性能を向上させるための戦略を適用できる
  • 個人的には、まだ実験的なものではあるもののRetrievalAugmentationAdvisorの方がオススメ

RetrievalAugmentationAdvisor

var ragAdvisor = RetrievalAugmentationAdvisor.builder()
        .queryTransformers(...)
        .queryExpander(...)
        .queryRouter(...)
        .documentRetriever(...)
        .documentJoiner(...)
        .queryAugmenter(...)
        .build();

this.chatClient = chatClientBuilder.defaultAdvisors(ragAdvisor).build();

RetrievalAugmentationAdvisor

  • QueryTransformer
    • 例)日本語で書かれたクエリーを英語に翻訳する
  • QueryExpander
    • 例)クエリーのバリエーションを増やす(マルチクエリー)
      • うらがみさんてどんな人?
        • うらがみさんの趣味は?
        • うらがみさんの職業は?
        • うらがみさんの出身は?

RetrievalAugmentationAdvisor

  • QueryRouter
    • 例)クエリーの内容を見てベクトルデータベースを切り替える
  • DocumentRetriever
    • 例)ベクトル検索を行い、ドキュメントを返す
  • DocumentJoiner
    • 例)複数のドキュメントを改行区切りでひとつにまとめる
  • QueryAugmenter
    • 例)クエリーにコンテキストを付与する

RetrievalAugmentationAdvisor

  • 単純なRAGであればDocumentRetrieverQueryAugmenterを設定すれば良い
DocumentRetriever documentRetriever = VectorStoreDocumentRetriever.builder()
    .vectorStore(vectorStore).build();

QueryAugmenter queryAugmenter = ContextualQueryAugmenter.builder().build();

Advisor ragAdvisor = RetrievalAugmentationAdvisor.builder()
    .documentRetriever(documentRetriever)
    .queryAugmenter(queryAugmenter)
    .build();
this.chatClient = builder.defaultAdvisors(ragAdvisor).build();

RAGのデモ

RAGのまとめ

  • RAGは生成AIが持ち合わせていない知識を補う手法
  • 知識は埋め込みモデルによって計算された数値ベクトルと類似度検索で取得される
  • Spring AIではQuestionAnswerAdvisorまたはRetrievalAugmentationAdvisorでRAGできる
    • うらがみ的には大は小を兼ねるの精神からRetrievalAugmentationAdvisorがオススメ

MCP(Model Context Protocol)
外部データソースを操作

MCP(Model Context Protocol)の概要

  • アプリケーションが生成AIにコンテキストを提供する方法を標準化するオープンプロトコル
  • 生成AIを様々なデータソースやツールに接続するための標準化された方法

出典: https://modelcontextprotocol.io/introduction#general-architecture

Spring AI MCP

出典: https://docs.spring.io/spring-ai-mcp/reference/overview.html

Spring AI MCP Client

  • FunctionCallbackを介して透過的に使える
  • ※MCPまわりは絶賛コードリーディング中なので説明が雑

MCPでPostgreSQLへ接続する例

ServerParameters params = ServerParameters.builder("npx")
    .args(
        "-y",
        "@modelcontextprotocol/server-postgres",
        "postgres://myuser:secret@localhost:5432/mydatabase")
    .build();

McpTransport transport = new StdioClientTransport(params);

McpSyncClient mcpClient = McpClient.using(transport).sync();
mcpClient.initialize();

MCPでPostgreSQLへ接続する例

McpFunctionCallback[] callbacks = mcpClient.listTools()
    .tools()
    .stream()
    .map(tool -> new McpFunctionCallback(mcpClient, tool))
    .toArray(McpFunctionCallback[]::new);

this.chatClient = builder.defaultFunctions(callbacks)
    .build();

MCPでPostgreSQLへ接続するデモ

MCPのまとめ

  • MCPを使えばアプリケーションへコンテキストを提供できる
  • Spring AIもMCPに対応していて、例えばPostgreSQLからデータを取得してコンテキストとしてプロンプトに付与できる
  • 今回紹介したのはMCP Clientだけだが、MCP Serverの実装もできる

総括

  • 生成AIをシステムに組み込む手段はPythonだけではない
  • JavaであればSpring AIが有力な候補となると感じた
  • それはそれとして、Spring AIのようにSpringに新規モジュールが出てくるのは嬉しい
  • Springのおかげで新しい技術に触れることを楽しめている気がする
  • オマエたち、オレと一緒にSpring AIで遊ぼうぜ!
🔚

Appendix