About

이번 블로그에서는 elasticsearch의 기본구조 및 개념과 elasticsearch에 query를 날리기위한 high-level Python 패키지인 elasticsearch-dsl의 기본 사용법에서 알아보고자한다. 특히 기본개념들을 상세히 서술하기위해 노력하였으니 자세히 읽어봐준다면 큰 도움이 될 것이라 생각한다.

Elasticsearch?

Elasticsearch는 Apache Lucene을 기반으로한 빅데이터를 거의 실시간(NRT, Near Real Time)으로 저장, 검색 및 분석할 수 있는 오픈소스 검색 엔진이다.

SQL vs Elasticsearch

간단하게 SQL과 Elasticsearch를 비교하며 간단히 구조를 살펴보도록 하자. 아래 내용 대부분은 Mapping concepts across SQL and Elasticsearch를 번역한 것이다.

SQL Elasticsearch Description
column field 두가지 경우 모두에서 가장 낮은 단계에서는 지명된 공간에 저장이되며, 여기에는 다양한 데이터 타입들이 존재한다. 나중에 Mapping에서 이 데이터 타입들은 중요한 역할을 하니, 시간을 들여서 살펴보는 것이 좋다. 어쨋든 큰 차이점 중에 하나는 elasticsearch에서는 한 field의 값으로 같은 타입의 값들을 가진 리스트를 저장할 수 있다는 것이다. SQL에서는 당연히 하나의 값만을 저장할 수 있다.
row document 위에서 설명한 columns and fields는 자신들만 독립적으로 존재할 수 없다. 그들은 row or document의 한 부분이다. 차이점은 row는 document에 비해서 좀 더 엄격한 기준을 가지고 있다는 점이다.
table index elasticsearch 6.0 이후부터는 type부분이 사라졌다. 이 점을 언급하는 이유는 우리나라 많은 블로그에서 type이 곧 SQL의 table이라고 다들 설명하기 때문이다. 하지만 6.0 이후 버젼부터는 type은 사라지고, index가 곧 table역할을 한다.
catalog or database cluster instance SQL에서는 catelog나 database는 set of schemas이거나 여러개의 tables를 의미한다. Elasticsearch에서는 여러개의 index들이 모인 cluster가 그러한 역할을 한다. 의미는 조금 차이가 있는데, SQL의 database는 근본적으로는 다른 namespace(소속)을 의미하지만, elasticsearch의 cluster는 runtime 기기, 정확히는 적어도 하나의 기기(일반적으로는 분산되어 작동되므로)를 의미한다. 즉, SQL은 하나의 기기에서 여러개의 catalogs를 가질 수 있지만, elasticsearch는 오직 하나만을 가지는 것이다.
cluster cluster
(federated)
전통적으로 SQL에서 cluster는 여러개의 catalogs or databases를 가진 하나의 RDMBS 객체를 의미한다. Elasticsearch에서도 비슷한 의미지만 역시 다른점이있다. RDBMS는 하나의 기기 위에서만 작동하지만, elasticsearch는 분산되어 여러개의 기기에서 작동한다는 점이 다르다.

elasticsearch에서 single cluster란 여러개의 객체가 여러 기기에 분산되어 있는 상태에서 같은 namespace를 가지는 것을 의미한다. multiple clusters란 각각의 namespace를 가진 여러개의 elasticsearch 객체들이 서로 fedarated setup(연합 셋업)으로 연결되어 있는 경우를 말한다.(자세한 것은 Cross-cluster search를 확인하자.)

필자는 elasticsearch 공부를 시작하면서 다양하게 구글링하며 기본개념을 찾았으나, 잘 설명되어있는 경우를 찾기가 쉽지않았다. 특히 typetable과 같다는 말이 없는 경우가 없다. 다시 한번 알려드리지만, elasticsearch 6.0 이후에는 없어진 개념이다.

Query context vs Filter context

