💻 Tech

[AWS OpenSearch/Elasticsearch] 외부 플러그인 설치 없이 한글 초성검색, 자/모음 분리 검색 기능 구현하기

date
Aug 2, 2024
slug
aws-opensearch-chosung
author
status
Public
category
💻 Tech
tags
Elasticsearch
AWS OpenSearch
summary
댑댑댑은 기술블로그 조회 및 검색을 위해 Elasticsearch를 검색엔진으로 사용 중이다. 사용자 검색을 개선하기 위해 검색어 자동완성 기능을 구현했지만, 초성 검색이나 자/모음 분리 검색을 지원하지 않아 사용자에게 불편함을 주고 있어 이 부분을 개선하고자 한다.
type
Post
thumbnail
스크린샷 2024-08-01 오후 10.44.23.png
 
 

시작

댑댑댑은 기술블로그 조회 및 검색을 위해 Elasticsearch를 검색엔진으로 사용 중이다. 사용자 검색을 개선하기 위해 검색어 자동완성 기능을 구현했지만, 초성 검색이나 자/모음 분리 검색을 지원하지 않아 사용자에게 불편함을 주고 있어 이 부분을 개선하고자 한다.
 

요구사항

검색어
원하는 결과
실제 결과
ㅈㅂ
자바
조회 X
자바
조회 X
이를 해결하기 위해 다음과 같은 기능을 구현하고자 한다:
  1. 초성검색
  1. 자/모음 분리 검색 기능
  1. 동시에 구현하고자 한다.
 
데이터를 인덱싱하기 전에 한글 문자열에서 초성만을 추출하는 전처리 스크립트를 작성하여 이를 별도의 필드로 지정하는 방법이 있을 것이다. 그러나 전처리를 위한 별도의 파이프라인을 구축하는 것은 시스템 복잡도를 증가시키고, Elasticsearch 자체 기능을 사용하면 유지보수가 용이해지고, 확장성을 유지하면서 성능 저하를 최소화할 수 있기에 나는 전처리가 아닌 Elasticsearch 자체에서 제공하는 기능을 통해 인덱싱을 하고 싶었다.
 

🤯 첫 번째 난관: 외부 plugin 설치 필요

Elasticsearch에서 초성 추출을 구현하기 위해 조금 과장 보태어 인터넷에 있는 Elasticsearch 초성 추출 관련 자료를 거의 모두 찾아보았는데, 거의 다 외부 커스텀 plugin을 설치해야 가능한 방법들이었다.
그러나 우리가 사용하고 있는 AWS OpenSearch은 외부 플러그인 설치가 불가능하고, 제공하는 플러그인만 추가할 수 있다는 제약이 있었다. 한글 분석기는 nori만 설치할 수 있었고, 많이 사용되는 은전한닢과 같은 플러그인을 추가할 수 없었던 것이다… 하 골칫덩어리 엘라스틱서치…🚬
 
그러던 중 하해와 같은 은혜를 내려주는 게시글을 발견했다.
 
스푼라디오의 용근님께서 AWS OpenSearch 환경에서 한글 초성을 추출하여 인덱싱한 과정을 기술블로그로 남겨주셨던 것이다! 내가 원하던 내용에 적확하게 맞아 떨어지는 내용이었다.
용근님께서 해결하신 방법은 Opensearch에 내장되어있는 ICU-analysis 를 사용하는 것이었다.

ICU-analysis

notion image
  • icu_normalizer char 필터, icu_tokenizer 및 icu_folding 토큰 필터를 사용하여 기본 정규화, 토큰화 및 문자 접기를 수행하는 분석기라고 한다.
 
이 분석기를 사용하여 요구사항인 1. 초성검색과 2. 자/모음 분리 검색 기능을 구현하기 위한 실습을 몇 가지 진행해보겠다.

0. 실습

1) decompose : 초성, 중성, 종성 분리

  • icu_normalizer의 decompose 모드를 사용하여 초성/중성/종성으로 분리한다.
  • (주의) NFD 처리 된 문자는 일반 문자와 다른 고유한 값을 가진다.
    • 즉, 일반 문자 “ㄴ”과 NFD 처리된 문자 “ㄴ”은 다르다.
notion image
GET _analyze { "text": "꿈빛 파티시엘", "char_filter": [ { "type": "icu_normalizer", "name": "nfc", "mode": "decompose" } ] }
 

