LangChain4jを導入したが、Spring Bootアプリに乗っ取らせなかった方法

Dev.to / 2026/4/6

💬 オピニオンDeveloper Stack & InfrastructureIdeas & Deep AnalysisTools & Practical Usage

要点

  • この記事では、実運用のSpring BootアプリにおけるAIフレームワークのよくある問題を説明する。導入後は、フレームワークの都合がコントローラ、サービス、検索(リトリーバル)ロジックへと漏れ出し、プロバイダの入れ替えが難しくなる。
  • LangChain4jを用いて、ドキュメントの取り込み、チャンク化、埋め込み生成、pgvectorへの格納、ハイブリッド検索、プロンプトによる回答生成までを一連で実行するSpring Bootのナレッジベース・システムを紹介する。
  • 重要なアーキテクチャ上の選択は、LangChain4jを境界部で使う「アウトバウンド技術」として扱い、アプリケーションの中核と、検索/データの所有(PostgreSQL/pgvector)をドメイン駆動の管理下に置くことだ。
  • プロジェクトは、まずビジネス上の文脈(ドキュメント、検索、共有)で整理し、その各文脈の中でヘキサゴナル(ポート&アダプタ)アーキテクチャを実装し、依存関係は内側へ向くように設計している。
  • カップリングを防ぐために、アプリケーションは自前でポート(例:DocumentChunker、EmbeddingPort、ChatPort)を定義し、コアからLangChain4jのモデルを直接呼び出すのではなく、アダプタを介してLangChain4jのコンポーネントへルーティングする。

ほとんどのAIの例は、だいたい5分くらいはきれいに見えます。

しかし、フレームワークがどこにでも漏れ始めます:

  • コントローラが埋め込みモデルについて知っている
  • サービスがフレームワークの型を返す
  • 検索がブラックボックスになる
  • プロバイダを入れ替えるにはアプリケーションの半分を書き換える必要がある

私はここでそれを望みませんでした。

このプロジェクトは、PostgreSQL、pgvector、そしてLangChain4jをバックエンドにしたSpring Bootのナレッジベースです。実用的なRAGスタイルのフローをサポートします:

  1. HTTP API経由でドキュメントを受け付ける
  2. それらをチャンクに分割する
  3. 埋め込み(embeddings)を生成する
  4. ベクトルをPostgreSQLに保存する
  5. ハイブリッド検索で関連するチャンクを取得する
  6. プロンプトを組み立てて回答を生成する

面白いのはLangChain4jが存在することではありません。面白いのは、それがどう存在しているかです。

LangChain4jは実際の実行フローの一部になっていますが、まだ“外向きの技術”として扱われています。アプリケーションのコアがユースケースを所有しています。PostgreSQLは引き続き検索を所有しています。LangChain4jはチャンク分割、埋め込み、プロンプトのテンプレート、チャットに役立ちますが、アーキテクチャを定義はしません。

アーキテクチャ上のルール

このプロジェクトは、まずビジネスコンテキストによって整理されています:

  • document: インジェスト、インデックス作成、チャンクの永続化、インデックス作成イベント
  • search: 検索(retrieval)、プロンプト構築、回答生成
  • shared: AIポート、LangChain4jアダプタ、設定

各コンテキストの内部では、コードはヘキサゴナル構造に従います:

  • domain
  • application
  • adapter/in
  • adapter/out

これにより、プロジェクトにはシンプルなルールができます:依存関係は内側を指す。

  • コントローラはアプリケーションのサービスを呼び出す
  • アプリケーションのサービスはポートに依存する
  • アダプタがそれらのポートを実装する
  • ドメインのクラスはフレームワークの都合から自由でいる

この点が重要なのは、このプロジェクトが一度に複数のインフラ寄りの関心事に触れているからです:

  • HTTP
  • Springのイベント
  • PostgreSQLとpgvector
  • LangChain4j

これらをすべてコアに漏らしてしまうと、ユースケースが消えてしまいます。アプリケーションは“フレームワークの形をしたサービスの山”になります。

LangChain4jがここでやっていること、やっていないこと

