5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

検索システムの作り方(Spring Boot編)

Last updated at Posted at 2023-03-11

この記事では、検索システムをSpringBootで構築する具体的な方法を解説します。
ハンズオン形式で、最終的には先日リリースした「Hotty技術書検索」と同じシステムを構築できることを目指します。

HottyDBというRDBMSを使うと、検索エンジンや機械学習システムを新たに導入することなく、簡単に高度な検索システムを導入できます。その様子を紹介していきたいと思います。

HottyDBとは?
HottyDBは、検索エンジンと推薦エンジンの機能を搭載したRDBMSです。
HottyDBを使うと、新たに検索エンジンや機械学習システムを導入することなく、これ一台で転置インデックスによる全文検索や機械学習によるランキング最適化を簡単に実現することができます。

HottyDBのマネージドAPIサービスを近日公開予定!!
HottyDBのマネージドAPIサービスを近日リリースする予定です。興味がある方は下記のリンクからウェイトリストへの登録をお願いします。
高精度AI搭載のマネージド検索エンジン「HottyDB」

はじめに

この記事では、具体的なシステム構築の手順を解説します。前回の記事(検索システムの作り方)で、一般的な検索システムの解説をしていますので、適宜そちらも参照しながら解説を進めていきたいと思います。

例えば、「転置インデックスとは何か?」みたいな解説は前回記事で解説していますので、そちらを参照ください。本記事では、具体的なシステム構築の手順を解説します。

本記事の構成

本記事は以下の構成でお届けします。

  1. 通常システムの紹介(HottyDBを使わない基本システム)
  2. DBをHottyDB に切り替える
  3. 転置インデックスを用いた全文検索
  4. 検索結果のランキングを機械学習で最適化

1. 通常システムの紹介(HottyDBを使わない基本システム)

最初に、HottyDBを使わない基本システムを紹介していきます。
基本システムでは、「前回記事におけるLIKE句によるテキスト検索」で検索システムを実現します。

まず動かしてみる

Git Clone

まず、通常のシステムをローカル環境で動かすために、下記のGithubリポジトリをCloneしてください。

Javaプログラムなので、IntelliJ IDEAなどのIDEで作業することをお勧めします。

楽天ウェブサービスでアプリIDの発行

本記事で作り方を紹介する「Hotty技術書検索」では、楽天APIを利用して検索対象の書籍データを抽出します。したがって、楽天APIのアプリIDを取得する必要があります。

下記URLから楽天ウェブサービスにアクセスし、アプリIDの発行を行なってください。

アプリIDを発行すると、アプリ情報の確認のページでアプリID(applicationId)を確認できます。

application.propertiesでアプリIDを設定

git cloneしたリポジトリの src/main/resources/application.properties の設定ファイルを開いてください。

下記のような記述があると思うので、先ほど発行したアプリIDを rakuten.api.applicationId の箇所に記述してください。

### RAKUTEN API SETTINGS ###
## Required
rakuten.api.applicationId=XXX

ローカルで起動

下記のコマンドを実行してください。

./gradlew bootRun

実行すると、楽天書籍APIの「本 > パソコン・システム開発 > プログラミング > SQL」のジャンルを走査し、書籍データを抽出する処理が走ります。

本番実行時には「本 > パソコン・システム開発」のジャンルの書籍を全て抽出しますが、数時間かかるので開発時はこのジャンルを指定しています。抽出対象ジャンルを変更する方法は、本記事の最後に解説があります。

しばらくすると、

【1周目の楽天データ抽出は完了しています。2周目以降はゆっくり実行します。】

というログメッセージが表示されます。そうするとデータ抽出は完了したことになりますので、
http://localhost:8080/
にアクセスし、検索ボックスに「SQL」と打ち込んでみてください。こうするとLIKE句による通常の検索処理が実行できるはずです。

ソースコードの説明

上記で動かしたプログラムのソースコードを解説していきます。

システムの全体像

HottyDBを使わない基本システムは下記のような構成になっています。

項目 利用技術
フロントエンド ThymeLeaf + Vue.js
バックエンド Spring Boot
データベース H2

バックエンドのSpring Bootは、通常のHTMLの返却とREST APIとしても動いています。

フロントエンドは、サーバーサイドがThymeLeafで動いており、クライアントサイドはVue.jsを使っています。Vuew.jsは、検索結果一覧などREST APIから取得した情報を非同期でレンダリングする際に使っています。

データベースはH2という通常のRDBMSを利用しています。H2はJava上で組み込みモードで動作するため、別途DBサーバーを立ち上げる必要がありません(この点はHottyDBも同様です)。

重要な設定ファイル

build.gradle

dependencies の部分で、必要なライブラリの読み込みをしています。

runtimeOnly 'com.h2database:h2