다음은 Query contextFilter context에 대해서 살펴보자.

context description
Query Query는 해당 query가 의도한 바와 가장 일치하는 document를 찾아온다. _score를 통해서 순서를 결정한다.
Filter Filter는 해당 query와 document가 일치하는가?를 확인하며, 답으로는 Yes or No만을 받는다. score 계산은 하지않는다. 주로 filtering하는 용도로 사용되며 예로는, 1. timestamp가 2015-2016년 사이에 작성된 글인가? 2. status가 published인가? 등이 있다.

즉, 완전 일치하는 결과를 찾을 때는 filter를 쓰고, 연관순으로 찾을 때는 query를 사용하면 된다.

Inverted Index

Elasticsearch는 Indexing(색인이라 해석하지만 저장으로 보면된다)할 때, Inverted Index를 생성한다. 이는 역색인이라고 불리는데, 보통 책에서 목차는 Index를 의미하고 뒷부분에는 단어들이 나열되어있고 각 단어들이 몇 페이지에 있었는지가 기록되어있는데 그 부분을 역색인 즉 Inverted Index라고 부른다.

두 가지 경우가 있다. 첫 번째로 field type이 text로 analyzed된 경우다.

1. field type == ‘text’

예를 들어 The QUICK brown fox jumped over the lazy dog라는 문장을 색인/저장하게 되면 먼저 Elasticsearch의 Tokenizer가 이를 조각낸다.

text = 'The QUICK brown fox jumped over the lazy dog!'
tokenized_text = tokenizer(text)

tokenized_text는 [ quick, brown, fox, jump, over, lazi, dog ]와 같은 형태가 된다. elasticsearch가 standard analyzer를 쓰는 것이 default이기 때문이다. standard analyzer가 stopwords에 지정되어 있는 the나 !와 같은 것들은 필터링하고 분리저장한다. 또한, 나중에 search하기 좀 더 유리하도록 QUICK과 같은 대문자는 소문자로 변경시켜주는데 이는 Token Filter가 담당하는 것이다.

2. field type == ‘keyword’ or ‘number’ or ‘date’

이 경우에는 Analyzer가 사용되지 않는다. 밑에서 terms를 설명할 때 자세히 말하겠지만 이 사실은 매우 중요하다. keyword나 number, date와 같은 type의 field들은 그 값 자체로 찾기쉽도록 Inverted Index된다.

위의 두 경우와 같이 Inverted Index방식으로 인해 elasticsearch의 Full Text Search가 아주 탁월한 성능을 내게되는 것이다.

Implementation

이제 기본 사용법을 코드로 살펴보자. 이 블로그는 url방식이 아닌 python 패키지인 elasticsearch-dsl을 이용해서 구현하는 법을 알아볼 것이다.

먼저 아래와 같이 모듈들을 임포트하고, es에는 elasticsearch 객체를 생성하는 것을 아래의 코드들에서도 공통되게 사용할 것이다.

import elasticsearch
from elasticsearch_dsl import Search, Q, Index

es = elasticsearch.Elasticsearch('localhost:9200')
1. match_all

가장 기본이다. SELECT * FROM TABLE와 같은 query라 볼 수 있다.

s = Search(index='index_name', using=es)
s = s.query('match_all')
search_result = []
for hit in s.scan():
    search_result.append(hit)
2. match

이번에는 조금 더 조건을 줘서 찾아오는 것을 해보겠다.

s = Search(index='index_name', using=es)
s = s.query('match', pk=1)
res = s.execute()
res.to_dict()

만약 우리가 찾는 field가 text라면 유사한 값들을 반환한다. 위 경우 primary_key와 같이 unique한 경우에는 하나만 찾아서 반환한다.

반환한 값에 to_dict method로 dictionary화 할 수 있다.

3. multi_match

