ElasticSearch 정리 4일차 - Analysis

0

elasticsearch

목록 보기
4/9

Analysis

Analysis는 elasticsearch에서 internal하게 사용하는 search최적화 test분석이다. text분석이라고도 불리며, text fields와 value에 적용된다. text값들은 document를 index를 인덱싱 할 때 분석되며, 그 결과는 효율적으로 검색하기위해서 자료구조에 저장된다. 재밌게도 document의 실제 content인 _source는 대상이 아닌데, 이유는 elasticsearch에서 internal하게 해당 field를 search최적화를 위해서 사용하지 않기 때문이다.

한 가지 명심할 것은 text가 저장되기 이전에 analysis를 위해서 처리된다는 사실이다. text value가 indexed되면 analyzer가 text를 처리하기위해서 사용된다.

----------        ----------        ---------
|Document| -----> |Analyzer| -----> |storage|
----------        ----------        ---------
                      |
            -----------------------------
            |               |           |
            ▼               ▼           ▼
    ------------------- ----------- ---------------
    |Character filters| |Tokenizer| |Token filters|
    ------------------- ----------- ---------------

Analyzer는 3개의 component로 이루어져 있고, document text가 들어오면 Analyzer의 3개의 component를 거친다음 storage로 저장된다.

  1. Character filters: 원본 character에 무언가를 더하거나 삭제하거나 변경한다. 0~n개까지 가능하다. 가령, 원본 문자에 html태그가 섞여있다면 이를 삭제해주는 것도 가능하다. ex) i am a <em>good</em>html_strip filter를 쓰면 i am a good이 된다. (default로 0개 이상)
  2. Tokenizer: Analyzer는 1개의 tokenizer를 포함하며 tokenizer는 string을 토큰화 시킨다. 가령 i am good이면 ["i", "am", "good"]이 된다. (default로 1개만 가능)
  3. Token filters: tokenizer의 결과를 input으로 받아서 token들을 수정하고 추가하고 삭제한다. 0~N개까지 있을 수 있으며, 가령 다음과 같이 소문자로 바꾸기도 가능하다. ex) ["I", "AM", "GOOD"]["i", "am", "good"]이 된다. (default로 0개 이상)

이 과정을 정리하면 다음과 같다.

  • Analyzing 과정
