
opensearch를 이용한 한글 자동완성
opensearch를 이용한 한글 자동완성
시작하며
볼트업지도 화면에서 충전소 명으로 검색할 수 있었습니다.
검색할 때 자동완성 된 충전소 이름이 노출되어야 하는 요구가 있었고, 해당 기능을 개발하는 과정 중 만난 이슈들을 공유할 수 있으면 좋을 것 같아 이 글을 쓰게 되었습니다.
지도 검색 등 다른 기능을 위해서도 있지만, 자동완성을 하기 위해 opensearch를 충전소 저장소로 이용했습니다.
자동완성은 예술의전당 이라는 충전소 이름이 있을 때
‘예술’ 이라는 단어까지 완성해서 자동완성 조회를 할 시 예술의전당이 response에 포함되어야 하며
또 ‘옛’, ‘예술ㅇ’, ‘전다’ 같은 단어들도 검색 했을 때 예술의전당이 리턴되면 더욱 완성도가 높아질 것입니다.
우선 elastic search 나 opensearch에서 Analyzer가 동작하는 방식을 보면
- [char filters] → tokenizer → [token filters(filters)]
- char filters
- 0~n개로 구성되고 일종의 전처리 도구.
- input, output 모두 문자열 하나.
- ex : <p>안녕하세요. <b>오픈서치</b>입니다.</p> → 안녕하세요. 오픈서치입니다.
- mapping 타입을 사용하면 a → b로 치환 가능.
- tokenizer
- 1개로 구성되어 있고 본격적인 인덱싱을 위한 도구.
- input은 문자열 하나, output은 문자열 리스트.
- ex Hello world! How are you-and-me? → ["Hello", "world", "How", "are", "you-and-me"]
- token filter
- 는 0 ~ n 개로 구성될 수 있고 후처리 도구.
- 입력으로 문자열 리스트가 들어가고 출력으로 문자열 리스트가 나온다. 정확하게는 하나의 토큰(토크나이저 아웃풋의 엘리먼트 하나)당 0 n 개의 문자열이 나올 수 있다.
- ex :
- input : ["bike", "racer"]
- rule : "bicycle, bike" "fast, quick, rapid"
- output : ["bicycle", "bike", "racer"]
자동완성
영문 자동완성
영문에 대한 자동완성 예시를 쉽게 찾을 수 있었습니다.
Auto Complete is hard!!! 라는 문장을 가지고 자동완성을 해보자면.
- 우선 영어를 제외한 문자를 제거한다. Auto Complete is hard
- 빈칸도 제거한다. AutoCompleteishard
- case를 하나로 통일한다. autocompleteishard
- ngram tokenizer를 사용해 문장을 분해한다. [au, ut, to ….]
//인덱스 생성
PUT autocomplete_test
{
"settings": {
"analysis": {
"analyzer": {
"autocomplete_analyzer": {
"tokenizer": "autocomplete_ngram_tokenizer",
"char_filter": ["pattern_char_filter"],
"filter": ["lowercase"]
}
},
"tokenizer": {
"autocomplete_ngram_tokenizer": {
"type": "ngram",
"min_gram": 3,
"max_gram": 4
}
},
"char_filter": {
"pattern_char_filter": {
"type": "pattern_replace",
"pattern": "[^a-zA-Z]",
"replacement": ""
}
}
}
},
"mappings": {
"properties": {
"autocomplete_field": {
"type": "text",
"analyzer": "autocomplete_analyzer"
}
}
}
}
//테스트 셋 생성
POST /autocomplete_test/_bulk
{"index": {"_id": "1"}}
{"autocomplete_field": "Tesla Model S"}
{"index": {"_id": "2"}}
{"autocomplete_field": "Hyundai Ioniq 5"}
{"index": {"_id": "3"}}
{"autocomplete_field": "Nissan Leaf"}
{"index": {"_id": "4"}}
{"autocomplete_field": "Tesla Model X"}
{"index": {"_id": "5"}}
{"autocomplete_field": "Chevrolet Bolt EV"}
{"index": {"_id": "6"}}
{"autocomplete_field": "Ford Mustang Mach-E"}
//세팅 확인
GET /autocomplete_test/_settings
//결과 확인 쿼리
GET /autocomplete_test/_search
{
"query": {
"match": {
"autocomplete_field": "tes"
}
}
}
//결과
{
"took": 0,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 2,
"relation": "eq"
},
"max_score": 1.3192703,
"hits": [
{
"_index": "autocomplete_test",
"_id": "1",
"_score": 1.3192703,
"_source": {
"autocomplete_field": "Tesla Model S"
}
},
{
"_index": "autocomplete_test",
"_id": "4",
"_score": 1.3192703,
"_source": {
"autocomplete_field": "Tesla Model X"
}
}
]
}
}
한글 자동완성
하지만 한글은 알파벳과 다릅니다.
알파벳는 음소문자이고 한글은 음절문자입니다.
그래서 영문에 대한 자동완성 인덱싱은 간단하지만 한글은 전처리 과정이 필요합니다.
1. 음소문자화
영문처럼 음소문자화 하는 것입니다.
- 오케스트라 -> ㅇㅗㅋㅔㅅㅡㅌㅡㄹㅏ
한글의 위처럼 자소를 분리 해주는 char_filter는 opensearch에서 기본적으로 제공합니다.
→ icu_normalizer_filter
//인덱스 생성
PUT /korean_test
{
"settings": {
"index": {
"max_ngram_diff": 100 // 테스트용 섫정
},
"analysis": {
"analyzer": {
"auto_complete_analyzer": {
"tokenizer": "keyword",
"char_filter": [
"icu_normalizer_filter"
],
"type": "custom"
}
},
"char_filter": {
"icu_normalizer_filter": {
"type": "icu_normalizer",
"mode": "decompose",
"name": "nfc"
}
}
}
},
"mappings": {
"properties": {
"content": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword"
}
},
"copy_to": [
"content_auto_complete"
]
},
"content_auto_complete": { //content 필드 원문은 그대로 두고 자동 완성 검색용 필드를 추가한 뒤 원문에서 복사 되도록 설정.
"type": "text", //match 쿼리를 이용하기 위해 "text" 타입으로 설정
"analyzer": "auto_complete_analyzer"
}
}
}
}
//analyzer 동작 확인
POST /korean_test/_analyze
{
"analyzer": "auto_complete_analyzer",
"text": "오케스트라"
}
//결과
{
"tokens": [
{
"token": "ㅇᅩㅋᅦㅅᅳㅌᅳㄹᅡ",
"start_offset": 0,
"end_offset": 5,
"type": "word",
"position": 0
}
]
}
2. ngram 토크나이저 적용
이제 ngram을 적용하고 자동 완성 검색 테스트를 해보겠습니다.
위 인덱스 생성 구문에서 달라진 부분은
- ngram_tokenzier 를 추가하고
- auto_complete_analyzer의 tokenizer를 ngram_tokenizer 로 변경한 부분입니다.
//다시 인덱스 생성
PUT /korean_test
{
"settings": {
"index": {
"max_ngram_diff": 100
},
"analysis": {
"analyzer": {
"auto_complete_analyzer": {
"tokenizer": "ngram_tokenizer",
"char_filter": [
"no_space_filter",
"icu_normalizer_filter"
],
"type": "custom"
}
},
"char_filter": {
"icu_normalizer_filter": {
"type": "icu_normalizer",
"mode": "decompose",
"name": "nfc"
},
"no_space_filter": {
"pattern": """\s+""",
"type": "pattern_replace",
"replacement": ""
}
},
"tokenizer": {
"ngram_tokenizer": {
"token_chars": [
"letter",
"digit"
],
"min_gram": "1",
"type": "ngram",
"max_gram": "24"
}
}
}
},
"mappings": {
"properties": {
"content": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword"
}
},
"copy_to": [
"content_auto_complete"
]
},
"content_auto_complete": {
"type": "text",
"analyzer": "auto_complete_analyzer"
}
}
}
}
//analyzer 동작 확인
POST /korean_test/_analyze
{
"analyzer": "auto_complete_analyzer",
"text": "오케스트라"
}
//결과
{
"tokens": [
{
"token": "ㅇ",
"start_offset": 0,
"end_offset": 0,
"type": "word",
"position": 0
},
{
"token": "ㅇᅩ",
"start_offset": 0,
"end_offset": 1,
"type": "word",
"position": 1
},
{
"token": "ㅇᅩㅋ",
"start_offset": 0,
"end_offset": 1,
"type": "word",
"position": 2
},
{
"token": "ㅇᅩㅋᅦ",
"start_offset": 0,
"end_offset": 2,
"type": "word",
"position": 3
},
{
"token": "ㅇᅩㅋᅦㅅ",
"start_offset": 0,
"end_offset": 2,
"type": "word",
"position": 4
},
{
"token": "ㅇᅩㅋᅦㅅᅳ",
"start_offset": 0,
"end_offset": 3,
"type": "word",
"position": 5
},
{
"token": "ㅇᅩㅋᅦㅅᅳㅌ",
"start_offset": 0,
"end_offset": 3,
"type": "word",
"position": 6
},
{
"token": "ㅇᅩㅋᅦㅅᅳㅌᅳ",
"start_offset": 0,
"end_offset": 4,
"type": "word",
"position": 7
},
{
"token": "ㅇᅩㅋᅦㅅᅳㅌᅳㄹ",
"start_offset": 0,
"end_offset": 4,
"type": "word",
"position": 8
},
{
"token": "ㅇᅩㅋᅦㅅᅳㅌᅳㄹㅏ",
"start_offset": 0,
"end_offset": 5,
"type": "word",
"position": 9
},
....
}
(아래 잘림)
이런 식으로 최대 길이 24까지 분할되어 ngram 토큰이 생성됩니다.
데이터를 삽입 후
//데이터 삽입
POST /korean_test/_bulk
{ "index": { "_id": "1" } }
{ "content": "피아노" }
{ "index": { "_id": "2" } }
{ "content": "바이올린" }
{ "index": { "_id": "3" } }
{ "content": "기타" }
{ "index": { "_id": "4" } }
{ "content": "드럼" }
{ "index": { "_id": "5" } }
{ "content": "첼로" }
{ "index": { "_id": "6" } }
{ "content": "플루트" }
{ "index": { "_id": "7" } }
{ "content": "색소폰" }
{ "index": { "_id": "8" } }
{ "content": "하프" }
{ "index": { "_id": "9" } }
{ "content": "트럼펫" }
{ "index": { "_id": "10" } }
{ "content": "클라리넷" }
실제 검색 테스트를 해보면
//검색
GET /korean_test/_search
{
"query": {
"match": {
"content_auto_complete": "피아"
}
}
}
//결과
{
"took": 2,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 8,
"relation": "eq"
},
"max_score": 16.958368,
"hits": [
{
"_index": "korean_test",
"_id": "1",
"_score": 16.958368,
"_source": {
"content": "피아노"
}
},
{
"_index": "korean_test",
"_id": "2",
"_score": 4.17308,
"_source": {
"content": "바이올린"
}
},
{
"_index": "korean_test",
"_id": "3",
"_score": 2.1547592,
"_source": {
"content": "기타"
}
},
{
"_index": "korean_test",
"_id": "8",
"_score": 1.8822912,
"_source": {
"content": "하프"
}
},
{
"_index": "korean_test",
"_id": "10",
"_score": 1.1524278,
"_source": {
"content": "클라리넷"
}
},
{
"_index": "korean_test",
"_id": "6",
"_score": 0.6941577,
"_source": {
"content": "플루트"
}
},
{
"_index": "korean_test",
"_id": "7",
"_score": 0.6216504,
"_source": {
"content": "색소폰"
}
},
{
"_index": "korean_test",
"_id": "9",
"_score": 0.6216504,
"_source": {
"content": "트럼펫"
}
}
]
}
}
어째서인지 피아노 만 검색되는 게 아니라 8개나 검색이 됩니다.
처음 의도대로 동작하지 않는 이유는 match query의 특성인데
‘피아’ 를 검색하면 content_auto_complete에 걸려있는 auto_complete_analyzer가 검색어를 아래처럼 여러 토큰으로 분리합니다.
{
"tokens": [
{
"token": "ᄑ",
"start_offset": 0,
"end_offset": 0,
"type": "word",
"position": 0
},
{
"token": "ᄑㅣ",
"start_offset": 0,
"end_offset": 1,
"type": "word",
"position": 1
},
{
"token": "ᄑㅣㅇ",
"start_offset": 0,
"end_offset": 1,
"type": "word",
"position": 2
},
...
{
"token": "ᄋㅏ",
"start_offset": 1,
"end_offset": 2,
"type": "word",
"position": 8
},
{
"token": "ᅡ",
"start_offset": 1,
"end_offset": 2,
"type": "word",
"position": 9
}
]
}
위 결과는 이 토큰들 중 단 하나라도 역인덱스에 매칭되는 doc들을 전부 검색한 것입니다.
위에서 검색 된 악기들을 보면
검색어 : 피아
검색 성공(자소 분리 후 ㅍ ㅣ ㅇ ㅏ 중 단 하나라도 겹침)
피아노 (ㅍ)
바이올린 (ㅏ)
기타 (ㅣ)
플루트 (ㅍ)
색소폰 (ㅍ)
하프 (ㅏ)
트럼펫 (ㅍ)
클라리넷 (ㅏ)
검색 실패 (자소 분리 후 ㅍ ㅣ ㅇ ㅏ 단 하나도 안겹침)
첼로
드럼
3. search analyzer 추가
이를 해소하기위해 다양한 방법이 있겠지만, 기본적으로 검색어가 전부 포함되어 있어야지만 검색이 되도록 수정해보겠습니다.
그러기 위해서는 content_auto_complete 필드에 search_analyzer를 따로 적용 하겠습니다.
//글자 수가 늘어나면(자소 수가 max ngram = 24 초과) 자동완성이 안될것이지만,
//자동완성이라는 게 조금만 타이핑 해서 완성하는 것이니 큰 문제 없을것 같다.
"auto_complete_search_analyzer": {
"char_filter": [
"no_space_filter",
"icu_normalizer_filter"
],
"type": "custom",
"tokenizer": "keyword" //토크나이저를 keyword로 설정해 단 하나의 자소 분리 값으로 역인덱스 검색
}
PUT /korean_test
{
"settings": {
"index": {
"max_ngram_diff": 100
},
"analysis": {
"analyzer": {
"auto_complete_analyzer": {
"tokenizer": "ngram_tokenizer",
"char_filter": [
"no_space_filter",
"icu_normalizer_filter"
],
"type": "custom"
},
"auto_complete_search_analyzer": {
"char_filter": [
"no_space_filter",
"icu_normalizer_filter"
],
"type": "custom",
"tokenizer": "keyword"
}
},
"char_filter": {
"icu_normalizer_filter": {
"type": "icu_normalizer",
"mode": "decompose",
"name": "nfc"
},
"no_space_filter": {
"pattern": """\s+""",
"type": "pattern_replace",
"replacement": ""
}
},
"tokenizer": {
"ngram_tokenizer": {
"token_chars": [
"letter",
"digit"
],
"min_gram": "1",
"type": "ngram",
"max_gram": "24"
}
}
}
},
"mappings": {
"properties": {
"content": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword"
}
},
"copy_to": [
"content_auto_complete"
]
},
"content_auto_complete": {
"type": "text",
"analyzer": "auto_complete_analyzer",
"search_analyzer": "auto_complete_search_analyzer"// 필드에 search analyzer 추가 적용
}
}
}
}
다시 검색을 해보면
//검색
GET /korean_test/_search
{
"query": {
"match": {
"content_auto_complete": "피아"
}
}
}
//결과
{
"took": 842,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 1,
"relation": "eq"
},
"max_score": 2.2221186,
"hits": [
{
"_index": "korean_test",
"_id": "1",
"_score": 2.2221186,
"_source": {
"content": "피아노"
}
}
]
}
}
잘 나온다!
하지만 또 다른 문제가 있습니다.
검색을 할 때
피아 —> 피아노 가 검색 되지만
피안 → 피아노 는 검색이 안되고
핑 → 피아노 역시 마찬가지입니다.
자동 완성에서의 당연한 기대사항이 충족이 안되고 있습니다.
//auto_complete_search_analyzer 테스트
POST /korean_test/_analyze
{
"analyzer": "auto_complete_search_analyzer",
"text": "핑"
}
//결과... auto_complete_search_analyzer 결과는 이상이 없어 보인다.
{
"tokens": [
{
"token": "ㅍᅵㅇ",
"start_offset": 0,
"end_offset": 1,
"type": "word",
"position": 0
}
]
}
4. 한글 특성에 따른 mapping 타입의 char_filter 추가
이런 일이 발생하는 이유는 한글의 자음, 모음은 종류가 여러개이기 때문입니다.
유니코드에서는 한글의 자모를 초성 중성 종성으로 구분하고 또한 단일 자음, 단일 모음도 구분합니다.
예를 들어 ‘ㄱ’은
- U+3131 HANGUL LETTER KIYEOK
- U+1100 HANGUL CHOSEONG KIYEOK
- U+11A8 HANGUL JONGSEONG KIYEOK
이렇게 3종류로 나누어집니다.
모음도 비슷하게 단일 모음과 중성 모음이 있습니다.
하지만 자동 완성에서 단일 모음이 쿼리로 들어오는 경우는 없을 거로 생각해 자음에만 모든 종류의 유니코드를 하나로 통일하는 작업을 추가했습니다.
한글 char를 하나로 통일하는 mapping 타입의 char_filter를 추가로 적용하고
이를 auto_complete_analyzer, auto_complete_search_analyzer 에 적용합니다.
순서는 icu_normalizer_filter의 다음입니다.
{
"settings": {
"index": {
"max_ngram_diff": 100
},
"analysis": {
"analyzer": {
"auto_complete_analyzer": {
"tokenizer": "ngram_tokenizer",
"char_filter": [
"no_space_filter",
"icu_normalizer_filter",
"hangul_char_mapping"
],
"type": "custom"
},
"auto_complete_search_analyzer": {
"char_filter": [
"no_space_filter",
"icu_normalizer_filter",
"hangul_char_mapping"
],
"type": "custom",
"tokenizer": "keyword"
}
},
"char_filter": {
"icu_normalizer_filter": {
"type": "icu_normalizer",
"mode": "decompose",
"name": "nfc"
},
"no_space_filter": {
"pattern": """\s+""",
"type": "pattern_replace",
"replacement": ""
},
"hangul_char_mapping": {
"type": "mapping",
"mappings": [
"ᄀ => ㄱ", "ᆨ => ㄱ",
"ᄁ => ㄲ", "ᆩ => ㄲ",
"ᄂ => ㄴ", "ᆫ => ㄴ",
"ᄃ => ㄷ", "ᆮ => ㄷ",
"ᄄ => ㄸ",
"ᄅ => ㄹ", "ᆯ => ㄹ",
"ᄆ => ㅁ", "ᆷ => ㅁ",
"ᄇ => ㅂ", "ᆸ => ㅂ",
"ᄈ => ㅃ",
"ᄉ => ㅅ", "ᆺ => ㅅ",
"ᄊ => ㅆ", "ᆻ => ㅆ",
"ᄋ => ㅇ", "ᆼ => ㅇ",
"ᄌ => ㅈ", "ᆽ => ㅈ",
"ᄍ => ㅉ",
"ᄎ => ㅊ", "ᆾ => ㅊ",
"ᄏ => ㅋ", "ᆿ => ㅋ",
"ᄐ => ㅌ", "ᇀ => ㅌ",
"ᄑ => ㅍ", "ᇁ => ㅍ",
"ᄒ => ㅎ", "ᇂ => ㅎ",
"ᆪ => ㄱㅅ",
"ᆬ => ㄴㅈ",
"ᆭ => ㄴㅎ",
"ᆰ => ㄹㄱ",
"ᆱ => ㄹㅁ",
"ᆲ => ㄹㅂ",
"ᆳ => ㄹㅅ",
"ᆴ => ㄹㅌ",
"ᆵ => ㄹㅍ",
"ᆶ => ㄹㅎ",
"ᆹ => ㅂㅅ"
]
}
},
"tokenizer": {
"ngram_tokenizer": {
"token_chars": [
"letter",
"digit"
],
"min_gram": "1",
"type": "ngram",
"max_gram": "24"
}
}
}
},
"mappings": {
"properties": {
"content": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword"
}
},
"copy_to": [
"content_auto_complete"
]
},
"content_auto_complete": {
"type": "text",
"analyzer": "auto_complete_analyzer",
"search_analyzer": "auto_complete_search_analyzer"
}
}
}
}
//검색
GET /korean_test/_search
{
"query": {
"match": {
"content_auto_complete": "피안"
}
}
}
//결과
{
"took": 1,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 1,
"relation": "eq"
},
"max_score": 2.2221186,
"hits": [
{
"_index": "korean_test",
"_id": "1",
"_score": 2.2221186,
"\_source": {
"content": "피아노"
}
}
]
}
}
이로써 기본적인 자동완성이 동작하게 되었습니다.
cli 에서 검색을 구현한 것. 구현 편의상 검색할 때마다 enter를 누르도록 했지만, 웹에서 입력받을 때마다 검색하고 표시하도록 구현한다면 더욱 그럴듯하게 나올 것 같다.
나아가 자동 완성 검색을 조금 더 고도화할 만한 부분을 적어 보자면
- 저장 공간 효율화를 위해 content 필드를 토크나이징 할 때 중성 혹은 종성으로 시작하는 ngram은 제외할 수도 있다.
- search 시 검색 조건이 무조건 일치가 아니라 유사성을 추가할 수도 있을 것 같다.
- auto_complete 필드를 추가한것과 비슷하게 초성 필드를 추가해 초성만으로 검색하게 할 수도 있어보인다.
- 자동완성 검색 결과 순위도 빈도나 중요도에 따라 조절할 수 있어 보인다.
이번 글을 통해 OpenSearch의 강력한 분석기 기능을 직접 경험해 보셨으면 좋겠습니다. 혹시 이 글의 내용에 대해 궁금한 점이나, 더 좋은 방법이 있다면 댓글로 알려주세요! 여러분의 피드백은 더 나은 글을 만드는 데 큰 도움이 됩니다.
참고 자료: