💧

Elasticsearch 의 이모저모

태그
Elasticsearch
포스팅 날짜
2022/03/17

시작에 앞서...

본 포스트는 Elasticsearch 의 기본에 대해서 설명해주지는 않습니다. 다만, Elasticsearch 를 다루면서 경험한 내용을 바탕으로 도움이 될만한 내용을 정리해둔 것입니다.
본 포스트는 Python, Django, Elasticsearch 의 환경을 기준으로 작성되었습니다. 다른 환경에서의 내용은 본 포스트의 내용과 상이할 수 있습니다.

사소한 실수인데, 생각보다 찾기 어려운 오류

1.
Analyzer 든, Token Filter 든, Char Filter 든 Index Mapping 단에서 정의되는 모든 요소는 이름이 중복되서는 안됩니다. 중복되었을 경우 둘 중 하나가 다른 하나를 overrding 하여 전역에서 적용됩니다.
from elasticsearch_dsl import analyzer, tokenizer def get_synonym_list(): with open(f"{BASE_DIR}/env/synonym.txt") as f: return f.read().splitlines() synonym_tokenfilter = token_filter( 'synonym_tokenfilter', 'synonym_graph', synonyms=get_synonym_list(), ) index_analyzer = analyzer( 'nori_analyzer', tokenizer=tokenizer('custom', 'nori_tokenizer'), filter=['lowercase', synonym_tokenfilter] ) search_analyzer = analyzer( 'nori_analyzer', tokenizer=tokenizer('custom', 'nori_tokenizer'), filter=['lowercase'] )
Python
복사
위의 예시에서 index_analyzer 와 search_analyzer 는 같은 nori_analyzer 라는 이름을 사용하고 있기 때문에 Index Mapping 내에서 search_analyzer 가 index_analyzer 를 override 하는 형태로 적용됩니다.

도움이 되는 사용법

1.
django-elasticsearch-dsl 은 검색해서 웹에서 찾아보아도 되지만, 개인적으로는 아래 pdf 에서 검색하는 것이 더 용이하게 사용할 수 있었습니다.
2.
Elasticsearch Search Query 든, Search Result 든 to_dict() method 를 사용하면 그 내용을 dictionary 형태로 볼 수 있습니다. 이 방법으로 django_elasticsearch_dsl 에서 제공하는 context 로 Elasticsearch document 에서 제시하는 Query 를 제대로 구현했는지, 결과가 원하는 대로 나오는지 확인할 수 있습니다.
>>> from search_conditions.documents import ConditionEncyclopediaDocument >>> from elasticsearch_dsl.query import MatchPhrase >>> >>> q = ConditionEncyclopediaDocument.search().query(MatchPhrase(name='B형간염')) >>> >>> q.to_dict() >>> {'query': {'match_phrase': {'name': 'B형간염'}}} >>> >>> response = q.execute() >>> >>> response.to_dict() >>> {'took': 19, 'timed_out': False, '_shards': {'total': 5, 'successful': 5, 'skipped': 0, 'failed': 0}, 'hits': {'total': {'value': 1, 'relation': 'eq'}, 'max_score': 7.4461513, 'hits': [{'_index': 'condition_encyclopedias', '_type': '_doc', '_id': '1225', '_score': 7.4461513, '_source': {'syn': 'B형간염', 'no_syn': 'B형간염', 'suggestion': 'B형간염', 'id': 1225, 'name': 'B형간염'}}]}}
Python
복사
3.
Elasticvue 라는 Chrome Extension 을 사용하면 디버깅을 빠르게 할 수 있습니다. 생성된 Index Mapping 정보나 생성한 Query 가 어떻게 동작하는지를 확인하고 싶으면 사용하는 것을 추천합니다.
상단의 Indices 탭의 각 Index 에서 Show Info 를 선택해 Index Mapping 정보를 볼 수 있고, Search 탭에서 원하는 쿼리 결과를 확인해볼 수도 있습니다.
4.
Index Analyzer 로 인해 특정 document 를 어떻게 분석되어 저장되어 있는지 알고 싶다면 다음 명령어를 사용하면 됩니다.
curl -XGET 'localhost:9200/condition_encyclopedias/_termvectors/2664?fields=name
Shell
복사
위 명령어는 condition_encyclopedias 라는 이름의 index 에서 id 가 2664 인 document 의 name 필드의 term vector 를 산출해 주는데, 해당 anlyzer 가 동의어가 적용이 되는지, 어떤 형태로 형태소가 분리되는지 등을 알기에 용이합니다. 위 명령어는 3 번의 Elasticvue 의 Rest 탭을 사용해서도 확인할 수 있습니다.
5.
4 번에서처럼 Analyzer 에 의해 Elasticsearch 의 Index 에 어떻게 inverted index 가 걸려있는지가 아니라, 정의한 analyzer 가 어떻게 분석되는지를 알고 싶다면 다음과 같이 작성하면 됩니다.
from elasticsearch_dsl import analyzer, tokenizer search_analyzer = analyzer( 'search_analyzer', tokenizer=tokenizer('custom', type='nori_tokenizer'), filter=['lowercase'] )
Python
복사
>>> from search_conditions.documents import search_analyzer >>> >>> search_analyzer.simulate('B형간염').to_dict() >>> {'tokens': [{'token': 'b', 'start_offset': 0, 'end_offset': 1, 'type': 'word', 'position': 0}, {'token': '형', 'start_offset': 1, 'end_offset': 2, 'type': 'word', 'position': 1}, {'token': '간염', 'start_offset': 2, 'end_offset': 4, 'type': 'word', 'position': 2}]}
Python
복사

