search API는 기본적으로 다음의 API를 사용하고, 여기에서 크게 멋어나는 일이 없다.
GET [index-name]/_search
POST [index-name]/_search
GET _search
POST _search
index-name
을 전달하지 않으면, 모든 index에서 검색을 하므로 index-name
을 전달해주어 성능 부하를 줄이는 것이 좋다.
index-name
에 *
와 같은 wildcard가 가능ㅎ기 때문에 다음과 같이 여러 검색 대상을 지정할 수 있다. 또한, ,
를 통해서 여러 index들을 나열할 수도 있다.
GET my_index*,analyzer_test*,mapping_test/_search
query방법은 두 가지가 있는데, 여태까지 사용해온 query DSL과 URL에 query string을 사용하는 방법이다.
GET my_index/_search
{
"query": {
"match": {
"title": "hello"
}
}
}
GET my_index/_search?q=title:hello
단, 다음과 같이 query string을 사용하려면 q
에 lucene string을 넣어야 한다. 만약 query DSL과 query string을 같이쓰면 query string이 우선을 갖게 된다.
그런데 query string으로 lucene의 query방법을 사용하는 것은 매우 복잡한 일이고, 어렵다. 따라서 query DSL을 쓰도록 하자.
match_all
query는 모든 document를 매치하는 query이다. query부분을 비워 두면 default값으로 지정되는 query이다.
GET [인덱스 이름]/_search
{
"query": {
"match_all": {}
}
}
match
query는 지정한 field의 내용이 질의어와 match되는 document를 찾는 query이다. field가 text
type이라면 field의 값도 질의어도 모두 analyzer로 분석된다.
GET [인덱스 이름]_search
{
"query": {
"match": {
"fieldName": {
"query": "test query sentence"
}
}
}
}
참고로 fieldName
는 document의 field
를 일반화하여 표현한 것이다. 가령, document가 name
field를 가지면 query.match.name.query
가 되는 것이다.
fieldName
이 text
type이고 standard
analyzer를 사용한다면 test query sentence
는 test
token, query
token, sentence
token으로 총 3개의 token으로 분석된다.
match
query는 fieldName
의 값을 분석해서 만든 inverted index에서 이 3개의 term을 찾아 match되는 document를 반환한다. 이 때, match
query의 기본 동작은 OR
조건으로 동작한다. 가령, fieldName
값이 sentence structure
인 document가 있다면, setence
term으로만 매치되고 test
, query
term은 매치되지 않지만 최종 검색 결과에 남는다.
만약 and
조건 검색을 하고싶다면 operator를 사용하면 된다.
GET [인덱스 이름]/_search
{
"query": {
"match": {
"fieldName": {
"query": "test query sentence",
"operator": "and"
}
}
}
}
term
query는 지정한 field의 값이 query문과 정확히 일치하는 document를 찾는 query이다. 대상 field에 normalizer가 지정되어있다면 query문도 normalizer 처리를 거친다.
GET [인덱스 이름]/_search
{
"query": {
"term": {
"fieldName": {
"value": "hello"
}
}
}
}
term
query는 keyword
type과 잘 맞는다. query도 document도 같은 normalizer처리를 거치므로 직관적으로 사용할 수 있다.
text
type의 field를 대상으로 term
query를 사용하는 경우는 약간 다르다. query문은 normalizer를 거치지만 field의 값은 analyzer로 분석된 후에 생성된 inverted index를 사용한다. 분석 결과 단일 term이 생성되었고, 그 값이 normalizer를 거친 query문과 완전히 같은 경우에만 검색에 성공하는 것이다.
terms query는 지정한 field의 값이 정확히 일치하는 document를 찾되, 여러 개의 query를 지정할 수 있다. 하나 이상의 query가 일치하면 검색 결과에 포함된다.
GET [인덱스 이름]/_search
{
"query": {
"terms": {
"fieldName": ["hello", "world"]
}
}
}
range
query는 지정한 field값이 특정 범위 내에 있는 document를 찾는 query이다.
GET [인덱스 이름]/_search
{
"query": {
"range": {
"fieldName": {
"gte": 100,
"lt": 200
}
}
}
}
범위는 gt
, lt
, gte
, lte
를 이용하여 지정한다. 단, 조심해야할 것은 range
query는 숫자형일 때만 사용하는 것을 추천한다. elasticsearch에서 문자열 field를 대상으로 한 range query는 부하가 큰 query로 분류한다. 따라서 range
query는 data양상을 파악하고 부담이 없는 상황에서만 사용해야 한다.
range
query는 field가 date
type이라면 다음과 같이 간단한 날짜 시간 계산식도 사용할 수 있다.
GET [인덱스 이름]/_search
{
"query": {
"range": {
"dateField": {
"gte": "2019-01-15T00:00:000Z||+36h/d",
"lte": "now-3h/d"
}
}
}
}
날짜 시간 계산에는 다음과 같은 표현이 사용된다.
||
: 날짜 시간 문자열의 마지막에 붙인다. 이 뒤에 붙은 문자열은 시간 계산식으로 파싱된다.+
와 -
: 지정된 시간만큼 더하거나 빼는 연산을 수행한다./
:버림을 수행한다. 가령 d
는 날짜 단위 이하의 시간을 버림한다.prefix
query는 field의 값이 지정한 질의어로 시작하는 document를 찾는 query이다.
GET [인덱스 이름]/_search
{
"query": {
"prefix": {
"fieldName": {
"value" "hello"
}
}
}
}
prefix
도 무거운 query로 분류된다. 다만 prefix
query는 와일드카드처럼 성능에 엄청난 부하를 주는 API는 아니다. 단발성 query로는 문제가 없지만 일상적으로 호출되는 service query로는 적절하지 못하다. 만약, service query로 prefix
를 사용하려고 한다면, prefix
를 미리 indexing해놓는 방법을 사용하는 것이 좋다. 이는 index_prefixes
를 설정하는 것이다.
PUT prefix_mapping_test
{
"mappings": {
"properties": {
"prefixField": {
"type": "text",
"index_prefixes": {
"min_chars": 3,
"max_chars": 5
}
}
}
}
}
index_prefixes
를 지정하면 elasticsearch document를 indexing할 때 min_chars
와 max_chars
사이의 prefix를 미리 별도로 indexing해놓는 것이다. default로 min_chars
가 2이고 max_chars
가 5이다.
exists
query는 지정한 field를 포함하고 있는 document를 검색한다. 즉, 지정한 field를 가진 document를 얻어내는 것이다.
GET [인덱스 이름]/_search
{
"query": {
"exists": {
"field": "fieldName"
}
}
}
bool
query는 여러 query를 조합하여 검색하는 query이다. must
, must_not
, filter
, should
의 4가지 종류의 조건절에 다른 query를 조합하여 사용한다.
GET [인덱스 이름]/_search
{
"query": {
"bool": {
"must": [
{"term": {"field1": {"value": "hello"}}},
{"term": {"field2": {"value": "world"}}}
],
"must_not": [
{"term": {"field4": {"value": "elasticsearch-test"}}}
],
"filter": [
{"term": {"field3": {"value": true}}}
],
"should": [
{"term": {"field4": {"value": "elasticsearch"}}},
{"term": {"field5": {"value": "lucene"}}}
],
"minimum_should_match": 1
}
}
}
must
, must_not
, should
, filter
이 중 필요한 부분만 골라쓰면 된다.
must
조건절과 filter
조건절에 들어간 하위 query는 모두 AND
조건으로 만족해야 죄종 검색 결과에 포함된다. must_not
조건절에 들어간 query를 만족하는 document는 최종 검색 결과에서 제외된다. should
조건절에 들어간 query는 minimum_should_match
에 지정한 개수 이상의 하위 query를 만족하는 document가 최종 검색 결과에 포함된다. minimum_should_match
의 기본값은 1이며, 이는 1개 이상만 들어가면 된다말이다. 즉 OR
이다.
must
와 filter
는 모두 AND
조건으로 검색을 수행하지만 점수를 계산하느냐의 여부가 다르다.
filter
, must_not
는 점수를 매기지 않는다. 이렇게 점수를 매기지 않고 단순히 조건을 만족하는 지 여부만 참, 거짓으로 따지는 검색 과정을 filter context
라고 한다.
반면에, document가 주어진 검색 조건을 얼마나 더 잘 만족하는지 유사도 점수를 매기는 검색 과정은 query context
이라고 한다.
query context
와 filter context
를 정리하면 다음과 같다.
||query context | filter context|
|----------------------|---------------------------|------------------------|
|query| document가 query와 얼마나 잘 매치되는가? | query 조건을 만족하는 가? |
|score| 계산 | 계산하지 않음 |
|성능 | 계산 때문에 느림 | 계산이 없어 상대적으로 빠름 |
|캐시 | query 캐시 불가 | query 캐시 가능 |
|종류 | bool
의 must
, bool
의 should
, match
, term
등 | bool
의 filter
, bool
의 must_not
, exists
, range
, constance_score
등 |
bool
query와 같이 여러 query를 조합하면 무엇이 먼저 실행될까? 사실 어떤 query가 먼저 수행된다는 규칙은 없다. elasticsearch는 내부적으로 query를 licene의 여러 query로 쪼갠 뒤 조합하여 재작성한다. 그 뒤 쪼개진 각 query를 수행할 경우 비용이 얼마나 소모되는지 내부적으로 추정한다.
이 비용 추정에는 inverted index에 저장해 둔 정보나 통계 정보 등이 활용된다. 따라서, 추정한 비용과 효과를 토대로 유리할 것으로 생각되는 부분을 먼저 수행한다.
내부적으로 점수를 추정하고, query
순서와 하위 query를 실행하는 방법들은 매우 복잡하므로 최적화 작업이 이루어진다고만 생각하도록 하자.
explain
을 사용하면 검색을 수행하는 동안 query의 각 하위 부분에서 score가 어떻게 계산됐는지 설명해준다. 그러므로 디버깅 용도로 사용할 수 있다. 다음과 같이 expain
매개변수를 true
로 지정해서 검색을 수행하면 된다.
GET [인덱스 이름]/_search?explain=true
{
"query": {
//..
}
}
다음과 같이 document를 indexing을 한 후 bool
query에 explain
을 지정해서 테스트해보자.
PUT my_index3/_doc/1
{
"field1": "hello",
"field2": "world",
"field3": true
}
위와 같이 my_index3
index를 만들었다. 다음으로 _search
를 해주고 explain=true
를 해주도록 하자.
GET my_index3/_search?explain=true
{
"query": {
"bool": {
"must": [
{"term": {"field1": {"value": "hello"}}},
{"term": {"field2": {"value": "world"}}}
],
"must_not": [
{"term": {"field4": {"value": "elasticsearch-test"}}}
],
"filter": [
{"term": {"field3": {"value": true}}}
],
"should": [
{"term": {"field4": {"value": "elasticsearch"}}},
{"term": {"field5": {"value": "lucene"}}}
]
}
}
}
엄청나게 많은 score
결과들이 나올 것이다. explain
은 오직 디버깅을 위해서만 쓰는 것이 좋다.
sort
를 지정하면 검색 결과를 정렬할 수 있다. 정렬에 사용할 field이름과 오름차순 또는 내림차순 종류를 지정하면 된다.
GET [인덱스 이름]/_search
{
"query": {
//...
},
"sort": [
{"field1": {"order": "desc"}},
{"field2": {"order": "asc"}},
"field3"
]
}
위와 같이 정렬 대상 field를 여럿 지정할 수도 있다. 이런 경우 요청에 지정한 순서대로 정렬을 수행한다. field3
처럼 field 이름만을 명시하면 내림차순으로 정렬한다.
숫자 타입
, date
, boolean
, keyword
타입은 정렬 대상이 되지만, text
타입은 정렬 대상으로 지정할 수 없다. 물론 설정을 해주면 쓸 수 있지만 성능상 심각한 문제를 야기할 수 있다.
검색 결과를 pagenation할 수도 있다. 기본적으로 from
, size
, scroll
을 사용하고 성능 부담은 상대적으로 낮춘 채, 본격적인 pagenation을 할 수 있는 search_after
사용할 수 있다.
size
는 search API의 결과로 몇 개의 document를 반환할 것인지를 지정한다. from
은 몇 번째 문서부터 결과를 반환할지 그 offset을 지정한다.
GET [인덱스 이름]/_search
{
"from": 10,
"size": 5,
"query": {
// ...
}
}
위와 같이 지정해 검색하면 유사도 점수로 내림차순 정렬된 문서들 중 11번째부터 15번째까지 5개의 문서가 반환된다.
from
의 default값은 0, size
는 10이다. 그러나 사실 from
과 size
는 pagenation에 사용하지 않는다. 다음의 이유가 있다.
from
의 값만큼 넘기는 것이 아니라, 실제 검색하고 size
만큼 가져오는 연산이다. 즉, from
이 15이면 15개의 document를 실제로 검색한다음에 넘기고, size
만큼 document를 가져오는 것이다. 이는 CPU와 memory소모량이 너무 크다.from
, size
로 document를 가져오는 것은 마치 고정된 dataset에서 data를 가져오는 것처럼보이지만, 실제로 data는 계속추가된다. 즉 앞에서 5개의 document를 보았고 다음으로 5개를 보려고 했는데 새로운 document가 추가되거나 삭제되면, 기대했던 page가 나오지 않을 수 있다.따라서 pagenation을 위해서 from
과 size
를 사용해서는 안된다.
scroll
은 검색 조건에 매칭되는 전체 document를 모두 순회해야 할 때 적합한 방법이다. scroll을 순회하는 동안에는 최초 검색 시의 문맥(search context
)이 유지된다. 중복이나 누락도 발생하지 않는다.
GET [인덱스 이름]/_search?scroll=1m
{
"size": 1000,
"query": {
//...
}
}
결과가 다음과 같이 나올 것이다.
{
"_scroll_id" : "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFlJsRDVoSkU2Ullpai1Kcl9FZjdoVUEAAAAAAAADqhZ2WDRRN3ljclJCdUdkT2ZjRHoyU19B",
"took" : 0,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 90677,
"relation" : "eq"
},
"max_score" : 1.0,
"hits" : [
...
]
}
}
hits
에 실제 data들이 나올 것이다. 다음 data들을 가져오기 위해서는 _scroll_id
값을 이용하면 된다.
GET _search/scroll
{
"scroll_id": "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFlJsRDVoSkU2Ullpai1Kcl9FZjdoVUEAAAAAAAADeRZ2WDRRN3ljclJCdUdkT2ZjRHoyU19B",
"scroll": "1m"
}
_search/scroll
을 API를 이용하여 scroll_id
를 넘겨주면 위에서 검색한 결과 다음 결과를 hit시켜준다. 재밌는 것은 context가 유지되는 것인데, 계속 해당 API를 호출하면 계속해서 다음으로 넘어간다는 것이다. 이렇게 hit 더 이상 나오지 않을 때까지 반복하면 된다. scroll:1m
에서의 1m
이 바로 이 context을 얼마나 유지시킬 것인가를 말한다. 1분후가 지난 후에는 다음의 결과가 나올 것이다.
{
"error" : {
"root_cause" : [
{
"type" : "search_context_missing_exception",
"reason" : "No search context found for id [889]"
}
],
"type" : "search_phase_execution_exception",
"reason" : "all shards failed",
"phase" : "query",
"grouped" : true,
"failed_shards" : [
{
"shard" : -1,
"index" : null,
"reason" : {
"type" : "search_context_missing_exception",
"reason" : "No search context found for id [889]"
}
}
],
"caused_by" : {
"type" : "search_context_missing_exception",
"reason" : "No search context found for id [889]"
}
},
"status" : 404
}
만약 더 빨리 해당 context를 해제시키고 싶다면 DELETE
API를 이용하면 된다.
DELETE _search/scroll
{
"scroll_id": "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFlJsRDVoSkU2Ullpai1Kcl9FZjdoVUEAAAAAAAADeRZ2WDRRN3ljclJCdUdkT2ZjRHoyU19B"
}
scroll
은 search context를 보존한 뒤에 전체 문서를 순회하는 동작 특성상 검색 결과의 정렬 여부가 상관없는 경우가 많다. 그렇기 때문에 sort
부분을 _doc
으로 지정하는 것이 좋은데, 이렇게하면 유사도 점수를 계산하지 않으며 정렬을 위한 별도의 자원도 사용하지 않는다. 이를 통해 scroll을 순회하는 성능을 끌어올릴 수 있다.
GET [인덱스 이름]/_search?scroll=1m
{
"size": 1000,
"query": {
//...
},
"sort": [
"_doc"
]
}
단, 현재 scroll을 사용을 추천하지 않는다. scroll은 지속적으로 호출하는 것을 의도하고 만들어진 기능이 아니다. 주로 대량의 데이터를 다른 스토리지로 이전하거나 덤프하는 용도로 사용한다. 서비스에서 사용자가 지속적으로 호출하기 위한 pagenation 용도로는 search_after
가 좋다.
서비스에서 사용자가 search 결과를 요청하게하고 result에 pagenation을 제공하는 용도라면 search_after
를 사용하는 것이 가장 적합하다. search_after
에는 sort
를 지정해야한다. 이때 동일한 정렬 값이 등장할 수 없도록 최소한 1개 이상의 동점 제거(tiebreaker)용 field를 지정해야한다.
GET [인덱스 이름]/_search
{
"size": 10,
"query": {
"term": {
"log": {
"value": "health"
}
}
},
"sort": [
{
"@timestamp": "desc"
},
{
"_id": "asc"
}
]
}
다음과 같이 sort
옵션을 주어야한다. sort
가 주어져야 sort
에서 나온 key값을 통해 pagenation이 가능하다. 가장 많이 사용되는 것이 @timestamp
이다.
결과가 다음과 같이 나온다.
{
"took" : 31,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
...
},
"max_score" : null,
"hits" : [
{
..
"sort" : [
1701560794404,
"8uykR4wBZMKdxxkqIGib"
]
}
]
}
}
sort
부분에 나온 값들을 가져와 search_after
부분에 넣어 다음 검색을 요청하면 된다.
GET [인덱스 이름]/_search
{
"size": 10,
"query": {
"term": {
"log": {
"value": "health"
}
}
},
"search_after": [1701560794404, "8uykR4wBZMKdxxkqIGib"],
"sort": [
{
"@timestamp": "desc"
},
{
"_id": "asc"
}
]
}
이런 식으로 pagenation을 사용하면 된다.
위의 예에서는 _id
를 sort
의 정렬값으로 사용했지만 사용하지 않는 것이 좋다. _id
field는 doc_value
가 꺼져 있기 때문에 이를 기준으로 하는 정렬은 많은 메모리를 소모하게 된다.
_id
field값과 동일한 값을 별도의 field에 저장해 두었다가 동점 제거용으로 사용하는 것이 좋다.
그러나, search_after
역시도 인덱스 상태가 변하는 도중이라면 pagenation과정에서 누락되는 document가 발생하거나, 이전 document가 두 번 출력되는 등의 문제가 발생할 수 있다. 즉, 일관적이지 않을 수 있다는 것이다. search_after
를 사용할 때 index상태를 특정 시점으로 고정하려면 point in time API
를 함께 조합해서 사용하면 된다.
point int time API
(pit)는 검색 대상의 상태를 고정할 때 사용한다. keep_alive
매개변수에 상태를 유지할 시간을 지정하면 된다.
POST [인덱스 이름]/_pit?keep_alive=1m
다음의 응답이 전달될 것이다.
{
"id" : "q9u1AwEgc2VydmVyLTE3MC1yaWMtMjAyMy4xMi4wMi0wMDAwMDEWRHRBaHRxV3NSckdKV1R1eURrWEVodwAWdlg0UTd5Y3JSQnVHZE9mY0R6MlNfQQAAAAAAAAAzpxZSbEQ1aEpFNlJZaWotSnJfRWY3aFVBAAEWRHRBaHRxV3NSckdKV1R1eURrWEVodwAA"
}
이렇게 얻은 pit id를 search_after
와 같은 곳에 활용할 수 있다. 다음과 같이 검색해보도록 하자.
GET _search
{
"size": 10,
"query": {
"term": {
"log": {
"value": "health"
}
}
},
"pit": {
"id": "q9u1AwEgc2VydmVyLTE3MC1yaWMtMjAyMy4xMi4wMi0wMDAwMDEWRHRBaHRxV3NSckdKV1R1eURrWEVodwAWdlg0UTd5Y3JSQnVHZE9mY0R6MlNfQQAAAAAAAAA0ThZSbEQ1aEpFNlJZaWotSnJfRWY3aFVBAAEWRHRBaHRxV3NSckdKV1R1eURrWEVodwAA",
"keep_alive": "1m"
},
"sort": [
{
"@timestamp": "desc"
},
{
"_id": "asc"
}
]
}
다음과 같이 요청하면, 이전과 같이 hit
에 item들이 나오게되는데 이 상태는 현재 고정이기 때문에 변동이 없다. 주의할 것은 API를 잘보면 인덱스 이름
부분이 없다. 이는 이미 pit
로 현재 지점을 딱 지정했기 때문이다. 때문에 index
를 쓰지 않아도되는 것이다.
응답이 다음과 같이 나올 것이다.
{
"pit_id" : "q9u1AwEgc2VydmVyLTE3MC1yaWMtMjAyMy4xMi4wMi0wMDAwMDEWRHRBaHRxV3NSckdKV1R1eURrWEVodwAWdlg0UTd5Y3JSQnVHZE9mY0R6MlNfQQAAAAAAAAA1gxZSbEQ1aEpFNlJZaWotSnJfRWY3aFVBAAEWRHRBaHRxV3NSckdKV1R1eURrWEVodwAA",
"took" : 1,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 659,
"relation" : "eq"
},
"max_score" : null,
"hits" : [
{
...
"sort" : [
1701560794404,
"8uykR4wBZMKdxxkqIGib",
22035
]
}
]
}
}
이 pit
를 사용해서 계속해서 다음으로 검색을 넘기고 싶다면 search_after
에 sort부분의 field값을 동일하게 넣어주면 된다. 3개의 값이 있는데, 1701560794404
과 22035
을 이용하면 된다.
GET _search
{
"size": 10,
"query": {
"term": {
"log": {
"value": "health"
}
}
},
"pit": {
"id": "q9u1AwEgc2VydmVyLTE3MC1yaWMtMjAyMy4xMi4wMi0wMDAwMDEWRHRBaHRxV3NSckdKV1R1eURrWEVodwAWdlg0UTd5Y3JSQnVHZE9mY0R6MlNfQQAAAAAAAAA1gxZSbEQ1aEpFNlJZaWotSnJfRWY3aFVBAAEWRHRBaHRxV3NSckdKV1R1eURrWEVodwAA",
"keep_alive": "1m"
},
"search_after": [1701560729410, 21296],
"sort": [
{
"@timestamp": "desc"
},
{
"_id": "asc"
}
]
}
재밌는 것은 이후부터는 응답에서 sort
field의 값이 3개가 아니라, 2개로 나온다.
{
"sort" : [
1701560794404,
"8uykR4wBZMKdxxkqIGib"
]
}
]
}
}
계속해서 pagenation을 하려면 1701560794404
, 8uykR4wBZMKdxxkqIGib
을 다음 search_after
에 넣어주면 되는 것이다.