DATABASE - ELASTIC SEARCH - 데이터 색인과 텍스트 분석

류정현·2024년 10월 2일

풀 텍스트 검색을 하기 위해서는 데이터를 검색에 맞게 가공하는 작업을 필요로 하는데 Elasticsearch는 데이터를 저장하는 과정에서 이 작업을 처리합니다. 이번 장에서는 Elasticsearch가 검색을 위해 텍스트 데이터를 어떻게 처리하고 데이터를 색인 할 때 Elasticsearch에서 어떤 과정이 이루어지는지에 대해 살펴보겠습니다.

역 인덱스 - Inverted Index

데이터 시스템에 다음과 같은 문서들을 저장한다고 가정 해 보겠습니다.

일반적으로 오라클이나 MySQL 같은 관계형 DB에서는 위 내용을 보이는 대로 테이블 구조로 저장을 합니다. 만약에 위 테이블에서 Text 에 fox가 포함된 행들을 가져온다고 하면 다음과 같이 Text 열을 한 줄씩 찾아 내려가면서 fox가 있으면 가져오고 없으면 넘어가는 식으로 데이터를 가져 올 것입니다.

테이블 데이터에서 한 줄씩 like 검색

전통적인 RDBMS 에서는 위와 같이 like 검색을 사용하기 때문에 데이터가 늘어날수록 검색해야 할 대상이 늘어나 시간도 오래 걸리고, row 안의 내용을 모두 읽어야 하기 때문에 기본적으로 속도가 느립니다. Elasticsearch는 데이터를 저장할 때 다음과 같이 역 인덱스(inverted index)라는 구조를 만들어 저장합니다.

역 인덱스(Inverted Index) 구조

이 역 인덱스는 책의 맨 뒤에 있는 주요 키워드에 대한 내용이 몇 페이지에 있는지 볼 수 있는 찾아보기 페이지에 비유할 수 있습니다. Elasticsearch에서는 추출된 각 키워드를 텀(term) 이라고 부릅니다. 이렇게 역 인덱스가 있으면 fox를 포함하고 있는 도큐먼트들의 id를 바로 얻어올 수 있습니다.

Elasticsearch는 데이터가 늘어나도 찾아가야 할 행이 늘어나는 것이 아니라 역 인덱스가 가리키는 id의 배열값이 추가되는 것 뿐이기 때문에 큰 속도의 저하 없이 빠른 속도로 검색이 가능합니다. 이런 역 인덱스를 데이터가 저장되는 과정에서 만들기 때문에 Elasticsearch는 데이터를 입력할 때 저장이 아닌 색인을 한다고 표현합니다.

텍스트 분석 - Text Analysis

Elasticsearch에 저장되는 도큐먼트는 모든 문자열(text) 필드 별로 역 인덱스를 생성합니다. 검색에 사용하는 경우에는 앞에서 설명한 역 인덱스의 예제는 실제로는 보통 아래와 같이 저장됩니다.

실제로 역 인덱스에 저장된 텀

Elasticsearch는 문자열 필드가 저장될 때 데이터에서 검색어 토큰을 저장하기 위해 여러 단계의 처리 과정을 거칩니다. 이 전체 과정을 텍스트 분석(Text Analysis) 이라고 하고 이 과정을 처리하는 기능을 애널라이저(Analyzer) 라고 합니다. Elasticsearch의 애널라이저는 0~3개의 캐릭터 필터(Character Filter)와 1개의 토크나이저(Tokenizer), 그리고 0~n개의 토큰 필터(Token Filter)로 이루어집니다.

애널라이저 구성 : 캐릭터 필터 - 토크나이저 - 토큰필터

텍스트 데이터가 입력되면 가장 먼저 필요에 따라 전체 문장에서 특정 문자를 대치하거나 제거하는데 이 과정을 담당하는 기능이 캐릭터 필터입니다. 뒤에서 자세히 설명하고 지금 설명 할 예제에서는 적용하지 않겠습니다.