Elasticsearch Similarity 의 기본

Similarity 는 텍스트간의 유사도를 정의할 수 있는 수단입니다. 기본적으로 Elasticsearch 에서 Query 를 실행하게 되면 Okapi BM25 algorithm 을 따라 similarity 를 산출하고, hit 된 document 들을 높은 similarity 순으로 정렬해 반환합니다.
위의 Okapi BM25 algorithm 을 요약하여 설명하자면,
score(D,Q)=i=1nIDF(qi)f(qi,D)(k1+1)f(qi,D)+k1(1b+bDavgdl){\rm score}(D,Q) =\sum_{i=1}^n {\rm IDF}(q_i)\frac{f(q_i,D)\cdot(k_1+1)}{f(q_i,D)+k_1\cdot(1-b+b\cdot\frac{\left\vert D \right\vert}{avgdl})}
위와 같이 표현할 수 있습니다. 여기서 각각의 항목에 대한 부연설명을 하자면,
1.
DD 는 hit 된 document 입니다.
2.
QQ 는 검색어로 사용된 query string 입니다.
3.
qiq_iQQ 의 tokenize 된 각각의 term 입니다.
4.
IDF(qi){\rm IDF}(q_i) 는 term qiq_iinverse doument frequency 입니다.
IDF(qi)=ln(Nn(qi)+0.5n(qi)+0.5+1){\rm IDF}(q_i)=\ln(\frac{N-n(q_i)+0.5}{n(q_i)+0.5}+1)
위 식에서 NN 은 전체 document 의 개수이며, n(qi)n(q_i)qiq_i 를 포함하는 document 의 개수입니다. IDF{\rm IDF} 는 특정 term qiq_i 가 전체 document 내에서 얼마나 희귀한지를 의미합니다.
5.
f(qi,D)f(q_i, D) 는 document DD 내의 term qiq_i 의 frequency 입니다. 이를 document frequency 라고 합니다. Document 내 해당 term qiq_i 가 등장하는 빈도를 의미합니다.
6.
D\left\vert D \right\vert 는 document D 의 length 입니다. 이를 document length 라고 합니다. Document 가 Index Analyzer 로 tokenize 된 term 의 개수를 의미합니다.
그리고 나머지 k1k_1bb 는 상수이며 보통 default 로 k1[1.2,2.0]k_1 \in \left[ 1.2, 2.0 \right] 이고, b=0.75b=0.75 라고 합니다.
위에서 설명드린 것들을 바탕으로 Okapi BM25 algorithm 을 정성적으로 세 줄로 요약하자면 다음과 같습니다.
1.
document 에 매치되는 term 이 희귀할수록 해당 similarity 가 높습니다.
2.
매치되는 term 의 빈도가 높을수록 similarity 가 높습니다.
3.
Index Analyzer 로 분석된 document 의 term 수가 짧을수록 similarity 가 높습니다.
따라서, Custom Similarity 를 고민할 때 중점적으로 Elasticsearch 에서 기본으로 사용하는 Okapi BM25 algorithm 의 근간이 되는 4, 5, 6 번의 inverse document frequency, document frequency, document length 등의 context 에 대한 이해를 바탕으로 조절하려는 시도를 하는 것이 자연스러워 보입니다.

Custom Elasticsearch Similarity

