Elasticsearch
엘라스틱서치
일반적으로 검색엔진은 데이터베이스에서 일치하는 문자열을 찾는 것에 비해 더 유연한 검색 결과를 제공한다. 엘라스틱서치는 대표적인 검색엔진 중 하나로, 뛰어난 성능 덕분에 로그 데이터 수집 데이터베이스로도 많이 쓰이고 있다. 여러모로 장점이 많은 엘라스틱서치에 대해 알아보자.
개요
엘라스틱서치는 클러스터 단위로 운영되는데 클러스터는 여러대의 노드(검색서버)로 이루어진다. 노드에는 인덱싱이라는 과정을 통해 문서들이 검색하기 쉬운 형태로 저장되는데, 이렇게 저장된 문서의 그룹을 인덱스라고 부른다. 인덱스는 샤드와 레플리카를 통해 분할/확장이 가능하다. 인덱스를 생성/삭제하거나 문서를 추가/검색/갱신/삭제하는 것은 REST API를 사용한다. 이러한 API를 손쉽게 사용할 수 있도록 Node Client 등을 제공한다.
클러스터(cluster)
클러스터는 하나 이상의 노드(서버)가 모인 것이며, 이를 통해 전체 데이터를 저장하고 모든 노드를 포괄하는 통합 색인화 및 검색 기능을 제공합니다.
노드(node)
노드는 클러스터에 포함된 단일 서버로서 데이터를 저장하고 클러스터의 색인화 및 검색 기능에 참여합니다.
…클러스터 이름을 통해 어떤 클러스터의 일부로 구성될 수 있습니다.
인덱스(index)
비슷한 특성을 가진 문서(도큐먼트)의 모음. 인덱스 이름을 사용하여 인덱스에 포함된 문서에 대한 인덱싱, 검색, 업데이트, 삭제 작업 수행.
인덱싱(indexing)
문서(도큐먼트)를 검색어 토큰들로 전환하여 검색이 가능한 상태로 만드는 것
검색(search)
검색어 토큰을 포함하는 문서를 찾는 것
도큐먼트(document)
인덱싱할 수 있는 기본 정보 단위(JSON)
샤드(shards) & 레플리카
단일 노드에 방대한 데이터 저장 시 저장장치 용량을 초과하거나 속도가 저하되는 문제를 해결하고자 인덱스를 여러개의 조각으로 나눈 것. 그 자체가 온전한 기능을 가진 독립된 인덱스. 샤딩을 통해 볼륨의 수평 분할/확장이 가능. 성능/처리
레플리카(replicas)
샤드의 복사본. 노드가 깨졌을 때를 대비할 수 있고, 볼륨 스케일업을 할 수 있다.
NRT(near realtime)
문서를 색인화하는 시점부터 문서가 검색 가능해지는 시점까지 약간의 대기 시간(대개 1초)
윈도우에서 시작하기
윈도우 환경에서 엘라스틱서치 서버를 실행해보자. 기본적으로는 9200 포트에서 구동된다.
elasticsearch-7.7.1-windows-x86_64.zip
elasticsearch-7.7.1/bin/elasticsearch.bat
엘라스틱서치는 기본적으로 REST API 를 사용하여 데이터를 다룬다. PUT, POST, GET, DELETE 메소드를 사용한다.
우선, 서버가 잘 구동하는지 브라우저에서 확인해보자. 클러스터이름(기본값인 elasticsearch)과 노드이름(컴퓨터이름)을 확인할 수 있다. 단일 클러스터 내에서 단일 노드를 시작했다는 것을 알 수 있다.
http://localhost:9200/
크롬 콘솔에서 클러스터 상태를 확인해보자. 클러스터의 상태가 green인 것을 알 수 있다.
fetch("http://localhost:9200/_cat/health?v").then(res => res.text()).then(console.log)
클러스터의 노드들을 조회해보자. 한 개의 노드가 확인된다.
fetch("http://localhost:9200/_cat/nodes?v").then(res => res.text()).then(console.log)
클러스터에 존재하는 모든 인덱스를 나열해보자. 아직 아무런 인덱스가 없다.
fetch("http://localhost:9200/_cat/indices?v").then(res => res.text()).then(console.log)
인덱스를 만들어 보자. REST API를 본격적으로 사용해본다.
fetch("http://localhost:9200/customer", { method: 'PUT' })
만든 인덱스를 삭제해보자.
fetch("http://localhost:9200/customer", {method: 'DELETE',})
인덱스에 문서를 추가해보자.
- 존재하지 않는 인덱스는 자동 생성된다. (customer)
- 문서의 타입을 지정해주어야 한다. (external)
- ID의 경우, POST를 사용하면 자동 생성, PUT을 사용하면 사용자 지정이다.
fetch("http://localhost:9200/customer/external/1", {
method: 'PUT',
body: JSON.stringify({'name':'코코몽'}),
headers: { 'Content-Type': 'application/json', },
}).then(res => res.text()).then(console.log)
인덱스의 모든 문서를 조회해보자.
fetch("http://localhost:9200/customer/_search").then(res=>res.text()).then(console.log)
추가한 문서를 검색해보자. 문서를 찾았음을 found 값 true 를 통해 알 수 있다.
fetch("http://localhost:9200/customer/external/1").then(res=>res.text()).then(console.log)
기존 문서를 새로운 문서로 대체해보자. (인덱스가 read-only 모드인 경우 실패한다)
fetch("http://localhost:9200/customer/external/1", {
method: 'PUT',
body: JSON.stringify({'name':'뽀로로'}),
headers: { 'Content-Type': 'application/json', },
}).then(res => res.text()).then(console.log)
참고로, 만약 디스크 공간이 부족해서 인덱스가 read-only 모드가 되었다면 다음과 같이 해제해주어야 한다.
fetch("http://localhost:9200/_settings", {
method: 'PUT',
body: JSON.stringify({ 'index.blocks.read_only_allow_delete': null }),
headers: { 'Content-Type': 'application/json', },
}).then(res => res.text()).then(console.log)
문서를 삭제해보자.
fetch("http://localhost:9200/customer/external/1", { method: 'DELETE' })
벌크작업을 할 수도 있다. 오버헤드를 줄일 수 있다. 일단 브라우저에서 한 번 해보자. 잘 된다.
fetch("http://localhost:9200/customer/external/_bulk", {
method: 'POST',
body: '{"index":{"_id":"1"}}\n{"name": "John Doe" }\n{"index":{"_id":"2"}}\n{"name": "Jane Doe" }\n',
headers: { 'Content-Type': 'application/json', },
}).then(res => res.text()).then(console.log)
이번엔 curl for windows와 샘플데이터를 다운받아 벌크작업에 사용해보자. 윈도우에서는 큰 따옴표를 쓰자.
curl -H "Content-Type: application/json" -XPOST "localhost:9200/bank/account/_bulk?pretty&refresh" --data-binary "@accounts.json"
curl "localhost:9200/_cat/indices?v"
리눅스에서 엘라스틱서치
Centos 기반에서 설치해보자.
wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.8.0-x86_64.rpm
wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.8.0-x86_64.rpm.sha512
(shasum 명령어 없으면) yum install -y perl-Digest-SHA
shasum -a 512 -c elasticsearch-7.8.0-x86_64.rpm.sha512
sudo rpm --install elasticsearch-7.8.0-x86_64.rpm
서버를 실행해보자.
# 부팅시 자동시작 등록
sudo systemctl daemon-reload
sudo systemctl enable elasticsearch.service
# 수동시작/중지
sudo systemctl start elasticsearch.service
sudo systemctl stop elasticsearch.service
잘 시작되었는지 확인해보자.
curl "localhost:9200/"
노리 플러그인은 아래와 같이 설치하면 된다.
/usr/share/elasticsearch/bin/elasticsearch-plugin install analysis-nori
아래와 같이 엘라스틱서치를 제거할 수 있다.
sudo rpm -e elasticsearch
아래와 같이 노리 플러그인을 제거할 수 있다.
sudo /usr/share/elasticsearch/bin/elasticsearch-plugin remove analysis-nori
Node Client
node client 를 설치하고 사용해보자
npm install @elastic/elasticsearch
const { Client } = require('@elastic/elasticsearch');
const client = new Client({ node: 'http://localhost:9200' });
const result = await client.search({
index: 'customer',
body: {
"query": {
"match": { "_id": 1 }
}
}
})
인덱스를 해보자. 추가 또는 갱신 중 적절한 것이 수행된다.
await client.index({
index: 'ppost',
id: id,
body: {
mdtitle : frontmatter.title,
mdcontent : md.content,
mdfile : name,
}
})
검색
검색은 GET <인덱스명>/_search 형식으로 할 수 있다. 크게 두 가지 방법이 있다.인덱스명>
- 요청 URI에 검색매개변수(q) 사용 : 루씬 Query String Syntax를 사용하며, q 파라미터 값으로 검색할 내용을 보내면 된다. 주로 빠른 테스트 용도로 사용.
- 요청 본문에 JSON 사용 : Query DSL를 사용하며, JSON 형식의 body로 검색할 내용을 보내면 된다. 보다 복잡한 검색이 가능하다.
fetch("http://localhost:9200/bank/_search?q=*&sort=account_number:asc&pretty").then(res => res.text()).then(console.log)
fetch("http://localhost:9200/bank/_search", {
method: 'POST', // 브라우저에서는 POST를 사용했음
body: JSON.stringify(
{
"query": { "match_all": {} },
"sort": [
{ "account_number": "asc" }
]
}),
headers: { 'Content-Type': 'application/json', },
}).then(res => res.text()).then(console.log)
응답 결과가 의미하는 바를 알아보자.
took
– 검색 시간(밀리초)timed_out
– 시간 초과 여부_shards
– 검색한 샤드 수 및 검색에 성공/실패한 샤드 수hits
– 검색 결과hits.total
– 검색 조건과 일치하는 문서의 총 개수hits.hits
– 검색 결과 배열(기본 설정은 처음 10개 문서)hits.hits._score
- 스코어 (정확도)
hits.sort
- 정렬 키(점수 기준 정렬일 경우 표시되지 않음)
검색 결과를 몇 개나 반환할지 조절하려면 size 매개변수를 사용한다. 디폴트는 10개다. 다음은 한 개의 문서를 리턴한다.
fetch("http://localhost:9200/bank/_search?q=*&size=1&pretty").then(res => res.text()).then(console.log)
페이지네이션을 하려면 from과 size 매개변수를 사용한다. 다음은 10 ~ 19 번 문서를 리턴한다.
fetch("http://localhost:9200/bank/_search?q=*&sort=account_number:asc&from=10&size=10&pretty").then(res => res.text()).then(console.log)
특정 필드만 조회하고 싶다면 _source 매개변수를 사용한다. 다음은 account_number와 balance만 조회한다.
fetch("http://localhost:9200/bank/_search?q=*&_source=account_number,balance&size=10&pretty").then(res => res.text()).then(console.log)
Query String Syntax
Query String Syntax를 여러가지 사용해보자.
fetch("http://localhost:9200/bank/_search?q=account_number:1")
fetch("http://localhost:9200/bank/_search?q=firstname:Que*")
fetch("http://localhost:9200/bank/_search?q=firstname:(Que* OR Veg*)")
fetch('http://localhost:9200/bank/_search?q=address:"madison street"')
fetch('http://localhost:9200/bank/_search?q=address:"street madison"~2')
fetch('http://localhost:9200/bank/_search?q=account_number:[1 TO 3]')
fetch('http://localhost:9200/bank/_search?q=account_number:[1 TO 3}')
fetch('http://localhost:9200/bank/_search?q=address:(vi* -place -street -road)')
fetch('http://localhost:9200/bank/_search?q=address:place^2 madison street')
fetch('http://localhost:9200/bank/_search?q=balance:>49900')
Query DSL
Query DSL을 사용해서 쿼리를 해보자.
let query = { "query": { "match_all": {} },
"from": 10, "size": 5, "sort": {"balance":{"order":"desc"}}, "_source": ["balance"],
} // 디폴트는 match_all
let query = { "query": { "match": { "address": "mill lane" } } }; // 디폴트는 OR
let query = { "query": { "match": { "address" : { "query": "mill lane", "operator": "and" }}}}
let query = { "query": { "match_phrase": { "address": "mill lane" } } };
let query = { "query": { "match_phrase": { "address": { "query": "mill lane", "slop": 1 }}}} // 검색어 사이에 1 단어가 끼어드는 것을 허용
let query = { "query": {
"query_string": {
"default_field": "address",
"query": '(mill AND lane) OR "street"' }}}
let query = {
"query": {
"bool": {
"must": [{"match":{"address":"mill"}},{"match":{"address":"lane"}}] }}}
let query = {
"query": {
"bool": {
"should": [{"match":{/*...*/}},{"match":{/*...*/}}] }}}
let query = {
"query": {
"bool": {
"must": [{"match":{"age":"40"}}],
"must_not": [{"match":{"state":"ID"}}] }}}
let query = {
"query": {
"bool": {
"must": {"match_all":{}},
"filter": {"range":{"balance":{"gte":20000,"lte":30000}}} }}}
fetch("http://localhost:9200/bank/_search", {
method: 'POST', headers: { 'Content-Type': 'application/json', },
body: JSON.stringify( query );
});
검색 관련 알아두면 좋은 것
- 검색시 정렬을 별도로 지정하지 않으면 스코어 순으로 정렬된다.
- should를 사용해 스코어를 조절할 수 있다.
- query context와 filter context를 구분한다고 하니 잘 기억해두자.
- 검색은 기본적으로 하나의 인덱스를 대상으로 하지만, 여러 개의 인덱스를 대상으로 할 수도 있다.
GET <인덱스1>,<인덱스2>/_search
또는GET <인덱*>/_search
와 같은 형식을 사용한다.GET _all/_search
방법도 가능하지만 부하가 크다고 한다. - 엘라스틱서치의 인덱싱 과정에서 데이터는 검색어 토큰인 Term으로 나뉘어진다.
효율적인 검색을 위해 인덱싱 단계에서부터 준비하자
analyzer 를 잘 활용하면 보다 세세하게 검색할 수 있는 것 같다. 엘라스틱 커뮤니티 엔지니어가 작성한 Elasticsearch 에서 한글 형태소 분석 잘 해보기를 잘 읽어보도록 하자.
ElasticSearch 에서 wildcard 쿼리 대신 ngram을 활용하는 방법에 따르면 RDB의 LIKE ‘%…%’ 와 같은 결과를 얻기 위해 n-gram을 사용하면 효과적이라고 한다. 검색 속도는 빨라지고, 인덱스 크기는 커지는 것 같다. 유사한 토크나이저인 edge n-gram은 단어의 첫 글자 위주로 토큰화하는 것 같다.
집계(애그리게이션)
_search에는 집계(aggs) 기능이 있으며, 시각화 등에 사용할 수 있다.
- metrics : min, max, sum, avg, stats, cardinality, percentiles, percentile_ranks
- bucket : range, histogram, date_range, date_histogram, terms
- sub : 버킷 내부에 다시
"aggs" : { }
를 선언 - pipeline : min_bucket, max_bucket, avg_bucket, sum_bucket, stats_bucket, moving_avg, derivative, cumulative_sum
색인(Index)
- 색인 : 데이터를 저장하면서 역인덱스를 만드는 것
- 역인덱스(찾아보기) : 텀별로 텀을 포함하는 문서번호들을 정리해둔 것
- 텀 : 데이터로부터 추출한 각각의 키워드
텍스트분석
- 텍스트분석(Analysis) : 색인 중에서 특히 문자열 필드를 색인하는 과정. 분석기(애널라이저)를 사용한다.
- 애널라이저(Analyzer) : 텍스트분석을 담당한다. 캐릭터필터(문자치환), 토크나이저(문장분할), 토큰필터(토큰후처리)의 세 가지 요소로 이루어진다. 세 가지 요소로 애널라이저를 구성하는 방법은 커스텀 또는 프리셋 방식이 있다.
- 캐릭터필터 : 특정 문자를 다른 문자로 바꾸거나 제거
- html_strip : HTML 태그를 제거
- mapping : 단어를 다른 단어로 바꿈
- pattern_replace : 정규식을 이용해 바꿈
- 토크나이저 : 문장을 토큰으로 분리
- standard : 공백 기준으로 문장을 분리하고 @ 등의 특수문자와 단어 끝의 특수문자를 제거
- letter : 공백,숫자,특수문자 기준으로 문장을 분리
- whitespace : 공백 기준으로 문장을 분리
- uax_url_email : standard와 유사하나 이메일과 웹사이트주소는 보존
- nori_tokenizer :
- user_dictionary
- user_dictionary_rules
- decompound_mode : none, discard(디폴트), mixed
- 토큰필터 : 각각의 토큰에 대한 가공 (필터들의 적용 순서에 주의)
- lowercase : 토큰을 소문자로 변환
- uppercase : 토큰을 대문자로 변환
- stop : a, the 등의 토큰을 색인에서 제외
- snowball : jumps, jumping 등 문법적으로 유사한 토큰을 jump로 뭉침
- synonym : Amazon=AWS 등 유사한 토큰을 하나로 지정
- nGram: 지정한 길이만큼 단어를 쪼개 텀으로 저장
- edgeNGram : nGram과 유사하나 단어의 시작부분을 기준으로 쪼갬
- shingle : nGram의 단어 단위 버전
- unique : 중복된 텀을 제거
- nori_part_of_speech : 지정한 품사를 제거 nori pos tag summary
- nori_readingform : 한자를 한글로 변환
- 토큰 : 텍스트분석과정에서 추출된 키워드이며 색인 과정에서 텀으로 저장됨
- 캐릭터필터 : 특정 문자를 다른 문자로 바꾸거나 제거
형태소분석
-
형태소분석 : 텍스트분석으로 텀을 추출할 때 기본형(어간)을 분석하여 추출하는 것. 대표적인 형태소분석기는 아리랑, 은전한닢, Open Korean Text, 노리 등이 있다.
-
노리(Analyzer)
- 엘라스틱서치 공식 한글 형태소 분석기 (설치 : elasticsearch-plugin install analysis-nori)
- 제공 토크나이저
- nori_tokenizer
- user_dictionary : 사용자 사전 저장 경로
- user_dictionary_rules : 사용자 사전 배열
- decompound_mode : 합성어 저장 방식
- nori_tokenizer
- 제공 토큰필터
- nori_part_of_speech : 제거할 품사를 지정
- nori_readingform : 한자로 된 단어를 한글로
PUT <index>
{
"settings": {
"analysis": {
"analyzer": {
"nori": {
"tokenizer": "nori_tokenizer"
}
}
}
},
...
}
인덱스 설정
- settings : 인덱스를 만들 때 적용할 설정
- number_of_shards, number_of_shards
- refresh_interval
- analysis
- analyzer, char_filter, tokenizer, filter
- mappings : 인덱스에 데이터를 입력할 때 적용할 설정
- 데이터를 색인할 때 필드의 타입 등을 지정
- 데이터를 색인할 때 적용할 애널라이저를 지정
테스트 API
- _analyze API : 임의의 데이터를 임의의 분석기로 테스트해볼 수 있다.
GET _analyze
{
"text": "sample text for analyze API test",
"tokenizer": "whitespace",
"filter": [
"lowercase",
"stop"
]
}
- _termvectors API : 도큐먼트의 역인덱스를 확인할 수 있다.
GET <index>/_termvectors/<docId>?fields=<field>
참고 1
- Elastic 공식사이트 - 시작하기
- RPM 설치
- Elastic 가이드북
- Index API
- Get API
- Search API
- Query String Syntax
- 공식사이트 한국어문서
- 공식 Node.js client
- 공식 Node.js client 2
- 크몽 검색 기능 개선기
참고 2
- 검색 API
- filter, must_not, must
- 정확도
- Elastic 가이드북
- query string syntax
- Elasticsearch 에서 한글 형태소 분석 잘 해보기
- ElasticSearch 에서 wildcard 쿼리 대신 ngram을 활용하는 방법
- n-gram
- edge n-gram