はじめに
AIの効果的な利用法の1つとして「RAG」があります。
RAGのアプリケーションを作成する場合、言語としてはPythonであることが多いのではないでしょうか。RAG環境を構築するためのライブラリ「LangChain」がPythonに対応しているということが大きいように思います。
Java向けにも、LangChainライクに構築されている「LangChain4j」というライブラリがあり、同じような感覚でRAGアプリケーションを構築することができます。
この記事ではJavaでRAGを構築するサンプルアプリを見ていきたいと思います。
また、Javaならではのユースケースとして、Javaの適用領域で多く利用されるRDBをRAGのデータソースとして使用します。
この記事では、JavaのプラットフォームとしてOpenLibertyを使用して、実装面にフォーカスします。RAGそのものについて理解されたい場合はTechXchangeDojoの公開ファイルを参考にしてください。
サンプルプログラムの仕様
実現したいこと
製品テーブルを自然文で検索し、近しい条件の2つのレコードを示したうえで、その違いを説明する。
想定するユースケース
BtoBのECサイトで、次のような利用シーンと課題を想定します。
- 見積り作業で製品テーブルから必要な製品を抽出する
- 製品データは項目が多く検索機能の仕様をまとめることが難しい
- エンドユーザーのシステム利用頻度は低いことから、操作の習熟に期待できない。そのため、初見の人にも使いやすい必要がある
- 製品データは項目が多く、複数ヒットした際にどのような違いがあるのか把握しづらく選択に迷うことがある
自然文で検索を行ない、複数ヒットした場合にその違いを自然文でサジェストすることで、エンドユーザーの検索と選択に使いやすさを提供するという考え方です。
サンプルプログラムのポイント
- RDBの1レコードをチャンク単位とします
- ベクトルDBはLangChain4jで提供されているインメモリDBを使用します
コーディング前の準備
RDBの準備
今回はMariaDBを使用します。ローカルにPodmanなどのコンテナ環境があれば、次のコマンドで起動およびポッドにアクセスすることができます。
podman run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=<PASSWORD> --name mariapod mariadb:latest
podman exec -it mariapod bash
データベースの追加は、コンテナ内で操作します。
mysql -u root -p
create database ragtest;
RDBへのデータ登録
今回はRELATIONAL DATASET REPOSITORYで公開されているAdventureWorksデータセットのProductテーブルを使用します。これは自転車メーカーの製品テーブルです。
データ元はパブリックアクセス可能となっています。DBeaverなどのDBアクセスツールでローカルMariaDBとこのリポジトリに接続し、テーブル定義とデータをエクスポート/インポートで移送します(記事の本題ではないため詳細手順は省略します)。
AIサービスの選択
LangChain4jが利用可能な大規模言語モデル、エンベディングモデルは次のなかから選択できます。ご自身が利用可能なサービスを選択してください。
https://docs.langchain4j.dev/category/language-models
https://docs.langchain4j.dev/category/embedding-models
サンプルコードでは、HuggingFaceのサービスに接続して次のモデルを使用します。
言語モデル:mistralai/Mistral-7B-Instruct-v0.3
エンベディングモデル:sentence-transformers/all-MiniLM-L6-v2
OpenLibertyのスターターアプリ導入
こちらの記事を参考にしてOpenLibertyの環境を構築してください。サンプルプログラムではJakartaEEはNone、MicroProfileは6を選択しています。
定義ファイルの変更
スターターアプリをローカルの任意の場所に展開して、まずは定義ファイルの修正を行ないます。
pom.xml
dependencyにLangChain4j関連のライブラリ、DBクライアントライブラリ(例ではmariaDB)を追加します。
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
<version>0.32.0</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-hugging-face</artifactId>
<version>0.35.0</version>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-document-parser-apache-tika</artifactId>
<version>0.32.0</version>
</dependency>
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<version>2.7.12</version>
</dependency>
/src/main/liberty/config/server.xml
JDBCのfeatureを追加します。
<featureManager>
<feature>microProfile-6.1</feature>
<feature>jdbc-4.2</feature>
</featureManager>
修正後に、ターミナルから次のコマンドを使用して、各ライブラリをコードから参照できるようにします。
mvnw package
コードの追加
次の2クラスで処理を行ないます。今回UIはつくらずに、RESTAPIとしてリクエスト/レスポンスを行なう形態です。データベースとの接続はシンプルにJDBC直書きで行ないます。
クラス | 役割 |
---|---|
ProductAPIクラス | リクエストを受け付けて、DBからベクトルデータの蓄積、および実際のRAG処理を行ないます。 |
VectorStoreクラス | VectorStore関連のハンドリングを行ないます。 |
ProductAPIクラス
リクエストを受け付けて、DBからベクトルデータの蓄積、および実際のRAG処理を行ないます。
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
import dev.langchain4j.data.embedding.Embedding;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.model.huggingface.HuggingFaceLanguageModel;
import dev.langchain4j.store.embedding.EmbeddingMatch;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.EmbeddingSearchRequest;
import dev.langchain4j.store.embedding.EmbeddingSearchResult;
@Path("product")
public class ProductAPI {
@GET
@Path("req")
public String req(@QueryParam("q") String question) {
// 【1】ベクトルDBの初期化とデータ追加
EmbeddingStore<TextSegment> embeddingStore = VectorStore.getEmbeddingStore();
Embedding queryEmbedding = null;
EmbeddingSearchRequest searchRequest = null;
EmbeddingSearchResult<TextSegment> searchResult = null;
// 【2】ユーザーの問合せ内容をそのままベクトル化し、類似性検索を行なう
EmbeddingModel embeddingModel = VectorStore.getEmbeddingModel();
queryEmbedding = embeddingModel.embed(question).content();
searchRequest = EmbeddingSearchRequest.builder()
.queryEmbedding(queryEmbedding)
.maxResults(2)
.build();
searchResult = embeddingStore.search(searchRequest);
// 【3】類似した製品の結果取得
String selectedProduct = "";
int count = 1;
for (EmbeddingMatch<TextSegment> ts1 : searchResult.matches()) {
selectedProduct = selectedProduct + "Product" + count + ": " + ts1.embedded().text();
count ++;
}
// 【4】回答結果の生成(結果の製品名と両者の違いを解説)
HuggingFaceLanguageModel model = HuggingFaceLanguageModel.builder()
.accessToken(VectorStore.API_KEY_HUGGINGFACE)
.modelId("mistralai/Mistral-7B-Instruct-v0.3")
.temperature(0.7)
.maxNewTokens(600)
.waitForModel(true)
.build();
String prompt = "List the name and product number of the following two product data and explain the differences in content in 100 characters or less." +
"\n" + "Attributes of one product data are separated by a colon." +
"\n" + selectedProduct;
String answer = model.generate(prompt).content();
answer = answer.substring(prompt.length(), answer.length());
return answer;
}
}
コメントに記載した番号の部分の解説です。
注釈 | 解説 |
---|---|
【1】 | 前工程の処理として行なうベクトルデータの抽出と格納は、VectorStore.getEmbeddingStore()でまとめて実施しています。 |
【2】 | エンベディングモデルはデータをベクトル化するために使用します。「エンベディング」は日本語では「埋め込み」になります。直感的に分かりづらいので個人的には「特徴量算出」と意訳すると理解しやすいかなと考えています。 ここでは最大2件という条件をつけてリクエストの準備を行ない、類似性の検索処理を行なっています。最低類似度(指定以下の類似度は選択しない)という条件を追加することも可能です。 |
【3】 | EmbeddingMatchは、類似度のスコアが付いた検索結果の1件分のデータです。データの実態はTextSegmentで、これはベクトル化対象のデータと、任意に追加するメタデータの2パートから構成されるValueObjectです。 ここでは2件の検索結果を抽出して、2件分のデータを1テキストに結合しています。 |
【4】 | 【3】のデータに対して「次の2つの製品データの名称と製品番号を挙げ、内容の違いを100字以内で説明しなさい。」というプロンプトを渡して言語モデルに回答生成を依頼します。 最後のほうでsubstringを入れているのは、利用している言語モデルでは生成結果の前に質問文を付加するスタイルのため、元質問を削っているというものです。ここは言語モデルによって違いがあるようです。 |
LangChain4jの長所ですが、言語モデルに対する操作は直感的に理解しやすい構造となっているところ、さらにモデルを他製品に変更しても記載要領はほとんど変わらない(フレームワークが違いをラップする)というところです。
モデルの進化は急速に進んでいるため、システムのライフサイクルのなかで(場合によっては開発中に)入れ替えを行なう可能性も十分ありえます。その際に、コード修正を最小限に抑えられる可能性を持っているのは大きなメリットといえるでしょう。
VectorStoreクラス
ベクトルDB(コード内ではembeddingStore)のインスタンス管理とRDBからのベクトルデータ生成、当記事固有のロジックとして「ResultSet行データのチャンクデータ化」処理を行なっています。
import java.util.ArrayList;
import java.util.List;
import dev.langchain4j.data.document.Metadata;
import dev.langchain4j.data.embedding.Embedding;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.huggingface.HuggingFaceEmbeddingModel;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.store.embedding.EmbeddingStore;
import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore;
import java.sql.*;
public class VectorStore {
public static String API_KEY_HUGGINGFACE = "hf_XXXX";
public static String DB_CONN = "jdbc:mariadb://localhost:3306/ragtest?user=root&password=<PASSWORD>";
private static EmbeddingStore<TextSegment> embeddingStore = null;
private static EmbeddingModel embeddingModel = null;
static public EmbeddingStore<TextSegment> getEmbeddingStore() {
if (embeddingStore == null) {
// 【1】テーブルからチャンクデータを取得する
List<TextSegment> segments = getTextSegments();
// 【5】EmbeddingModelを使用して、チャンクデータのベクトル変換を行なう
embeddingModel = getEmbeddingModel();
List<Embedding> embeddings = embeddingModel.embedAll(segments).content();
// 【7】ベクトルDBを起動し、データを全件登録する
embeddingStore = new InMemoryEmbeddingStore<>();
embeddingStore.addAll(embeddings, segments);
}
return embeddingStore;
}
static public EmbeddingModel getEmbeddingModel() {
// 【6】ベクトル化のためのエンベディングモデルの取得
if (embeddingModel == null) {
//EmbeddingModel embeddingModel = OpenAiEmbeddingModel.withApiKey(Const.API_KEY_OPENAI);
embeddingModel = HuggingFaceEmbeddingModel.withAccessToken(API_KEY_HUGGINGFACE);
}
return embeddingModel;
}
static List<TextSegment> getTextSegments() {
// 【2】テーブルから全件検索を行ない、List<TextSegment> に変換する
String sql = "select * from Product";
List<TextSegment> textSegments = new ArrayList<TextSegment>();
try (Connection conn = DriverManager.getConnection(DB_CONN);
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql);
) {
ResultSetMetaData rsmd = rs.getMetaData();
while(rs.next()) {
// 【3】行データのチャンクデータへの変換
textSegments.add(getTextSegmentFromResultset(rs, rsmd));
}
return textSegments;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
static TextSegment getTextSegmentFromResultset(ResultSet rs, ResultSetMetaData rsmd) throws Exception{
// 【4】特定の項目をKeyValue形式で連結する
String body = "";
int numColumns = rsmd.getColumnCount();
for (int i=1; i<=numColumns; i++) {
String column_name = rsmd.getColumnName(i);
switch(column_name) {
case "Name", "ProductNumber", "MakeFlag", "FinishedGoodsFlag", "Color", "StandardCost", "ListPrice", "Size", "SizeUnitMeasureCode", "WeightUnitMeasureCode", "Weight":
body = body + column_name + "=" + rs.getObject(column_name) + ";";
break;
}
}
Metadata metadata = new Metadata();
metadata.put("key", rs.getInt("ProductID"));
return new TextSegment(body, metadata);
}
}
コメントに記載した番号の部分の解説です。番号は処理順に指定しているため、コード上の記載順とは異なります。
前半はRDBからの収集・編集が主で、ベクトル関連の処理は後半になります。
注釈 | 解説 |
---|---|
【1】【2】 | 指定テーブルの全件全項目を取得します。別のテーブルで試される場合はSELECT文を修正してください。 【1】の結果であるsegmentsは、全件データをチャンクデータのリストに変換した結果となります。 |
【3】【4】 | 文書ファイルなどの非構造化データを扱う際に、チャンク分割は精度を決める大きなポイントです。固定長や句読点を区切りの単位とすることがシンプルな対処です。LangChain4jでも多数のDocumentSplitterが選択でき、用途にあった分割を検討できます。 サンプルでは「ResultSetの1レコードを1チャンクとする」考え方を取ります。 また、1レコードの特徴量を算出するために「代表的な項目をKeyValue形式で文字列化する」という編集を行なっています。今回のロジックはベストプラクティスとはいえず、データや利用用途、選択できる編集技術によりさまざまな可能性があると考えています。 getTextSegmentFromResultsetメソッドでは、1行単位の編集を行なっています。 |
【5】【6】 | エンベディングモデルを使用して、ここまでに作成したチャンクデータリストをもとにベクトル形式の特徴量の算出を行ないます。Embeddingクラスはチャンクデータ単位のベクトルデータが格納されています。 コメントでOpenAIを使用する場合の記載例を示しています。このように異なるAIサービスを使う場合でも同じ感覚で実装することができます。 |
【7】 | 今回はサンプルのため、揮発性のあるインメモリのベクトルDBを使用します。実運用ではMilvusなどのベクトルDBを選択することになるでしょう。 |
実行結果
今回は利用モデルに合わせて英語で指示および結果を取得しています。モデルの選択により日本語対応は可能と考えています。
リクエストはブラウザから次の形式で行います。
http://localhost:9080/appmp/api/product/req?q=<抽出条件を英語で記述>
問合せ
言語 | 内容 |
---|---|
英語 | size is over 48,color is red,ListPrice is under 1000 |
日本語訳 | サイズは48以上、色は赤、定価は1000以下 |
抽出結果
選択レコード | レコードの内容 |
---|---|
1 | Name=Road-450 Red, 48;ProductNumber=BK-R68R-48;MakeFlag=true;FinishedGoodsFlag=true;Color=Red;StandardCost=884.7083;ListPrice=1457.9900;Size=48;SizeUnitMeasureCode=CM;WeightUnitMeasureCode=LB;Weight=17.13; |
2 | Name=Road-650 Red, 48;ProductNumber=BK-R50R-48;MakeFlag=true;FinishedGoodsFlag=true;Color=Red;StandardCost=486.7066;ListPrice=782.9900;Size=48;SizeUnitMeasureCode=CM;WeightUnitMeasureCode=LB;Weight=19.13; |
比較結果
回答が途中で切れているのは、100字またはトークン上限による制約によるものです。
言語 | 内容 |
---|---|
英語 | The differences are in the ProductNumber, StandardCost, and ListPrice. Product1 (BK-R68R-48) has a higher StandardCost ($884.71) and ListPrice ($1457.99) compared to Product2 (BK-R50R-48), which has a lower StandardCost ($486.71) and ListPrice ($782.99). Both products are Road- |
日本語訳 | 違いは、製品番号、標準価格、および定価である。製品1(BK-R68R-48)の標準コスト($884.71)とリストプライス($1457.99)は高く、製品2(BK-R50R-48)の標準コスト($486.71)とリストプライス($782.99)は低い。両製品ともロード... |
Productテーブルには約500件のレコードがあり、ペダルなど多様な製品構成となっています。レコード全体を確認しないと類似性の評価はできませんが、以上・以下という論理的な条件はやはり苦手であることが伺えます。この辺は通常の検索条件と併用するといった工夫が考えられます。
比較結果の文章回答もよくできていますが、ユーザーに対して有用な回答とするためにはもう少し工夫が必要かもしれません。
類似性検索ではなく、「入力された条件をSQLに翻訳する」という言語モデルの使い方もあります。ただこちらの使い方はインジェクション対策を考量する必要があるため少し注意が必要です。
まとめ
RAGというと「非構造化データをチャットで問合せする」パターンが王道です。
今回はJavaの主要な適用領域であるRDBを使用したアプリケーションに、RAG処理を組み込めることが確認できました。
適用ユースケースの検討や効果測定の考え方など、まだ解決すべき課題はありますが、業務アプリケーションにAIの可能性を掛け合わせることで、より魅力的で効果の高い価値をユーザーに届けることができそうです。