[Elasticsearch] 분석기 수정 및 인덱스 복사
상황
메디지 서비스에서는 약 검색 기능을 제공하는데, 약 검색 요청어에 따른 결과가 아쉬운점이 몇몇 있었다.
예를 들어 씬지록신정이라는 약을 검색하기 위해 씬지록신이라고 입력하였지만, 전혀 관련 없는 약들이 나오는 문제였다.
약 검색 기능은 서비스의 핵심적인 요소 중 하나이기 때문에, 정확하고 유연한 결과를 주는 것은 필수적이다.
따라서 해당 문제를 보완하기로 하였다.
문제 분석
약 검색 기능을 제공하기 위해 Elasticsearch 데이터베이스를 사용한다.
약 데이터를 저장할 때 형태소 분석을 통해 약 이름을 토큰화하여 저장하고, 검색할 때에도 똑같이 검색어를 토큰화하여, 일치하는 토큰이 많은 데이터를 우선순위로 보여준다.
토큰화를 하는 기준은 분석기에 따라 다른데, 한글 분석기의 예시 중 하나인 nori 분석기는 한글의 어간, 어미를 기준으로 토큰을 나누는 알고리즘을 가지고 있다.
약 이름에 대해서도 역시 nori 분석기를 사용하고 있지만, 문장이 아닌 단어로 이루어진 약 이름 필드로서는 효율적인 토큰화가 이루어지기 힘들다.
text 타입의 필드에 대해서 Elasticsearch에서는 Fuzziness 기능을 제공한다. 철자 오류, 누락으로 인해 토큰 매칭의 차이가 발생한 경우 n개의 글자에 대해서 보정을 해준다.
이러한 기능을 활용하기 위해 Fuzziness를 1로 설정하여, ‘씬지록신’ 입력 데이터가 ‘씬지록신정’으로 수정되어 결과를 쿼리하는 것을 기대하였지만, 관련 없는 의약품 결과만 나오는 문제가 발생하였다.
→ 한글에 대해서는 Fuzziness가 다르게 작동하는 것으로 추측
해결
문제를 해결하기 위해 우리 서비스에 맞는 분석기를 새로만들기로 하였다.
먼저 약 이름 데이터의 패턴에 대해서 분석하였다.
약 이름은 보통 아래와 같은 형식을 가진다.
구분 | 어미(접미사) | 설명 |
1 | ~정 | 일반적인 알약 형태 (ex. 씬지로이드정, 타이레놀정) |
2 | ~캡슐 | 캡슐 제형 (ex. 아스피린캡슐) |
3 | ~연질캡슐 | 부드러운 젤리 형태의 캡슐 (ex. 타이레놀연질캡슐) |
4 | ~경질캡슐 | 단단한 하드 캡슐 형태 (ex. 무기질경질캡슐) |
5 | ~서방캡슐 | 약물이 천천히 방출되는 서방성 캡슐 (ex. 서방형약서방캡슐) |
약의 의미와는 관계없는 어미들이 분석에 포함되지 않도록 토큰화에서 제외하는 것이 중요해 보였다.
또한 약 이름이 큰 단위로 토큰화되면 유연한 검색이 힘들 것 같다고 판단하여 2글자 정도로 토큰화하는 것이 효율적으로 보였다.
분석기 생성 명령어
분석기 생성 전체 쿼리는 아래와 같다.
왜 이렇게 작성하였는지 부분적으로 살펴볼 예정이다.
PUT /medicine_data
{
"settings": {
"analysis": {
"tokenizer": {
"pill_name_ngram_tokenizer": {
"type": "ngram",
"min_gram": 2,
"max_gram": 2,
"token_chars": ["letter", "digit"]
}
},
"filter": {
"remove_pill_suffix_filter": {
"type": "pattern_replace",
"pattern": "(연질캡슐|경질캡슐|서방캡슐|캡슐|서방정|정)$",
"replacement": ""
}
},
"analyzer": {
"pill_name_analyzer": {
"type": "custom",
"tokenizer": "pill_name_ngram_tokenizer",
"filter": [
"lowercase",
"remove_pill_suffix_filter"
]
}
}
}
},
"mappings": {
"properties": {
"item_name": {
"type": "text",
"analyzer": "pill_name_analyzer"
}
}
}
}
Filter
먼저 문제를 해결하는데 있어서 가장 핵심적인 부분인 filter이다.
이 부분에서 약 이름의 의미에서 상관없는 어미를 토큰화 과정에서 제외시켜야한다.
pattern에 지정한 어미로 끝나는 약들은 제외시키도록 작성하였다.
여기서 중요한 점은 캡슐로 끝나는 단어로는 ‘캡슐’, ‘연질캡슐’, ‘경질캡슐’이 존재한다.
하지만 ‘캡슐’을 먼저 제외하게 되면, 뒤에 오는 ‘연질캡슐’, ‘경질캡슐’에 대해서 제대로된 필터링이 적용되지 않는다. 따라서 ‘연질캡슐’, ‘경질캡슐’에 대해서 먼저 필터링을 시키고 나머지 캡슐에 대해서 필터링을 하도록 아래 순서로 패턴을 정의하였다.
"filter": {
"remove_pill_suffix_filter": {
"type": "pattern_replace",
"pattern": "(연질캡슐|경질캡슐|서방캡슐|캡슐|서방정|정)$",
"replacement": ""
}
Tokenizer
그 다음은 토크나이저이다.
token_chars를 통해 토큰화 시킬 텍스트의 종류를 정의하였다.
- letter: 영어, 한글 등
- digit: 숫자
또한 ngram 방식을 통하여 2개의 글자씩 토큰화 되도록 설정하였다.
#주의: 토큰화를 너무 작은 단위로 하게 되면 성능적으로 이슈, 잘못된 결과가 발생할 수 있다. → 토큰화의 의미가 감소 → 필요한 경우 차라리 wildcard를 쓰는 것이 목적성에 맞다고 볼 수 있다.
"tokenizer": {
"pill_name_ngram_tokenizer": {
"type": "ngram",
"min_gram": 2,
"max_gram": 2,
"token_chars": ["letter", "digit"]
}
Analyzer
정의한 filter 조건, 토크나이저 설정을 토대로 분석기를 생성하였다.
필요한 경우 위의 필터 생성 예시를 토대로 필터를 추가 생성하여, 등록할 수 있다.
"analyzer": {
"pill_name_analyzer": {
"type": "custom",
"tokenizer": "pill_name_ngram_tokenizer",
"filter": [
"lowercase",
"remove_pill_suffix_filter"
]
}
}
이렇게 분석기 생성은 완료하였다.
이제 기존 인덱스 데이터의 약 이름에 이 분석기를 적용시킬 차례이다.
분석기 적용
분석기를 교체하기 전 알아야하는 점이 있다. ElasticSearch는 인덱스의 수정을 제공하지 않는다.
→ 즉 새로 인덱스를 만들어 데이터를 복제해야한다.
분석기를 교체하기 위해 아래와 같은 순서로 작업을 진행하기로 하였다.
- 새로운 인덱스 생성 (mapping 정보 정의)
- 기존 인덱스 데이터 복사
- 정상적으로 복사되었는지 여부 확인
- 기존 인덱스 삭제
- 새로운 인덱스에 기존 인덱스 별칭 적용
새로운 인덱스 생성
먼저 기존 인덱스와 이름이 유사한 medicine_data_v2 인덱스를 정의하였다.
데이터의 필드가 많은 관계로 일부분만 예시로 들었다.
PUT /medicine_data_v2
{
"settings": {
"analysis": {
"tokenizer": {
"pill_name_ngram_tokenizer": {
"type": "ngram",
"min_gram": 2,
"max_gram": 2,
"token_chars": ["letter", "digit"]
}
},
"filter": {
"remove_pill_suffix_filter": {
"type": "pattern_replace",
"pattern": "(연질캡슐|경질캡슐|서방캡슐|캡슐|서방정|정)$",
"replacement": ""
}
},
"analyzer": {
"pill_name_analyzer": {
"type": "custom",
"tokenizer": "pill_name_ngram_tokenizer",
"filter": [
"lowercase",
"remove_pill_suffix_filter"
]
}
}
}
},
"mappings": {
"properties": {
"audio_url": { "type": "keyword" },
"cancel_name": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } },
"chart": { "type": "text" },
"class_name": { "type": "keyword" },
"class_no": { "type": "keyword" },
"color_classes": { "type": "keyword" },
"dosage": { "type": "text", "analyzer": "nori" },
"drug_shape": { "type": "keyword" },
"edi_code": { "type": "keyword" },
"entp_name": { "type": "text", "analyzer": "nori" },
"entp_seq": { "type": "keyword" },
"etc_otc_name": { "type": "keyword" },
"form_code_name": { "type": "keyword" },
"indications": { "type": "text", "analyzer": "nori" },
"is_pill": { "type": "long" },
"item_image": { "type": "keyword" },
"item_name": {
"type": "text",
"analyzer": "pill_name_analyzer",
"fields": {
"keyword": { "type": "keyword", "ignore_above": 256 }
}
},
인덱스 복사
엘라스틱서치에서는 간편한 인덱스 복사를 위해 reindex 기능을 제공한다.
기존 인덱스의 데이터를 그대로 복사하였다.
POST _reindex
{
"source": {
"index": "medicine_data"
},
"dest": {
"index": "medicine_data_v2"
}
}
정상 복사 확인
ES에서 제공하는 기능을 활용한 것이기 때문에, 간단히 모든 데이터가 옮겨졌는지 데이터 size로 확인해 주었다.
GET medicine_data/_count
GET medicine_data_v2/_count
기존 인덱스 제거
새로운 인덱스에 별칭을 주기 전 기존인덱스를 삭제하였다.
DELETE /medicine_data
새로운 인덱스 별칭
인덱스의 이름이 변경됨에 따라 외부 클라이언트의 접근에 오류가 발생할 수 있다.
따라서 기존 이름과 동일하게 별칭을 주어, 클라이언트에는 기존 인덱스와 동일하게 접근할 수 있도록 하였다.
POST _aliases
{
"actions": [
{
"add": {
"index": "medicine_data_v2",
"alias": "medicine_data"
}
}
]
}
결론
토큰화가 잘 적용되었는지 확인하기 위해 ‘씬지록신’ 요청데이터를 쿼리해보았다.
GET medicine_data/_search
{
"query": {
"match": {
"item_name": "씬지록신"
}
}
}
그 결과 원하는 결과가 잘 나오는 것을 확인하였다.