Elastic Search - Nori!

한글 유니코드

좀더 상세한 한글처리를 하려면 한글 유니코드에 대한 이해가 필요하다.

유니코드는 전세계 문자를 모두 표현하기 위한 코드로, 한글은 유니코드의 기본 영역인 0x0000 ~ 0xFFFF 내부에 정의돼 있다.

기본영역안에 한글, 한자, 가타카나, 로마자 등이 포함되어 있다.

주로 사용하는 한글 유니코드는 Hangul Syllables, Hangul Compatibility 이다.

유니코드 목록의 예시는 아래 그림을 참고

es1

초성 중성 종성

먼저 한글의 한글 음절 이 어떻게 자모 구성되는지 사전지식이 필요하다.

한글음절은 자모의 결합으로 글자를 표현하고 조합된 자모는 [초성, 중성, 종성] 으로 구분된다.

초성: 처음 소리인 자음, 에서
중성: 중간 소리인 모음, 에서
종성: 끝 소리인 자음, 에서

초성, 중성, 종성을 조합해서 한글 음절 한 글자를 만들수 있다.
이중 한글 자음과 모음을 표현하는 유니코드는 아래와 같다.

public static final char[] CHO_SUNG = { // 19개
  'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ',
  'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ',
  'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ',
  'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
};
public static final char[] JUNG_SUNG = { // 21개
  'ㅏ', 'ㅐ', 'ㅑ', 'ㅒ', 'ㅓ',
  'ㅔ', 'ㅕ', 'ㅖ', 'ㅗ', 'ㅘ',
  'ㅙ', 'ㅚ', 'ㅛ', 'ㅜ', 'ㅝ',
  'ㅞ', 'ㅟ', 'ㅠ', 'ㅡ', 'ㅢ',
  'ㅣ'
};
public static final char[] JONG_SUNG = { // 28개
  ' ', 'ㄱ', 'ㄲ', 'ㄳ', 'ㄴ',
  'ㄵ', 'ㄶ', 'ㄷ', 'ㄹ', 'ㄺ',
  'ㄻ', 'ㄼ', 'ㄽ', 'ㄾ', 'ㄿ',
  'ㅀ', 'ㅁ', 'ㅂ', 'ㅄ', 'ㅅ',
  'ㅆ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ',
  'ㅌ', 'ㅍ', 'ㅎ'
};

한글음절 구성에 규칙을 알고있고 [초성, 중성, 종성] 개수를 사전에 알고 있기 때문에 한글음절 한글자에 대한 규칙을 아래와 같이 정할 수 있다.

(초성순서 X 21 X 28) + (중성순서 X 28) + 종성순서 + 0xAC00

예로 이란 글자의 [초성, 중성, 종성] 의 인덱스는 아래와 같은데

  • 초성 인덱스: 0
  • 중성 인덱스: 8
  • 종성 인덱스: 16

인덱스를 규칙에 넣어 계산해보면 아래와 같은 유니코드가 나오는것을 알 수 있다.

0 X 21 X 28 + 8 X 28 + 16 + 0xAC00
  = 240 + 44032 
  = 44272
  = 0xACF0

반대로 한글음절 한문자를 [초성, 중성, 종성] 으로 분리하려면 아래 연산을 수행하면 된다.

초성 인덱스 = 유니코드 / (21 X 28)
중성 인덱스 = (유니코드 % (21 X 28)) / 28
종성 인덱스 = (유니코드 % (21 X 28)) % 28

한글 유니코드의 특징을 사용하여 자모를 음절로, 음절을 자모로 변경가능하다.

노리 (nori)

https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-nori.html

한글은 조사, 접미사, 명사, 동사 등이 결합되어 복합적을 단어를 구성한다.
이런 복합적인 단어를 복합명사라 하며 형태소 분석을 하려면 복합명사를 분리할 수 있어야 한다.
이를 위해 사용자 사전 을 만들어 복합명사 분리에 사용한다.

한글 형태소 분석기의 경우 아리랑, 은전한닢, Nori 등이 있는데
Lucene 에서 공식적으로 지원되고 기본 플러그인으로 포함되어 있는건 Nori 가 유일하다.

elasticsearch-plugin install analysis-nori

-> Downloading analysis-nori from elastic
[=================================================] 100%??
-> Installed analysis-nori

Nori 는 아래와 같이 구성되어 있다.

  • nori_tokenizer 토크나이저
  • nori_path_of_speech 토큰필터
  • nori_readingform 토큰필터

nori_tokenizer 토크나이저

복합명사를 형태소로 나누기 위해 decompound_mode, user_dictionary 두가지 파라미터를 제공한다.

PUT nori_analyzer
{
  "settings": {
    "index": {
      "analysis": {
        "tokenizer": {
          "nori_user_dict_tokenizer": {
            "type": "nori_tokenizer",
            "decompound_mode": "mixed",
            "user_dictionary": "userdict_ko.txt"
          }
        },
        "analyzer": {
          "nori_token_analyzer": {
            "type": "custom",
            "tokenizer": "nori_user_dict_tokenizer"
          }
        }
      }
    }
  }
}

user_dictionary

사용자 커스텀한 복합명사를 지정하고 싶다면 user_dictionary 를 지정하면 된다.
userdict_ko.txt 는 Elastic Search 에서 사용하는 config 디렉토리에 아래와 같이 저장

// userdict_ko.txt
삼성전자 
삼성전자 삼성 전자

user_dictionary 를 사용해 사용자 사전 을 지정하지 않더라도 기본적으로 세종말뭉치, mecab-ko-dic 를 사용해 토큰화를 지원한다.

decompound_mode

복합명사를 토크나이저가 처리하는 방식을 결정하며 아래 3 가지 방식이 있음.

  • none: 별도의 토큰화 하지 않음
  • discard: 복합명사로 구분, 원본데이터 삭제, [잠실역] → [잠실, 역]
  • mixed: 복합명사로 구분, 원본데이터 유지, [잠실역] → [잠실, 역, 잠실역]
POST nori_analyzer/_analyze
{
  "analyzer": "nori_token_analyzer",
  "text": "잠실역"
}

// result
{
  "tokens": [
    {
      "token": "잠실역",
      "start_offset": 0,
      "end_offset": 3,
      "type": "word",
      "position": 0,
      "positionLength": 2
    },
    {
      "token": "잠실",
      "start_offset": 0,
      "end_offset": 2,
      "type": "word",
      "position": 0
    },
    {
      "token": "역",
      "start_offset": 2,
      "end_offset": 3,
      "type": "word",
      "position": 1
    }
  ]
}

nori_part_of_speech 토큰 필터

한글 불용어(stoptags)를 제거하는 토큰 필터

PUT nori_analyzer/_settings
{
  "index": {
    "analysis": {
      "analyzer": {
        "nori_stoptags_analyzer": {
          "tokenizer": "nori_tokenizer",
          "filter": [
            "nori_posfilter"
          ]
        }
      },
      "filter": {
        "nori_posfilter": {
          "type": "nori_part_of_speech",
          "stoptags": [
            "E", "IC", "J", "MAG", "MAJ", "MM", "NA", "NR", 
            "SC", "SE", "SF", "SH", "SL", "SN", "SP", "SSC", 
            "SSO", "SY", "UNA", "UNKNOWN", "VA", "VCN", "VCP", 
            "VSV", "VV", "VX", "XPN", "XR", "XSA", "XSN", "XSV"
          ]
        }
      }
    }
  }
}

결과는 아래와 같다.

POST nori_analyzer/_analyze
{
  "analyzer": "nori_stoptags_analyzer",
  "text": "그대 이름은 장미"
}