다음으로는 문장에 속한 단어들을 텀 단위로 하나씩 분리 해 내는 처리 과정을 거치는데 이 과정을 담당하는 기능이 토크나이저 입니다. 토크나이저는 반드시 1개만 적용이 가능합니다. 다음은 whitespace 토크나이저를 이용해서 공백을 기준으로 텀 들을 분리 한 결과입니다.

whitespace 토크나이저 적용

다음으로 분리된 텀 들을 하나씩 가공하는 과정을 거치는데 이 과정을 담당하는 기능이 토큰 필터 입니다. 토큰 필터는 0개 부터 여러 개를 적용할 수 있습니다.

여기서는 먼저 lowercase 토큰 필터를 이용해서 대문자를 모두 소문자로 바꿔줍니다. 이렇게 하면 대소문자 구별 없이 검색이 가능하게 됩니다. 대소문자가 일치하게 되어 같은 텀이 된 토큰들은 모두 하나로 병합이 됩니다.


소문자로 변경 후 같은 텀 병합

이제 역 인덱스는 아래와 같이 변경됩니다.

병합이 완료 된 텀

텀 중에는 검색어로서의 가치가 없는 단어들이 있는데 이런 단어를 불용어(stopword) 라고 합니다. 보통 a, an, are, at, be, but, by, do, for, i, no, the, to … 등의 단어들은 불용어로 간주되어 검색어 토큰에서 제외됩니다. stop토큰 필터를 적용하면 우리가 만드는 역 인덱스에서 the가 제거됩니다.

불용어 the 가 제거된 텀

이제 형태소 분석 과정을 거쳐서 문법상 변형된 단어를 일반적으로 검색에 쓰이는 기본 형태로 변환하여 검색이 가능하게 합니다. 영어에서는 형태소 분석을 위해 snowball 토큰 필터를 주로 사용하는데 이 필터는 ~s, ~ing 등을 제거합니다. 그리고 happy, lazy 와 같은 단어들은 happiness, laziness와 같은 형태로도 사용되기 때문에 ~y~i 로 변경합니다. snowball 토큰 필터를 적용하고 나면 jumpsjumping은 모두 jump로 변경되고, 동일하게 jump 로 되었기 때문에 하나의 텀으로 병합됩니다.

snowball 형태소 분석 적용 후 텀 병합

필요에 따라서는 동의어를 추가 해 주기도 합니다. synonym 토큰 필터를 사용하여 quick 텀에 동의어로 fast를 지정하면 fast 로 검색했을 때도 같은 의미인 quick 을 포함하는 도큐먼트가 검색되도록 할 수 있습니다. AWS 와 Amazon 을 동의어로 놓아 amazon을 검색해도 AWS 를 찾을 수 있게 하는 등 실제로도 사용되는 사례가 많습니다.

애널라이저 - Analyzer

Elasticsearch 에는 애널라이저를 조합하고 그 동작을 자세히 확인할 수 있는 API 들이 있습니다. 계속해서 애널라이저와 관련된 기능들에 대해 살펴보겠습니다.

_analyze API

Elasticsearch 에서는 분석된 문장을 _analyze API를 이용해서 확인할 수 있습니다. 토크나이저는 tokenizer, 토큰 필터는 filter 항목의 값으로 입력하면 됩니다. 토크나이저는 하나만 적용되기 때문에 바로 입력하고, 토큰필터는 여러개를 적용할 수 있기 때문에 [ ] 안에 배열 형식으로 입력합니다. "The quick brown fox jumps over the lazy dog" 문장을 whitespace 토크나이저와 lowercase, stop, snowball 토큰 필터를 적용하면 다음과 같은 결과를 확인할 수 있습니다.

_analyzer API 를 이용해서 텍스트 분석