の行でH2のライブラリを読み込んでいます。

src/main/resources/application.properties

## for H2
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:file:./tmp/h2/testdb

の部分で、H2データベース向けの設定をしています。ドライバクラスの指定とデータの保存先の設定をしています。HottyDBに切り替える際はこの辺りの設定を変更することになります。

rakutenパッケージ

プログラムを起動すると、Webサーバーと同時に非同期で楽天APIからデータを定期的に抽出する処理が起動します。それが、 rakutenパッケージのLoadRakutenItemService クラスです。

設定ファイルで指定したジャンル配下のほぼ全ての書籍をDBに登録していきます。APIは一回のコールで30件しか書籍データを取得できないため、何度もAPIのパラメータを変化させながら登録処理を進めていきます。

ここの処理がプログラムの中で一番複雑になっていますが、本記事のメインテーマではないので詳細の説明は割愛させていただきます。

paramsパッケージ

上記の rakutenパッケージでAPIをコールする際に指定するAPIパラメータを管理するテーブルの関連クラスです。SpringBoot(JPA)でよくあるEntity, Repository, Serviceの構成になっていて、こちらも本記事のメインテーマではないため詳細な説明は割愛します。

itemパッケージ

書籍の商品データを管理するテーブルの関連クラスで、最も重要なパッケージです。
こちらもSpringBoot(JPA)でよくあるEntity, Repository, Serviceの構成になっていますが、各クラスについて詳しく解説していきます。

ItemEntityクラス

ItemEntityクラスは、楽天APIから取得した書籍データを格納するitemテーブルと関連づけたJPAのエンティティクラスです。

@Entity
@DynamicUpdate
@Table(name = "item")
@Data
public class ItemEntity {
    @Id Integer id;
    String title;
    String subTitle;
    String seriesName;
    ...
    int salesYear;
    @Lob String searchText;

ItemEntityクラスで定義したフィールドは全てitemテーブルのカラムと対応していて、そのほとんどが楽天APIから取得した情報です。

定義したメソッドのうち、setId, setSalesYear, setSearchTextは、楽天APIから取得できないフィールドをセットするためのメソッドです。

void setId() {
    String s = itemUrl.replaceFirst("https://books.rakuten.co.jp/rb/", "");
    s = s.replaceAll("/", "");
    id = Integer.parseInt(s);
}

void setSalesYear() {
    if (salesDate == null) salesYear = 1950;
    else salesYear = salesDate.toInstant().atZone(
                 ZoneId.systemDefault()).toLocalDate().getYear();
}

void setSearchText() {
    String s = String.format("%s %s %s", title, subTitle, itemCaption);
    searchText = StringUtils.normalize(s);
}
  • setId:商品ID(id)を商品URLの中から抽出し、セットします。
  • setSalesYear:書籍の発売年(salesYear)をセットします。salesDateと情報は重複しますが、機械学習によるランキング最適化の際の特徴量として利用します。
  • setSearchText:書籍のタイトル、サブタイトル、詳細を連結し、文字列正規化を行ったものをsearchTextにセットします。このフィールドは、テキスト検索時の検索対象となるフィールドです。

その他のメソッドは商品を表示する際に使うものなので詳細は割愛します。

文字列正規化とは、英字の大文字小文字や英数字の半角全角などを統一するための処理で、検索クエリに対しても行うことで検索の再現率を向上させることができます。

ItemServiceクラス

Controllerなどの他パッケージから itemテーブルを操作する際の窓口となるサービスクラスです。ItemRepositoryを使った処理を提供しますが、ItemRepositoryはインターフェースであり、ある程度決まった形の処理しかできないため、そのアダプター的な役割を担います。

テキスト検索処理で重要なのはsearchメソッドになります。ItemRepository.searchに検索クエリを送る前に文字列正規化処理などを施しています。

public List<ItemEntity> search(String query) {
    String normalizedQuery = StringUtils.normalize(query);
    List<ItemEntity> itemList = itemRepository.search(normalizedQuery);
    int to = Math.min(30, itemList.size());
    return itemList.subList(0, to);
}

ItemRepositoryインターフェース

ItemRepositoryインターフェースはJpaRepositoryインターフェースを継承しています。特に実装クラスを書かなくても itemテーブルに対するCRUD処理はこのインターフェースを利用して実行することができます(Spring.JPAの機能で実行時に実行クラスが自動生成されるらしい)。

そんな中、ItemRepositoryではJPQLという仕組みを使って、JpaRepositoryに未定義の検索処理を3つ作成しています。その中でもテキスト検索に利用している重要なメソッドが、 search メソッドです。
基本システムでは、LIKE句による検索処理を実行するため、以下のJPQLで検索を実行しています。

@Query("SELECT x FROM ItemEntity x WHERE x.searchText LIKE %?1% ORDER BY x.salesYear DESC")
List<ItemEntity> search(@Param("q") String q);

前回記事」では、LIKE句による方法でも複数キーワードによる検索も可能だと説明していますが、JPQLで複数キーワードを定義するのは難しいため、基本システムでは複数キーワードに対応することはできません。

その他、

