사용자 사전 작성 시 유의점

Hermaeus Mora ·

사용자 사전을 왜 작성해야 할까?

검색 정확도를 높이기 위해서이다.

예를 들어 쿠버네티스 라는 단어를 ES에 색인한다고 해보자. 형태소 분석기는 Nori를 사용한다고 하자.

사용자 사전이 없을 경우 Nori 토크나이저는 해당 단어를 다음과 같이 분해한다. (decompound_mode: "mixed", 복합어와 어근을 함께 저장)

{
        \"token\": \"\",
    \"start_offset\": 0,
    \"end_offset\": 1,
    \"type\": \"word\",
    \"position\": 0,
    \"bytes\": \"[ec bf a0]\",
    \"leftPOS\": \"NNP(Proper Noun)\",
    \"morphemes\": null,
    \"posType\": \"MORPHEME\",
    \"positionLength\": 1,
    \"reading\": null,
    \"rightPOS\": \"NNP(Proper Noun)\",
    \"termFrequency\": 1
},
{
        \"token\": \"버네\",
    \"start_offset\": 1,
    \"end_offset\": 3,
    \"type\": \"word\",
    \"position\": 1,
    \"bytes\": \"[eb b2 84 eb 84 a4]\",
    \"leftPOS\": \"NNP(Proper Noun)\",
    \"morphemes\": null,
    \"posType\": \"MORPHEME\",
    \"positionLength\": 1,
    \"reading\": null,
    \"rightPOS\": \"NNP(Proper Noun)\",
    \"termFrequency\": 1
},
{
        \"token\": \"티스\",
    \"start_offset\": 3,
    \"end_offset\": 5,
    \"type\": \"word\",
    \"position\": 2,
    \"bytes\": \"[ed 8b b0 ec 8a a4]\",
    \"leftPOS\": \"NNP(Proper Noun)\",
    \"morphemes\": null,
    \"posType\": \"MORPHEME\",
    \"positionLength\": 1,
    \"reading\": null,
    \"rightPOS\": \"NNP(Proper Noun)\",
    \"termFrequency\": 1
}

쿠버네티스는 Nori가 사용하는 기본 사전에 포함되어 있지 않지만, 버네티스로 분해되는 걸 보니 이 두 단어는 기본 사전에 포함되어 있을 가능성이 높다.

문제는, 분해된 때문에 쿠버네티스를 검색 시 쿠앤크가 나오는 골때리는 상황이 발생한다는것이다.

따라서 이같은 단어들을 사용자 사전에 등록해야 하는 것인데.. 그럼 쿠버네티스 같은 외래어들을 모두 사용자 사전에 등록해야 하는 걸까?

필라델피아라는 단어를 분석해보자.

{
        \"token\": \"필라델피아\",
    \"start_offset\": 0,
    \"end_offset\": 5,
    \"type\": \"word\",
    \"position\": 0,
    \"bytes\": \"[ed 95 84 eb 9d bc eb 8d b8 ed 94 bc ec 95 84]\",
    \"leftPOS\": \"NNP(Proper Noun)\",
    \"morphemes\": null,
    \"posType\": \"MORPHEME\",
    \"positionLength\": 1,
    \"reading\": null,
    \"rightPOS\": \"NNP(Proper Noun)\",
    \"termFrequency\": 1
}

이번에는 분해되지 않고 복합어 그대로 나온다. 해당 단어가 Nori 기본 사전에 포함되어 있기 때문이다.

따라서 Nori 기본 사전에 탑재되지 않아 단어가 분해되는 경우에만 사용자 사전에 등록해야 한다.

다음과 같이 사용자 사전을 작성했다고 하자.

그리고 다음 도큐먼트를 작성한다.

{
      \"_index\": \"post_2024_10\",
  \"_id\": \"0cd515d0-cf26-4eab-9a02-4787e0f1ad5b\",
  \"_score\": 2.9540634,
  \"_source\": {
        \"title\": \"C++은 왜\",
    \"content\": \"어려울까\"
  }
},

lowercase 필터를 적용했기에 사용자 사전에 있는 c++로 색인이 될거라 예상했지만 그렇지 않다.