GET _analyze
{
  "text": "The quick brown fox jumps over the lazy dog",
  "tokenizer": "whitespace",
  "filter": [
    "lowercase",
    "stop",
    "snowball"
  ]
}

토크나이저, 토큰필터를 이용해 처리된 "token" : "jump", "token" : "lazi" 같은 결과들을 확인할 수 있습니다.

여러 토큰 필터를 입력 할 때는 순서가 중요하며 만약에 stop 토큰 필터를 lowercase 보다 먼저 놓게 되면 stop 토큰필터 처리시 대문자로 시작하는 "The"는 불용어로 간주되지 않아 그냥 남아있게 됩니다. 그 후에 lowercase가 적용되어 소문자 "the"가 최종 검색 텀으로 역 색인에 남아있게 됩니다.

토크나이저 stop 을 lowercase 보다 먼저 처리

GET _analyze
{
  "text": "The quick brown fox jumps over the lazy dog",
  "tokenizer": "whitespace",
  "filter": [
    "stop",
    "lowercase",
    "snowball"
  ]
}

애널라이저는 _analyze API에서 analyzer 항목으로 적용해서 사용이 가능합니다. 애널라이저는 캐릭터 필터, 토크나이저 그리고 토큰 필터들을 조합해서 사용자 정의 애널라이저를 만들 수도 있고, Elasticsearch 에 사전에 정의되어 있어 바로 사용 가능 한 애널라이저들도 있습니다. 앞서 실행한 whitespace 토크나이저 그리고 lowercase, stop, snowball 토큰필터들을 조합한 것 것이
snowball 애널라이저 입니다. 다음은 snowball 애널라이저를 적용해서 "The quick brown fox jumps over the lazy dog" 문장을 분석한 예제입니다.

snowball 애널라이저로 문장 분석

GET _analyze
{
  "text": "The quick brown fox jumps over the lazy dog",
  "analyzer": "snowball"
}

snowball 애널라이저를 사용한 결과는 앞의 whitespace 토크나이저 그리고 lowercase, stop, snowball 토큰필터를 사용한 결과와 동일하게 나타납니다.

인덱스의 매핑(mappings) 설정에 snowball 애널라이저를 적용하고 "The quick brown fox jumps over the lazy dog" 값을 색인하면 fox, jump, lazi 등의 단어가 검색 텀으로 저장됩니다. match 쿼리로 검색을 수행하면 입력한 검색어도 앞에서 적용한 snowball 애널라이저를 똑같이 거치게 됩니다. jumps 또는 jumping 등으로 검색을 수행하면 lowercase, snowball토큰 필터 등이 적용되어 검색어를 jump로 바꾸어 검색합니다.

인덱스에 애널라이저는 아래 예제와 같이 지정합니다. 매핑에 대해서는 다음 장에서 더 자세히 설명하겠습니다.

my_index2 인덱스의 message 필드에 snowball 애널라이저 적용

PUT my_index2
{
  "mappings": {
    "properties": {
      "message": {
        "type": "text",
        "analyzer": "snowball"
      }
    }
  }
}

6.x 이전 버전의 매핑에서는 "mappings" |"properties" 사이에 도큐먼트 타입 값이 들어갑니다.

위에서 생성한 my_index2 인덱스에 "message": "The quick brown fox jumps over the lazy dog" 값을 넣고 jumping 으로 검색을 해 보도록 하겠습니다.
my_index2 에 jumps 를 포함하는 도큐먼트 입력

PUT my_index2/_doc/1
{
  "message": "The quick brown fox jumps over the lazy dog"
}

match 쿼리로 jump, jumping 또는 jumps 중 어떤 값으로 검색 해도 결과가 나타납니다.

my_index2 에서 match 쿼리로 jumping 검색

GET my_index2/_search
{
  "query": {
    "match": {
      "message": "jumping"
    }
  }
}


jumping 을 검색 할 때 실제로 jump 로 검색됩니다.

Term 쿼리