  • 問題点1. テーブルのフルスキャンとなる
  • 問題点2. 類似度計算ができない

といった問題点が残ります。次節以降でHottyDBを用いることで、これらの問題を解決していきます。

2. DBをHottyDB に切り替える

いよいよHottyDBを利用していきます。このセクションでは、これまでと処理を変えずに単にDBの切り替えのみを行います。

ソースコードは、Githubのブランチ 2_HottyDBに切り替えに対応しています。

build.gradleの変更

利用するDBが変わるため、利用するライブラリを変更します。

まず、下記のようにmavenリポジトリを追加してください。HottyDBのパッケージはmavenCentralにはまだ入ってないので、GitLabのリポジトリを指定します。

repositories {
	mavenCentral()
+	maven {
+		url 'https://gitlab.com/api/v4/projects/36750029/packages/maven'
+	}
}

次にdependenciesの箇所を下記のように書き換えます。

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

-	runtimeOnly 'com.h2database:h2'
+	implementation 'org.hottydb:hottydb:0.5.0'
+	implementation 'org.hottydb:hibernate-dialect-hottydb:5.6'

	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'
  • com.h2database:h2: H2はもう使わないので削除します。
  • org.hottydb:hottydb:0.5.0 はHottyDBのパッケージ
  • org.hottydb:hibernate-dialect-hottydb:5.6 はHottyDBにおけるSQLの方言をSpringJPAに知らせるためのパッケージです。H2のような有名なデータベースはSpringJPA(正確にはHibernate)にその実装が内包されていますが、HottyDBはまだ新しいデータベースのため別途用意する必要があります。

application.propertiesの変更

続いて、application.properitesを以下のように修正します。

-## for H2
-spring.datasource.driver-class-name=org.h2.Driver
-spring.datasource.url=jdbc:h2:file:./tmp/h2/testdb
+### for HottyDB
+spring.datasource.driver-class-name=org.hottydb.jdbc.embedded.EmbeddedDriver
+spring.datasource.url=jdbc:hottydb:tmp/dev1.db
+spring.jpa.properties.hibernate.dialect=com.hottydb.dialect.HottyDBDialect
  • spring.datasource.driver-class-nameでは、ドライバクラスをHottyDBのものに変更しています。
  • spring.datasource.urlでは、データ保存先のURLをHottyDBのものに変更しています。
  • spring.jpa.properties.hibernate.dialectは、SQLの方言をSpringJPAに知らせるための設定です。H2の場合はSpringJPAに実装が内包されているため設定不要でしたが、HottyDBでは方言クラスを指定する必要があります。この実装は、org.hottydb:hibernate-dialect-hottydb:5.6のパッケージに入っています。

以上でデータベースの切り替えは完了です。

ローカルで起動

下記コマンドでローカル環境で起動させてみましょう。

./gradlew bootRun

H2の場合と同様に動作することがわかると思います。

このセクションにおけるソースコードの変更点は、以下のPull Requestで詳細に確認できます。
https://github.com/toru1055/it-book-search/pull/1/files

3. 転置インデックスを用いた全文検索

データベースがHottyDBに切り替わったところで、ここから検索エンジンの機能を存分に使っていきたいと思います。まずは、「転置インデックスを用いた全文検索」機能です。転置インデックスの詳しい説明は「前回記事の同様のセクション」を参照ください。

このセクションでは、SpringBootで転置インデックスを作成し、それを利用した検索を実行するための手順を解説していきます。

ソースコードは、Githubのブランチ 3_転置インデックスを用いた全文検索に対応しています。

転置インデックスの作成

item/ItemServiceの修正

転置インデックスを作成するためには、HottyDBの独自機能である CREATE SEARCH INDEX コマンドを呼び出す必要があります。そのために、ItemServiceクラスに以下のように初期化用のメソッド initialize を用意します。

    private int trendsMinReviewCount;

+    @PersistenceContext
+    EntityManager entityManager;
+
+    @Transactional
+    public void initialize() {
+        entityManager.createNativeQuery("CREATE SEARCH INDEX IF NOT EXISTS s1 ON item (search_text)").executeUpdate();
+    }