2) decompose + replace : 초성만 남기기

  • 분석 결과에서 초성이 아닌 NFD 문자는 없애는 과정이 필요하다.
    • NFD 분석 결과는 초성/중성/종성으로 구분된다. 즉, “눈”을 분석했을 경우, 초성의 ”ㄴ”과 종성의 ”ㄴ”은 다른 문자로 구분된다.
    • 초성 ㄱ~ㅎ의 코드 = \u1100-\u1112
    • 따라서 pattern_replace를 사용하여 초성이 아닌 문자들은 empty string으로 치환한다.
notion image
GET _analyze { "text": "꿈빛 파티시엘", "char_filter": [ { "type": "icu_normalizer", "name": "nfc", "mode": "decompose" }, { "type": "pattern_replace", "pattern": "[^\u1100-\u1112]", "replacement": "" } ] }
 

참고) n-gram 분석

  • ngram: 주어진 텍스트에서 연속된 단어 또는 문자 시퀀스를 추출한다.
    • ex) “text"라는 단어에 대해 2-gram을 생성하면 ⇒ ["te", "ex", "xt"]
  • edge_ngram: 주어진 텍스트의 시작 부분부터 지정된 길이의 시퀀스를 추출한다.
    • ex) "text"라는 단어에 대해 2-gram을 생성하면 ⇒ ["t", "te"]
  • 검색어 자동완성 기능의 특성상 중간 부분 검색의 필요성이 없다고 판단하여, edge_ngram을 사용하도록 설정했다.
notion image
GET _analyze { "text": "꿈빛 파티시엘", "char_filter": [ { "type": "icu_normalizer", "name": "nfc", "mode": "decompose" }, { "type": "pattern_replace", "pattern": "[^\u1100-\u1112]", "replacement": "" } ], "tokenizer": { "type": "edge_ngram", "min_gram": 1, "max_gram": 20, "token_chars": [ "letter", "digit" ] } }
 
 
이제 실습한 내용을 토대로 초성 검색과 자/모음 분리 검색을 구현해보겠다.
 

1. 초성 검색 기능 구현

decompose + replace + ngram 분석기를 사용하여 초성 검색이 가능한 인덱스를 만들어보자.

1) 인덱스 생성

  • nfc_normalizer : nfc decompose 필터
  • korean_chosung_filter : 초성이 아닌 값들을 제거해주는 필터
  • lowercase_filter : 영어를 모두 소문자로 변경해주는 필터
  • my_default_analyzer : edge-ngram 토크나이저 + nfc_normalizer 필터 + korean_chosung_filter 필터 + lowercase_filter 필터를 혼합한 분석기
notion image
PUT /my_test { "settings": { "analysis": { "char_filter": { "nfc_normalizer": { "type": "icu_normalizer", "name": "nfc", "mode": "decompose" }, "korean_chosung_filter": { "type": "pattern_replace", "pattern": "[^\u1100-\u1112]", "replacement": "" } }, "tokenizer": { "my_edge_ngram_tokenizer": { "type": "edge_ngram", "min_gram": 1, "max_gram": 10, "token_chars": [ "letter", "digit" ] } }, "filter": { "my_lowercase_filter": { "type": "lowercase" } }, "analyzer": { "my_default_analyzer": { "type": "custom", "tokenizer": "my_edge_ngram_tokenizer", "char_filter": ["nfc_normalizer", "korean_chosung_filter"], "filter": ["my_lowercase_filter"] } } } }, "mappings": { "properties": { "text": { "type": "text", "analyzer": "my_default_analyzer" } } } }
 

2) 생성된 인덱스 분석기를 토대로 데이터 분석

  • “꿈빛 파티시엘”의 초성만 추출하여 edge-ngram을 적용한 인덱스들이 잘 생성된 것을 확인할 수 있다.
notion image
 

3) 초성검색 테스트

…를 해야 하는데 여기서 잠깐,

🤯 두 번째 난관: 분명 인덱싱을 했는데 초성 검색이 안 된다..?

분명 2)단계에서 데이터 분석 결과 초성들로 이루어진 인덱스가 잘 생성된 것을 확인했는데, 초성 검색을 해도 데이터가 조회되지 않는다.
notion image
 
앞서 말했듯, NFD 처리된 단어는 고유한 값을 가진다.
즉, “자바”를 분석한 결과의 “ㅈㅂ”와, 실제 초성 검색의 “ㅈㅂ”는 다른 것이다.
  • 전자의 “ㅈㅂ”는 \u110C, \u1107 이고,
  • 후자의 “ㅈㅂ”는 \u3148, \u3142 이다.
 
이 문제를 해결할 수 있는 방안은 총 두 가지 정도가 있을 것 같다.
  1. 인덱스를 분석결과인 NFD 코드 형태로 저장하고, 검색시 검색어를 NFD 코드로 변경하기
  1. 인덱스를 저장할 때, 분석된 NFD 코드를 한글 유니코드로 변경해서 저장하기
 
나는 2번을 택했다. 1번을 선택하면 검색어가 초성이 아닌 완전한 문자이더라도 굳이 불필요한 NFD 변환 과정을 거쳐야 하고, NFD 코드는 데이터의 인덱싱을 위한 분석 과정에서 쓰이는 개념이지, 이것이 실제로 인덱스의 저장값에 노출될 필요가 전혀 없다고 생각했기 때문이다.
 

NFD 분석결과 → 한글 유니코드로 변경하는 replace filter 추가하여 인덱스 재생성

  • mapping 을 사용하여 초/중/종성의 한글 NFD 코드들을 한글 유니코드로 변경시켜주는 작업을 추가했다.
notion image
 

3) 다시, 초성검색 테스트

  • 초성 검색이 잘 되는 것을 확인할 수 있다!
notion image
 

2. 자/모음 분리 검색 기능 구현

이제 decompose + replace + ngram 분석기를 사용하여 자/모음 분리 검색이 가능한 인덱스를 만들어보자.

1) 인덱스 생성

  • 인덱스 생성: 자바 ⇒ ㅈㅏㅂㅏ(nfc) ⇒ ㅈㅏㅂㅏ(utf8) * n-gram
  • 검색어 : 잡 ⇒ ㅈㅏㅂ(nfc) ⇒ ㅈㅏㅂ(uft)
인덱스 생성과 마찬가지로 검색어도 똑같이 nfd 분석이 필요하므로 search_analyzer를 추가로 적용해야 한다. 검색어는 n-gram 분석이 필요없으므로 edge_ngram 토크나이저가 없는 검색어 전용 분석기를 따로 만든다.
또한 초성만 따로 추출할 필요는 없으므로 1번에서 사용했던 korean_chosung_filter는 이번에 포함되지 않는다.
notion image
 

2) 데이터 분석

  • “자바”, “잡” 텍스트에 대해 자/모음 분리가 잘 이루어 진 것을 확인할 수 있다.
notion image
notion image
 

3) 자/모음 분리 검색 테스트

  • 자/모음 결합 여부와 상관없이 조회가 되는 것을 확인할 수 있다!
notion image
notion image
 

3. 초성 검색 + 자/모음 분리 검색 통합

이제 앞서 구현해본 두 기능을 하나로 합쳐보겠다.

1) 인덱스 생성

초성검색을 위한 인덱스와 자/모음분리 검색을 위한 인덱스가 모두 생성되어야 한다. 이를 위해 멀티 필드로 인덱스를 생성한다.
  • 엘라스틱서치에서 멀티필드(Multi-field)란, 하나의 필드를 여러 개의 방식으로 인덱싱할 수 있도록 하는 기능이다. 예를 들어, 같은 텍스트 데이터를 다르게 분석하고 검색할 수 있도록 여러 가지 분석기를 적용할 수 있다. 이를 통해 동일한 데이터에 대해 다양한 검색 기능을 제공할 수 있다.
  • text.nfc 필드는 my_default_analyzer를 적용하고(자/모음 분리 검색 인덱스 생성),
  • text.chosung 필드는 my_chosung_analyzer를 적용한다(초성 검색 인덱스 생성).
  • 두 필드 모두 검색어 분석기는 my_search_analyzer를 적용하여, 검색어의 자/모음을 분리하고 이를 한글 유니코드로 다시 변환한 결과를 검색하도록 한다.
 
