ElasticSearch 정리 8일차 - search API

0

elasticsearch

목록 보기
8/9

Search API

serach

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을 사용하는 방법이다.

  • query DSL
GET my_index/_search
{
    "query": {
        "match": {
            "title": "hello"
        }
    }
}
  • query string
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

match_all query는 모든 document를 매치하는 query이다. query부분을 비워 두면 default값으로 지정되는 query이다.

GET [인덱스 이름]/_search
{
    "query": {
        "match_all": {}
    }
}

match query

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가 되는 것이다.

fieldNametext type이고 standard analyzer를 사용한다면 test query sentencetest 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

termquery는 지정한 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

terms query는 지정한 field의 값이 정확히 일치하는 document를 찾되, 여러 개의 query를 지정할 수 있다. 하나 이상의 query가 일치하면 검색 결과에 포함된다.

GET [인덱스 이름]/_search
{
    "query": {
        "terms": {
            "fieldName": ["hello", "world"]
        }
    }
}

range query

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"
            }
        }
    }
}

날짜 시간 계산에는 다음과 같은 표현이 사용된다.

  • now: 현재 시각
  • ||: 날짜 시간 문자열의 마지막에 붙인다. 이 뒤에 붙은 문자열은 시간 계산식으로 파싱된다.
  • +-: 지정된 시간만큼 더하거나 빼는 연산을 수행한다.
  • /:버림을 수행한다. 가령 d는 날짜 단위 이하의 시간을 버림한다.

prefix query

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_charsmax_chars 사이의 prefix를 미리 별도로 indexing해놓는 것이다. default로 min_chars가 2이고 max_chars가 5이다.

exists query

exists query는 지정한 field를 포함하고 있는 document를 검색한다. 즉, 지정한 field를 가진 document를 얻어내는 것이다.

GET [인덱스 이름]/_search
{
    "query": {
        "exists": {
            "field": "fieldName"
        }
    }
} 

bool query

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이다.

query context와 filter context

mustfilter는 모두 AND조건으로 검색을 수행하지만 점수를 계산하느냐의 여부가 다르다.

filter, must_not는 점수를 매기지 않는다. 이렇게 점수를 매기지 않고 단순히 조건을 만족하는 지 여부만 참, 거짓으로 따지는 검색 과정을 filter context라고 한다.

반면에, document가 주어진 검색 조건을 얼마나 더 잘 만족하는지 유사도 점수를 매기는 검색 과정은 query context이라고 한다.

query contextfilter context를 정리하면 다음과 같다.
||query context | filter context|
|----------------------|---------------------------|------------------------|
|query| document가 query와 얼마나 잘 매치되는가? | query 조건을 만족하는 가? |
|score| 계산 | 계산하지 않음 |
|성능 | 계산 때문에 느림 | 계산이 없어 상대적으로 빠름 |
|캐시 | query 캐시 불가 | query 캐시 가능 |
|종류 | boolmust, boolshould, match, term 등 | boolfilter, boolmust_not, exists, range, constance_score 등 |

bool query와 같이 여러 query를 조합하면 무엇이 먼저 실행될까? 사실 어떤 query가 먼저 수행된다는 규칙은 없다. elasticsearch는 내부적으로 query를 licene의 여러 query로 쪼갠 뒤 조합하여 재작성한다. 그 뒤 쪼개진 각 query를 수행할 경우 비용이 얼마나 소모되는지 내부적으로 추정한다.

이 비용 추정에는 inverted index에 저장해 둔 정보나 통계 정보 등이 활용된다. 따라서, 추정한 비용과 효과를 토대로 유리할 것으로 생각되는 부분을 먼저 수행한다.

내부적으로 점수를 추정하고, query순서와 하위 query를 실행하는 방법들은 매우 복잡하므로 최적화 작업이 이루어진다고만 생각하도록 하자.

explain

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

검색 결과를 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이다. 그러나 사실 fromsize는 pagenation에 사용하지 않는다. 다음의 이유가 있다.

  1. from의 값만큼 넘기는 것이 아니라, 실제 검색하고 size만큼 가져오는 연산이다. 즉, from이 15이면 15개의 document를 실제로 검색한다음에 넘기고, size만큼 document를 가져오는 것이다. 이는 CPU와 memory소모량이 너무 크다.
  2. from, size로 document를 가져오는 것은 마치 고정된 dataset에서 data를 가져오는 것처럼보이지만, 실제로 data는 계속추가된다. 즉 앞에서 5개의 document를 보았고 다음으로 5개를 보려고 했는데 새로운 document가 추가되거나 삭제되면, 기대했던 page가 나오지 않을 수 있다.

따라서 pagenation을 위해서 fromsize를 사용해서는 안된다.

scroll

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_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을 사용하면 된다.

위의 예에서는 _idsort의 정렬값으로 사용했지만 사용하지 않는 것이 좋다. _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개의 값이 있는데, 170156079440422035을 이용하면 된다.

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에 넣어주면 되는 것이다.

0개의 댓글