multi_match는 아주 유용하다. 여러개의 field를 동시에 확인하는 방법이다. 예를들어 옷을 검색한다고 생각해보자. 사람들은 ‘브랜드명 상품명’식으로 검색할 확률이 높은데, 보통은 자신이 찾는 아이템의 정확한 이름을 오타없이 입력할 확률은 낮을 것이고, 둘 중에 최소 하나는 좀 더 정확하게 입력할 거라고 가정할 수 있다. 이런 경우에 아주 유용하게 활용된다.

query_text = '브랜드명 상품명'
s = Search(index='item_storage', using=es)
s = s.query(
    'multi_match', 
    query=query_text, 
    fuzziness='auto', 
    fields=['item_name', 'brand_name']
)

여기서 fuzziness를 이용하게 되면, Levenshtein edit distance를 이용해서 match를 확인한다.

edit distance를 간단하게 설명하자면 ‘abc’, ‘abd’와 같이 두개의 문자열이 있을 때 몇번 더하거나 빼거나 대체하여 서로 같아지는지 횟수를 계산하는 것이다. 위의 경우라면 c를 d라 한번 대체하면 되므로 거리는 1이 된다.

어쨋든 이 방법까지 추가되면 작은 오타는 해결할 수 있게 된다. 욕심을 내면 한글은 자소분리까지 해서 더 정밀한 검색을 할 수도 있다. 물론 자소분리해서 따로 field에 저장하거나 애당초에 값을 그렇게 변형해서 저장해야겠지만 말이다.

4. range

범위 조건은 range를 이용해서 줄 수 있다.

condition = {
    'gte': 10, 
    'lt': 20
}
s = Search(index='index_name', using=es)
s = s.filter('range', pk=condition)
res = s.execute()
res.to_dict()

pk값이 10보다 크거나 같고 20보다 작은 값들을 반환한다. 10보다 큰 경우만 찾을 때는

condition = {
  'gte': 10
}

처럼해서 넣어주면 된다.

5. Q를 사용하여 특정 값 filtering하기

이 경우에는 찾고자하는 field의 mapping type이 keyword여야 한다. article에 hello가 들어 있는 경우는 제외한 documents들만 반환한다.

must = [
  {'match': {'article.keyword': {'query': 'hello'}}}
]
s = Search(index='index_name', using=es)
s = s.query(Q('bool', must=must))
res = s.execute()

여기서 must의 개수나 should 등을 추가할 수도 있다.

s = Search(index='users').using(es)
must = [
  {'match': {'firstname.keyword': {'query': 'John'}}}
]
should = [
  {'match': {'lastname.keyword': 'Lennon'}},
  {'match': {'country': 'Korea'}}
]
s = s.query(Q('bool', must=must, should=should, minimum_should_match=1))
res = s.execute()

users index에서 firstname이 John이면서 lastename이 Lennon이거나 country가 Korea인 유저를 찾아온다. minimum_should_match를 통해서 최소 몇개만 해당하면 되는지도 설정이 가능하다.

6. terms

여기서 잠깐 field data type에 대해서 이야기해보자. String은 textkeyword 두가지가 있다.

name description
text full text를 의미한다. 예로는 email 본문 내용, article 본문 내용 등이 있다. 위에서 설명했듯 이 type은 analyzer를 사용해서 inverted indexing(역색인)한다.
keyword 정확한 값들을 의미한다. 예를들어 주민등록번호, email주소 등이 있다. 역시 위에서 설명했듯 이 type은 들어온 값 그대로 inverted indexing(역색인)하게 된다.

자 이제 terms에 대해서 설명하자면, terms는 Inverted Index에서 정확히 일치하는 항목들을 반환한다. elasticsearch 공식페이지에서 Term Query 설명한 예를 이용하여 살펴보자.

먼저 field data type을 mapping을 통해서 살펴보겠다. 이 부분은 공식페이지에서 가져왔으므로 python 패키지를 사용한 코드는 아니라는 점을 주의하자.

