학습일지/ElasticSearch

[ElasticSearch] ES 맛보기 - 자동검색 + 검색 기능

Merge Log 2025. 9. 28. 19:31

스프링 서버로 테스트 해보기

ES 의 검색 기능을 활용해보기 위해 자동완성과 일반적인 검색 기능에 대해서 구현해보기

  • 목표
    • 검색시 자동완성 기능 (multi field, search_as_you_type, multi_match, bool_prefix)
    • 검색 기능 향상
      • 오타 허용 (fuzziness)
      • 조회시 평점 4.0 상위 노출 (should)
      • 일치하는 키워드 하이라이팅 (highlight)
      • 페이지네이션 (from, size)
      • 동의어 설정 (synonyms filter)
      • 한글, 영어 혼용 검색 기능 (nori_tokenizer)
      • 상품명, 설명, 카테고리 가중치 검색 (3:1:2) (multi_match)
      • 가격, 카테고리 필터링 (multi field, range, term)

매핑과 세팅 정보

@Document(indexName = "products")
@Setting(settingPath = "/elasticsearch/product-settings.json")
public class ProductDocument {
    @Id
    private String id;

    @MultiField(
        mainField = @Field(type = FieldType.Text, analyzer = "products_name_analyzer"),
        otherFields = {
            @InnerField(suffix = "auto_complete", type = FieldType.Search_As_You_Type, analyzer = "nori")
        }
    )
    private String name;

    @Field(type = FieldType.Text, analyzer = "products_description_analyzer")
    private String description;

    @Field(type = FieldType.Integer)
    private Integer price;

    @Field(type = FieldType.Double)
    private Double rating;

    @MultiField(
            mainField = @Field(type = FieldType.Text, analyzer = "products_category_analyzer"),
            otherFields = {
                    @InnerField(suffix = "raw", type = FieldType.Keyword)
            }
    )
    private String category;
}
  • name, description, category 는 multi match 로 처리해야 하므로 좀더 유연한 검색을 위해 text 타입으로 선언되었다
  • name 은 이름 자동완성을 위해 멀티 필드 및 search_as_you_type 타입으로 정의하였다
  • 그리고 각각의 analyzer 는 커스텀 analyzer 를 통해 처리
    • 동의어
    • nori (한글 토크나이저)
  • category 또한 완전 일치하게 필터링해야하는 목표가 있으므로 keyword 용 필드를 따로 선언하였다
{
  "analysis": {
    "filter": {
      "product_synonyms": {
        "type": "synonym",
        "synonyms": [
          "samsung, 삼성",
          "apple, 애플",
          "노트북, 랩탑, 컴퓨터, computer, laptop, notebook",
          "전화기, 휴대폰, 핸드폰, 스마트폰, 휴대전화, phone, smartphone, mobile phone, cell phone",
          "아이폰, iphone",
          "맥북, 맥, macbook, mac"
        ]
      }
    },
    "analyzer": {
      "products_name_analyzer": {
        "char_filter": [],
        "tokenizer": "nori_tokenizer",
        "filter": ["nori_part_of_speech", "nori_readingform", "lowercase", "product_synonyms"]
      },
      "products_description_analyzer": {
        "char_filter": ["html_strip"],
        "tokenizer": "nori_tokenizer",
        "filter": ["nori_part_of_speech", "nori_readingform", "lowercase"]
      },
      "products_category_analyzer": {
        "char_filter": [],
        "tokenizer": "nori_tokenizer",
        "filter": ["nori_part_of_speech", "nori_readingform", "lowercase"]
      }
    }
  }
}
  • 동의어 설정
  • nori_tokenizer 를 통해 한글, 영어 혼용 토큰화 처리

자동완성 기능

