제품

"Query Then Fetch"와 "DFS Query Then Fetch" 이해하기

저희의 시작 구문 일치에 관한 지난 기사에서 실행 중인 상황을 발견했습니다. 복습 차원에서 해당 쿼리를 다시 보여 드립니다:

$ curl -XGET localhost:9200/startswith/test/_search?pretty -d '{
        "query": {
        "match_phrase_prefix": {
           "title": {
             "query": "d",
             "max_expansions": 5
           }
         }
       }
     }' | grep title

      "_score" : 1.0, "_source" : {"title":"drunk"}
      "_score" : 0.30685282, "_source" : {"title":"dzone"}
      "_score" : 0.30685282, "_source" : {"title":"data"}
      "_score" : 0.30685282, "_source" : {"title":"drive"}

“drunk” 문서가 1.0점을 받고 나머지는 0.3점을 받는 것을 보셨습니까? “d” 쿼리에 동일하게 일치하므로 이 문서들의 점수가 모두 같아야 하지 않을까요? 정답은 그렇다이지만, 이러한 점수 불일치에는 매우 타당한 이유가 있습니다.

관련성 점수 산정

Elasticsearch(및 내부의 Lucene)에서 사용하는 점수 산정 알고리즘의 일부에는 인덱스 내 문서의 관련성을 계산하는 데 도움이 되는 “TF-IDF(Term Frequency – Inverse Document Frequency, 용어 빈도-역문서 빈도)” 통계가 포함되어 있습니다.

TF-IDF에 관해서는 많은 내용이 작성되었지만, 기본적으로 “용어가 문서에 많이 나타날수록 해당 문서의 관련성이 높다. 그러나 관련성은 전체 인덱스에서 해당 용어가 얼마나 자주 나타나는지에 따라 감소한다”는 것을 의미합니다.

희귀 용어는 소수의 문서에만 존재하므로 희귀 용어와 일치하는 모든 쿼리는 관련성이 매우 높아집니다. 반대로 일반적인 용어는 어디에나 있으므로 쿼리에 대한 관련성이 낮습니다.

Elasticsearch는 검색을 실행할 때 흥미로운 딜레마에 직면합니다. 쿼리는 모든 관련 문서를 찾아야 하지만, 이 문서들은 클러스터 내의 여러 샤드에 흩어져 있습니다.

각 샤드는 기본적으로 자체 TF 및 DF 통계를 유지관리하는 Lucene 인덱스입니다. 샤드는 전체 클러스터가 아니라 해당 샤드 내에서 “pineapple”이 몇 번 나타나는지만 알고 있습니다.

하지만 관련성 알고리즘은 TF-IDF를 사용하는데, 각 샤드가 아니라 전체 인덱스에 대한 TF 및 DF를 알아야 하지 않을까요?

기본 검색 유형: Query Then Fetch

정답은 예와 아니오입니다. 기본적으로 Elasticsearch는 “Query Then Fetch”라는 검색 유형을 사용합니다. 작동 방식은 다음과 같습니다:

  1. 각 샤드에 쿼리 전송
  2. 로컬 용어/문서 빈도를 사용하여 일치하는 모든 문서를 찾고 점수를 계산합니다
  3. 결과 우선순위 큐 구축(정렬, from/to를 사용한 페이지 매김 등)
  4. 요청 노드에 결과에 대한 메타데이터를 반환합니다. 참고로, 실제 문서는 아직 전송되지 않으며 점수만 전송됩니다.
  5. 모든 샤드의 점수가 요청 Node에서 병합 및 정렬되고, 쿼리 기준에 따라 문서가 선택됩니다
  6. 마지막으로, 실제 문서는 해당 문서가 위치한 개별 샤드에서 검색됩니다.
  7. 결과가 클라이언트에 반환됩니다

