Hibernate Search 7.0 Migration

Hibernate Search 7.0がリリースされていたので、アップグレードした。今回プログラムの修正は必要なかった。

pom.xmlの変更点

  • lucene-analyzers-kuromojiをlucene-analysis-kuromojiに変更
  • hibernate-search-mapper-orm-orm6をhibernate-search-mapper-ormに変更
<!-- Hibernate Search & Lucene -->
<dependency>
    <groupId>org.apache.lucene</groupId>
    <artifactId>lucene-analysis-kuromoji</artifactId>
    <version>9.8.0</version>
</dependency>
<dependency>
    <groupId>org.hibernate.search</groupId>
    <artifactId>hibernate-search-mapper-orm</artifactId>
    <version>7.0.0.Final</version>
</dependency>
<dependency>
   <groupId>org.hibernate.search</groupId>
   <artifactId>hibernate-search-backend-lucene</artifactId>
   <version>7.0.0.Final</version>
</dependency>

バージョンを変えるだけでなく、artifactIdを変更する必要がある。luceneを使用している場合はそちらも変更する必要がある。

Hibernate ORM 6.2とHibernate Search 6.1とjandexについて

Hibernate ORM 6.2とHibernate Search 6.1を同時に使用する場合、それぞれの依存関係に設定されているjandexのバージョンが異なるため、method not foundエラーが発生する。(実験的な互換性らしい)

エラーが発生した場合は、以下の通りHibernate Searchの方にjandexを除外する設定を追記する。

<dependency>
    <groupId>org.hibernate.search</groupId>
    <artifactId>hibernate-search-mapper-orm-orm6</artifactId>
    <version>${hibernate-search.version}</version>
    <exclusions>
        <exclusion>
            <groupId>org.jboss</groupId>
            <artifactId>jandex</artifactId>
        </exclusion>
    </exclusions>
</dependency>

2023/7/11 追記:Hibernate Search 6.2がリリースされ、上記の対応は不要になった。

Hibernate Search 6でFacetを作成したい

初めに

Hibernate Searchを使用して、キーワードに登場する単語の回数を集計したい。※例)残酷な描写あり: 873, 異世界: 481, ハーレム: 471

Hibernate 5まではFacet専用の機能が実装されていたのだが、Hibernate 6からはAggregation機能が実装され、より一般的な集計処理となった。

実装例

集計を実施したいエンティティの変数に下記のようにアノテーションを追加する。

@KeywordField(name = "keyword_facet", aggregable = Aggregable.YES)

集計処理

@Transactional
public Map<String, Long> aggregateByKeywords() {
    AggregationKey<Map<String, Long>> countsByKeywordKey = AggregationKey.of("countsByKeyword");

    return Search.session(entityManager)
            .search(Novel.class) // エンティティ
            .where(f -> f.matchAll()) // 全検索
            .aggregation(countsByKeywordKey, f -> f.terms()
                    .field("keyword_facet", String.class)
                    .maxTermCount(10)) // 登場回数の多い順10件まで
            .fetch(10)
            .aggregation(countsByKeywordKey);
}

集計結果

Mapにキーワードとその文字の登場回数が設定されているので、Loop処理等で展開する。以下はVueの実装例。

<span v-for="(value, name, index) in aggregateByKeywords" :key="index">
    <span v-if="index > 0">, </span>
    {{ name }}: {{ value }}
</span>

R15: 1074, 残酷な描写あり: 873, 異世界: 481, ハーレム: 471, ファンタジー: 451, 異世界転移: 389, チート: 378, 魔法: 362, 冒険: 319, 主人公最強: 296

Hibernate ORMとHibernate Searchで検索結果をソートする

備忘録として、いくつかの実装例を記載しておく。

Hibernate ORMのソート(Spring Data JPAを使用する)

Spring Bootを使用していなくても、下記のように依存関係を読み込むことでSpring Data JPAを使用できる。

<dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-jpa</artifactId>
    <version>${spring-data.version}</version>
</dependency>

クエリメソッドを使用する

ルールに沿ったメソッド名を使用することにより、自動的にHibernateへSQLが発行される。

public interface CrudRepository<T, ID> extends Repository<T, ID> {
    // 登録、または更新
    <S extends T> S save(S entity);      
    // プライマリーキーで検索
    Optional<T> findById(ID primaryKey); 
    // 全検索
    Iterable<T> findAll();               
    // 全件数
    long count();                        
    // 削除
    void delete(T entity);               
    // 存在チェック
    boolean existsById(ID primaryKey); 

例えば、小説データを全検索して、小説のタイトルでソートをかけたい場合、下記のように書く。複数のソート条件を指定する場合は続けて記載する。

@Repository
public interface NovelRepository extends JpaRepository<Novel, Long>, JpaSpecificationExecutor<Novel> {
    List<Novel> findAllByOrderByTitle();
}

※「findAll」の後に余分な「By」があるのは、ルール的にそこに検索条件が入ることになるため、今回は全検索なので「By」だけが残っている。

条件 APIを使用する

criteria を記述することにより、クエリの where 句を定義している場合、ソートは以下のように定義する。複数のソート条件を指定する場合は引数を追加する。

novelRepository.findAll(spec, JpaSort.by("title"))
@Repository
public interface NovelRepository extends JpaSpecificationExecutor<Novel> {
}

Hibernate Searchのソート

ソートの処理実装前に、エンティティに@KeywordFieldを追記する必要がある。

    /** タイトル */
    @FullTextField(analyzer = "japanese")
    @KeywordField(name = "titleSort", sortable = Sortable.YES)
    private String title;

検索時に上記で定義した名称をソート条件に指定する。

import org.hibernate.search.mapper.orm.Search;

@Service
public class NovelService {
    private final EntityManager entityManager;

    @Transactional
    public List<Novel> findAllIndex() {
        return Search.session(entityManager)
                .search(Novel.class)
                .where(f -> f.matchAll())
                .sort(f -> f.field("titleSort"))
                .fetchAllHits();
    }

ソートの条件を指定していないときは、スコアの高い順にソートされている。大雑把に言えば、スコアが高いほど、より多くの述語にマッチしているか、より良くマッチしていることを意味する。そのため、Hibernate Searchに関しては明示的にソートの条件を指定しない方が、良いのかもしれない。

Spring Boot 2.6でHibernate Search 6を動かす

依存関係

implementation 'org.hibernate.search:hibernate-search-mapper-orm:6.1.4.Final'
implementation 'org.hibernate.search:hibernate-search-backend-lucene:6.1.4.Final'

実装

Entityクラスに@Indexed、メソッドに@FullTextFieldを追加する。

@Entity
@Table(name = "novel")
@Indexed
public class Novel implements Serializable {

...

    @FullTextField
    private String title;

Serviceクラスに検索処理のロジックを追加する。

@Service
public class NovelService {

...

    @Transactional
    public Stream<Novel> searchIndex(final String searchParameters) {
        SearchSession searchSession = Search.session(entityManager);

        SearchResult<Novel> result = searchSession.search(Novel.class)
                .where(f -> f.bool()
                        .must(f.match()
                                .field("title")
                                .matching("異世界")))
                .fetchAll();
        return result.hits().stream();
    }

実際に動くコードはGitHubに公開予定。公開完了したら、下記に追記する。

※4/22追記
NovelService.java – github

public Stream<Novel> searchIndex(final String searchParameters) {
    SearchSession searchSession = Search.session(entityManager);
    String operationSetExper = String.join("|", SearchOperation.SIMPLE_OPERATION_SET);
    Pattern pattern = Pattern.compile(
            "(\\p{Punct}?)(\\w+?)(" + operationSetExper + ")(\\p{Punct}?)(\\w+?)(\\p{Punct}?),",
            Pattern.UNICODE_CHARACTER_CLASS);
    Matcher matcher = pattern.matcher(searchParameters + ",");

    SearchResult<Novel> result = searchSession.search(Novel.class)
            .where(f -> f.bool(b -> {
                b.must(f.matchAll());
                while (matcher.find()) {
                    b.must(f.match().field(matcher.group(KEY))
                            .matching(matcher.group(VALUE)));
                }
            }))
            .fetchAll();

    return result.hits().stream();
}

Spring BootでLuceneAnalysisConfigurerがうまく動かなかった件

Spring Boot 2.6はHibernate Search 6に対応している。そこで、日本語の検索も出来るようにするため、lucene-analyzers-kuromojiのAnalyzerをLuceneAnalysisConfigurerで設定しようとした。しかし、公式のガイド通りやってもうまくいかなかったため、メモを残しておく。

依存関係

implementation 'org.apache.lucene:lucene-analyzers-kuromoji:8.11.1'
implementation 'org.hibernate.search:hibernate-search-mapper-orm:6.1.4.Final'
implementation 'org.hibernate.search:hibernate-search-backend-lucene:6.1.4.Final'

LuceneAnalysisConfigurerの実装

@Component("customLuceneAnalysisConfigurer")
public class CustomLuceneAnalysisConfigurer implements LuceneAnalysisConfigurer {