notion image
PUT my_test { "settings": { "analysis": { "char_filter": { "nfc_normalizer": { "type": "icu_normalizer", "name": "nfc", "mode": "decompose" }, "korean_filter": { "type": "mapping", "mappings": [ "\u1100 => ㄱ", "\u1101 => ㄲ", "\u1102 => ㄴ", "\u1103 => ㄷ", "\u1104 => ㄸ", "\u1105 => ㄹ", "\u1106 => ㅁ", "\u1107 => ㅂ", "\u1108 => ㅃ", "\u1109 => ㅅ", "\u110A => ㅆ", "\u110B => ㅇ", "\u110C => ㅈ", "\u110D => ㅉ", "\u110E => ㅊ", "\u110F => ㅋ", "\u1110 => ㅌ", "\u1111 => ㅍ", "\u1112 => ㅎ", "\u1161 => ㅏ", "\u1162 => ㅐ", "\u1163 => ㅑ", "\u1164 => ㅒ", "\u1165 => ㅓ", "\u1166 => ㅔ", "\u1167 => ㅕ", "\u1168 => ㅖ", "\u1169 => ㅗ", "\u116A => ㅘ", "\u116B => ㅙ", "\u116C => ㅚ", "\u116D => ㅛ", "\u116E => ㅜ", "\u116F => ㅝ", "\u1170 => ㅞ", "\u1171 => ㅟ", "\u1172 => ㅠ", "\u1173 => ㅡ", "\u1174 => ㅢ", "\u1175 => ㅣ", "\u11A8 => ㄱ", "\u11A9 => ㄲ", "\u11AA => ㄳ", "\u11AB => ㄴ", "\u11AC => ㄵ", "\u11AD => ㄶ", "\u11AE => ㄷ", "\u11AF => ㄹ", "\u11B0 => ㄺ", "\u11B1 => ㄻ", "\u11B2 => ㄼ", "\u11B3 => ㄽ", "\u11B4 => ㄾ", "\u11B5 => ㄿ", "\u11B6 => ㅀ", "\u11B7 => ㅁ", "\u11B8 => ㅂ", "\u11B9 => ㅄ", "\u11BA => ㅅ", "\u11BB => ㅆ", "\u11BC => ㅇ", "\u11BD => ㅈ", "\u11BE => ㅊ", "\u11BF => ㅋ", "\u11C0 => ㅌ", "\u11C1 => ㅍ", "\u11C2 => ㅎ" ] }, "korean_chosung_filter": { "type": "pattern_replace", "pattern":"[^\u1100-\u1112]", "replacement":"" } }, "tokenizer": { "my_edge_ngram_tokenizer": { "type": "edge_ngram", "min_gram": 1, "max_gram": 10, "token_chars": [ "letter", "digit" ] } }, "filter": { "my_lowercase_filter": { "type": "lowercase" } }, "analyzer": { "my_default_analyzer": { "type": "custom", "tokenizer": "my_edge_ngram_tokenizer", "char_filter": ["nfc_normalizer", "korean_filter"], "filter": ["my_lowercase_filter"] }, "my_chosung_analyzer": { "type": "custom", "tokenizer": "my_edge_ngram_tokenizer", "char_filter": ["nfc_normalizer", "korean_chosung_filter", "korean_filter"], "filter": ["my_lowercase_filter"] }, "my_search_analyzer": { "type": "custom", "tokenizer": "standard", "char_filter": ["nfc_normalizer", "korean_filter"], "filter": ["my_lowercase_filter"] } } } }, "mappings": { "properties": { "text": { "type": "text", "fields": { "nfc":{ "type": "text", "analyzer": "my_default_analyzer", "search_analyzer": "my_search_analyzer" }, "chosung":{ "type": "text", "analyzer": "my_chosung_analyzer", "search_analyzer": "my_search_analyzer" } } } } } }
 
 

2) 각 분석기 처리 결과 확인

  • 자/모음 분리 인덱싱 생성
notion image
  • 초성 추출 인덱스 생성
notion image
  • 검색어 분석 및 전처리
notion image
 

3) 멀티 쿼리로 조회 테스트

  • text 필드가 멀티필드이기 때문에, 단순히 text 필드에 대해 조회를 하면 아래와 같이 조회가 되지 않는다. 두 번째 사진과 같이 검색을 원하는 필드를 명확히 명시해주어야 한다.
notion image
notion image
  • 따라서 멀티필드의 경우 아래와 같이 멀티쿼리를 사용하여 멀티필드들을 모두 검색 대상으로 지정해주어야 한다.
  • 우리의 요구사항이었던 초성과 자/모음 분리 검색이 모두 잘 이루어지는 것을 확인할 수 있다!!^^
notion image
notion image
 
 

마치며(+오타 교정 검색)

AWS OpenSearch는 외부 플러그인 설치가 불가능하기 때문에 구현 제약사항이 많지만, ICU-analysis 처럼 기본으로 제공해주는 플러그인도 많으므로 이를 잘 활용하면 굳이 외부 플러그인을 설치하지 않아도 다양한 기능을 구현할 수 있으리라 생각한다.
이번에 사용했던 ICU-analysis를 사용하여 영문으로 검색해도 한글이 검색되도록 하거나, 반대로 한글로 검색해도 영문이 검색될 수 있도록 지원할 수도 있을 것 같다! 이 사항은 백로그에 남겨두고 추후 오타교정 기능도 추가해보도록 하겠다!^0^
검색어
기대하는 결과
wkqk
자바
ㅓㅁㅍㅁ
java
 
 

Aug 3, 2024