character filters(None)     ----Tokenizer(standard)-----      --Token filters(lowercase)---
|input: "I AM good"   |     |input: "I AM good"        |      |input: ["I", "AM", "good]  |
|                     | --> |                          | -->  |                           |
|output: "I AM good"  |     |output: ["I", "AM", "good]|      |output: ["i", "am", "good] |
-----------------------     ----------------------------      -----------------------------

character filter

character filter는 text를 character stream으로 받아서 특정한 문자를 추가, 변경, 삭제한다. analyzer에는 0개 이상의 charater filter를 지정할 수 있다. 조심해야할 것은 지정한 순서대로 적용된다는 것이다. built-in character filter는 다음과 같다.

  1. HTML strip: 와 같은 html요소 안쪽의 데이터를 꺼낸다.
  2. mapping: 치환할 대상이 되는 문자와 치환 문자를 맵 형태로 선언한다.
  3. pattern replace: 정규 표현식을 이용해서 문자를 치환한다.

한가지 예시로 HTML strip charater filter를 사용해보도록 하자.

POST _analyze
{
  "char_filter": ["html_strip"],
  "text": "<p>I&apos;m so <b>happy!</b>!</p>"
}

다음의 결과가 나오게 된다.

{
  "tokens" : [
    {
      "token" : """
I'm so happy!!
""",
      "start_offset" : 0,
      "end_offset" : 33,
      "type" : "word",
      "position" : 0
    }
  ]
}

<p>, </p>가 줄바꿈 문자로 치환됐고, <b></b>가 제거되었으며 &apos;가 따옴표로 디코딩되었음을 알 수 있다.

Tokenizer

charactor stream을 받아서 여러 token으로 쪼개어 token stream으로 만든다. analyzer에는 한 개의 tokenizer만 지정할 수 있다. built-in tokenizer는 다음과 같다.

  1. standard tokenizer: text를 word 단위로 바꾸며 대부분의 문장 부호(punctuation symbol)가 사라진다. default tokenizer이다.
  2. keyword tokenizer: 들어온 text를 word로 쪼개지 않고, 그래도 커다란 단일 token으로 만든다.
POST _analyze
{
  "tokenizer": "keyword",
  "text": "Hello, HELLO, World!"
}

결과로 나온 token이 text통째인 것을 알 수 있다.

{
  "tokens" : [
    {
      "token" : "Hello, HELLO, World!",
      "start_offset" : 0,
      "end_offset" : 20,
      "type" : "word",
      "position" : 0
    }
  ]
}
  1. ngram tokenizer: text를 min~max range만큼 앞부터 시작해 token으로 잘라낸다.
POST _analyze
{
  "tokenizer": {
    "type": "ngram",
    "min_gram": 3,
    "max_gram": 4,
    "token_chars": ["letter"]
  },
  "text": "Hello, World!"
}

token으로 Hel, Hell, ell, ello, llo, Wor, Worl, orl, orld, rld로 총 10개의 token으로 쪼개진다. 단, 공백 문자를 전후해 위치한 문자로 구성된 oWo와 같은 token은 만들어지지 않는다. ngram을 사용하는 이유는 자동완성과 같은 기능에 유용하기 때문이다.

  1. edge_ngram tokenizer: 앞부분만을 min, max만큼 잘라낸다. 가령 위의 경우에는 Hel, Hell, Wor, Worl로 token을 만든다.

  2. letter tokenizer: 공백, 특수문자 등 언어의 글자로 분류되는 문자가 아닌 문자를 만났을 대 쪼갠다.

  3. whitespace tokenizer: 공백 문자를 만났을 때 쪼갠다.

  4. pattern tokenizer: 지정한 정규표현식을 단어의 구분자로 사용하여 쪼갠다.

Token filter

token filter는 token stream을 받아 token을 추가, 변경, 삭제한다. default로 0개 이상 지정할 수 있다. 0개 이상을 지정할 수 있고 여러 개 지정하면 순서대로 적용된다.

  1. lowercase, uppercase: 각 token을 소문자, 대문자로 바꿔준다.
  2. stop: 불용어를 지정하여 제거할 수있다. ex) the, a, an, in etc
  3. synonym: 유의어 사전 파일을 지정하여 지정된 유의어를 치환한다.
  4. pattern_replace: 정규식을 사용하여 token의 내용을 치환한다.
  5. trim: token의 전후에 위치한 공백을 제거
  6. truncate: 지정한 길이로 token을 자른다.

다음과 같이 사용할 수 있다.

POST _analyze
{
  "filter": ["lowercase"],
  "text": "Hello, World!"
}

text를 token으로 만들고, token들을 소문자로 바꿔주는 것이다.

{
  "tokens" : [
    {
      "token" : "hello, world!",
      "start_offset" : 0,
      "end_offset" : 13,
      "type" : "word",
      "position" : 0
    }
  ]
}

Analyzing API

analyzer는 charater filter, tokenizer, token filter 조합으로 구성된다. 엘라스틱서치에서는 이 3가지를 조합하여 미리 만들어놓은 다양한 analyzer가 있다. 그 중 몇 가지를 정리했다.

  1. standard: standard tokenizer와 lowercase token filter로 구성된다. 가장 default analyzer이다.
  2. simple: letter가 아닌 문자 단위로 토큰을 쪼갠 뒤, lowercase token filter를 적용한다.
  3. whitespace: whitespace tokenizer로 구성되다. 즉 공백 다위로 token을 쪼갠다.
  4. keyword: keyword tokenizer로 구성된다. 특별히 분석을 실시하지 않고 하나의 큰 token을 그대로 반환한다.
  5. pattern: pattern tokenizer와 lowercase token filter로 구성된다.

analyzer를 호출할 수 있는 API가 있다.

  1. path: /_analyze
  2. method: POST
  3. body: "text"에 분석할 내용을 담고, "analyzer"에 어떤 "analyzer"를 사용할 지 지정하도록 한다. 또는 char_filter, tokenizer, filter를 각각 지정할 수 있다.
POST /_analyze
{
  "text": "2 guys walk into a bar, but the third... DUCKS! :-)",
  "analyzer": "standard"
}