    /**
     * {@inheritDoc}
     */
    @Override
    public void configure(LuceneAnalysisConfigurationContext context) {
        context.analyzer("japanese").instance(new JapaneseAnalyzer());
    }
}

CustomLuceneAnalysisConfigurerの設定

公式の例では、実装したクラスを直接crawlerapi.config.CustomLuceneAnalysisConfigurerのように指定していたのだが、上記のようにComponentとして登録してから、下記のように参照するようにしないとSpring Bootの起動でハングアップした。

spring:
  jpa:
    properties:
      hibernate:
        search.backend:
          analysis.configurer: customLuceneAnalysisConfigurer

設定したAnalyzerの使用方法

先ほど定義した名前で参照できる。

@FullTextField(analyzer = "japanese")

Hibernate Search 6.0 Migration

自作のアプリのマイグレーションする過程で問題になった個所を纏めておく。

Hibernate Search 6の変更点

APIが大幅に変わっているため、コード修正なしに移行は不可。APIをElasticsearchに最適化したそうなので、Luceneを使用してきたがこれを機にElasticsearchに変更することを検討しても良いかも知れない。

インデックスの再作成
// Hibernate5
FullTextEntityManager txtentityManager = Search.getFullTextEntityManager(entityManager);
MassIndexer massIndexer = txtentityManager.createIndexer();

// Hibernate6
SearchSession searchSession = Search.session(entityManager);
MassIndexer massIndexer = searchSession.massIndexer();

// 共通
massIndexer.start()
ファセットの作成

全く同じように移行は出来なかった。5の場合は戻り値がList<Facet>で、6の場合はMap<String, Long>になる。Stringの部分はフィールドの属性毎に変更する必要がある。

// Hibernate5
FullTextEntityManager txtentityManager = Search.getFullTextEntityManager(entityManager);
SearchFactory searchFactory = txtentityManager.getSearchFactory();
QueryBuilder builder = searchFactory.buildQueryBuilder().forEntity(searchedEntity).get();
FacetingRequest categoryFacetingRequest = builder.facet()
    .name(field + searchedEntity.getSimpleName()).onField(field).discrete()
    .orderedBy(FacetSortOrder.COUNT_DESC).includeZeroCounts(false).maxFacetCount(maxCount)
    .createFacetingRequest();

Query luceneQuery = builder.all().createQuery();
FullTextQuery fullTextQuery = txtentityManager.createFullTextQuery(luceneQuery);
FacetManager facetManager = fullTextQuery.getFacetManager();
facetManager.enableFaceting(categoryFacetingRequest);

return facetManager.getFacets(field + searchedEntity.getSimpleName());

// Hibernate6
SearchSession searchSession = Search.session(entityManager);
AggregationKey<Map<String, Long>> countByKey = AggregationKey.of(field);

SearchResult<?> result = searchSession.search(User.class)
    .where(f -> f.matchAll())
    .aggregation(countByKey, f -> f.terms()
        .field(field, String.class)
        .orderByCountDescending()
        .minDocumentCount(1)
        .maxTermCount(maxCount))
    .fetch(20);

result.hits();

return result.aggregation(countByKey);
アナライザーの変更

@Analyzer(impl = JapaneseAnalyzer.class)はなくなり、@FullTextField(analyzer = “japanese”)の様にでフィールド毎に指定する必要がある。しかも、カスタムアナライザーを別途自分で定義する必要がある。@NormalizerDefも使用出来なくなっているため、アノテーションで細かく処理を指定出来なくなっている。

public class CustomLuceneAnalysisConfigurer implements LuceneAnalysisConfigurer {

