본문으로 바로가기

노리(Nori) 형태소 분석기

  • 노리는 Elasticsearch 6.6 버전 부터 공식적으로 개발해서 지원하기 시작했다.

  • 노리는 루씬에 있는 일본어 형태소 분석기 Kuromoji 를 재활용한 것이다.

  • MeCab 사전을 활용할 수 있다.

 

6.7.2 노리 (nori) 한글 형태소 분석기

이 문서의 허가되지 않은 무단 복제나 배포 및 출판을 금지합니다. 본 문서의 내용 및 도표 등을 인용하고자 하는 경우 출처를 명시하고 김종민(kimjmin@gmail.com)에게 사용 내용을 알려주시기 바랍�

esbook.kimjmin.net

 

설치

 

환경은 다음과 같습니다.

 

Ubuntu 18.04.4 LTS

Elasticsearch 7.8.0

 

다음 명령어로 플러그인을 설치합니다.

sudo /usr/share/elasticsearch/bin/elasticsearch-plugin install analysis-nori
-> Installing analysis-nori
-> Downloading analysis-nori from elastic
[=================================================] 100%   
-> Installed analysis-nori

 

플러그인이 설치되었는지 확인합니다.

sudo /usr/share/elasticsearch/bin/elasticsearch-plugin list
analysis-nori

 

elasticsearch 재시작

sudo systemctl restart elasticsearch

 

 

인덱스 설정

 

파이썬 코드를 통해 형태소 분석기를 지정해서 index 를 생성합니다.

 

decompound_mode: mixed 는 복합명사를 분리하고 기존 형태도 보존합니다. (다다익선)

 

setting.json