Elasticsearch 의 Similarity 를 커스텀하는 방법에는 크게 두 가지가 있습니다.
첫 번째 방법은 Index Mapping 단에서 Similarity 를 정의하는 방법입니다. 이 방법은 기본 Okapi BM25 algorithm 을 override 하여 Cutom Similarity 를 사용할 수 있게끔 해줍니다.
이 방법은 또 다시 두 가지로 나뉘는데 기본적으로 제공하는 algorithm 들의 상수 부분만을 재 정의하는 형태로 사용할 수도 있고, 아예 script 를 새롭게 작성할 수도 있습니다. 상수 부분을 재정의하는 방법은 위 문서를 참고해 쉽게 진행할 수 있으니, 여기서는 custom script 를 작성하여 similarity 를 재정의하는 방법에 대해서만 다루도록 하겠습니다.
from django_elasticsearch_dsl import Document, fields condition_analyzer = analyzer( 'condition_analyzer', tokenizer=tokenizer('custom', type='nori_tokenizer'), filter=['lowercase', synonym_tokenfilter], ) search_analyzer = analyzer( 'search_analyzer', tokenizer=tokenizer('custom', type='nori_tokenizer'), filter=['lowercase'] ) @registry.register_document class ConditionEncyclopediaDocument(Document): name = fields.TextField( 'name', analyzer=condition_analyzer, search_analyzer=search_analyzer, similarity='custom_similarity', ) class Index: name = 'condition_encyclopedias' settings = { 'number_of_shards': 5, 'number_of_replicas': 1, "index": { "similarity": { "custom_similarity": { "type": "scripted", "script": { "lang": "painless", "source": """ double tf = doc.freq; return tf; """, }, } } }, } class Django: model = ConditionEncyclopedia fields = ['id', 'name']
Python
복사
위처럼 Index Mapping 에서 custom_similarity 라는 이름의 similarity 를 생성하고 이를 적용할 필드에 해당 이름의 similarity 를 할당해주면 됩니다. 이 때 script 에서 사용할 수 있는 context 는 다음과 같습니다.
두 번째 방법은 Query 단에서 Similarity 를 정의하는 방법입니다. 이는 Index Mapping 단에서 정의된 Similarity 를 override 하여 Custom Similarity 를 사용할 수 있게끔 해주며, Index Mapping 단에서 산출한 score 에 추가적인 작업을 할 수도 있습니다.
간단하게 적용할 수 있는 예시는 다음과 같습니다.
from elasticsearch_dsl.query import MatchPhrase, ScriptScore, Script q = ConditionEncyclopediaDocument.search().query( ScriptScore( query=MatchPhrase(name='B형'), script={ 'source': """ return _score / params._source.name.length(); """ }, ) )
Python
복사
ScriptScore query 를 사용하면 Custom Similarity script 를 작성할 수 있습니다. 이 때 _score 를 통해 Index Mapping 단에서 정의한 Similarity 가 산출한 score 에 접근할 수 있고, params._source 를 통해 document 에 직접 접근하는 것도 가능합니다. 실제로 document 에 doc['name'] 으로 접근하는 것과 달리 document 의 실제 길이(term 개수가 아닌) 에 접근할 수 있다는 메리트가 있습니다. 다만 이 문서에 적혀있는 것 처럼 쿼리를 느리게 만드는 요인이 될 수 있어 검토가 필요할 수 있습니다.

Elasticsearch Similarity 에 관련한 번외의 것들

