Skip to content

엘라스틱 서치 키워드 검색

Kyunghun Kim edited this page Jan 18, 2024 · 1 revision

키워드 검색

image

클론 대상으로 잡은 인터파크 티켓의 검색의 경우, 하나의 키워드로 검색하면 제목, 장르, 설명 등에서의 결과를 종합해 모두 보여준다.

이를 SQL로만 구현한다면 제목, 장르 등을 기준으로 풀 스캔한뒤 결과를 합쳐줘야하는데, 이는 역색인을 사용하는 엘라스틱 서치에 비해 성능적으로 매우 떨어지고, 유사어 검색은 구현하기 까다롭다.

따라서 엘라스틱 서치를 이용해 키워드 서치를 구현해주었다.

도큐먼트

@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@Document(indexName = "event")
@Mapping(mappingPath = "es/event-mapping.json")
@Setting(settingPath = "es/event-setting.json")
public class EventDocument {

	@Id
	private Long id;

	private String title;

	private String description;

	@Field(type = FieldType.Date, format = {date_hour_minute_second, epoch_second})
	private LocalDateTime startedAt;

	@Field(type = FieldType.Date, format = {date_hour_minute_second, epoch_second})
	private LocalDateTime endedAt;

	private String viewRating;

	private GenreType genreType;

	private Double averageScore = 0.0;

	private Long eventHallId;

