Notice
Recent Posts
Recent Comments
Link
«   2024/10   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
Archives
Today
Total
관리 메뉴

디지안의 개발일지

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

etc

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

안덕기 2024. 8. 8. 23:26

개요

우리가 구글, 네이버, 다음과 같은 곳에서 검색을 하다보면 내가 작성한 키워드에 맞춰 자동으로 검색어를 추천해주는 기능이 있다. 이런 검색 사이트에서는 데이터가 너무 많기 때문에 정밀한 스코어 정보나 사용자의 정보를 바탕으로 랭킹을 매겨야하겠지만 너무 복잡함으로 엘라스틱의 간단한 기능으로 어떻게 스코어를 주어 자동 완성 기능을 구현하는지 알아보자.

요구사항

먼저 무엇을 어떻게 했을 때 검색을 하더라도 값이 나오길 원하는지가 중요하다. 요구사항은 아래와 같이 간단하게 정의 내렸다.

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

엘라스틱 서치에 대한 오해

엘라스틱 서치에 대해 잘 모르는 입장에서 엘라스틱 서치를 사용하면 완전 만능으로 검색할 수 있을 것 같은 착각을 하게 된다. 엘라스틱 서치가 Full Text Search를 하는데 유리하다는 설명이 나오는데 그 말이 나에게 오해를 불러오는 것으로 보인다. 엘라스틱 서치의 검색을 이해하려면 내가 느끼기엔 저장하는 데이터가 어떤 토큰으로 저장을 했고 각 토큰을 어떻게 찾을지에 초점을 맞춰야하는 것으로 보였다.

예를 들어, 아래와 같이 간단하게 데이터를 정의했다면 엘라스틱 서치는 이를 엘라스틱 서치 라는 토큰으로 저장 했기 때문에

PUT /elastic_test
{
  "mappings": {
    "properties": {
      "keyword": {
        "type": "keyword"
      }
    }
  },
  "settings": {
    "number_of_shards": 5,
    "number_of_replicas": 1
  }
}

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

검색을 할 때 1번은 검색은 결과가 나오지만 2,3번은 검색이 되지 않는다.

// 1번
POST /elastic_test/_search
{
  "query": {
    "match": {
     "keyword": "엘라스틱 서치"
    }  
  }

}

// 2번
POST /elastic_test/_search
{
  "query": {
    "match": {
     "keyword": "엘라스틱서치" 
    }  
  }

}

// 3번
POST /elastic_test/_search
{
  "query": {
    "match": {
      "keyword": "엘라스틱"
    }
  }
}

하지만 데이터 타입을 text 로 지정을 하면 standard analyzer 를 사용하기 때문에 띄워쓰기를 기준으로 토큰 을 만든다.

PUT /elastic_test
{
  "mappings": {
    "properties": {
      "keyword": {
        "type": "text"
      }
    }
  },
  "settings": {
    "number_of_shards": 5,
    "number_of_replicas": 1
  }
}

그렇기 때문에 1,3번 검색으로 결과가 나오고 2번으로는 검색 결과가 안나온다.

// 1번
POST /elastic_test/_search
{
  "query": {
    "match": {
     "keyword": "엘라스틱 서치"
    }  
  }

}

// 2번
POST /elastic_test/_search
{
  "query": {
    "match": {
     "keyword": "엘라스틱서치" 
    }  
  }

}

// 3번
POST /elastic_test/_search
{
  "query": {
    "match": {
      "keyword": "엘라스틱"
    }
  }
}

WildCard

그렇다면 매핑 정의를 어떻게 해야하는걸까? 간단하게 검색을 해봤을 때는 검색할 때 wildcard를 이용하면 손쉽게 구현할 수 있었다. wildcard 는 RDB의 %문자열% 와 마찬가지인 방식이다. 내용의 모든 컨텍스트를 확인해서 내가 원하는 값을 찾는 것이다. 예를 들어, 아래와 같이 데이터를 넣었다고 가정하자.

PUT /elastic_test
{
  "mappings": {
    "properties": {
      "keyword": {
        "type": "text"
      }
    }
  },
  "settings": {
    "number_of_shards": 5,
    "number_of_replicas": 1
  }
}

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

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

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

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

그리고 아래와 같이 검색해보자. 그러면 원하는대로 넣었던 데이터를 모두 검색할 수 있다.

POST /elastic_test/_search
{
  "query": {
    "wildcard": {
      "keyword": "*엘라스틱*"
    }
  }
}

하지만 그렇게 한다면 RDB의 %문자열% 와 같으니 굳이 엘라스틱 서치를 사용하는 이유가 없다고 생각이 들었다.

그래서 엘라스틱 서치에 대해서 더 공부해보니 엘라스틱 서치에는 n-gram이라는 개념이 있었다.

n-gram

엘라스틱 서치에서는 데이터를 저장하고 찾을 때 분석기를 통해 데이터를 분석하는 과정을 갖는다. 들어온 데이터를 가공하고 토큰화한 다음 토큰을 다시 가공하는 과정을 갖는다. 설명이 거창하였지만 n-gram 은 쉽게 글자 단위로 문자열을 나눠서 토큰화하는 것이다. 예를 들어 문자열이 엘라스틱 서치 라고 한다면

  • 1-gram: [”엘”, “라”, “스”, “틱”, “서”, “치”]
  • 2-gram: [”엘라”, “라스”, “스틱”, “틱서”, “서치”]