// result
{
  "tokens": [
    {
      "token": "그대",
      "start_offset": 0,
      "end_offset": 2,
      "type": "word",
      "position": 0
    },
    {
      "token": "이름",
      "start_offset": 3,
      "end_offset": 5,
      "type": "word",
      "position": 1
    },
    {
      "token": "장미",
      "start_offset": 7,
      "end_offset": 9,
      "type": "word",
      "position": 3
    }
  ]
}
값(tag) 영문명 한글명 예시
E Verbal endings 어미 사랑/하(E)/다
IC Interjection 감탄사 와우(IC), 맙소사(IC)
J Ending Particle 조사 나/는(J)/너/에게(J)
MAG General Adverb 일반 부사 빨리(MAG)/달리다, 과연(MAG)/범인/은/누구/인가
MAJ Conjunctive adverb 접속 부사 그런데(MAJ), 그러나(MAJ)
MM (*) ES:Modifier(한정사), Lucene API:Determiner(관형사) 설명이 다름 맨(MM)/밥
NA Unknown 알 수 없음  
NNB Dependent noun (following nouns) 의존명사  
NNBC Dependent noun 의존명사(단위를 나타내는 명사)  
NNG General Noun 일반 명사 강아지(NNG)
NNP Proper Noun 고유 명사 비숑(NNP)
NP Pronoun 대명사 그것(NP), 이거(NP)
NR Numeral 수사 하나(NR)/밖에, 칠(NR)/더하기/삼(NR)
SC(*) Separator (· : /) 구분자 nori_tokenizer가 특수문자 제거
SE(*) Ellipsis 줄임표(…) nori_tokenizer가 특수문자 제거
SF(*) Terminal punctuation (? ! .) 물음표, 느낌표, 마침표 nori_tokenizer가 특수문자 제거
SH Chinese character 한자 中國(SH)
SL Foreign language 외국어 hello(SL)
SN Number 숫자 1(SN)
SP Space 공백  
SSC(*) Closing brackets 닫는 괄호 ),] nori_tokenizer가 특수문자 제거
SSO(*) Opening brackets 여는 괄호 (,[ nori_tokenizer가 특수문자 제거
SY Other symbol 심벌  
UNA Unknown 알 수 없음  
UNKNOWN Unknown 알 수 없음  
VA Adjective 형용사 하얀(VA)/눈
VCN Negative designator 부정 지정사(서술격조사) 사람/이/아니(VCN)/다
VCP Positive designator 긍정 지정사(서술격조사) 사람/이(VCN)/다
VSV Unknown 알 수 없음  
VV Verb 동사 움직이(VV)/다,먹(VV)/다
VX Auxiliary Verb or Adjective 보조 용언 가지/고/싶(VX)/다, 먹/어/보(VX)/다
XPN(*) Prefix 접두사(체언 접두사?) ES에서 매핑되는 단어를 찾지 못함
XR(*) Root 어근 ES에서 매핑되는 단어를 찾기 못함
XSA Adjective Suffix 형용사 파생 접미사 멋/스럽(XSA)/다
XSN(*) Noun Suffix 명사 파생 접미사 ES에서 매핑되는 단어를 찾기 못함
XSV(*) Verb Suffix 동사 파생 접미사 ES에서 매핑되는 단어를 찾기 못함

출처: https://smin1620.tistory.com/285

nori_readingform 토큰 필터

문서에 존재하는 한자를 한글로 변경하는 필터

PUT nori_readingform
{
  "settings": {
    "index": {
      "analysis": {
        "analyzer": {
          "nori_readingform_analyzer": {
            "tokenizer": "nori_tokenizer",
            "filter": [ "nori_readingform" ]
          }
        }
      }
    }
  }
}
POST nori_readingform/_analyze
{
  "analyzer": "nori_readingform_analyzer",
  "text": "中國"
}

// result
{
  "tokens": [
    {
      "token": "중국",
      "start_offset": 0,
      "end_offset": 2,
      "type": "word",
      "position": 0
    }
  ]
}

Term Suggester API

오타수정을 위한 Term Suggester API, 자동완성을 위한 Completion Suggester API 서비스를 구성할 수 있다.

맞춤법 오타의 경우 아래와 같이 자모로 분리하고

  • 샴성전자의 경우 ㅅㅑㅁㅅㅓㅇㅈㅓㄴㅈㅏ
  • 삼성전자의 경우 ㅅㅏㅁㅅㅓㅇㅈㅓㄴㅈㅏ

두 한글의 자모로 분리된 문자열의 편집거리를 측정하여 오타수정이 가능하다.

그리고 한글 검색에서 자주 실수 하는것이 한영키 오타이다.

  • 삼성전자 를 영문으로 입력할경우 tkatjdwjswk
  • samsung 을 한글로 입력할 경우 ㄴ므녀ㅜㅎ

영문과 한문을 잘못입력했을 때 이를 교정하려면 자모와 영문을 키보드 위치에 맞게 변환할 수 있어야 한다.

아래와 같이 영문-자모 매핑 배열을 정의해두면 한영키 오타교정이 가능하다.

// 키보드상에서 한영키에 의해서 오타 교정이 필요한 키배열 (영문키 33자)
public static final String[] KEYBOARD_KEY_ENG = {
  "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", 
  "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", 
  "u", "v", "w", "x", "y", "z", "Q", "W", "E", "R", 
  "T", "O", "P"
};

// 키보드상에서 한영키에 의해서 오타 교정이 필요한 키배열 (한글키 33자)
public static final String[] KEYBOARD_KEY_KOR  = {
  "ㅁ", "ㅠ", "ㅊ", "ㅇ", "ㄷ", "ㄹ", "ㅎ", "ㅗ", "ㅑ", "ㅓ", 
  "ㅏ", "ㅣ", "ㅡ", "ㅜ", "ㅐ", "ㅔ", "ㅂ", "ㄱ", "ㄴ", "ㅅ", 
  "ㅕ", "ㅍ", "ㅈ", "ㅌ", "ㅛ", "ㅋ", "ㅃ", "ㅉ", "ㄸ", "ㄲ", 
  "ㅆ", "ㅒ", "ㅖ"
};

Completion Suggester API

completion 옵션을 사용할경우 prefix 기준으로 문서를 찾기 때문에 한글과는 어울리지 않는다.
한글의 경우 복합명사가 많아 전방일치 자동완성 외의 기능은 구현하기 힘들다.

만약 전방일치 자동완성만 구현해도 된다하면 기존 영문 Completion Suggester API 방식을 사용하면 된다.

예로 아래와 같이 인덱스를 정의, 문서 3개 추가, prefix신혼을 지정하면
문서 3개 모두 신혼 문자열로 시작하기 때문에 3개 문서가 suggest 로 출력된다.

PUT /ac_test
{
  "settings": {
    "index": {
      "number_of_shards": 5,
      "number_of_replicas": 1
    }
  },
  "mappings": {
    "_doc": {
      "properties": {
        "itemSrc": { "type": "keyword" },
        "itemCompletion": { "type": "completion" }
      }
    }
  }
}

POST /ac_test/_doc/1
{ "itemSrc": "신혼", "itemCompletion": "신혼" }
POST /ac_test/_doc/2
{ "itemSrc": "신혼가전", "itemCompletion": "신혼가전" }
POST /ac_test/_doc/3
{ "itemSrc": "신혼가전특별저", "itemCompletion": "신혼가전특별전" }

GET /ac_test/_search
{
  "suggest": {
    "ac_test_completion": {
      "prefix": "신혼",
      "completion": {
        "field": "itemCompletion",
        "size": 10
      }
    }
  }
}

completion 기능은 메모리에 로드되어 빠르지만, 한글의 경우 부분일치 자동완성 을 구현하기 위해 속도에서 일부 손해를 보더라도 Lucene에서 제공하는 검색쿼리를 많이 사용한다.

분석기에서 배웠던 토크나이저(ngram, edge_ngram) 와 토큰필터(edge_ngram back)를 사용하면 왠만한 글자는 모두 자동완성처리할 수 있다.

아래와 같이 ngram, edge_ngram, edge_ngram back 을 지원하는 토크나이저와, 토큰필터를 정의하고 3가지 분석기를 생성.

// 인덱스 생성
PUT /ac_test2
{
  "settings": {
    "index": {
      "number_of_shards": 5,
      "number_of_replicas": 1
    },
    "analysis": {
      "analyzer": {
        "ngram_analyzer": { "tokenizer": "ngram_tokenizer" },
        "edge_ngram_analyzer": { "tokenizer": "edge_ngram_tokenizer" },
        "edge_ngram_analyzer_back": {
          "tokenizer": "edge_ngram_tokenizer",
          "filter": [ "edge_ngram_filter_back" ]
        }
      },
      "tokenizer": {
        "ngram_tokenizer": {
          "type": "ngram", "min_gram": "1", "max_gram": "50",
          "token_chars": [ "letter" ]
        },
        "edge_ngram_tokenizer": {
          "type": "edge_ngram", "min_gram": "1", "max_gram": "50",
          "token_chars": [ "letter" ]
        }
      },
      "filter": {
        "edge_ngram_filter_back": {
          "type": "edge_ngram", "min_gram": "1", "max_gram": "50",
          "side": "back"
        }
      }
    }
  },
  "mappings": {
    "_doc": {
      "properties": {
        "item": {
          "type": "keyword",
          "boost": 30
        },
        "itemNgram": {
          "type": "text",
          "analyzer": "ngram_analyzer",
          "search_analyzer": "ngram_analyzer",
          "boost": 3
        },
        "itemNgramEdge": {
          "type": "text",
          "analyzer": "edge_ngram_analyzer",
          "search_analyzer": "ngram_analyzer",
          "boost": 2
        },
        "itemNgramEdgeBack": {
          "type": "text",
          "analyzer": "edge_ngram_analyzer_back",
          "search_analyzer": "ngram_analyzer",
          "boost": 1
        }
      }
    }
  }
}

// 문서 삽입
POST /ac_test2/_doc/1
{
  "item": "신혼",
  "itemNgram": "신혼",
  "itemNgramEdge": "신혼",
  "itemNgramEdgeBack": "신혼"
}
POST /ac_test2/_doc/2
{
  "item": "신혼가전",
  "itemNgram": "신혼가전",
  "itemNgramEdge": "신혼가전",
  "itemNgramEdgeBack": "신혼가전"
}
POST /ac_test2/_doc/3
{
  "item": "신혼가전특별전",
  "itemNgram": "신혼가전특별전",
  "itemNgramEdge": "신혼가전특별전",
  "itemNgramEdgeBack": "신혼가전특별전"
}

토크나이저와 토큰필터로 문자 하나하나씩 ngram 방식으로 조립해두었기 때문에 왠만한 문자열에 대해 검색 가능하다.

GET /ac_test2/_search
{
  "query": {
    "bool": {
      "should": [
        { "term": { "item": "신혼" } },
        { "term": { "itemNgram": "신혼" } },
        { "term": { "itemNgramEdge": "신혼" } },
        { "term": { "itemNgramEdgeBack": "신혼" } }
      ],
      "minimum_should_match": 1
    }
  }
}