etc

자동 완성 기능 구현하기(with 엘라스틱 서치) 2편

안덕기 2024. 8. 9. 21:15

개요

엘라스틱 서치에서 우선순위를 설정해서 정렬을 하는 방법에 대해서 알아보자.

요구사항

저번에 봤던 요구사항을 다시 살펴보자.

  • 완전 일치한 키워드면 검색 결과에 상위에 표출한다.
  • 일치하는 키워드의 position에 따라 상위에 표출한다.
    • 예를 들어, 엘라스틱 이라는 키워드로 검색하게 됐을 때, 아래와 같이 나오게 한다.
    • 엘라스틱 서치
    • 엘라스틱 서치
    • 가나엘라스틱 서치
    • 가나다엘라스틱 서치
  • 검색어는 1-30 글자의 범위를 가진다.

완전일치

완전일치에 가중치를 줘야한다. 하지만 안타깝게도 저번에 설정했던 매핑 정보에서는 이미 n-gram으로 토큰을 다 나눴기 때문에 완전일치에 대한 값을 지정할 수 없다. 그래서 검색을 하기 위한 키워드와 원본 데이터인 컨텐트를 나눠서 매핑을 설정해보겠다.

PUT /elastic_test
{
  "settings": {
    "number_of_shards": 5,
    "number_of_replicas": 1,
    "index.max_ngram_diff": 29,
    "analysis": {
      "char_filter": {
        "remove_whitespace": {
          "type": "pattern_replace",
          "pattern": "\\s+",
          "replacement": ""
        }
      }, 
      "tokenizer": {
        "ngram_tokenizer": {
          "type": "ngram",
          "min_gram": 1,
          "max_gram": 30,
          "token_chars": ["letter", "digit"]
        }
      },
      "analyzer": {
        "ngram_analyzer": {
          "type": "custom",
          "tokenizer": "ngram_tokenizer",
          "filter": ["lowercase"],
          "char_filter": ["remove_whitespace"]
        },
        "remove_whitespace_analyzer": {
          "type": "custom",
          "tokenizer": "standard",
          "char_filter": ["remove_whitespace"]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "keyword": {
        "type": "text",
        "analyzer": "ngram_analyzer",
        "search_analyzer": "remove_whitespace_analyzer"
      },
      "content": {
        "type": "keyword"
      }
    }
  }
}

그리고 나서 데이터를 아래와 같이 다시 삽입하였다.

POST /elastic_test/_doc
{
  "keyword": "엘라스틱 서치",
  "content": "엘라스틱 서치"
}

POST /elastic_test/_doc
{
  "keyword": "가엘라스틱 서치",
  "content": "가엘라스틱 서치"
}

POST /elastic_test/_doc
{
  "keyword": "가나엘라스틱 서치",
  "content": "가나엘라스틱 서치"
}

POST /elastic_test/_doc
{
  "keyword": "가나다엘라스틱 서치",
  "content": "가나다엘라스틱 서치"
}

그리고 boost를 설정하여 완전 일치하는 경우에 가중치를 더 주는 쿼리를 작성하자.

POST /elastic_test/_search
{
  "query": {
    "bool": {
      "should": [
        {"match": { "content": {"query": "엘라스틱 서치", "boost": 2} }},
        {"match": { "keyword": "엘라스틱 서치" }}
      ]
    }
  }
}

그러면 결과는 아래와 같다. boost의 값을 올릴수록 스코어가 높게 나오고 스코어 값에 따라 정렬이 되는 것을 확인할 수 있다.

{
  "took" : 5,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 4,
      "relation" : "eq"
    },
    "max_score" : 1.5906117,
    "hits" : [
      {
        "_index" : "elastic_test",
        "_type" : "_doc",
        "_id" : "TlO1NJEB9v6Dw7EeV38A",
        "_score" : 1.5906117,
        "_source" : {
          "keyword" : "엘라스틱 서치",
          "content" : "엘라스틱 서치"
        }
      },
      {
        "_index" : "elastic_test",
        "_type" : "_doc",
        "_id" : "T1O1NJEB9v6Dw7EeV38K",
        "_score" : 0.2015199,
        "_source" : {
          "keyword" : "가엘라스틱 서치",
          "content" : "가엘라스틱 서치"
        }
      },
      {
        "_index" : "elastic_test",
        "_type" : "_doc",
        "_id" : "UVO1NJEB9v6Dw7EeV38X",
        "_score" : 0.16818406,
        "_source" : {
          "keyword" : "가나다엘라스틱 서치",
          "content" : "가나다엘라스틱 서치"
        }
      },
      {
        "_index" : "elastic_test",
        "_type" : "_doc",
        "_id" : "UFO1NJEB9v6Dw7EeV38S",
        "_score" : 0.16460134,
        "_source" : {
          "keyword" : "가나엘라스틱 서치",
          "content" : "가나엘라스틱 서치"
        }
      }
    ]
  }
}

Painless 스크립트