결과는 다음과 같다.

{
  "tokens" : [
    {
      "token" : "2",
      "start_offset" : 0,
      "end_offset" : 1,
      "type" : "<NUM>",
      "position" : 0
    },
    {
      "token" : "guys",
      "start_offset" : 2,
      "end_offset" : 6,
      "type" : "<ALPHANUM>",
      "position" : 1
    },
    {
      "token" : "walk",
      "start_offset" : 7,
      "end_offset" : 11,
      "type" : "<ALPHANUM>",
      "position" : 2
    },
    {
      "token" : "into",
      "start_offset" : 12,
      "end_offset" : 16,
      "type" : "<ALPHANUM>",
      "position" : 3
    },
    {
      "token" : "a",
      "start_offset" : 17,
      "end_offset" : 18,
      "type" : "<ALPHANUM>",
      "position" : 4
    },
    {
      "token" : "bar",
      "start_offset" : 19,
      "end_offset" : 22,
      "type" : "<ALPHANUM>",
      "position" : 5
    },
    {
      "token" : "but",
      "start_offset" : 24,
      "end_offset" : 27,
      "type" : "<ALPHANUM>",
      "position" : 6
    },
    {
      "token" : "the",
      "start_offset" : 28,
      "end_offset" : 31,
      "type" : "<ALPHANUM>",
      "position" : 7
    },
    {
      "token" : "third",
      "start_offset" : 32,
      "end_offset" : 37,
      "type" : "<ALPHANUM>",
      "position" : 8
    },
    {
      "token" : "ducks",
      "start_offset" : 41,
      "end_offset" : 46,
      "type" : "<ALPHANUM>",
      "position" : 9
    }
  ]
}

token들이 나오고 token들의 타입이 나온다. 재밌는 것은 특수 문자들은 token화에 반영되지 않는다는 것이다.

다음으로, token filter로 lowercase를 적용해보도록 하자.

POST /_analyze
{
  "text": "2 guys walk into a bar, but the third... DUCKS! :-)",
  "char_filter": [],
  "tokenizer": "standard",
  "filter": ["lowercase"]
}

결과는 다음과 같다.

{
  "tokens" : [
    {
      "token" : "2",
      "start_offset" : 0,
      "end_offset" : 1,
      "type" : "<NUM>",
      "position" : 0
    },
    {
      "token" : "guys",
      "start_offset" : 2,
      "end_offset" : 6,
      "type" : "<ALPHANUM>",
      "position" : 1
    },
    {
      "token" : "walk",
      "start_offset" : 7,
      "end_offset" : 11,
      "type" : "<ALPHANUM>",
      "position" : 2
    },
    {
      "token" : "into",
      "start_offset" : 12,
      "end_offset" : 16,
      "type" : "<ALPHANUM>",
      "position" : 3
    },
    {
      "token" : "a",
      "start_offset" : 17,
      "end_offset" : 18,
      "type" : "<ALPHANUM>",
      "position" : 4
    },
    {
      "token" : "bar",
      "start_offset" : 19,
      "end_offset" : 22,
      "type" : "<ALPHANUM>",
      "position" : 5
    },
    {
      "token" : "but",
      "start_offset" : 24,
      "end_offset" : 27,
      "type" : "<ALPHANUM>",
      "position" : 6
    },
    {
      "token" : "the",
      "start_offset" : 28,
      "end_offset" : 31,
      "type" : "<ALPHANUM>",
      "position" : 7
    },
    {
      "token" : "third",
      "start_offset" : 32,
      "end_offset" : 37,
      "type" : "<ALPHANUM>",
      "position" : 8
    },
    {
      "token" : "ducks",
      "start_offset" : 41,
      "end_offset" : 46,
      "type" : "<ALPHANUM>",
      "position" : 9
    }
  ]
}

각 token들이 소문자로 나오는 것을 알 수 있다.

Inverted indices

field의 값들은 field의 data type과 사용 용도에 따라 저장되는 방식이 다른데, 가령 text와 number의 경우 search하는 용도에서 저장되는 방식이 다른데, 더불어 text에서 search와 aggregate기능의 data접근 방식이 달라 data저장 구조가 다르다. 참고로 search의 경우는 lucene가 전적으로 담당한다.