PUT my_index
{
  "mappings": {
    "_doc": {
      "properties": {
        "full_text": {
          "type":  "text" 
        },
        "exact_value": {
          "type":  "keyword" 
        }
      }
    }
  }
}

PUT my_index/_doc/1
{
  "full_text":   "Quick Foxes!", 
  "exact_value": "Quick Foxes!"  
}

위와 같은 경우라면 full_texttext타입이므로 analyze가 되고, keyword타입인 exact_value는 analyze되지 않는다.

역색인에는 아래처럼 저장된다.

Field Name Inverted Index
full_text [quick, foxes]
exact_value [Quick Foxes!]

자 이제 다음 코드를 살펴보자.

s = Search(index='index_name', using=es)
s = s.query('term', exact_value='Quick Foxes!')
res = s.execute()

이 경우는 term에 의해서 먼저 exact_value의 역색인에 “Quick Foxes!”가 있는지 확인하게 되고, 위에서 볼 수 있듯이 정확히 그 값이 존재하므로 값을 반환한다.

s = Search(index='index_name', using=es)
s = s.query('term', full_text='Quick Foxes!')
res = s.execute()

이 경우에는 반환하는 값이 없다. 이유는 full_text의 Inverted Index(역색인)를 확인하면 오직 quick, foxes밖에 없기 때문이다.

s = Search(index='index_name', using=es)
s = s.query('term', full_text='foxes')
res = s.execute()

full_text 역색인에 foxes가 있으므로 값이 반환된다.

s = Search(index='index_name', using=es)
s = s.query('match', full_text='Quick Foxes!')
res = s.execute()

이 경우는 match를 사용했는데, 이렇게 되면 query string인 ‘Quick Foxes!’를 저장할 때와 같은 analyzer로(이게 default다. 원하면 index와 search때의 analyzer는 달리 할 수 있다) tokenize를 하기 때문에, quick, foxes로 나누어서 둘 중에 하나라도 있거나 둘 다 있는 경우의 값을 반환하게 된다.

7. terms, sort

terms를 이용하게되면 여러개의 pks or ids의 item들을 찾아올 수 있는데, 이때 원하는 sort도 가능하다. 코드로 살펴보자.

s = Search(index='index_name', using=es)
s = s.query('terms', pks=[0, 3, 5, 9]).sort('pk')

결과값을 pk 오름차순으로 반환한다. 내림차순을 원할 경우에는 앞에 ’-‘를 붙여서 ‘-pk’라고 해주면 된다.

만약 우리가 query로 요청한 순서대로 반환하는 법을 알고싶으시다면 이전 블로그( Search 결과를 요청한 순서대로 Sorting하여 Return 받는법)에서 확인하자.

8. source
s = Search(index='users', using=es)
s = s.query('terms', pks=[0, 3, 5, 9]).sort('pk').source([
  'email',
  'profile_image'
])

source를 이용하면 원하는 값들만 가져올 수 있다. 위의 경우에는 가져온 user의 email주소와 프로필 사진만을 반환받는 코드다.

9. from, size
s = Search(index='users', using=es)
s = s.query('terms', pk=range(100)).sort('pk').source([
  'email',
  'profile_image'
])
s = s[10:20]
res = s.execute()

python 리스트를 slicing하듯이 하면된다. 물론 10000번이 넘는 경우에는 조금 변형해야 하는데 그 부분은 이 블로그에서 다루지 않겠다. 위의 코드는 0번부터 99번의 pk를 가진 user들 중에서 10번부터 19번까지의 user만을 반환받는 코드다. 알려주는 용으로 쓴 코드라 좋은 코드는 아님을 명심하자.

Conculusion

기본적인 사용법과 개념들을 알아봤다. 각 부분별로 좀 더 심도있는 내용은 조만간 공부하여 블로그를 작성하도록 하겠다. 도움이 되었다면 널리 퍼뜨려주시면 감사하겠다.