コードは自前のアプリケーションポートを定義しています:

  • DocumentChunker
  • EmbeddingPort
  • ChatPort

この1つの判断が、境界をきれいに保ちます。

アプリケーションは次に直接は話しません:

  • DocumentSplitter
  • EmbeddingModel
  • ChatModel

その代わり、LangChain4jはアダプタを通して端の方へ押し出されます。つまり、ユースケースは“それを実装する今日のフレームワークの型”ではなく、必要としているアプリケーションの契約に依存することになります。

これは「フレームワークを使う」ことと「フレームワークがコードベースの形を決めるのを許す」の違いです。

インデックス作成フローはアプリケーションのコアにとどまる

ドキュメント作成のエンドポイントは薄く保たれています。リクエストを受け取り、アプリケーションサービスに委譲します。チャンク、埋め込み、ベクトルの保存については知りません。

そのサービスはドキュメントを永続化し、アプリケーションレベルのイベントを公開します。次にイベントリスナが、そのイベントをインデックス作成ユースケースへ転送します:

@Transactional
@EventListener
public void handle(KnowledgeDocumentCreatedEvent event) {
    indexer.index(event.documentId());
}

このリスナは意図的に退屈です。ビジネスロジックではなく、トランスポートのためのつなぎです。

本当の作業はKnowledgeDocumentIndexerにあります:

List<String> chunks = documentChunker.chunk(document.getContent());

int index = 0;
for (String chunkText : chunks) {
    EmbeddingVector embedding = embeddingPort.embed(chunkText);

    KnowledgeDocumentChunk knowledgeChunk = KnowledgeDocumentChunk.builder()
            .documentId(document.getId())
            .chunkIndex(index++)
            .chunkText(chunkText)
            .embedding(embedding.values())
            .embeddingModel(embedding.modelName())
            .build();

    chunkStore.save(knowledgeChunk);
}

こここそ、チャンク分割と埋め込みが属する場所です:インデックス作成ユースケースの中。

コントローラの中ではありません。イベントリスナの中でもありません。フレームワークのコールバックの奥に隠れてもいません。

LangChain4jがここで有用なのは、制約があるから

良い例の1つがチャンク分割です。

このプロジェクトは、LangChain4jのDocumentSplitterを直接コアに公開しません。代わりに、アプリケーションはDocumentChunkerに依存し、アダプタの実装はParagraphPreservingDocumentSplitterです。

このクラスは、1チャンクにつき1段落という元のプロジェクトの挙動を維持しつつ、段落が大きすぎる場合には内部的にLangChain4jを使います:

@Override
public List<String> chunk(String text) {
    return split(Document.from(text)).stream()
            .map(TextSegment::text)
            .toList();
}

そして、実際の段落の取り扱いは明示的です:

String[] paragraphs = document.text().split("\\R\\s*\\R");

for (String paragraph : paragraphs) {
    String normalized = paragraph.strip();

    if (normalized.isEmpty()) {
        continue;
    }

    List<TextSegment> paragraphSegments =
            characterSplitter.split(Document.from(normalized));
}

これは、フレームワーク統合にとって良いパターンです:

  • 気にしているビジネス上の振る舞いを維持する
  • 得意なこと(メカニクス)にはフレームワークを使う
  • フレームワークのデフォルトを無分別に受け入れない

同じ考え方が、埋め込みアダプタにも現れます:

@Override
public EmbeddingVector embed(String text) {
    return new EmbeddingVector(
            embeddingModel.embed(text).content().vector(),
            embeddingModel.modelName()
    );
}

ユースケースは必要なものをそのまま得ます:

  • ベクトル値
  • モデル名

アプリケーション層で LangChain4j のレスポンスラッパーを使う必要はありません。

チャット側も同じパターンに従います:

@Override
public String ask(String prompt) {
    return chatModel.chat(prompt);
}

アプリケーションはプロンプトに対する回答を求めます。それが契約です。ChatModel について知る必要はないはずです。

検索(Retrieval)は依然として PostgreSQL に属する

このプロジェクトで私が最も気に入っているのは、この部分です。

LangChain4j は、検索をフレームワークの抽象化に委ねることなく導入されました。

