Spring Data Elasticsearchにおける検索結果のキーワードハイライト実装

検索結果のUX向上のため、ヒットしたキーワードを特定のタグで囲んで強調表示(ハイライト)する機能は多くのアプリケーションで求められます。Spring Data Elasticsearchを使用する際、標準のリポジトリ(Repository)インターフェースのみではこの高機能なマッピング処理を実装するのが難しいため、ElasticsearchTemplateを活用してカスタムクエリと結果マッパーを構築する必要があります。

ElasticsearchTemplateの設定

まず、ElasticsearchTemplateはSpringコンテナによって管理されるBeanであるため、サービスクラス等に直接インジェクションして利用できます。

@Service
public class SearchService {

    @Autowired
    private ElasticsearchTemplate esTemplate;
    
    // ... other implementations
}

ハイライト設定を含むクエリの構築

検索クエリを構築する際、NativeSearchQueryBuilderを使用してHighlightBuilderを適用します。ここでは、タイトルと本文を対象にし、キーワードに対してHTMLタグを前後に付与する設定を行います。

private NativeSearchQuery buildSearchQuery(SearchRequest request) {
    int page = request.getPageNumber();
    int size = request.getPageSize();
    Pageable pageable = PageRequest.of(page, size);

    String targetTitle = "title";
    String targetBody = "body";
    
    // 強調表示用のタグ定義
    String startTag = "<span class='highlight-text'>";
    String endTag = "</span>";

    // ハイライト対象フィールドの設定
    HighlightBuilder.Field titleField = new HighlightBuilder.Field(targetTitle)
        .preTags(startTag).postTags(endTag);
    HighlightBuilder.Field bodyField = new HighlightBuilder.Field(targetBody)
        .preTags(startTag).postTags(endTag)
        .fragmentSize(200) // スニペットの長さを制限
        .numOfFragments(3); // 返却するスニペット数

    BoolQueryBuilder boolQuery = QueryBuilders.boolQuery()
        .must(QueryBuilders.termQuery("isDeleted", false))
        .should(QueryBuilders.matchQuery(targetTitle, request.getKeyword()))
        .should(QueryBuilders.matchQuery(targetBody, request.getKeyword()));

    return new NativeSearchQueryBuilder()
            .withPageable(pageable)
            .withQuery(boolQuery)
            .withHighlightFields(titleField, bodyField)
            .build();
}

カスタムResultMapperの作成

デフォルトのマッパーではハイライト情報をエンティティに自動マッピングできないため、SearchResultMapperを実装したカスタムマッパーを作成します。このクラスでは、JSONからのエンティティ変換に加え、ハイライト情報の抽出とエンティティへの反映(リフレクション使用)を行います。

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.Pageable;
import org.springframework.data.elasticsearch.core.SearchResultMapper;
import org.springframework.data.elasticsearch.core.aggregation.AggregatedPage;
import org.springframework.data.elasticsearch.core.aggregation.impl.AggregatedPageImpl;
// ... other imports

@Component
public class CustomHighlightMapper implements SearchResultMapper {

    private static final Logger logger = LoggerFactory.getLogger(CustomHighlightMapper.class);
    private static final ObjectMapper mapper = new ObjectMapper();

    static {
        mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    }

    private static final Pattern FIELD_SUFFIX_PATTERN = Pattern.compile("\\..*");
    private static final String HIGHLIGHT_SUFFIX = "Highlighted";

    @Override
    public <T> AggregatedPage<T> mapResults(SearchResponse response, Class<T> clazz, Pageable pageable) {
        List<T> results = new ArrayList<>();
        long totalHits = response.getHits().getTotalHits();

        for (SearchHit hit : response.getHits()) {
            if (hit.getSourceAsString() == null) continue;

            // 1. ソースJSONをエンティティに変換
            T entity = mapJsonToEntity(hit.getSourceAsString(), clazz);
            if (entity == null) continue;

            // 2. ハイライトフィールドの処理
            Map<String, String> highlightMap = extractHighlights(hit.getHighlightFields());
            
            // 3. エンティティにハイライト文字列をセット
            injectHighlightFields(entity, highlightMap, hit.getSourceAsMap());

            results.add(entity);
        }
        return new AggregatedPageImpl<>(results, pageable, totalHits);
    }