Elasticsearch에서 제공하는 쿼리 중에는 term 쿼리가 있습니다. match 쿼리와 문법은 유사하지만 term 쿼리는 입력한 검색어는 애널라이저를 적용하지 않고 입력된 검색어 그대로 일치하는 텀을 찾습니다. 따라서 jumps, jumping 으로 검색하면 결과가 나타나지 않고 jump로 검색해야 결과가 나타납니다.

term 쿼리로 my_index2 에서 jumps 검색

GET my_index2/_search
{
  "query": {
    "term": {
      "message": "jumps"
    }
  }
}

term 쿼리로 my_index2 에서 jump 검색

GET my_index2/_search
{
  "query": {
    "term": {
      "message": "jump"
    }
  }
}

이렇게 도큐먼트의 원문은 jumps 이지만 어떤 쿼리를 사용하느냐에 따라 원문과 같은 jumps 검색어를 넣어도 검색이 되지 않는 경우가 있습니다.

텍스트 분석(Analysis) 과정은 검색에 사용되는 **역 인덱스**에만 관여합니다. 원본 데이터는 변하지 않으므로 쿼리 결과의 **_source** 항목에는 항상 **원본 데이터**가 나옵니다.

지금까지 본 것 처럼 Elasticsearch는 데이터를 실제로 검색에 사용되는 텀(Term) 으로 분석 과정을 거쳐 저장하기 때문에 검색 시 대소문자, 단수나 복수, 원형 여부와 상관 없이 검색이 가능합니다. 이러한 Elasticsearch의 특징을 풀 텍스트 검색(Full Text Search) 이라고 하며 한국어로 전문 검색 이라고도 합니다.

앞에서 설명한 것들 외에도 elasticsearch에서 사용 가능한 애널라이저, 캐릭터 필터, 토크나이저, 토큰필터 들의 목록은 공식 홈페이지 도큐먼트에서 확인이 가능합니다.

사용자 정의 애널라이저 - Custom Analyzer

_analyze API로 애널라이저, 토크나이저, 토큰필터들의 테스트가 가능하지만, 실제로 인덱스에 저장되는 데이터의 처리에 대한 설정은 애널라이저만 적용할 수 있습니다. 인덱스 매핑에 애널라이저를 적용 할 때 보통은 이미 정의되어 제공되는 애널라이저 보다는 토크나이저, 토큰필터 등을 조합하여 만든 사용자 정의 애널라이저(Custom Analyzer)를 주로 사용합니다. 이미 정의된 애널라이저들은 매핑에 정의한 text 필드의 analyzer 항목에 이름을 명시하기만 하면 쉽게 적용이 가능합니다.

Elasticsearch에 사전에 만들어진 애널라이저들은 https://www.elastic.co/ 홈페이지의 공식 도큐먼트를 참고하시기 바랍니다. 매핑에 아무 설정을 하지 않는 경우 디폴트로 적용되는 애널라이저는 standard 애널라이저 입니다.

