ほとんどのAIの例は、だいたい5分くらいはきれいに見えます。
しかし、フレームワークがどこにでも漏れ始めます:
- コントローラが埋め込みモデルについて知っている
- サービスがフレームワークの型を返す
- 検索がブラックボックスになる
- プロバイダを入れ替えるにはアプリケーションの半分を書き換える必要がある
私はここでそれを望みませんでした。
このプロジェクトは、PostgreSQL、pgvector、そしてLangChain4jをバックエンドにしたSpring Bootのナレッジベースです。実用的なRAGスタイルのフローをサポートします:
- HTTP API経由でドキュメントを受け付ける
- それらをチャンクに分割する
- 埋め込み(embeddings)を生成する
- ベクトルをPostgreSQLに保存する
- ハイブリッド検索で関連するチャンクを取得する
- プロンプトを組み立てて回答を生成する
面白いのはLangChain4jが存在することではありません。面白いのは、それがどう存在しているかです。
LangChain4jは実際の実行フローの一部になっていますが、まだ“外向きの技術”として扱われています。アプリケーションのコアがユースケースを所有しています。PostgreSQLは引き続き検索を所有しています。LangChain4jはチャンク分割、埋め込み、プロンプトのテンプレート、チャットに役立ちますが、アーキテクチャを定義はしません。
アーキテクチャ上のルール
このプロジェクトは、まずビジネスコンテキストによって整理されています:
-
document: インジェスト、インデックス作成、チャンクの永続化、インデックス作成イベント -
search: 検索(retrieval)、プロンプト構築、回答生成 -
shared: AIポート、LangChain4jアダプタ、設定
各コンテキストの内部では、コードはヘキサゴナル構造に従います:
domainapplicationadapter/inadapter/out
これにより、プロジェクトにはシンプルなルールができます:依存関係は内側を指す。
- コントローラはアプリケーションのサービスを呼び出す
- アプリケーションのサービスはポートに依存する
- アダプタがそれらのポートを実装する
- ドメインのクラスはフレームワークの都合から自由でいる
この点が重要なのは、このプロジェクトが一度に複数のインフラ寄りの関心事に触れているからです:
- HTTP
- Springのイベント
- PostgreSQLと
pgvector - LangChain4j
これらをすべてコアに漏らしてしまうと、ユースケースが消えてしまいます。アプリケーションは“フレームワークの形をしたサービスの山”になります。
LangChain4jがここでやっていること、やっていないこと
コードは自前のアプリケーションポートを定義しています:
DocumentChunkerEmbeddingPortChatPort
この1つの判断が、境界をきれいに保ちます。
アプリケーションは次に直接は話しません:
DocumentSplitterEmbeddingModelChatModel
その代わり、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 を使います:
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 を使っていることです。
中核となる考え方は単純です:
- まずユースケースを定義する
- フレームワークの依存関係をポートの背後に置く
- PostgreSQL が検索(リトリーバル)を担当し続けるようにする
- コントローラーとリスナーを薄く保つ
- プロバイダーの置き換えを、書き換えではなく配線の問題にする
それこそが、コピーする価値がある部分です。
Spring Boot アプリケーションに AI 機能を組み込もうとしているなら、教訓は ?フレームワークを避けろ.? ではありません。教訓はもっと狭く、より役に立つものです:
フレームワークはアダプターとして使う。
フレームワークをあなたのアーキテクチャにしてはいけません。