    public List<ItemEntity> trends() {

このメソッドでは、生のSQLを直接実行するために EntityManager というインターフェースを使っています。EntityManagerの実装クラスは@PersistenceContext アノテーションにより注入することができます。

CREATE SEARCH INDEX コマンドを詳しくみていきましょう。

CREATE SEARCH INDEX IF NOT EXISTS s1 ON item (search_text)

このコマンドでは、s1という名前の転置インデックスを作成しています。IF NOT EXISTSという条件をつけているので、SpringBootを起動する度に転置インデックスが作成されることはありません。初回起動時のみ作成されます。itemテーブルのsearch_textという検索対象フィールドとして用意したフィールドに転置インデックスを作成しているのがわかると思います。

また、ItemServiceのその他の修正も見てみましょう。

    public List<ItemEntity> similarItems(int seedId) {
        Optional<ItemEntity> seedItem = itemRepository.findById(seedId);
        String normalizedQuery = StringUtils.normalize(seedItem.get().getTitle());
-        normalizedQuery = normalizedQuery.substring(0, 3);
        List<ItemEntity> itemEntityList = itemRepository.similarItems(normalizedQuery);
        itemEntityList.removeIf(x -> x.getId() == seedId);
        int to = Math.min(30, itemEntityList.size());

このメソッドは、商品詳細ページに表示される関連書籍の一覧を取得するためのメソッドです。基本システムでは文字列の類似度による検索ができないため、関連書籍を取得するためにタイトルの先頭3文字を使ってLIKE検索をしていました。HottyDBを使えば文字列の類似度による検索が可能になるので、先頭3文字を抜き出す必要がなくなります。

BookSearchApplicationの修正

続いてmainメソッドのあるBookSearchApplicationを修正します。これは先ほど作成したItemService.initialize()メソッドをサーバー起動後に実行するためです。

	public static void main(String[] args) {
		ConfigurableApplicationContext ctx = SpringApplication.run(BookSearchApplication.class, args);
+		ItemService itemService = ctx.getBean(ItemService.class);
+		itemService.initialize();
		LoadRakutenItemService loadRakutenItemService = ctx.getBean(LoadRakutenItemService.class);
		loadRakutenItemService.initialize();
		loadRakutenItemService.run();

のように2行追加しましょう。

以上で転置インデックスの作成ができるはずです。

転置インデックスを利用した検索の実行

続いて先ほど作成した転置インデックスを利用して検索を実行していきます。変更するのは、item/ItemRepositoryのクラスのみです。

    List<ItemEntity> trendItems(@Param("minSalesYear") int minSalesYear, @Param("minReviewCount") int minReviewCount);

-    @Query("SELECT x FROM ItemEntity x WHERE x.searchText LIKE %?1% ORDER BY x.salesYear DESC")
+    @Query(value = "SELECT * FROM SEARCH(item, search_text, :q) ORDER BY _similarity DESC", nativeQuery = true)
    List<ItemEntity> search(@Param("q") String q);

-    @Query("SELECT x FROM ItemEntity x WHERE x.searchText LIKE %?1% ORDER BY x.salesYear DESC")
+    @Query(value = "SELECT * FROM SEARCH(item, search_text, :seedTitle, OR) ORDER BY _similarity DESC", nativeQuery = true)
    List<ItemEntity> similarItems(@Param("seedTitle") String seedTitle);

変更点は2行ですが、1つずつ解説していきます。

searchメソッド

searchメソッドは検索クエリに対する検索結果を取得するメソッドです。LIKE句の場合は通常のJPQLでSQLを記述していました。HottyDBで全文検索を実行する場合、特殊なSQLを使うためnativeQueryをtrueに指定する必要があります。

その上で、中身のSQLを確認してみましょう。

SELECT * FROM SEARCH(item, search_text, :q) ORDER BY _similarity DESC

FROM句の後にあるSEARCHメソッドに注目してください。SEARCH(テーブル名, フィールド名, 検索キーワード) とすることで、転置インデックスを利用した検索結果を返すことができます(転置インデックスが作成されているテーブル・フィールドのため)。このSEARCHメソッドの出力は、通常のテーブルと同じように扱うことができるのです!(他テーブルとJOINすることも可能だし、GROUP BYなどで集約することも可能!)

またここで、_similarityという見慣れないフィールドが突然出てきました。これは、SEARCHメソッドが自動的に追加するフィールドです。_similarityは検索キーワードと該当レコードとの類似度を表すスコアになっています。ORDER BY _similarity DESC とすることで、類似度の高い順に検索結果を並べることが可能です。

similarItemsメソッド

similarItemsメソッドは、入力された書籍タイトルと類似する書籍一覧を取得するメソッドです。searchメソッドの場合とよく似ていますが、そのSQLをみていきましょう。

SELECT * FROM SEARCH(item, search_text, :seedTitle, OR) ORDER BY _similarity DESC

SQLはほとんど一緒ですが、FROM句後のSEARCHメソッドに第四引数が増えていることに着目してください。

第四引数は、オペレータといって検索をAND検索にするかOR検索にするかを指定するものです。指定がない場合はAND検索になりますので、OR検索を実行したい場合はこのようにORと指定する必要があります。OR検索を指定すると、検索クエリのトークンのうち、いずれかのトークンにマッチしたレコードを返却します。similarItemsは、検索クエリに書籍タイトルの全体を入力することから、AND検索ではほとんど結果が返ってきません。OR検索にし、類似度順で結果を取得することで、類似した書籍を取得することができます。

ローカルで起動

修正点は以上です。早速ローカルでサーバーを起動して全文検索を実行してみましょう。

./gradlew bootRun

検索ボックスに「SQL」と入力して検索してみてください。先ほどまでのLIKE句による検索とは変化していることが分かると思います。

また、何か書籍をクリックして書籍の詳細ページを開いてみてください。関連書籍の箇所に書籍タイトルに類似した書籍が並んでいると思います。これも前回までのLIKE句によるものから変化していると思います。

このセクションにおけるソースコードの変更点は、以下のPull Requestで詳細に確認できます。
https://github.com/toru1055/it-book-search/pull/2/files

単純な全文検索の問題点

HottyDBを使うことで転置インデックスを用いた全文検索を利用することができました。しかし、単純に類似度順に検索結果を並べることは必ずしも最適なランキングとは言えません。例えば、item テーブルの例では類似度だけでなく、レビューの件数や平均点・価格や発売日なども考慮に入れたランキングにしたくなりますが、単純な方法ではそれができません。高度な検索システムでは、検索結果のランキングを類似度以外の指標も組み合わせて機械学習によりランキングを最適化しています。

次の節では、機械学習を用いて検索結果のランキングを最適化する方法を解説します。

4. 検索結果のランキングを機械学習で最適化

HottyDBの機能を使って検索結果のランキングを機械学習により最適化する方法を解説します。前回記事でも同様のセクションがありますが、SpringBootでこれを実現する方法を解説していきます。

前回記事と同様に下記4つの手順を実装していく必要があります。

  1. (準備1)MLRテンプレートの作成
  2. (準備2)MLRモデルの作成
  3. (推論)機械学習ランキングの推論(並び替え)
  4. (学習)機械学習ランキングの学習

少々変更点が多いですが、通常の機械学習システムの学習パイプラインを構築する手間と比べればとても簡単です。

ソースコードは、Githubのブランチ 4_検索結果のランキングを機械学習で最適化に対応しています。

4-1. (準備1)MLRテンプレートの作成

まずMLRテンプレートと呼ばれる、機械学習ランキングを行う対象のテーブルデータを生成するテンプレートを作成します。詳しい解説は前回記事のこちらを参照してください。

item/ItemServiceクラスのinitializeメソッドに下記のDDLを追加します。

    @Transactional
    public void initialize() {
        entityManager.createNativeQuery("CREATE SEARCH INDEX IF NOT EXISTS s1 ON item (search_text)").executeUpdate();
+        entityManager.createNativeQuery("CREATE MLR_TEMPLATE IF NOT EXISTS mt1 KEY(id) " +
+                "'SELECT * FROM SEARCH(item, search_text, ?)'").executeUpdate();
    }

SQLを詳しくみていきます。

CREATE MLR_TEMPLATE IF NOT EXISTS mt1 KEY(id) 
'SELECT * FROM SEARCH(item, search_text, ?)'

MLRテンプレートとして、itemテーブルのsearch_textフィールドを全文検索した結果を登録していることがわかります。つまり、「このSELECT文の結果レコードを機械学習で最適なランキングにしますよー」と宣言をしている訳です。

続いてSEARCHメソッド内の ?に注目してみましょう。?はプレースホルダーを意味し、テンプレート作成時点では値を決められない定数(この場合検索キーワード)を仮置きすることができます。
MLRテンプレートという名前の謂れはここから来ています。プレースホルダーは何個でも設定することができ、検索キーワード以外にもWHERE句の条件を仮置きすることなども可能です。このプレースホルダーは、のちの機械学習ランキングの推論時に挿入することになります。

4-2. (準備2)MLRモデルの作成

続いて、先ほど作成したMLRテンプレートに対して、MLRモデルを作成します。MLRモデルでは、MLRテンプレートの出力フィールドのうち、特徴量として使うフィールドを指定します。

item/ItemServiceクラスのinitializeメソッドに、さらに下記の修正を入れます。

    @Transactional
    public void initialize() {
        entityManager.createNativeQuery("CREATE SEARCH INDEX IF NOT EXISTS s1 ON item (search_text)").executeUpdate();
        entityManager.createNativeQuery("CREATE MLR_TEMPLATE IF NOT EXISTS mt1 KEY(id) " +
                "'SELECT * FROM SEARCH(item, search_text, ?)'").executeUpdate();
+        entityManager.createNativeQuery("CREATE MLR_MODEL IF NOT EXISTS mm1 WITH mt1 (" +
+                "item_price, review_count, review_average, sales_year, _similarity)").executeUpdate();
    }

SQLを詳しくみていきましょう。

CREATE MLR_MODEL IF NOT EXISTS mm1 WITH mt1 (
item_price, review_count, review_average, sales_year, _similarity)

MLRモデルをmm1という名前で作成しています。先ほど作成したMLRテンプレートmt1を並び替えるモデルであることを指定しています。ランキング学習に用いる特徴量として、商品価格(item_price), レビュー数(review_count), レビュー平均点(review_average), 発売年(sales_year), クエリとの類似度(_similarity)を登録しています。

4-3. (推論)機械学習ランキングの推論(並び替え)

こちらの項は、前回記事のこちらの項と対応していますので、適宜そちらも参照してください。

まず、機械学習ランキングの推論(レコードの並び替え)を実行するためのSQLを最初に紹介します。

SELECT * FROM MLR(mm1, :q)(30, 0)

:qの部分には検索クエリが入ります。MLRメソッドの第1引数にはMLRモデル名を指定する必要があり、第2引数以降はMLRテンプレートでPlaceholderとしていた部分と対応しています。つまりこの場合、検索クエリ:qは、MLRテンプレートにおけるSEARCHメソッドの第3引数(検索クエリの部分)に挿入されます。

MLRメソッドの後ろの(30, 0) は(LIMIT, OFFSET)を意味しており、LIMITは検索結果を何件取得するか、OFFSETは何件目から取得開始するか?という意味です。

このSELECT文が出力する結果レコードはitemテーブルのフィールドを全て出力しますが、それ以外に下記4つのフィールドが追加されます。

  • _similarity: SEARCHメソッドで追加される、検索クエリとドキュメントの類似度を表すフィールド
  • _request_id: 検索リクエストを一意に特定するIDで、学習データを送信する際に利用します。
  • _key_id: 検索結果内でアイテムを一意に特定するIDで、学習データを送信する際に利用します。MLRテンプレートでKEYに指定したフィールドの値と同じ値が返ります。
  • _score: アイテムの機械学習ランキングにおけるスコアを返します。このスコアの降順でレコードは返却されます。

後述の機械学習ランキングの学習部分を実現するために、特に大事なのは_request_id_key_idのフィールドです。これらの追加的なフィールドを受け取り、フロントエンドに渡すためには既存のItemEntityクラスではフィールドが足りません。したがって、このMLRメソッドの結果レコードを受け取るための新たなEntitiyクラス作成する必要があります。

item/SearchItemEntityの新規作成

上記のようにMLRメソッドの結果レコードを受け取るためのEntitiyクラスとして、SearchItemEntityクラスを新規に実装します。中身はほとんどがItemEntityクラスと同じです。

@Entity
@Table(name = "dummy_item") // ダミーではあるが、テーブルは作成される.
@Data
public class SearchItemEntity {
    /* ==ItemEntityにもあるフィールド== */
    // 省略

    /* ==追加フィールド== */
    @Column(name = "_similarity") Double similarity;
    @Column(name = "_request_id") Integer requestId;
    @Column(name = "_key_id") Integer keyId;
    @Column(name = "_score") Double score;


    /* ==表示用メソッド. ItemEntityにもある== */
    // 省略
}

共通した部分は省略していますので、実際のコードはこちらで確認してください。

変化している部分について解説します。まず、@Table(name = "dummy_item") のアノテーションを確認してください。MLRメソッドの結果レコードを受け取るためには@Tableアノテーションが必要なのでテーブルは作成されてしまいますが、ダミーのテーブルなので中身は空となります。

続いて追加フィールドの部分です。

    /* ==追加フィールド== */
    @Column(name = "_similarity") Double similarity;
    @Column(name = "_request_id") Integer requestId;
    @Column(name = "_key_id") Integer keyId;
    @Column(name = "_score") Double score;

こちらは、先ほど説明したMLRメソッドで追加されるフィールドに対応しているのがわかります。

item/SearchItemRepositoryの新規作成

続いて先ほどのSearchItemEntityに対応するJpaRepositoryを新規作成していきます。

public interface SearchItemRepository extends JpaRepository<SearchItemEntity, Integer> {
    @Query(value = "SELECT * FROM MLR(mm1, :q)(30, 0)", nativeQuery = true)
    List<SearchItemEntity> search(@Param("q") String q);
}

searchメソッドのJPQLには先ほど紹介した機械学習ランキングの推論用のSQLが設定されているのがわかります。

item/ItemServiceの修正

続いて、ItemServiceクラスのsearchメソッドを先ほど作成したSearchItemRepository.searchを使うように修正していきます。

    @PersistenceContext
    EntityManager entityManager;

+    @Autowired
+    private SearchItemRepository searchItemRepository;

    @Transactional
    public void initialize() {

まず、フィールド宣言を追加して@Autowiredアノテーションをつけておきます。

次にsearchメソッド内のItemRepository利用をSearchItemRepositoryに変更していきます。

-    public List<ItemEntity> search(String query) {
+    public List<SearchItemEntity> search(String query) {
        String normalizedQuery = StringUtils.normalize(query);
-        List<ItemEntity> itemDtoList = itemRepository.search(normalizedQuery);
+        List<SearchItemEntity> itemDtoList = searchItemRepository.search(normalizedQuery);
        int to = Math.min(30, itemDtoList.size());
        return itemDtoList.subList(0, to);
    }

この時、searchメソッドの返却値がList<ItemEntity>からList<SearchItemEntity>に変化するので、このメソッドを呼び出している側の修正も必要になります。

api/ItemRestControllerの修正

メソッド呼び出し側の修正は下記の通りです。

    @GetMapping("/api/items/search")
    @ResponseBody
-    public List<ItemEntity> search(@RequestParam("q") String query) {
+    public List<SearchItemEntity> search(@RequestParam("q") String query) {
        return itemService.search(query);
    }

このメソッドの戻り値の型も変更していますが、JSONのフォーマットが自動的に変化してくれるのでこれ以上の修正は不要です。返却されるJSONにMLRメソッドで追加された4つのフィールドが増えているはずです。

4-4. (学習)機械学習ランキングの学習

最後に機械学習ランキングの学習部分の実装を進めます。

item/SearchItemRepositoryの修正

まず、先ほど新規作成したSearchItemRepositoryに学習用のメソッドを追加します。

public interface SearchItemRepository extends JpaRepository<SearchItemEntity, Integer> {
    @Query(value = "SELECT * FROM MLR(mm1, :q)(30, 0)", nativeQuery = true)
    List<SearchItemEntity> search(@Param("q") String q);

+    @Modifying
+    @Query(value = "INSERT MLR_POSITIVE(mm1, :request_id, :key_id)", nativeQuery = true)
+    void train(@Param("request_id") int requestId, @Param("key_id") int keyId);
}

SQL部分を詳しくみていきます。

INSERT MLR_POSITIVE(mm1, :request_id, :key_id)

INSERT MLR_POSITIVE命令は、先ほどのMLRメソッドの結果レコードのうち、どのレコードがユーザーに選択されたかをHottyDBに教えるためのコマンドです。

  • 第1引数のmm1は作成したMLRモデルの名前を指定しています。
  • 第2引数の:request_idは、MLRメソッドで返却された_request_id
  • 第3引数の:key_idは、MLRメソッドで返却された_key_idを指定します。

このようにすることで、どのリクエストでどのアイテムが選択されたのかをHottyDBに教えることができます。
それにより、どのようなアイテムが選択され、どのようなアイテムが選択されないのかの違いを機械学習により学習することができます。

item/ItemServiceの修正

続いて作成したSearchItemRepositoryのtrainメソッドを使うための修正をItemServiceにも行います。

    @Autowired
    private SearchItemRepository searchItemRepository;

+    @Transactional
+    public void train(int requestId, int keyId) {
+        searchItemRepository.train(requestId, keyId);
+    }

    @Transactional
    public void initialize() {

api/ItemRestControllerの修正

同様にItemService.trainを使うAPIのエンドポイントも追加します。

+    @GetMapping("/api/items/search/click")
+    @ResponseBody
+    public boolean click(@RequestParam("request_id") int requestId, @RequestParam("key_id") int keyId) {
+        itemService.train(requestId, keyId);
+        return true;
+    }

GETパラメータで受け取ったrequest_idkey_idを先ほどのItemService.trainに渡しています。

src/main/resources/templates/partial/template.htmlの修正

続いて、作成したAPIエンドポイントをコールするためのJavaScriptのメソッド(train_mlr)をフロントエンドのコードに追加します。

        });
        return rec_items.slice(0, 30);
    };

+    const train_mlr = function(item) {
+        if (item.request_id == null || item.key_id == null) return;
+        fetch("/api/items/search/click?request_id="+item.request_id+"&key_id="+item.key_id);
+    }
</script>
<div id="header" th:replace="~{partial/header :: header}"></div>
<div id="body" th:replace="${body}"></div>

src/main/resources/templates/partial/item-list.htmlの修正

最後に、検索結果一覧を表示するためのHTMLを修正し、書籍をクリックすると先ほど作成したtrain_mlrメソッドを呼び出すように修正します。

-    <a :href="'/item/' + item.id">
+    <a :href="'/item/' + item.id" :data-request_id="item.requestId" :data-key_id="item.keyId" onclick="train_mlr(this.dataset)">
        <img class="img-thumbnail" :src="item.largeImageUrl" :alt="item.title">
    </a>
...
        <h3 class="card-title fs-5">
-            <a :href="'/item/' + item.id" class="link-primary">
+            <a :href="'/item/' + item.id" class="link-primary" :data-request_id="item.requestId" :data-key_id="item.keyId" onclick="train_mlr(this.dataset)">
                {{item.title}} {{item.subTitle}}
            </a>
        </h3>

修正しているのは aタグの二箇所で、どちらも下記のように修正しています。

<a :href="'/item/' + item.id" 
    :data-request_id="item.requestId" 
    :data-key_id="item.keyId" 
    onclick="train_mlr(this.dataset)">

Vue.jsのテンプレートエンジンになっていますが、itemにはSearchItemEntityのオブジェクトが入っています。このように記述することでdata-request_idの属性にSearchItemEntity.requestIdの値が、data-key_id属性にSearchItemEntity.keyIdの値が入ります。onclickでtrain_mlr(this.dataset)のメソッド呼び出しをしていますが、これでdata-XXXの属性値を渡すことができます。

ローカルで起動

修正点は以上です。再度ローカルサーバーを起動して、テキスト検索を実行してみましょう。

./gradlew bootRun

検索ボックスに「SQL」と入力して検索してみてください。初回時点では意味のないランキングになっています。

例えば、評価件数が多い書籍をクリックし、再度検索して同様に評価件数の多い書籍をクリックするという行為を数回(2、3回)繰り返してみてください。すると、評価件数の多い書籍がランキング上位に表示されるようになると思います。

HottyDBが自動的に選択されやすい書籍の傾向を学習し、ランキング上位に表示しているのです!!!

このセクションにおけるソースコードの変更点は、以下のPull Requestで詳細に確認できます。
https://github.com/toru1055/it-book-search/pull/3/files

本番用設定に変更

技術書全体を検索対象にする

以上で実装の変更は終わりですが、最後に検索対象ジャンルを開発時の設定から本番運用時の設定に変更します。

src/main/resources/application.propertiesを修正

rakuten.api.rootGenreIdの項目を修正

001005005008 をコメントアウトし、001005 をアクティブにしてください。

## rakuten.api.rootGenreId
#### For DEV:  本 > パソコン・システム開発 > プログラミング > SQL
#rakuten.api.rootGenreId=001005005008
#### For PROD: 本 > パソコン・システム開発
rakuten.api.rootGenreId=001005

spring.datasource.urlの修正

DBの保存先を切り替えるために下記の修正もしましょう。

### for HottyDB
spring.datasource.driver-class-name=org.hottydb.jdbc.embedded.EmbeddedDriver
- spring.datasource.url=jdbc:hottydb:tmp/dev1.db
+ spring.datasource.url=jdbc:hottydb:tmp/prod.db
spring.jpa.properties.hibernate.dialect=com.hottydb.dialect.HottyDBDialect

アフィリエイト設定をする場合(任意)

アフィリエイトの設定をしたい場合は下記のように設定を追加します。

src/main/resources/application.propertiesを修正

rakuten.api.affiliateId の項目のコメントアウトを外し、アフィリエイトIDを設定します。

## Optional
rakuten.api.affiliateId=(アフィリエイトIDを設定する)

アフィリエイトIDは、こちらのページで確認できます。

ローカルで起動

再度ローカルサーバーを起動してください。この起動には数時間かかります(楽天APIからデータ抽出する処理)

./gradlew bootRun
【1周目の楽天データ抽出は完了しています。2周目以降はゆっくり実行します。】

というログメッセージが表示され始めたらデータ抽出は完了したことになります。

先ほどと同様に検索ボックスに検索キーワードを入力してみて、何度か評価件数の高い書籍を選択してみてください。書籍データは全体で1万件程度になりますが、問題なく全文検索・機械学習ランキング・関連書籍の機能が使えると思います!!

さいごに

最後まで記事を読んでいただきありがとうございます!今回はSpringBootによる検索システムの作り方を解説しましたが、今後はJava以外の言語(例えばRuby on Railsなど)でもHottyDBを利用して検索システムを構築する方法を記事にしていこうと思います。

もし記事に関して不明な点、間違いなどありましたらお問い合わせ先までご連絡いただけると幸いです。HottyDBに関するご要望などもあれば是非お願いします!

HottyDBのマネージドAPIサービスを近日公開予定!!
HottyDBのマネージドAPIサービスを近日リリースする予定です。興味がある方は下記のリンクからウェイトリストへの登録をお願いします。
AI搭載のマネージド検索推薦エンジン「HottyDB」

5
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?