사용자 정의 애널라이저는 인덱스 settings"index" : { "analysis" : 부분에 정의합니다. 생성한 다음에는 해당 인덱스에서 GET 또는 POST <인덱스명>/_analyze 명령으로 사용이 가능합니다. 다음은 my_index3 안에 whitespace 토큰크나이저 그리고 lowercase, stop, snowball 토큰필터를 사용하는 my_custom_analyzer 라는 이름의 애널라이저를 추가하는 예제입니다.

my_index3 인덱스의 settings 안에 my_custom_analyzer 생성

PUT my_index3
{
  "settings": {
    "index": {
      "analysis": {
        "analyzer": {
          "my_custom_analyzer": {
            "type": "custom",
            "tokenizer": "whitespace",
            "filter": [
              "lowercase",
              "stop",
              "snowball"
            ]
          }
        }
      }
    }
  }
}

이제 my_index3 에서 my_custom_analyzer를 사용할 수 있습니다.

_analyzer API 로 my_index2 에서 my_custom_analyzer 사용

GET my_index3/_analyze
{
  "analyzer": "my_custom_analyzer",
  "text": [
    "The quick brown fox jumps over the lazy dog"
  ]
}

사용자 정의 토큰필터

토크나이저, 토큰필터의 경우에도 옵션을 지정하는 경우에는 사용자 정의 토크나이저, 토큰필터로 만들어 추가해야 합니다. 다음은 stop 토큰필터에 "brown"을 불용어로 적용한 my_stop_filter 사용자 정의 토큰필터를 생성하고 이것을 my_custom_analyzer에서 사용하도록 설정 한 예제입니다.

아래 명령 실행 전에 기존 my_index3 인덱스는 먼저 삭제해야 합니다.

my_stop_filter 를 생성 후 my_custom_analyzer 에서 사용

PUT my_index3
{
  "settings": {
    "index": {
      "analysis": {
        "analyzer": {
          "my_custom_analyzer": {
            "type": "custom",
            "tokenizer": "whitespace",
            "filter": [
              "lowercase",
              "my_stop_filter",
              "snowball"
            ]
          }
        },
        "filter": {
          "my_stop_filter": {
            "type": "stop",
            "stopwords": [
              "brown"
            ]
          }
        }
      }
    }
  }
}

https://esbook.kimjmin.net/~gitbook/image?url=https%3A%2F%2F2678746270-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-legacy-files%2Fo%2Fassets%252F-Ln04DaYZaDjdiR_ZsKo%252F-Lo3VoDvQWW6J1O6mJ7U%252F-Lo3Vs9uXQwN4FydmUDW%252F6.3.3-01.png%3Falt%3Dmedia%26token%3D5819649b-c059-400a-8097-b989c3bb5dab&width=768&dpr=4&quality=100&sign=26dc6eaf32f1dff6958f4d06a32660d671cda35e4c138bb35c19283cca10a577

                             filter 에서 선언한 my_stop_filter 를 analyzer 에서 사용

이제 다시 my_custom_analyzer를 사용해서 텍스트를 분석 해 보면 brown이 불용어 처리가 되어 사라진 것을 확인할 수 있습니다.

my_stop_filter 가 적용된 my_custom_analyzer 로 텍스트 분석

GET my_index3/_analyze
{
  "analyzer": "my_custom_analyzer",
  "text": [
    "The quick brown fox jumps over the lazy dog"
  ]
}

매핑에 사용자 정의 애널라이저 적용

애널라이저를 실제 인덱스에 입력할 데이터에 적용하려면 settings 부분에서 만든 애널라이저를 mappings 의 text 필드별로 지정합니다. 앞에서 만든 my_custom_analyzermessage 필드에 적용하는 방법은 다음과 같습니다. setting 부분은 위 예제와 동일합니다.

message 필드에 my_custom_analyzer 애널라이저 적용

PUT my_index3
{
  "settings": {
    "index": {
      "analysis": {
        "analyzer": {
          "my_custom_analyzer": {
            "type": "custom",
            "tokenizer": "whitespace",
            "filter": [
              "lowercase",
              "my_stop_filter",
              "snowball"
            ]
          }
        },
        "filter": {
          "my_stop_filter": {
            "type": "stop",
            "stopwords": [
              "brown"
            ]
          }
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "message": {
        "type": "text",
        "analyzer": "my_custom_analyzer"
      }
    }
  }
}

이제 my_indexmessage 필드에 입력되는 값은 위에 지정된 my_custom_analyzer 애널라이저가 적용됩니다. my_index의 message 필드에 값을 입력하고 검색 해 보면 brown은 불용어 처리가 되어 검색되지 않는 것을 확인할 수 있습니다.

my_index3 에 도큐먼트 입력

PUT my_index3/_doc/1
{
  "message": "The quick brown fox jumps over the lazy dog"
}

my_index3 에서 brown 검색

GET my_index3/_search
{
  "query": {
    "match": {
      "message": "brown"
    }
  }
}

결과 : my_index3 에서 brown 검색 결과 : 불용어 처리 되었기 때문에 검색 안됨

캐릭터 필터 - Character Filter

캐릭터 필터는 텍스트 분석 중 가장 먼저 처리되는 과정으로 색인된 텍스트가 토크나이저에 의해 텀으로 분리되기 전에 전체 문장에 대해 적용되는 일종의 전처리 도구입니다. 이 책에서 설명하는 7.0 버전 기준으로 캐릭터 필터는 HTML Strip, Mapping, Pattern Replace 총 3개가 존재합니다. char_filter 항목에 배열로 입력하며 하나만 적용하거나 차례대로 입력해서 3개를 모두 적용할 수도 있습니다.

토크나이저 - Tokenizer

데이터 색인 과정에서 검색 기능에 가장 큰 영향을 미치는 단계가 토크나이저 입니다. 데이터 분석 과정에서 토크나이저는 반드시 한 개만 사용이 가능하며 tokenizer 항목에 단일값으로 설정합니다. 이 책에서는 자주 사용되고 유용한 토크나이저들 위주로 설명하겠습니다.

토크나이저들 중 NGram, Lowercase 같은 토크나이저들은 대부분은 Standard 토크나이저에 같은 이름의 토큰 필터를 내장한 들입니다. 이 책에서 다루지 않는 토크나이저들은 공식 홈페이지의 도큐먼트를 확인하시기 바랍니다.

토큰 필터 - Token Filter

토크나이저를 이용한 텀 분리 과정 이후에는 분리된 각각의 텀 들을 지정한 규칙에 따라 처리를 해 주는데 이 과정을 담당하는 것이 토큰 필터입니다. 토큰 필터는 filter (token_filter가 아닙니다!) 항목에 배열 값으로 나열해서 지정합니다. 하나만 사용하더라도 배열 값으로 입력해야 하며 나열된 순서대로 처리되기 때문에 순서를 잘 고려해서 입력해야 합니다.

토큰 필터 역시 종류가 상당히 많고 계속 업데이트 되기 때문에 이 책에서는 자주 사용되는 토큰 필터 위주로 살펴보겠습니다. 공식 도큐먼트에서 내가 필요로 하는 토큰 필터가 있는지 종종 들러서 살펴보시기 바랍니다.

Lowercase, Uppercase

Synonym

NGram, Edge NGram, Shingle

Unique

형태소 분석 - Stemming

우리가 사용하는 언어들은 영어만 보더라도 문법에 따라 명사 뒤에 ~s, ~ness 등이 붙거나 동사 뒤에 ~ing, ~ed 등이 붙는 등 변화가 많습니다. 검색을 할 때는 보통 이런 문법에 따른 단어의 변형에 상관 없이 검색이 가능해야 하기 때문에 텍스트 데이터를 분석할 때 각각의 텀에 있는 단어들을 기본 형태인 어간을 추출하는 과정을 진행해야 합니다. 이 과정을 보통 어간 추출 또는 형태소 분석 이라고 하며 영어로는 stemming 이라고 합니다. 그리고 형태소 분석을 하는 도구를 형태소 분석기, 영어로는 stemmer 라고 합니다.

Elasticsearch 에서는 다양한 형태소 분석기들을 지원하며 Elastic사에서 공식적으로 지원하지 않는 국가의 언어들도 플러그인 형태로 사용 가능하도록 오픈소스로 배포되는 분석기들이 많이 있습니다. Elasticsearch 에서 사용 가능한 형태소 분석기 중에서 가장 많이 알려진 형태소 분석 알고리즘인 Snowball 과 한글 형태소 분석기인 Nori에 대해 살펴보도록 하겠습니다.

Snowball

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

profile
사용자의 경험을 설계하고 시스템의 생명주기를 책임지는 개발자

0개의 댓글