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로 저장된다.
i am a <em>good</em>
에 html_strip
filter를 쓰면 i am a good
이 된다. (default로 0개 이상)i am good
이면 ["i", "am", "good"]
이 된다. (default로 1개만 가능)["I", "AM", "GOOD"]
을 ["i", "am", "good"]
이 된다. (default로 0개 이상)이 과정을 정리하면 다음과 같다.
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는 text를 character stream으로 받아서 특정한 문자를 추가, 변경, 삭제한다. analyzer에는 0개 이상의 charater filter를 지정할 수 있다. 조심해야할 것은 지정한 순서대로 적용된다는 것이다. built-in character filter는 다음과 같다.
한가지 예시로 HTML strip
charater filter를 사용해보도록 하자.
POST _analyze
{
"char_filter": ["html_strip"],
"text": "<p>I'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>
가 제거되었으며 '
가 따옴표로 디코딩되었음을 알 수 있다.
charactor stream을 받아서 여러 token으로 쪼개어 token stream으로 만든다. analyzer에는 한 개의 tokenizer만 지정할 수 있다. built-in tokenizer는 다음과 같다.
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
}
]
}
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을 사용하는 이유는 자동완성과 같은 기능에 유용하기 때문이다.
edge_ngram tokenizer: 앞부분만을 min, max만큼 잘라낸다. 가령 위의 경우에는 Hel
, Hell
, Wor
, Worl
로 token을 만든다.
letter tokenizer: 공백, 특수문자 등 언어의 글자로 분류되는 문자가 아닌 문자를 만났을 대 쪼갠다.
whitespace tokenizer: 공백 문자를 만났을 때 쪼갠다.
pattern tokenizer: 지정한 정규표현식을 단어의 구분자로 사용하여 쪼갠다.
token filter는 token stream을 받아 token을 추가, 변경, 삭제한다. default로 0개 이상 지정할 수 있다. 0개 이상을 지정할 수 있고 여러 개 지정하면 순서대로 적용된다.
다음과 같이 사용할 수 있다.
POST _analyze
{
"filter": ["lowercase"],
"text": "Hello, World!"
}
text
를 token으로 만들고, token들을 소문자로 바꿔주는 것이다.
{
"tokens" : [
{
"token" : "hello, world!",
"start_offset" : 0,
"end_offset" : 13,
"type" : "word",
"position" : 0
}
]
}
analyzer는 charater filter, tokenizer, token filter 조합으로 구성된다. 엘라스틱서치에서는 이 3가지를 조합하여 미리 만들어놓은 다양한 analyzer가 있다. 그 중 몇 가지를 정리했다.
analyzer를 호출할 수 있는 API가 있다.
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들이 소문자로 나오는 것을 알 수 있다.
field의 값들은 field의 data type과 사용 용도에 따라 저장되는 방식이 다른데, 가령 text와 number의 경우 search하는 용도에서 저장되는 방식이 다른데, 더불어 text에서 search와 aggregate기능의 data접근 방식이 달라 data저장 구조가 다르다. 참고로 search의 경우는 lucene가 전적으로 담당한다.
다양한 data 구조에 대해서는 추후에 알아보고, 이번에는 Inverted index에 대해서 알아보도록 하자.
inverted index는 term
과 term
을 담는 document
사이의 mapping일 뿐이다. term
은 위의 tokenizer를 거쳐서 token화 된 각 단어들을 말하며, 이 token들을 가진 document들을 맵핑시킨 것이다.
token과 term은 같은 말인데, 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에서만 출현한다. 이를 정리하면 다음과 같다.
TERM | document1 | document2 | document3 |
---|---|---|---|
2 | o | o | o |
gyuys | o | o | o |
walk | o | x | o |
went | x | o | x |
into | o | o | x |
around | x | x | o |
a | o | o | x |
bar | o | o | x |
but | o | x | x |
the | x | x | o |
lake | x | x | o |
이렇게 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를 관리한다는 것은 name
과 description
을 따로 관리한다는 것이다. 따라서, 다음과 같은 inverted indices가 만들어진다.
TERM | document1 | document2 |
---|---|---|
coffee | o | x |
maker | o | x |
toaster | x | o |
TERM | document1 | document2 |
---|---|---|
coffee | o | x |
delicious | x | o |
fast | o | x |
makes | o | o |
super | o | x |
toast | x | o |
다음과 같이 서로 다른 각각의 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은 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을 만들어주는 것이다.
각 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로 변경했다.
defaultText
는 text
type이지만 default analyzer인 keyword
analyzer를 적용받도록 하였다. standartText
는 standard
analyzer를 적용받도록하여, 이들의 결과는 다르게 나올 것이다.
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 Doe
가 4
점을 넘는 지 아닌지를 확인하고 싶다고 하자. flatten하기 이전에는 elasticsearch에서 쉽게 알 수 있었지만, flatten시키는 순간 elasticsearch는 John Doe
와 3.5
점을 매칭하지 못한다.
이때 사용하는 것이 nested
data type이다. nested
data type은 object data type과 많이 닮았지만 object관계를 담는다. object들의 array들을 indexing할 때 매우 좋다.
PUT /products
{
"mappings": {
"properties": {
"name": {"type": "text"},
"reviews": {"type": "nested"}
}
}
}
reviews
를 nested
로 설정한 것을 볼 수 있다. array일지라도 이 array가 nested하게 서로 관련이 있는 object라는 것을 알려주는 것이다.