    @Override
    public void configure(LuceneAnalysisConfigurationContext context) {
        context.analyzer("japanese").instance(new JapaneseAnalyzer());
    }
}
hibernate.search.backend.analysis.configurer = <パッケージ>.CustomLuceneAnalysisConfigurer

LuceneAnalysisConfigurerを定義して、以下の様に指定する。

@FullTextField(analyzer = "japanese")
private String text;
FullTextQueryの廃止

今までは全文検索用クエリ(FullTextQuery)を作成するためにluceneを使用する場面があったが、それらはバックエンドに移動したから、今後は使用しないようにということか。luceneのQueryを使用しなくても検索ロジックを記述することが出来るようになった。

Search.session(entityManager).search(User.class)
        .where(f -> {
            if (userSearchCriteria.getUsername() == null && userSearchCriteria.getEmail() == null) {
                return f.matchAll();
            } else {
                return f.bool(b -> {
                    if (userSearchCriteria.getUsername() != null) {
                        b.should(f.match().field(UserSearchCriteria.USERNAME_FIELD)
                                .matching(userSearchCriteria.getUsername()));
                    }
                    if (userSearchCriteria.getEmail() != null) {
                        b.should(f.match().field(UserSearchCriteria.EMAIL_FIELD)
                                .matching(userSearchCriteria.getEmail()));
                    }
                });
            }
        })
        .sort(f -> f.field(UserSearchCriteria.USERNAME_FIELD + "Sort"))
        .fetchHits(Long.valueOf(pageRequest.getOffset()).intValue(), pageRequest.getPageSize());
起動設定の変更
// Hibernate5
<prop key="hibernate.search.lucene_version">LUCENE_CURRENT</prop>
<prop key="hibernate.search.default.directory_provider">filesystem</prop>
<prop key="hibernate.search.default.locking_strategy">simple</prop>
<prop key="hibernate.search.default.exclusive_index_use">true</prop>
<prop key="hibernate.search.default.indexBase">${hibernate.search.indexBase}</prop>

// Hibernate6
<prop key="hibernate.search.backend.lucene_version">LATEST</prop>
<prop key="hibernate.search.backend.directory.type">local-filesystem</prop>
<prop key="hibernate.search.backend.directory.root">${hibernate.search.indexBase}</prop>
<prop key="hibernate.search.backend.analysis.configurer">common.dao.impl.CustomLuceneAnalysisConfigurer</prop>

Beanアノテーションの変更
// Hibernate5
@Field
@Field(name = "usernameSort", normalizer = @Normalizer(definition = "userSort"))
@SortableField(forField = "usernameSort")
private String username;

@Field(name = "firstNameFacet", analyze = Analyze.NO)
@Facet(forField = "firstNameFacet")
private String firstName;

// Hibernate6
@FullTextField(analyzer = "japanese")
@KeywordField(name = "usernameSort", sortable = Sortable.YES)
private String username;

@KeywordField(name = "firstNameFacet", aggregable = Aggregable.YES)
private String firstName;
Spring Boot

マニュアル通り下記を追加すると、エラーが発生してしまう。まだ、対応していないのかもしれない。
Caused by: org.hibernate.search.util.common.AssertionFailure: Unexpected duplicate key: enabled — this may indicate a bug or a missing test in Hibernate Search. Please report it: https://hibernate.org/community/

implementation 'org.apache.lucene:lucene-analyzers-kuromoji:8.7.0'
implementation 'org.hibernate.search:hibernate-search-mapper-orm:6.0.0.Final'
implementation 'org.hibernate.search:hibernate-search-backend-lucene:6.0.0.Final'

Hibernate Searchでソート機能を使用する

@SortableFieldを付けるとそのFieldに対してソート用のインデックスが作成されるようになる。

@Field(analyze = Analyze.NO)
@SortableField
private String description;

但し、ソート可能とするFieldはトークン化してはいけない。そのため、FieldをAnalyze.NOとするか、Normalizerを指定する必要がある。(AnalyzerとNormalizerは排他的なのでNormalizerを指定すると結果的にAnalyze.NOになっている)

@NormalizerDef(name = "novelSort", filters = @TokenFilterDef(factory = LowerCaseFilterFactory.class))
public class Novel extends BaseObject implements Serializable {

    @Field
    @Field(name = "descriptionSort", normalizer = @Normalizer(definition = "novelSort"))
    @SortableField(forField = "descriptionSort")
    private String description;

実は@SortableFieldを指定しなくてもソート自体は出来るがインデックスが無いので、パフォーマンスが悪い。

Sort sort = new Sort(new SortField("descriptionSort", SortField.Type.STRING))

FullTextQuery query = ...
query.setSort(sort);