한국임상정보의 검색 결과의 케이스 별 개인적으로 생각했던 문제점들은 아래와 같습니다.
검색어: B형 검색 결과: 만성 B형 간염, B형간염, 급성 B형 간염 피드백: B형간염이 먼저 오는 게 자연스러워 보임
검색어: 지방 검색 결과: 복부비만, 지방 흡입, 지방간, 비만, 체지방 과잉, 셀리악병, 비열대스프루 피드백: 지방이 직접 언급된 지방 흡임, 지방간 등이 먼저 오는 것이 자연스러워 보임
검색어: 우울 검색 결과: 만성우울증, 산후우울증, 기분부전증, 사춘기 우울증, 우울장애, 산후우울, 소아청소년기 우울증 피드백: 우울증 등 유사도가 높은 간단한 단어가 아예 등장하지 않음 (개수 범위를 넘어감)
검색어: 폐암 검색 결과: 폐악성종양, 폐암, 폐암종, 기관지암, 폐 악성신생물, 소세포성 폐암, 소세포폐암 피드백: 폐암이 폐악성종양보다 앞에 있는 것이 더 자연스러워 보임
검색어: 간암 검색 결과: liver cancer, 간세포암, 간세포암종, 간암, 간세포성 암종 피드백: 간암이 제일 앞에 있는 것이 자연스러워 보임
검색어: 유방암 검색 결과: 유방종괴, 가슴종양, 유방 종양, Breast Tumor, 유방 악성신생물, 유방종, Breast Cancer 피드백: 유방암 등 유사도가 높은 간단한 단어가 아예 등장하지 않음 (개수 범위를 넘어감)
검색어: 대장암 검색 결과: 결장암, 대장악성종양, 대장의 악성신생물, 대장암종, 대장암, 비용종성 직장암, 유전성 대장암 피드백: 대장암이 제일 앞에, 대장 등의 단어가 포함된 단어가 그 다음으로 오는 것이 자연스러워 보임
1. 유사도가 높은 단어 (겹치는 부분이 많은 경우)
2. 직접 언급된 단어가 포함된 친구
3. 동의어
순으로 오는 것이 좋아보인다는 직관으로 Custom Similarity 구현 진행
가장 먼저 진행한 것은 동의어의 우선순위를 가장 낮게 설정하는 것인데, 이는 synonym 을 적용한 field 와 그렇지 않은 field 를 두어 해당 필드 중 어느 하나라도 MatchPhrase query 에 hit 되면 산출되도록 query 를 설계하되, synonym 을 적용하지 않은 field 에 boost 가중치를 두는 형태로 구현해 보았습니다.
from django_elasticsearch_dsl import Document, fields from elasticsearch_dsl import analyzer, tokenizer, token_filter synonym_tokenfilter = token_filter('synonym_tokenfilter', 'synonym', synonyms=get_synonym_list()) condition_analyzer = analyzer( 'condition_analyzer', tokenizer=tokenizer('custom', type='nori_tokenizer'), filter=['lowercase', synonym_tokenfilter], ) no_syn_condition_analyzer = analyzer( 'no_syn_condition_analyzer', tokenizer=tokenizer('x_custom', type='nori_tokenizer', decompound_mode='mixed'), filter=['lowercase'], ) @registry.register_document class ConditionEncyclopediaDocument(Document): syn = fields.TextField('name', analyzer=condition_analyzer) no_syn = fields.TextField('name', analyzer=no_syn_condition_analyzer) class Index: name = 'condition_encyclopedias' settings = { 'number_of_shards': 5, 'number_of_replicas': 1, "index": { "similarity": { "scripted_tfidf": { "type": "scripted", "script": { "lang": "painless", "source": """ double tf = doc.freq; return tf; """, }, } } }, } class Django: model = ConditionEncyclopedia fields = ['id', 'name']
Python
복사
search_query = ( ConditionEncyclopediaDocument.search() .query( ScriptScore( query=Bool( should=[ MatchPhrase(no_syn={'query': name, 'boost': 10}), MatchPhrase(syn=name), ] ), script={ 'source': """ return _score / params._source.name.length(); """ }, ), ) .source(['name']) )
Python
복사
그 결과 바뀐 검색 결과는 아래와 같습니다.
검색어: B형 검색 결과: B형간염, 만성 B형 간염, 급성 B형 간염 피드백: 없음
검색어: 지방 검색 결과: 지방간, 지방종, 지방 흡입, 체지방 과잉, 내장지방형비만, 소아지방변증, 알콜성지방간, 알코올성 지방간, 비알콜성지방간, 비알코올성 지방간 피드백: 없음
검색어: 우울 검색 결과: 우울증, 우울병, 산후우울, 우울장애, 만성우울증, 산후우울감, 산후 우울증, 아동 우울증, 소아 우울증, 주요 우울장애 피드백: 없음
검색어: 폐암 검색 결과: 폐암, 폐암종, 기관지암, 폐악성종양, 폐 악성신생물, 폐암 전이, 소세포폐암, 전이성폐암, 전이된폐암, 비소세포 폐암 피드백: 폐암이 직접적으로 언급된 것들의 우선순위가 조금 더 높으면 좋을 듯함
검색어: 간암 검색 결과: 간암, 간세포암, 간세포암종, 간세포성 암종, liver cancer 피드백: 없음
검색어: 유방암 검색 결과: 유방암, 유방종, 유방 악성신생물, Breast tumor, Breast Cancer 피드백: 없음
검색어: 대장암 검색 결과: 대장암, 결장암, 대장암종, 대장악성종양, 대장의 악성신생물, 유전성 대장암, 비용종성 대장암 피드백: 결장암이 상당히 앞에 있는데, document length 에 기반한 결과로 보고 있음
Term 의 Match 에 fuzziness 를 적용하면서도 Match Phrase 에서 강제되는 순서를 지키고 싶은데 방법이 없을까에 대해서 고민을 하고 있는 상태입니다. (검색어에 오타가 있을 때의 Spell Correction 관점)
또한 Match 의 fuzziness 설정 자체에도 korean edit distance 를 적용해야 할 것 같은데, fuzziness 를 아무리 높여도 매칭되지 않는 것을 보아 무언가 잘 적용되고 있다는 느낌은 들지 않는 상태입니다.