위와 같은 방식으로 문자열을 갯수 만큼 나눠서 별도로 역색인을 다 만드는 것이다. 그렇다면 모든 문서를 볼 필요 없이 역색인이 되어 있으므로 해당 문서를 바로 찾을 수 있다. 속도가 빨라진 대신 그만큼 디스크를 더 많이 사용하는 단점이 생기기도 한다.

자동 완성 구현

매핑 정의

매핑은 아래와 같이 정의 내렸다. 정의 내린 것을 보면

  • "index.max_ngram_diff": 29 → 최소 n-gram과 최대 n-gram의 차이가 29인 것을 알 수 있다.
  • "min_gram": 1 → 최소 n-gram의 값이 1인 것을 알 수 있다.
  • "max_gram": 30 → 최대 n-gram의 값이 30인 것을 알 수 있다.
  • "remove_whitespace" → 문자열에 띄워쓰기가 있는 경우 띄워쓰기를 없앤다.
  • "analyzer": "ngram_analyzer" → 데이터를 색인할 때는 ngram_analyzer를 사용한다.
  • "search_analyzer": "standard" → 데이터를 검색할 때는 standard로 검색어를 띄워쓰기로 나눠서 각각 검색한다. 하지만 "remove_whitespace"가 적용되어 있어 띄워쓰기를 모두 없앤다고 보면 된다.
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"
      }
    }
  }
}

데이터 넣기

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

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

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

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

검색하기

1.엘라스틱 서치로 검색

POST /elastic_test/_search
{
  "query": {
    "match": {
      "keyword": "엘라스틱 서치"
    }
  }
}

결과

{
  "took" : 2,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 4,
      "relation" : "eq"
    },
    "max_score" : 0.29032138,
    "hits" : [
      {
        "_index" : "elastic_test",
        "_type" : "_doc",
        "_id" : "SVNWMpEB9v6Dw7EeUn86",
        "_score" : 0.29032138,
        "_source" : {
          "keyword" : "가나다엘라스틱 서치"
        }
      },
      {
        "_index" : "elastic_test",
        "_type" : "_doc",
        "_id" : "SFNWMpEB9v6Dw7EeSH9W",
        "_score" : 0.2876821,
        "_source" : {
          "keyword" : "가나엘라스틱 서치"
        }
      },
      {
        "_index" : "elastic_test",
        "_type" : "_doc",
        "_id" : "RlNWMpEB9v6Dw7EeO3_d",
        "_score" : 0.19363807,
        "_source" : {
          "keyword" : "엘라스틱 서치"
        }
      },
      {
        "_index" : "elastic_test",
        "_type" : "_doc",
        "_id" : "R1NWMpEB9v6Dw7EeQn-O",
        "_score" : 0.17225474,
        "_source" : {
          "keyword" : "가엘라스틱 서치"
        }
      }
    ]
  }
}

2.엘라로 검색

POST /elastic_test/_search
{
  "query": {
    "match": {
      "keyword": "엘라"
    }
  }
}

결과

{
  "took" : 5,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 4,
      "relation" : "eq"
    },
    "max_score" : 0.29032138,
    "hits" : [
      {
        "_index" : "elastic_test",
        "_type" : "_doc",
        "_id" : "SVNWMpEB9v6Dw7EeUn86",
        "_score" : 0.29032138,
        "_source" : {
          "keyword" : "가나다엘라스틱 서치"
        }
      },
      {
        "_index" : "elastic_test",
        "_type" : "_doc",
        "_id" : "SFNWMpEB9v6Dw7EeSH9W",
        "_score" : 0.2876821,
        "_source" : {
          "keyword" : "가나엘라스틱 서치"
        }
      },
      {
        "_index" : "elastic_test",
        "_type" : "_doc",
        "_id" : "RlNWMpEB9v6Dw7EeO3_d",
        "_score" : 0.19363807,
        "_source" : {
          "keyword" : "엘라스틱 서치"
        }
      },
      {
        "_index" : "elastic_test",
        "_type" : "_doc",
        "_id" : "R1NWMpEB9v6Dw7EeQn-O",
        "_score" : 0.17225474,
        "_source" : {
          "keyword" : "가엘라스틱 서치"
        }
      }
    ]
  }
}

3.가엘라스틱 서치로 검색

POST /elastic_test/_search
{
  "query": {
    "match": {
      "keyword": "가엘라스틱 서치"
    }
  }
}

결과

{
  "took" : 7,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 0.6548753,
    "hits" : [
      {
        "_index" : "elastic_test",
        "_type" : "_doc",
        "_id" : "R1NWMpEB9v6Dw7EeQn-O",
        "_score" : 0.6548753,
        "_source" : {
          "keyword" : "가엘라스틱 서치"
        }
      }
    ]
  }
}

결론

n-gram을 통해 자동완성을 기능을 어떻게 구현하는지에 대해 테스트를 해보았다. 아직 score를 줘서 랭킹을 어떻게 매기는지에 대해서 알아보지 않았다. 그리고 데이터를 여기서 더 많이 넣었을 때 어떻게 적용하는지에 대해서 좀 더 알아볼 필요가 있어보인다. 다음 포스팅에는 해당 내용을 알아보려고 한다.