다양한 data 구조에 대해서는 추후에 알아보고, 이번에는 Inverted index에 대해서 알아보도록 하자.

inverted indextermterm을 담는 document사이의 mapping일 뿐이다. term은 위의 tokenizer를 거쳐서 token화 된 각 단어들을 말하며, 이 token들을 가진 document들을 맵핑시킨 것이다.

tokenterm은 같은 말인데, token이라는 단어는 elasticsearch의 analyzer말고는 쓰이지 않고 보통 term이라고 한다.

inverted index를 예로들면 다음과 같다.

document1 -> "2 gyus walk into a  bar but"
document2 -> "2 gyus went into a  bar"
document3 -> "2 gyus walk around the lake"

다음의 document들을 tokenizer로 토큰화시키면, 다음과 같이 term들의 리스트로 정리할 수 있다.

document1 -> "2 gyus walk into a  bar but" -> ["2", "gyus", "walk", "into", "a" , "bar", "but"]
document2 -> "2 gyus went into a  bar" -> ["2", "gyus", "went", "into", "a" , "bar"]
document3 -> "2 gyus walk around the lake" -> ["2", "gyus", "walk", "around", "the" , "lake"]

그럼 이제 반대로 term을 중심으로 document를 맵핑시키는 것이다. 가령 2라는 term은 document1,2,3 모두 출현하였다. 반면에 into는 doument1, 2에서만 출현한다. 이를 정리하면 다음과 같다.

TERMdocument1document2document3
2ooo
gyuysooo
walkoxo
wentxox
intooox
aroundxxo
aoox
baroox
butoxx
thexxo
lakexxo

이렇게 interted index를 만드는 이유는 term을 index삼아 document들을 쉽게 찾을 수 있기 때문이다. 가령 walk가 있는 document를 찾고 싶다면 walk term으로 가서 확인하면 document1, docunemt2에 있다는 것을 쉽게 확인할 수 있다.

inverted indices는 lucene에 의해서 관리되며 성능을 위해, 알파벳 순서로 term들과 document id를 저장한다. 이때 추가적으로 relevance scoring을 저장하는데, 이는 얼마나 해당 document가 term에 잘 일치하는 지를 알려주는 점수이다. 즉, rank를 만들 때 사용한다. 추후에 알아보도록 하자.

중요한 것은 inverted indices는 document의 각 text field마다 서로 다른 inverted indices table을 관리한다는 것이다.

가령 다음의 document들을 보도록 하자.

- document1
{
  "name": "Coffee Maker",
  "description": "Makes coffee super fast!",
  "price": 64
}

- document2
{
  "name": "Toaster",
  "description": "Makes delicious toasts...",
  "price": 49
}

text field마다 inverted indices를 관리한다는 것은 namedescription을 따로 관리한다는 것이다. 따라서, 다음과 같은 inverted indices가 만들어진다.

  • name field
TERMdocument1document2
coffeeox
makerox
toasterxo
  • description field
TERMdocument1document2
coffeeox
deliciousxo
fastox
makesoo
superox
toastxo

다음과 같이 서로 다른 각각의 text field에 대해서 inverted indices가 나온다는 것이다. 위의 document에서는 price field에 관해서 inverted indices가 만들어지지 않은 것을 볼 수 있다. 왜냐하면 text field가 아니기 때문이다. 그렇다면 왜 이렇게 text field에만 inverted index를 만드는 것일까?

이유는 각 field마다 서로 다른 data type을 가질 수 있고, data type이 다르면 data를 저장하는 data구조가 다르기 때문이다. 가령 text field를 제외한 numeric, date, geospatial fields는 모두 BKD tree에 저장된다. 때문에 inverted indices를 사용하는 것이 효율적이지 않다.

정리하자면 다음과 같다.
1. text field값은 analyzed되고 그 결과로 inverted index가 만들어진다.
2. 각 text field마다 inverted index가 있다.
3. inverted index는 term들과 이 term을 포함하는 document들의 맵핑이다.
4. term들은 성능상의 이유로 알파벳 별로 정렬된다.
5. inverted index는 apache lucene로 관리되지 elasticsearch가 아니다.
6. inverted indices는 빠른 search를 가능하게 해준다.
7. text field이외에는 inverted indices말고 BKD tree를 사용하여 search를 빠르게 한다.