엘라스틱 서치는 자체적으로 Painless 스크립트를 가지고 있다. 단순한 스크립트로 보통 랭킹등 정렬을 위해 사용하는 스크립트다. 내가 여기서 하려는 것은 내가 검색한 단어의 position에 따라 스코어링을 주려고 하기 때문에 코드는 아래와 같이 구성할 수 있다.

POST /elastic_test/_search
{
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "should": [
            {
              "match": { 
                "content": {
                  "query": "엘라스틱 서치", 
                  "boost": 2
                }
              }
            },
            {
              "match": {
                 "keyword": "엘라스틱 서치"
              }
            }
          ]
        }
      },
      "functions": [
        {
          "script_score": {
            "script": {
              "source": """
              String searchKeywordFirstChar = params.query.substring(0, 1).toLowerCase();
              String fieldValue = doc['keyword'].value;
              int position = fieldValue.indexOf(searchKeywordFirstChar);

              return position > 0 ? 10 * position : 1.0;
              """,
              "params": {
                "query": "엘라스틱 서치"
              }
            }
          }
        }
      ],
      "boost_mode": "multiply"
    }
  }
}

하지만 쿼리를 실행해보니 fielddata를 true로 변경하라는 경고창이 나왔다.

"caused_by" : {
    "type" : "illegal_argument_exception",
    "reason" : "Text fields are not optimised for operations that require per-document field data like aggregations and sorting, so these operations are disabled by default. Please use a keyword field instead. Alternatively, set fielddata=true on [keyword] in order to load field data by uninverting the inverted index. Note that this can use significant memory."
}

이 에러 메시지가 나오는 이유는 기본적으로 text 타입의 필드는 집계나 정렬과 같은 작업에 최적화되어 있지 않기 때문에 작업이 비활성화되어 있다는 의미다. 이를 해결하기 위해서 fielddatatrue 로 설정 해야한다. 하지만 text 필드를 정렬하려고 하면 메모리에 과부하가 올 수 있다.

이를 해결하는 방법은 간단하다. text 타입의 필드는 정렬을 허용하지 않으니까 keyword 타입의 필드로 정렬을 해주면 된다.

POST /elastic_test/_search
{
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "should": [
            {
              "match": { 
                "content": {
                  "query": "엘라스틱", 
                  "boost": 2
                }
              }
            },
            {
              "match": {
                 "keyword": {
                   "query": "엘라스틱",
                   "boost": 0.00001
                 }
              }
            }
          ]
        }
      },
      "functions": [
        {
          "script_score": {
            "script": {
              "source": """
              String searchKeywordFirstChar = params.query.substring(0, 1).toLowerCase();
              String fieldValue = doc['content'].value;
              int position = fieldValue.indexOf(searchKeywordFirstChar);

              return position >= 0 ? 1.0 / (10 * position) / fieldValue.length() : 0.0;
              """,
              "params": {
                "query": "엘라스틱"
              }
            }
          }
        }
      ],
      "boost_mode": "sum"
    }
  }
}

결과는 아래와 같다.

{
  "took" : 5,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 5,
      "relation" : "eq"
    },
    "max_score" : 3.4028235E38,
    "hits" : [
      {
        "_index" : "elastic_test",
        "_type" : "_doc",
        "_id" : "XlOvNZEB9v6Dw7EegH8G",
        "_score" : 3.4028235E38,
        "_source" : {
          "keyword" : "엘라스틱 서치",
          "content" : "엘라스틱 서치"
        }
      },
      {
        "_index" : "elastic_test",
        "_type" : "_doc",
        "_id" : "X1OvNZEB9v6Dw7EegH8P",
        "_score" : 0.012501117,
        "_source" : {
          "keyword" : "가엘라스틱 서치",
          "content" : "가엘라스틱 서치"
        }
      },
      {
        "_index" : "elastic_test",
        "_type" : "_doc",
        "_id" : "YFOvNZEB9v6Dw7EegH8V",
        "_score" : 0.005556565,
        "_source" : {
          "keyword" : "가나엘라스틱 서치",
          "content" : "가나엘라스틱 서치"
        }
      },
      {
        "_index" : "elastic_test",
        "_type" : "_doc",
        "_id" : "YlOvNZEB9v6Dw7EegH8g",
        "_score" : 0.0050029033,
        "_source" : {
          "keyword" : "가나엘라스틱다 서치",
          "content" : "가나엘라스틱다 서치"
        }
      },
      {
        "_index" : "elastic_test",
        "_type" : "_doc",
        "_id" : "YVOvNZEB9v6Dw7EegH8b",
        "_score" : 0.0033342538,
        "_source" : {
          "keyword" : "가나다엘라스틱 서치",
          "content" : "가나다엘라스틱 서치"
        }
      }
    ]
  }
}

결론

text 필드를 가지고 직접 정렬을 할 수 없기 때문에 별도의 필드를 하나 더 선언하여 keyword 필드를 만들었다. 그리고 keyword 필드를 이용하여 엘라스틱 서치의 자체적인 스크립트인 painless script 를 활용하여 정렬을 할 수 있었다.