RetrievalService における検索フローは今も明示的です:

float[] questionEmbedding = embeddingPort.embed(question).values();

String vector = vectorFormatter.toPgVector(questionEmbedding);
String metadataFilterJson = toMetadataFilterJson(metadataFilters);

List<SimilarChunk> results =
        knowledgeChunkSearchPort.searchTopK(
                vector,
                normalizeKeywordQuery(keywordQuery),
                metadataFilterJson,
                topK
        );

実際の検索戦略は今も PostgreSQL にあります:

  • pgvector によるベクトル類似度
  • 全文検索によるキーワードのランキング
  • jsonb による厳密なメタデータ・フィルタリング

これは重要なアーキテクチャ上の選択です。

あまりにも多くの例が、検索を魔法のような AI 機能として扱っています。違います。それは検索問題です。このプロジェクトでは、データを順位付けしフィルタするシステムとして PostgreSQL が見えるままです。そうすることで、振る舞いが理解しやすく、デバッグもしやすくなります。

プロンプトのレンダリングはフレームワーク支援であり、フレームワーク所有ではない

プロンプトの構築では LangChain4j の PromptTemplate を使います:

返却形式: {"translated": "翻訳されたHTML"}
private static final PromptTemplate PROMPT_TEMPLATE = PromptTemplate.from("""
        你是知識ベースのアシスタントです。
        以下のコンテキストのみを使って回答してください。
        回答がコンテキスト内に存在しない場合は、分からないと言ってください。

        コンテキスト:
        {{context}}

        ユーザーの質問:
        {{question}}

        回答:
        """);

しかし、PromptBuilder は依然としてアプリケーション層に対してプレーンな String を返します。

それが正しい妥協です。LangChain4j はプロンプトのテンプレート処理の仕組みを助けてくれますが、フレームワークがコアサービスの API になるわけではありません。

偽のモデルはもうショートカットではない

このプロジェクトにはまだ偽のモデルが同梱されていますが、それは良いことです。

重要な点は、今はそれらが偽の LangChain4j モデルになっていることです:

  • FakeEmbeddingModel は LangChain4j の EmbeddingModel を実装しています
  • FakeChatModel は LangChain4j の ChatModel を実装しています

つまり、ローカル開発やテストは、プロバイダーの認証情報なしで実行できます。それでも、実際のプロバイダーが使うのと同じアーキテクチャの処理フローを引き続き検証できます。

これは、ローカル作業用の偽のアーキテクチャと本番用の別の実アーキテクチャを維持するよりずっと良いです。ここでは、偽のプロバイダーを置き換えるのは主に配線(ワイヤリング)の変更で済みます。

明示され続けるべき制約が1つある

細かな説明の裏に埋もれさせてはいけない技術的な詳細が1つあります:

  • データベースのカラムは vector(1536) です
  • 現在の偽の埋め込みモデルも 1536 次元を返します

実際の埋め込みプロバイダーに差し替えるなら、この次元は一致している必要があります。さもなければスキーマを変更しなければなりません。

これは実装の詳細ではありません。永続化(持続化)に関する契約の一部です。

なぜこの設計が機能するのか

このプロジェクトに信頼性があるのは、LangChain4j を使っているからではありません。

重要なのは、このプロジェクトがアーキテクチャを手放さずに LangChain4j を使っていることです。

中核となる考え方は単純です:

  1. まずユースケースを定義する
  2. フレームワークの依存関係をポートの背後に置く
  3. PostgreSQL が検索(リトリーバル)を担当し続けるようにする
  4. コントローラーとリスナーを薄く保つ
  5. プロバイダーの置き換えを、書き換えではなく配線の問題にする

それこそが、コピーする価値がある部分です。

Spring Boot アプリケーションに AI 機能を組み込もうとしているなら、教訓は ?フレームワークを避けろ.? ではありません。教訓はもっと狭く、より役に立つものです:

フレームワークはアダプターとして使う。
フレームワークをあなたのアーキテクチャにしてはいけません。

プロジェクトはこちら

返却形式: {"translated": "翻訳されたHTML"}