Mapping

mapping은 analyze와 매우 연관이 깊은데, document들을 보고 이 document들의 fields의 data type을 정의하는 것이다. 또한, 이 field의 값들을 인덱싱하는 것과 관련이 깊다.

가령, RDB의 table schema가 다음과 같이 있다고 하자.

CREATE TABLE employees (
  id INT AUTO_INCREMENT PRIMARY KEY,
  dob DATE,
  description TEXT
)

이를 elasticsearch에 align을 맞출 필요가 있다. 즉 field가 어떤 타입인지 그리고, 해당 field의 값을 어떻게 인덱싱할 것인지에 대한 것이다. 아래는 각 field에 대한 정의를 하여 document를 mapping한 것이다.

PUT /employees
{
  "mappings": {
    "properties": {
      "id": {"type": "integer"},
      "dob": {"type": "date"},
      "description": {"type": "text"}
    }
  }
}

이렇게 원본과 document 사이의 연결 및 정의가 바로 mapping이라는 것이다. mapping은 위와 같이 explicit mapping이 존재하고, inverted indices처럼 내부적으로 analyze가 이루어지면서 분석하는 Dynamic mapping이 존재한다. elasticsearch가 field mapping을 만들어주는 것이다.

Mapping analyzer

각 filed를 mapping할 때, 각 field마다의 analyzer를 적용시킬 수 있다.

PUT analyzer_test
{
  "settings": {
    "analysis": {
      "analyzer": {
        "default": {
          "type": "keyword"
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "defaultText": {
        "type": "text"
      },
      "standardText": {
        "type": "text",
        "analyzer": "standard"
      }
    }
  }
}

index.settings.analysis.analyzer 설정을 통해서 기본 analyzer를 추가할 수 있다. 위 예제에서는 default인 standard analyzer를 keyword analyzer로 변경했다.

defaultTexttext type이지만 default analyzer인 keyword analyzer를 적용받도록 하였다. standartTextstandard analyzer를 적용받도록하여, 이들의 결과는 다르게 나올 것이다.

Data Types

https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-types.html

elasticsearch에는 여러 data type들이 있는데, 가장 대표적인 data type들만 알아보도록 하자.

object는 JSON obejct로 nested한 형식도 지원한다. 그런데 apache lucene은 object를 지원하지 않아서 object자체로 저장되는 것이 아니라 인덱싱할 수 있도록 변환되어 저장된다.

재밌는 것은 flatten이 지원되는데 다음의 예제를 보도록 하자.

{
  "name": "Coffee Maker",
  "manufacturer": {
    "name": "Nespresso"
  }
}

다음의 JSON object를 flatten시키면 다음과 같다.

{
  "name": "Coffee Maker",
  "manufacturer.name": "Nespresso"
}

그러나 이러한 flatten에는 미묘한 문제가 있는데, 다음의 경우를 보도록 하자.

{
  "name": "Coffee Maker",
  "reviews": [
    {
      "rating": 5.0,
      "author": "Average Joe"
    },
    {
      "rating": 3.5,
      "author": "John Doe"
    }
  ]
}

이를 flatten시키면 다음과 같아진다.

{
  "name": "Coffee Maker",
  "reviews.rating": [5.0, 3.5],
  "reviews.author": ["Average Joe", "John Doe"]
}

언뜻보면 문제가 없어보이지만 아주 문제가 많다.

가령 Coffee Maker에서 John Doe4점을 넘는 지 아닌지를 확인하고 싶다고 하자. flatten하기 이전에는 elasticsearch에서 쉽게 알 수 있었지만, flatten시키는 순간 elasticsearch는 John Doe3.5점을 매칭하지 못한다.

이때 사용하는 것이 nested data type이다. nested data type은 object data type과 많이 닮았지만 object관계를 담는다. object들의 array들을 indexing할 때 매우 좋다.

PUT /products
{
  "mappings": {
    "properties": {
      "name": {"type": "text"},
      "reviews": {"type": "nested"}
    }
  }
}

reviewsnested로 설정한 것을 볼 수 있다. array일지라도 이 array가 nested하게 서로 관련이 있는 object라는 것을 알려주는 것이다.

0개의 댓글