GET _termvectors/0cd515d0-cf26-4eab-9a02-4787e0f1ad5b?fields=title
{
        \"_index\": \"post_2024_10\",
    \"_id\": \"0cd515d0-cf26-4eab-9a02-4787e0f1ad5b\",
    \"_version\": 1,
    \"found\": true,
    \"took\": 1,
    \"term_vectors\": {
            \"title\": {
                \"field_statistics\": {
                    \"sum_doc_freq\": 134,
                \"doc_count\": 32,
                \"sum_ttf\": 139
            },
            \"terms\": {
                    \"c\": {
                        \"term_freq\": 1,
                    \"tokens\": [
                            {
                                \"position\": 0,
                            \"start_offset\": 0,
                            \"end_offset\": 1
                        }
                    ]
                },
                \"\": {
                        \"term_freq\": 1,
                    \"tokens\": [
                            {
                                \"position\": 2,
                            \"start_offset\": 5,
                            \"end_offset\": 6
                        }
                    ]
                },
                \"\": {
                        \"term_freq\": 1,
                    \"tokens\": [
                            {
                                \"position\": 1,
                            \"start_offset\": 3,
                            \"end_offset\": 4
                        }
                    ]
                }
            }
        }
    }
}

왜 c로 색인되는것일까?

다음 두 요청을 보내보자