이 시스템은 보통 잘 작동합니다. 대부분의 경우 인덱스에는 용어/문서 빈도 통계를 원활하게 처리할 수 있는 “충분한” 문서가 있습니다. 따라서 각 샤드가 클러스터 전체의 빈도에 대한 완전한 정보를 가지고 있지 않더라도, 빈도가 모든 곳에서 상당히 유사하기 때문에 결과는 “충분히 좋은” 수준입니다.

하지만 이 글의 시작 부분에서 언급한 쿼리의 경우, 기본 검색 유형이 때때로 실패합니다.

DFS Query Then Fetch

지난 글에서 샤드 수를 지정하지 않고 인덱스를 구축했을 때, Elasticsearch는 기본값인 5개의 샤드를 사용했습니다. 그런 다음 인덱스에 겨우 5개의 문서를 삽입하고 ES에 관련성 높은 결과와 정확한 점수를 반환하도록 요청했습니다. 공평하지 않죠?

점수 불일치는 Query Then Fetch 검색 유형으로 인해 발생했습니다. 각 샤드에는 1~2개의 문서만 포함되어 있었습니다(ES에서 사용하는 해싱 알고리즘은 비교적 무작위적인 분포를 보장합니다). Elastic에 점수 계산을 요청했을 때, 각 샤드는 5개 문서로 구성된 인덱스의 아주 작은 부분만 볼 수 있었기 때문에 점수가 부정확했습니다.

다행히 Elasticsearch는 사용자를 방치하지 않습니다. 이러한 점수 불일치가 문제가 되는 상황이라면, ES는 “DFS Query Then Fetch”라는 검색 유형을 제공합니다. 이 절차는 Query Then Fetch와 거의 동일하지만, 전역 문서 빈도를 계산하기 위해 사전 쿼리를 수행한다는 점이 다릅니다.

  1. 용어 및 문서 빈도에 대해 각 샤드를 사전 쿼리합니다
  2. 각 샤드에 쿼리 전송
  3. 모든 일치하는 문서를 찾고 사전 쿼리에서 계산된 전역 용어/문서 빈도를 사용하여 점수를 계산합니다.
  4. 결과 우선순위 큐 구축(정렬, from/to를 사용한 페이지 매김 등)
  5. 요청 노드에 결과에 대한 메타데이터를 반환합니다. 참고로, 실제 문서는 아직 전송되지 않으며 점수만 전송됩니다.
  6. 모든 샤드의 점수가 요청 Node에서 병합 및 정렬되고, 쿼리 기준에 따라 문서가 선택됩니다
  7. 마지막으로, 실제 문서는 해당 문서가 위치한 개별 샤드에서 검색됩니다.
  8. 결과가 클라이언트에 반환됩니다

이 새로운 검색 유형을 이전 쿼리에 적용하면 의미 있는(예: 모두 동일한) 스코어링 결과를 얻을 수 있습니다:

$ curl -XGET 'localhost:9200/startswith/test/_search?pretty=true&search_type=dfs_query_then_fetch' -d '{
        "query": {
        "match_phrase_prefix": {
           "title": {
             "query": "d",
             "max_expansions": 5
           }
         }
       }
     }' | grep title

      "_score" : 1.9162908, "_source" : {"title":"dzone"}
      "_score" : 1.9162908, "_source" : {"title":"data"}
      "_score" : 1.9162908, "_source" : {"title":"drunk"}
      "_score" : 1.9162908, "_source" : {"title":"drive"}

결론

물론, 더 나은 정확도는 공짜로 얻어지는 것이 아닙니다. 사전 쿼리(prequery)는 샤드 간의 추가적인 왕복을 유발하며, 이는 인덱스 크기, 샤드 수, 쿼리 속도 등에 따라 성능 저하를 일으킬 수 있습니다. 대부분의 경우 이는 전혀 불필요하며... “충분한” 데이터가 있으면 문제가 해결됩니다.

하지만 때때로 이상한 스코어링 상황을 마주할 수 있으며, 그러한 경우에는 DFS Query then Fetch를 사용하여 검색 실행 계획을 조정하는 방법을 알아두는 것이 유용합니다.