{
  "settings": {
    "analysis": {
      "analyzer": {
        "content": {
          "type": "custom",
          "tokenizer": "nori_tokenizer",
          "decompound_mode": "mixed"
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "content"
      },
      "cont": {
        "type": "text",
        "analyzer": "content"
      }
    }
  }
}

 

import pprint
import json
from elasticsearch import Elasticsearch

def create_index(body=None):
    if not es.indices.exists(index=index):
        return es.indices.create(index=index, body=body)

def insert(body):
    return es.index(index=index, doc_type=doc_type, body=body)

if __name__ == '__main__':
    url = '192.168.111.176'
    port = '9200'
    index = 'news'
    doc_type = 'daum'

    es = Elasticsearch(f'{url}:{port}')

    with open('setting.json', 'r', encoding='utf-8') as f:
        setting = json.load(f)

    create_index(setting)
    
    pprint.pprint(es.indices.get_settings(index))
{'news': {'settings': {'index': {'analysis': {'analyzer': {'content': {'tokenizer': 'nori_tokenizer',
                                                                       'type': 'custom'}}},
                                 'creation_date': '1597334588569',
                                 'number_of_replicas': '1',
                                 'number_of_shards': '1',
                                 'provided_name': 'news',
                                 'uuid': '1wKVrq5nSImKkNk3closlQ',
                                 'version': {'created': '7080099'}}}}}

 

analyze() 함수를 통해 지정한 노리 형태소 분석기로 토크나이징을 테스트할 수 있습니다.

결과는 다음과 같습니다. (index 에 저장되어있는 데이터 아님)

body = {
        'analyzer': 'content',
        'text': '매일 비가 오네요.'
    }
r = es.indices.analyze(index=index, body=body)
pprint.pprint(r)
{'tokens': [{'end_offset': 2,
             'position': 0,
             'start_offset': 0,
             'token': '매일',
             'type': 'word'},
            {'end_offset': 4,
             'position': 1,
             'start_offset': 3,
             'token': '비',
             'type': 'word'},
            {'end_offset': 5,
             'position': 2,
             'start_offset': 4,
             'token': '가',
             'type': 'word'},
            {'end_offset': 7,
             'position': 3,
             'start_offset': 6,
             'token': '오',
             'type': 'word'},
            {'end_offset': 9,
             'position': 4,
             'start_offset': 7,
             'token': '네요',
             'type': 'word'}]}

 

 

테스트

 

노리 형태소 분석기의 사용 전후를 테스트 해보겠습니다.

 

데이터

[
    {
        "id": 1,
        "title": "큰 비 온다는 경보에도 수영등산 처벌은?",
        "cont": "집중호우와 산사태 경보가 내려졌는데도, 입산이 통제된 산에 올라가거나 바다에서 수영을 즐기던 동호회원들이 적발되거나 구조됐습니다."
    },
    {
        "id": 2,
        "title": "'다주택 매물 쏟아질 것'..서울 36만 호 공급 어디에?",
        "cont": "정부는 집을 여러 채 가지고 있던 법인과 임대사업자들이 시장에 집을 내놓을 것이라면서 그러면 집값이 차츰 안정될 것이라고 강조했습니다. 그러면서 수도권 주택공급 세부 계획도 다시 정리해서 발표했습니다.        자세한 내용은 전형우 기자가 설명해드리겠습니다."
    },
    {
        "id": 3,
        "title": "여성 납치 후 경찰과 흉기 대치..남양주 대낮 인질극",
        "cont": "오늘(13일) 경기도 남양주에선 대낮에 인질극이 벌어졌습니다. 용의자가 여성을 인질로 잡고 경찰과 대치하는 영상입니다. 금품을 뜯어내려고 이렇게 납치를 했습니다. 용의자는 체포됐고 피해자도 무사한 것으로 확인됐습니다."
    }
]

 

index 생성하는 부분만 스왑해서 테스트합니다.

import pprint
import json
from elasticsearch import Elasticsearch

def create_index(body=None):
    if not es.indices.exists(index=index):
        if body is None:
            r = es.indices.create(index=index)
        else:
            r = es.indices.create(index=index, body=body)
        return r

def delete_index():
    if es.indices.exists(index=index):
        return es.indices.delete(index=index)

def insert(body):
    return es.index(index=index, body=body)

def search(keyword=None):
    body = {
        "query": {
            "multi_match": {
                "query": keyword,
                "fields": ['title', 'content']
            }
        }
    }
    res = es.search(index=index, body=body)
    return res

if __name__ == '__main__':
    url = '192.168.111.176'
    port = '9200'
    index = 'news'
    doc_type = 'daum-news'

    es = Elasticsearch(f'{url}:{port}')
    
    with open('index.json', 'r', encoding='utf-8') as f:
        setting = json.load(f)
        
    delete_index()    
    create_index()
    # create_index(setting)

    with open('data.json', 'r', encoding='utf-8') as f:
        json_data = json.load(f)
    
    for d in json_data:
        insert(d)

    r = search('수영')
    pprint.pprint(r)
    # if r:
    #     for doc in r['hits']['hits']:
    #         for k, v in doc['_source'].items():
    #             print(k, v)

 

"수영" 이라는 키워드를 검색해보았습니다.

r = search('수영')

 

노리 형태소 분석기 사용 전의 결과

create_index()
{'_shards': {'failed': 0, 'skipped': 0, 'successful': 1, 'total': 1},
 'hits': {'hits': [],
          'max_score': None,
          'total': {'relation': 'eq', 'value': 0}},
 'timed_out': False,
 'took': 1}

 

노리 형태소 분석기를 사용 후의 결과

"수영등산" 이라는 복합명사를 분리해서 추출이 되었네요.

create_index(setting)
{'_shards': {'failed': 0, 'skipped': 0, 'successful': 1, 'total': 1},
 'hits': {'hits': [{'_id': '0ALV6HMBluDb6jxfa4ju',
                    '_index': 'news',
                    '_score': 1.1569381,
                    '_source': {'cont': '집중호우와 산사태 경보가 내려졌는데도, 입산이 통제된 산에 '
                                        '올라가거나 바다에서 수영을 즐기던 동호회원들이 적발되거나 '
                                        '구조됐습니다.',
                                'id': 1,
                                'title': '큰 비 온다는 경보에도 수영등산 처벌은?'},
                    '_type': '_doc'}],
          'max_score': 1.1569381,
          'total': {'relation': 'eq', 'value': 1}},
 'timed_out': False,
 'took': 2}

 

분석기를 통해 토크나이징 결과를 확인해보겠습니다.

body = {
        'analyzer': 'content',
        'text': '큰 비 온다는 경보에도 수영등산 처벌은?'
    }
r = es.indices.analyze(index=index, body=body)
pprint.pprint(r)
{'tokens': [{'end_offset': 1,
             'position': 0,
             'start_offset': 0,
             'token': '크',
             'type': 'word'},
            {'end_offset': 1,
             'position': 1,
             'start_offset': 0,
             'token': 'ᆫ',
             'type': 'word'},
            {'end_offset': 3,
             'position': 2,
             'start_offset': 2,
             'token': '비',
             'type': 'word'},
            {'end_offset': 7,
             'position': 3,
             'start_offset': 4,
             'token': '오',
             'type': 'word'},
            {'end_offset': 7,
             'position': 4,
             'start_offset': 4,
             'token': 'ᆫ다는',
             'type': 'word'},
            {'end_offset': 10,
             'position': 5,
             'start_offset': 8,
             'token': '경보',
             'type': 'word'},
            {'end_offset': 11,
             'position': 6,
             'start_offset': 10,
             'token': '에',
             'type': 'word'},
            {'end_offset': 12,
             'position': 7,
             'start_offset': 11,
             'token': '도',
             'type': 'word'},
            {'end_offset': 15,
             'position': 8,
             'start_offset': 13,
             'token': '수영',
             'type': 'word'},
            {'end_offset': 17,
             'position': 9,
             'start_offset': 15,
             'token': '등산',
             'type': 'word'},
            {'end_offset': 20,
             'position': 10,
             'start_offset': 18,
             'token': '처벌',
             'type': 'word'},
            {'end_offset': 21,
             'position': 11,
             'start_offset': 20,
             'token': '은',
             'type': 'word'}]}