GET /_analyze
{
      \"analyzer\": \"nori_analyzer\",
  \"text\": \"c++\",
  \"explain\": true
}
{
        \"detail\": {
            \"custom_analyzer\": true,
        \"charfilters\": [],
        \"tokenizer\": {
                \"name\": \"custom_nori_tokenizer\",
            \"tokens\": [
                    {
                        \"token\": \"c++\",
                    \"start_offset\": 0,
                    \"end_offset\": 3,
                    \"type\": \"word\",
                    \"position\": 0,
                    \"bytes\": \"[63 2b 2b]\",
                    \"leftPOS\": \"NNG(General Noun)\",
                    \"morphemes\": null,
                    \"posType\": \"MORPHEME\",
                    \"positionLength\": 1,
                    \"reading\": null,
                    \"rightPOS\": \"NNG(General Noun)\",
                    \"termFrequency\": 1
                }
            ]
        },
        \"tokenfilters\": [
                {
                    \"name\": \"lowercase\",
                \"tokens\": [
                        {
                            \"token\": \"c++\",
                        \"start_offset\": 0,
                        \"end_offset\": 3,
                        \"type\": \"word\",
                        \"position\": 0,
                        \"bytes\": \"[63 2b 2b]\",
                        \"leftPOS\": \"NNG(General Noun)\",
                        \"morphemes\": null,
                        \"posType\": \"MORPHEME\",
                        \"positionLength\": 1,
                        \"reading\": null,
                        \"rightPOS\": \"NNG(General Noun)\",
                        \"termFrequency\": 1
                    }
                ]
            },
            {
                    \"name\": \"stop_filter\",
                \"tokens\": [
                        {
                            \"token\": \"c++\",
                        \"start_offset\": 0,
                        \"end_offset\": 3,
                        \"type\": \"word\",
                        \"position\": 0,
                        \"bytes\": \"[63 2b 2b]\",
                        \"leftPOS\": \"NNG(General Noun)\",
                        \"morphemes\": null,
                        \"posType\": \"MORPHEME\",
                        \"positionLength\": 1,
                        \"reading\": null,
                        \"rightPOS\": \"NNG(General Noun)\",
                        \"termFrequency\": 1
                    }
                ]
            },
            {
                    \"name\": \"nori_filter\",
                \"tokens\": [
                        {
                            \"token\": \"c++\",
                        \"start_offset\": 0,
                        \"end_offset\": 3,
                        \"type\": \"word\",
                        \"position\": 0,
                        \"bytes\": \"[63 2b 2b]\",
                        \"leftPOS\": \"NNG(General Noun)\",
                        \"morphemes\": null,
                        \"posType\": \"MORPHEME\",
                        \"positionLength\": 1,
                        \"reading\": null,
                        \"rightPOS\": \"NNG(General Noun)\",
                        \"termFrequency\": 1
                    }
                ]
            },
            {
                    \"name\": \"synonym_filter\",
                \"tokens\": [
                        {
                            \"token\": \"c++\",
                        \"start_offset\": 0,
                        \"end_offset\": 3,
                        \"type\": \"word\",
                        \"position\": 0,
                        \"bytes\": \"[63 2b 2b]\",
                        \"leftPOS\": \"NNG(General Noun)\",
                        \"morphemes\": null,
                        \"posType\": \"MORPHEME\",
                        \"positionLength\": 1,
                        \"reading\": null,
                        \"rightPOS\": \"NNG(General Noun)\",
                        \"termFrequency\": 1
                    }
                ]
            }
        ]
    }
}
GET /_analyze
{
      \"analyzer\": \"nori_analyzer\",
  \"text\": \"C++\",
  \"explain\": true
}
{
        \"detail\": {
            \"custom_analyzer\": true,
        \"charfilters\": [],
        \"tokenizer\": {
                \"name\": \"custom_nori_tokenizer\",
            \"tokens\": [
                    {
                        \"token\": \"C\",
                    \"start_offset\": 0,
                    \"end_offset\": 1,
                    \"type\": \"word\",
                    \"position\": 0,
                    \"bytes\": \"[43]\",
                    \"leftPOS\": \"SL(Foreign language)\",
                    \"morphemes\": null,
                    \"posType\": \"MORPHEME\",
                    \"positionLength\": 1,
                    \"reading\": null,
                    \"rightPOS\": \"SL(Foreign language)\",
                    \"termFrequency\": 1
                }
            ]
        },
        \"tokenfilters\": [
                {
                    \"name\": \"lowercase\",
                \"tokens\": [
                        {
                            \"token\": \"c\",
                        \"start_offset\": 0,
                        \"end_offset\": 1,
                        \"type\": \"word\",
                        \"position\": 0,
                        \"bytes\": \"[63]\",
                        \"leftPOS\": \"SL(Foreign language)\",
                        \"morphemes\": null,
                        \"posType\": \"MORPHEME\",
                        \"positionLength\": 1,
                        \"reading\": null,
                        \"rightPOS\": \"SL(Foreign language)\",
                        \"termFrequency\": 1
                    }
                ]
            },
            {
                    \"name\": \"stop_filter\",
                \"tokens\": [
                        {
                            \"token\": \"c\",
                        \"start_offset\": 0,
                        \"end_offset\": 1,
                        \"type\": \"word\",
                        \"position\": 0,
                        \"bytes\": \"[63]\",
                        \"leftPOS\": \"SL(Foreign language)\",
                        \"morphemes\": null,
                        \"posType\": \"MORPHEME\",
                        \"positionLength\": 1,
                        \"reading\": null,
                        \"rightPOS\": \"SL(Foreign language)\",
                        \"termFrequency\": 1
                    }
                ]
            },
            {
                    \"name\": \"nori_filter\",
                \"tokens\": [
                        {
                            \"token\": \"c\",
                        \"start_offset\": 0,
                        \"end_offset\": 1,
                        \"type\": \"word\",
                        \"position\": 0,
                        \"bytes\": \"[63]\",
                        \"leftPOS\": \"SL(Foreign language)\",
                        \"morphemes\": null,
                        \"posType\": \"MORPHEME\",
                        \"positionLength\": 1,
                        \"reading\": null,
                        \"rightPOS\": \"SL(Foreign language)\",
                        \"termFrequency\": 1
                    }
                ]
            },
            {
                    \"name\": \"synonym_filter\",
                \"tokens\": [
                        {
                            \"token\": \"c\",
                        \"start_offset\": 0,
                        \"end_offset\": 1,
                        \"type\": \"word\",
                        \"position\": 0,
                        \"bytes\": \"[63]\",
                        \"leftPOS\": \"SL(Foreign language)\",
                        \"morphemes\": null,
                        \"posType\": \"MORPHEME\",
                        \"positionLength\": 1,
                        \"reading\": null,
                        \"rightPOS\": \"SL(Foreign language)\",
                        \"termFrequency\": 1
                    }
                ]
            }
        ]
    }
}

토큰화 과정에서 c++는 c++로 색인된 반면 C++는 c로 색인되었다

이렇게 되는 이유는 색인 과정이 토크나이저 -> 필터 순서이기 때문이다. 토크나이저는 C++와 c++가 일치하지 않으므로 특수문자를 제거해버리고, 그 이후에 C를 lowercase 필터가 c로 변환시키는 것이다. 그럼 사용자 사전을 대소문자를 구별해서 작성해야 할까?

Character Filter에 mapping 또는 pattern_replace 필터를 적용하여 입력 스트림 자체의 대문자 C를 소문자 c로 먼저 치환해 버리는 설정을 추가하면 된다.