	public static EventDocument from(Event event) {
		return EventDocument.builder()
			.id(event.getId())
			.title(event.getTitle())
			.description(event.getDescription())
			.startedAt(event.getStartedAt())
			.endedAt(event.getEndedAt())
			.viewRating(event.getViewRating())
			.genreType(event.getGenreType())
			.averageScore(event.getAverageScore())
			.eventHallId(event.getEventHall().getId())
			.build();
	}
}
  • 매핑 파일

    {
      "properties": {
        "id": {
          "type": "short"
        },
        "title": {
          "type": "text",
          "copy_to": [
            "title_chosung"
          ],
          "fields" : {
            "kor" : {
              "type": "text",
              "analyzer": "korean"
            },
            "ngram" : {
              "type": "text",
              "analyzer": "ngram_analyzer"
            }
          }
        },
        "title_chosung": {
          "type": "text",
          "analyzer": "chosung"
        },
        "description": {
          "type": "text",
          "analyzer": "korean"
        },
        "startedAt": {
          "type": "date",
          "format": "yyyy-MM-dd'T'HH:mm:ss||epoch_second"
        },
        "endedAt": {
          "type": "date",
          "format": "yyyy-MM-dd'T'HH:mm:ss||epoch_second"
        },
        "viewRating": {
          "type": "keyword"
        },
        "genreType": {
          "type": "keyword"
        },
        "averageScore": {
          "type": "double"
        },
        "eventHallId": {
          "type": "keyword"
        }
      }
    }

    이때 title의 "fields" 필드로 멀티 필드 설정을 해주었다.

    단어를 한 글자 단위로 색인하고자 ngram 설정을 추가로 해주었다. [ngram 참고 블로그](https://www.gimsesu.me/elasticsearch-customize-tokenizer/#n-gram-tokenizer)

  • 세팅 파일

    {
      "index" : {
        "max_ngram_diff": 5
      },
      "analysis": {
        "analyzer": {
          "korean": {
            "type": "nori"
          },
          "ngram_analyzer" : {
            "type": "custom",
            "tokenizer" : "my_ngram"
          },
          "chosung": {
            "type": "custom",
            "tokenizer": "standard",
            "filter": [
              "lowercase",
              "hanhinsam_chosung"
            ]
          }
        },
        "tokenizer": {
          "my_ngram": {
            "type": "ngram",
            "min_gram": "2",
            "max_gram": "5",
            "token_chars": [
              "letter",
              "digit",
              "whitespace",
              "punctuation"
            ]
          }
        }
      }
    }

    마찬가지로 ngram, nori, hanhinsam 등 분석기 및 플러그인에 대한 설정을 작성해주었다.

엘라스틱 서치 QueryDsl

"query": {
	"function_score": {
		 "query": {
			  "bool": {
			   "must": [
				 {
				   "multi_match": {
				   "query": "검색 키워드",
				   "fields": [ "title^1", "title_chosung^1", "description^1", "genreType^1" ],
				   "minimum_should_match": 1
				   }
				 }
			   ],
			 "filter" : [
				 {
				   "term": { "genreType" : "CONCERT" }
				 },
				 {
				   "range" : {
					 "startedAt" : {
					   "gte" : "2024-03-01T12:00:00"
					 }
				   }
				 }
			  ]
		   }
		 },
		 "functions": [
			 {
				 "field_value_factor": {
					 "field": "id",
					 "factor": 1.2,
					 "modifier": "none",
					 "missing": 1
				 }
			 }
		 ]
	}
}

제목, 장르, 설명 등에서의 결과를 종합해 검색하고자 엘라스틱 서치의 멀티 매치 쿼리를 사용하였다.

장르와 공연 시작일을 기준으로 필터링 할 수 있도록 filter를 추가하고,

function score을 통해 id에 가중치를 주어 기본적으로 최신순으로 데이터가 정렬되도록 하였다.

Native Query

이제 위에서 작성한 엘라스틱 서치 쿼리를 Spring Data Elastic Search의 Native Query로 옮겨보자.

Native Query를 사용하면 동적으로 쿼리를 만들기 수월하다.

하지만, 버전마다 사용법이 상이하고 관련 문서를 찾기가 어려워 쿼리를 문자열로 직접 넣는 String Query 사용이 유지보수 측면에서 더 용이할 수 있다.

[Spring Data Elastic Search Query Operations 공식문서](https://docs.spring.io/spring-data/elasticsearch/reference/elasticsearch/template.html#elasticsearch.operations.stringquery)

  • 코드
public Page<EventDocumentResponse> findByKeyword(EventKeywordSearchDto eventKeywordSearchDto) {
	Pageable pageable = eventKeywordSearchDto.pageable();
	NativeQuery query = getKeywordSearchNativeQuery(eventKeywordSearchDto).setPageable(pageable);

	SearchHits<EventDocument> searchHits = elasticsearchOperations.search(query, EventDocument.class);
	log.info("event-keyword-search, {}", eventKeywordSearchDto.keyword());

	return SearchHitSupport.searchPageFor(searchHits, query.getPageable()).map(s -> {
		EventDocument eventDocument = s.getContent();
		return EventDocumentResponse.of(eventDocument);
	});
}

private NativeQuery getKeywordSearchNativeQuery(EventKeywordSearchDto eventKeywordSearchDto) {
	NativeQueryBuilder queryBuilder = new NativeQueryBuilder();

	Query multiQuery = QueryBuilders.multiMatch()
		.query(eventKeywordSearchDto.keyword())
		.fields("title.ngram^1", "title_chosung^1", "description^1", "genreType^1")
		.minimumShouldMatch(MINIMUM_SHOULD_MATCH_PERCENTAGE)
		.build()._toQuery();

	List<Query> filterList = new ArrayList<>();

	if (eventKeywordSearchDto.genreType() != null) {
		List<FieldValue> fieldValues = eventKeywordSearchDto.genreType().stream()
			.map(FieldValue::of)
			.toList();

		TermsQueryField termsQueryField = new TermsQueryField.Builder()
			.value(fieldValues)
			.build();

		Query genreFilterQuery = QueryBuilders
		.terms()
			.field("genreType")
			.terms(termsQueryField)
			.build()._toQuery();

		filterList.add(genreFilterQuery);
	}

	if (eventKeywordSearchDto.startedAt() != null) {
		Query startedAtFilterQuery = QueryBuilders
			.range()
			.field("startedAt")
			.gte(JsonData.of(eventKeywordSearchDto.startedAt()))
			.build()._toQuery();

		filterList.add(startedAtFilterQuery);
	}

	if (eventKeywordSearchDto.endedAt() != null) {
		Query endedAtFilterQuery = QueryBuilders
			.range()
			.field("endedAt")
			.gte(JsonData.of(eventKeywordSearchDto.endedAt()))
			.build()._toQuery();

		filterList.add(endedAtFilterQuery);
	}

	Query boolQuery = QueryBuilders.bool()
		.filter(filterList)
		.must(multiQuery)
		.build()._toQuery();

	FunctionScore fieldValueFactorScoreFunction = new FieldValueFactorScoreFunction.Builder()
		.field("id")
		.factor(1.2)
		.modifier(FieldValueFactorModifier.None)
		.missing(1.0)
		.build()._toFunctionScore();

	Query functionScoreQuery = QueryBuilders.functionScore()
		.functions(List.of(fieldValueFactorScoreFunction))
		.query(boolQuery)
		.build()._toQuery();

	return queryBuilder.withQuery(functionScoreQuery)
		.build();
}

실행결과

### 키워드 검색
GET http://localhost:8080/api/v1/events/search/keyword?page=1&size=10&keyword=히사&startedAt=2023-01-01T12:00:00

image

'히사'를 키워드로 하여 검색한 결과, 3건의 검색 결과를 성공적으로 얻을 수 있었다.