public List<String> getSuggestions(String query) {
    Query multiMatchQuery = MultiMatchQuery.of(m -> m
            .query(query)
            .type(TextQueryType.BoolPrefix)
            .fields(
                    "name.auto_complete",
                    "name.auto_complete._2gram",
                    "name.auto_complete._3gram"
            )
    )._toQuery();

    NativeQuery nativeQuery = NativeQuery.builder()
            .withQuery(multiMatchQuery)
            .withPageable(PageRequest.of(0, 5))
            .build();

    SearchHits<ProductDocument> searchHits =
            elasticsearchOperations.search(nativeQuery, ProductDocument.class);

    return searchHits.getSearchHits().stream()
            .map(hit -> {
              ProductDocument productDocument = hit.getContent();
              return productDocument.getName();
            })
            .toList();
  • 먼저 BoolPrefix 를 통해 앞의 두 글자는 아무 위치나 포함되어도 조회되도록 하고 마지막 글자는 무조건 맨 앞의 글자와 일치해야 한다

    • 간단히 이야기하면 자동완성을 정의하는 알고리즘이다
  • 이후 name.auto_complete 라는 멀티 필드에 대해 2gram, 3gram 필드까지 조회하도록 한다

    • 2gram : 두 글자씩 묶어서 토큰화
      • 3gram : 세 글자씩 묶어서 토큰화
  • 그 이후 5개까지 제한

    GET /products/_search
    {
    "query": {
        "multi_match": {
            "query": "스마",
            "type": "bool_prefix",
            "fields": [
                "name.auto_complete",
                "name.auto_complete._2gram",
                "name.auto_complete._3gram"
            ]
        }
    },
    "size": 5
    }
  • 위 코드의 실제 쿼리는 아래와 같다

실제 더미데이터로 테스트

  • "곱창 돌김생김"
  • "구운 돌김"
  • "완도 곱창 돌김 100매"

"돌" 키워드로 검색


"곱" 키워드로 검색


"구운" 키워드로 검색



검색 기능

public List<ProductDocument> searchProducts(
          String query,
          String category,
          double minPrice,
          double maxPrice,
          int page,
          int size
  ) {
    Query multiMatchQuery = MultiMatchQuery.of(m -> m
            .query(query)
            .fields(
                    "name^3",
                    "description^1",
                    "category^2"
            )
            .fuzziness("AUTO")
    )._toQuery();

    Query categoryFilter = TermQuery.of(t -> t
            .field("category.raw")
            .value(category)
    )._toQuery();

    Query priceRangeFilter = NumberRangeQuery.of(r -> r
            .field("price")
            .gte(minPrice)
            .lte(maxPrice)
    )._toRangeQuery()._toQuery();

    Query ratingShould = NumberRangeQuery.of(r -> r
            .field("rating")
            .gt(4.0)
    )._toRangeQuery()._toQuery();

    Query boolQuery = BoolQuery.of(b -> b
            .must(multiMatchQuery)
            .filter(categoryFilter, priceRangeFilter)
            .should(ratingShould)
    )._toQuery();

    HighlightParameters highlightParameters = HighlightParameters.builder()
            .withPreTags("<b>")
            .withPostTags("</b>")
            .build();
    Highlight highlight = new Highlight(highlightParameters, List.of(new HighlightField("name")));

    HighlightQuery highlightQuery = new HighlightQuery(highlight, ProductDocument.class);

    NativeQuery nativeQuery = NativeQuery.builder()
            .withQuery(boolQuery)
            .withHighlightQuery(highlightQuery)
            .withPageable(PageRequest.of(page - 1, size))
            .build();

    SearchHits<ProductDocument> searchHits =
            elasticsearchOperations.search(nativeQuery, ProductDocument.class);

    return searchHits.getSearchHits().stream()
            .map(hit -> {
              ProductDocument productDocument = hit.getContent();
              String highlightName = hit.getHighlightField("name").get(0);
              productDocument.setName(highlightName);
              return productDocument;
            })
            .toList();
  }
  • 이름, 설명, 카테고리 세 가지 필드에 대한 키워드 검색을 위해 멀티 매치 및 가중치 적용 → 3:1:2
  • 카테고리 키워드 필드에 대한 완전한 매치를 위해 term 적용
  • 가격에 대한 Range Filter
  • rating 에 대한 Range Filter
  • 각각의 쿼리를 종합 → BoolQuery (must, filter, should)
  • 하이라이트 적용

```
GET /products/_search
{
"query": {
    "bool": {
        "must": [
          {
            "multi_match": {
            "query": "삼성",
            "fields": ["name^3", "description", "category^2"],
            "fuzziness": "AUTO"
            }
          }
        ],
        "filter": [
          {
            "range": {
              "price": {
                "gte": 10000,
                "lte": 50000
              }
            }
          },
          {
            "term": {
                "category.raw": "가전제품"
            }
          }
        ],
        "should": [
            {
                "range": {
                    "rating": {
                        "gt": 4.0
                    }
                }
            }
        ]
    }
},
"highlight": {
    "fields": {
        "name": {
            "pre_tags": ["<b>"],
            "post_tags": ["</b>"]
        }
    }
},
"from": 0,
"size": 5
}
```
  • 위 코드를 실제 쿼리로 적용한 결과

오타인 경우 검색

  • 삼성 스마트 → 삼성 스파티


영어로도 검색

  • 삼성 → samsung


테스트한 코드