    private <T> T mapJsonToEntity(String json, Class<T> clazz) {
        try {
            return mapper.readValue(json, clazz);
        } catch (IOException e) {
            logger.error("JSON parsing error: {}", json, e);
            return null;
        }
    }

    private Map<String, String> extractHighlights(Map<String, HighlightField> fields) {
        Map<String, String> map = new HashMap<>();
        for (Map.Entry<String, HighlightField> entry : fields.entrySet()) {
            // フィールド名の正規化(例: title.pinyin -> title)
            String rawKey = entry.getKey();
            String normalizedKey = FIELD_SUFFIX_PATTERN.matcher(rawKey).replaceAll("");
            String targetFieldName = normalizedKey + HIGHLIGHT_SUFFIX;

            StringBuilder fragmentText = new StringBuilder();
            for (Text text : entry.getValue().getFragments()) {
                fragmentText.append(text.string());
            }
            map.put(targetFieldName, fragmentText.toString());
        }
        return map;
    }

    private <T> void injectHighlightFields(T entity, Map<String, String> highlightMap, Map<String, Object> sourceMap) {
        try {
            for (Field field : entity.getClass().getDeclaredFields()) {
                String fieldName = field.getName();
                if (!fieldName.endsWith(HIGHLIGHT_SUFFIX)) continue;

                field.setAccessible(true);
                String value = highlightMap.get(fieldName);
                
                if (value != null) {
                    field.set(entity, value);
                } else {
                    // ハイライトがなければ通常のフィールド値をセットするなどのフォールバック処理
                    String originalFieldName = fieldName.replace(HIGHLIGHT_SUFFIX, "");
                    field.set(entity, sourceMap.get(originalFieldName));
                }
            }
        } catch (IllegalAccessException e) {
            logger.warn("Failed to inject highlight fields", e);
        }
    }
}

DTOの定義と検索実行

検索結果を受け取るDTO(Document Object)には、ハイライト用のフィールドを追加します。また、作成したCustomHighlightMapperを使用して検索を実行します。

@Document(indexName = "article_index", type = "article")
public class ArticleDocument {

    @Id
    private Long id;

    private String title;
    private String body;

    // ハイライト用フィールド(元フィールド名 + サフィックス)
    private String titleHighlighted;
    private String bodyHighlighted;

    // Getters and Setters
}
@Service
public class SearchService {

    @Autowired
    private ElasticsearchTemplate esTemplate;

    @Autowired
    private CustomHighlightMapper highlightMapper;

    public Page<ArticleDocument> searchArticles(SearchRequest request) {
        NativeSearchQuery query = buildSearchQuery(request);
        // カスタムMapperを指定してクエリ実行
        return esTemplate.queryForPage(query, ArticleDocument.class, highlightMapper);
    }
}

実行結果のJSON例

検索を実行すると、以下のように通常フィールドに加え、ハイライトタグが埋め込まれたフィールドが返却されます。

[
  {
    "id": 102,
    "title": "健康的な食生活のための野菜摂取",
    "body": "緑黄色野菜にはビタミンAやCが豊富に含まれています。特にブロッコリーや人参は...",
    "titleHighlighted": "健康的な食生活のための野菜摂取",
    "bodyHighlighted": "緑黄色野菜にはビタミンAやCが豊富に含まれています。特に<span class='highlight-text'>ブロッコリー</span>や人参は..."
  }
]

タグ: Elasticsearch Spring Data Java Highlighting Search Result Mapping

7月30日 21:07 投稿