[엘라스틱서치] Elasticsearch in action 정리(4) - 유사도 검색

2020. 8. 3. 15:48 Big Data/빅데이터

문서의 구조가 정형화되지 않은 환경에서 다양한 저장소와 검색엔진이 검색조건에 맞는 문서를 찾을 수 있다. 엘라스틱 서치가 "SELECT * FROM users WHERE name LIKE 'bob%'"이라는 질의와 갖는 차이점은 관련 있는 문서를 점수를 통해 묶어주는 기능이다. 이 점수로부터 찾고자 하는 질의와 문서가 얼마나 관련있는지 알 수 있다.

 

사용자가 웹사이트 검색창에 입력하는 검색어는 입력한 검색어에 꼭 맞는 것뿐만 아니라, 유사한 것들도 같이 보기를 원한다. 알려진 대로 엘라스틱서치는 문서의 연관성을 결정하는데 유연성을 가지고 있고, 관련성 높은 결과를 얻기 위하여 사용자가 검색을 정의하는 많은 방법이 있다.

 

문서가 질의에 얼마나 잘맞는지 특별히 고려하지 않고 단지 문서가 일치하는지 안하는지 여부만 고려하는 상황에 있어도 문제될건 없다. 문서를 걸러내는 유연한 방법을 방법을 알아보고, 필드 데이터 캐시를 이해하는 것이 중요하다. 필드 데이터 캐시는 엘라스틱서치가 색인 문서들에서 필드의 값을 저장하는 인메모리 캐시이고, 정렬, 스크립팅 혹은 필드들 안의 값들을 집계할 때 사용한다. 

 

엘라스틱서치의 기본 점수 계산 알고리즘뿐만 아니라, 부스팅을 이용하여 점수에 직접 영향을 미치는 방법, 그리고 explain API를 이용하여 점수가 어떻게 계산되는지 알아본다. 질의 리스코어링을 사용하여 점수의 영향을 감소하는 방법도 존재한다. 질의 리스코어링은 쿼리를 확장하여 function score 질의로 점수에 대한 완벽한 통제를 하고 스크립트를 이용해서 원하는 정렬을 할 수 있다. 인메모리 필드 데이터캐시가 질의에 어떤 영향을 미치는지와 doc values라고 불리는 필드 데이터 캐시의 대안도 살펴본다.

 

엘라스틱서치가 문서에 대한 점수를 계산하는 방법.

엘라스틱서치는 질의가 문서에 관련 있는지로 검색에 접근한다. '문서가 일치한다/일치하지 않는다'와 같은 이분법으로는 A 문서가 B 문서보다 더 질의에 일치하는지를 알아낼 수 없다. 질의에 대한 문서를 찾는 과정을 'Scoring'이라고 하고, 엘라스틱서치가 점수를 부여하는 방식을 알 필요는 없지만, 안다면 사용하는데 유용하다. 

 

 

문서 점수 계산 방법

루씬 및 엘라스틱서치의 점수 계산 방법은 문서를 받아 몇가지 방법을 이용하여 문서의 점수를 계산하고 있다. 이용자가 찾고자 하는 문서와 관련 있는 문서가 처음으로 나오길 바라는데, 루씬과 엘라스틱서치에서는 이를 점수라고 한다. 점수 계산의 시작은 단어가 얼마나 반복되는지와 얼마나 자주 사용되는 단어진지가 점수에 영향을 미친다. 하나의 문서에서 단어가 여러번 반복되면 관련성은 높아진다. 하지만 전체 문서에서 단어가 자주 반복된다면, 관련성은 낮아진다. 이를 TF-IDF라고 부른다. (TF = term frequency / IDF = inverse document frequency)

 

단어 빈도 (term frequency)

문서에서 점수를 얻는 첫번째 방법은 단어가 얼마나 자주 문서에서 반복되는가이다. 

 

역문서 빈도 (inverse document frequency)

역문서 빈도는 단어 빈도보다 조금 더 복잡하다. 전체 문서에서 자주 반복되는 토큰은 덜 중요하다는 것이다. 역문서 빈도는 전체 문서에서 단어가 나온 빈도를 확인한다. 단어가 자주 나온것을 확인하지는 않는다. 

 

"We use Elasticsearch to power the search for our website."
"The developers like Elasticsearch so far."
"The scoring of documents is calculated by the scoring formula."
  • "Elasticsearch" 단어의 문서 빈도의 값은 2이다(2개의 문서에서 사용되었기 때문에). IDF 점수는 1/DF를 곱한 점수가 된다. DF는 문서에서 단어가 반복된 횟수다. 단어가 자주 반복되면 가중치는 낮아진다.
  • "the" 단어의 문장빈도는 3이다. 모든 3개의 문서에서 사용되었기 때문이다. 마지막 예문에서 "the"가 2번 사용되어 총 4번 사용되었지만, 문장 빈도는 여전히 3이다. IDF는 문서에서 단어 사용 여부만 확인하고, 단어가 얼마나 자주 사용되었는지는 확인하지 않기 때문이다. 단어의 빈도는 단어 빈도에서 확인한다.

 

역문서 빈도는 단어의 빈도를 균형 잡은 중요 요인이다. 예를 들어 역문서 빈도로 균형이 잡혀있지 않다면, 사용자가 "the score" 단어를 검색한다고 할때, 찾는 "the" 단어의 빈도가 "score" 단어의 빈도를 완전히 압도하게 된다. "the"와 같은 자주 사용되는 단어는 IDF 균형에 영향을 미치고, 질의 단어가 유사도 점수에 더 영향을 주게 된다. TF-IDF로 계산되고 나면, TF-IDF 공식에 따라 문서의 점수가 계산되는 것이다.

 

루씬의 점수 계산 공식

루씬의 기본 점수 계산 공식은 TF-IDF에 기초해서 계산된다.

루씬 질의와 문서에 대한 점수 계산 공식

중요한 점은 단어 빈도와 역문서 빈도가 문서의 점수에 어떻게 영향을 주는가와 엘라스틱서치 색인에서 문서의 점수를 결정할때 무엇이 핵심적인가를 이해하는 것이다.

 

단어 빈도가 높으면 점수가 높은 것처럼, 역문서 빈도가 높으면 색인에서 단어는 드물게 나타난다. 그리고 조합인자(coordination factor)와 표준화(query normalization)가 있다. coordination factor는 얼마나 많은 문서에서 찾았는지와 얼마나 많은 단어를 찾았는지를 고려한다. query normalization은 질의 결과를 비교할 수 있도록 한다. 이건 어려운 일이지만, 실제로 다른 질의들의 점수를 비교하면 안된다. 기본 점수는 TF-IDF와 벡터 공간 모델의 조합으로 이루어진다.

 

참고: https://lucene.apache.org/core/7_4_0/core/org/apache/lucene/search/similarities/TFIDFSimilarity.html

 

검색 점수 모델을 다른 것으로 변경할 수도 있다. scoring model 설정을 변경한다면, 문서 점수의 순위와 점수의 변화를 판단할 수 있는 좋은 테스트 환경을 가지고 있어야 한다. 변경을 재현하여 평가하는 방법없이 관련성 알고리즘 설정을 변경하는 것은 의미가 없다. 그것은 단지 추측일 뿐이다.

 

부스팅

부스팅은 문서의 관련성을 수정하는 절차이다. 두가지 유형의 부스팅이 있다. 두 가지 유형의 부스팅이 있다. 문서를 색인하거나 문서를 질의할때 점수 부스트를 할 수 있다. 문서의 부스팅을 바꾸는 것은 색인에 데이터를 색인하는 시간과 문서를 재색인하는 시점에만 변경할 수 있다. 질의 시점 부스팅을 사용하는것이 권장된다. 왜냐하면 이 방법이 가장 유연하고 데이터의 재색인 없이 필드나 단어를 바꾸는 것을 허용하기 때문이다.

 

get-together의 예제에서 그룹을 찾는다면, 그룹명이 그룹 설명보다 더 중요하다. 엘라스틱서치 베를린 그룹을 찾아보자. 그룹을 찾을때 그룹명은 아마도 많은 단어를 포함하고 있는 그룹 설명보다 중요한 정보다. 그룹명은 설명보다 더 비중이 있다. 이런 경우에 부스팅을 쓸 수 있다.

 

부스트 숫자는 곱셈과 같지 않다. 이 뜻은 점수를 계산할 때 부스트 값은 정규화된다. 예를 들어, 모든 필드에 10이라는 boost 점수를 설정했다면, 모든 값이 같으므로 이건 1로 정규화된다. 결국 boost를 적용하지 않은 것과 같다. 부스트 숫자는 상대적이라, 필드에 3이라고 부스트 점수를 설정하면, 해당 필드는 다른 필드에 비해 3배쯤 중요하다고 생각하면 된다.

 

 

색인 시점에 부스팅

색인 시점에 문서의 질의에 대한 추가적인 부스트를 할 수 있다. 비록 이런 형식의 부스팅을 추천하지는 않지만, 특정 경우에는 유용할 수 있다. 이런 형식의 부스팅을 할때는 필드에 부스트 파라미터를 사용하여 특정 매핑을 지정해야 한다. 예를 들어, 그룹 유형의 이름 필드를 부스트하면, 색인을 다음 예제에서 표시하는 것과 같은 매핑으로 생성해야 한다.

curl -XPUT 'localhost:9200/get-together' -d '{
  "mappings": {
    "group": {
      "properties": {
        "name": {
          "boost": 2.0,   // 색인 시점 이름 필드의 값을 강화
          "type": "string"
        }
      },
      ...
    }
  }
}'

색인에 매핑을 지정한 이후에는 모든 문서가 자동으로 이름 필드에 부스트가 적용되어 루씬의 색인에 저장된다. 다시 정해진 부스트 값을 수정하려 하면, 재색인 작업을 해야 한다.

 

색인 시점에 부스팅을 하지 말아야 할 또 다른 이유는 부스트 값이 루씬 내부 색인 구조에 낮은 정밀도로 저장되어, 오직 단일 바이트로 부동소수 숫자를 저장하는데, 문서의 최종점수를 계산할때 정밀도가 손실될 수 있기 때문이다.

 

색인 시점에 부스트하지 말아야 하는 이유는, 부스트가 모든 단어에 적용되기 때문이다. 그러므로 여러 부스트에 매핑되는 단어는 여러번 부스트되어 가중치를 더 가중한다. 이런 이유로 질의시점에 부스트를 하는것이 좋다.

 

 

질의 시점에 부스팅

검색을 할때 부스팅하는 방법은 몇가지가 있다. match, multi_match, simple_query_string, 그리고 query_string 질의를 사용한다면, 단어별/필드별 부스트를 설정할 수도 있다. 거의 모든 엘라스틱서치의 질의가 부스팅을 지원한다. 이 외에도 function_score 질의를 이용하여 더 세밀하게 사용하는것도 가능ㅎ다.

 

match 질의의 경우, 다음에 보여주는 부스트 파라미터를 사용하여 질의를 부스트할 수 있다. 질의를 부스팅한다는 것은 설정된 필드에서 찾은 각각의 단어에 부스트를 한다는 것이다.

curl -XPOST 'localhost:9200/get-together/_search?pretty' -d '{
  "query": {
    "bool": {
      "should": [
        {
          "match": {
            "description": {
              "query": "elasticsearch big data",
              "boost": 2.5
            }
          }
        },
        {
          "match": {
            "name": {
              "query": "elasticsearch big data"
            }
          }
        }
      ]
    }
  }
}'

이런 방법은 엘라스틱서치에서 제공하는 다른 질의들인 텀 질의, prefix 질의나 기타 질의에서도 동작한다. 이전 예제에서 첫번째 match 질의에만 부스트가 추가되어 있다. 이제 첫번째 match 질의의 최종 결과는 두번째 match 질의보다 높다. 이런 질의는 여러 질의를 bool이나 and/or/not 등을 사용했을때 의미가 있다.

 

여러 필드에 질의하기

multi_match 질의와 같이 여러 필드에 질의하기 위해서는 다른 형식으로 접근해야 한다. 전체 multi 질의에 부스팅을 명시할 수 있다. 다음 예제외 같이 match 질의에 부스팅하는 것은 이전과 유사하다.

curl -XPOST 'localhost:9200/get-together/_search?pretty' -d '{
  "query": {
    "multi_match": {
      "query": "elasticsearch big data",
      "fields": ["name", "description"],
      "boost": 2.5
    }
  }
}'

 

또는 일부 필드에만 부스트를 명시할 수도 있다. 필드명에 캐럿(^) 표시를 추가하여, 해당 필드만 부스트하도록 엘라스틱서치에 알려줄 수 있다. 다음 예제와 같이 변경함으로써 전체 필드에 부스팅하는 것이 아닌 이름 필드에만 부스트할 수 있다.

curl -XPOST 'localhost:9200/get-together/_search?pretty' -d '{
  "query": {
    "multi_match": {
      "query": "elasticsearch big data",
      "fields": ["name^3", "description"]	// 이름필드 뒤에 ^3을 추가하여 3의 값으로 부스팅
    }
  }
}'

 

query_string 질의의 경우, 단어에 "^"를 추가하는 문법을 이요하여 각각의 단어를 강화할 수 있다. 다음 예제와 같이 "elasticsearch"와 "big data"를 찾는 경우 "elasticsearch"에만 3의 값으로 부스팅할 수 있다.

curl -XPOST 'localhost:9200/get-together/_search?pretty' -d '{
  "query": {
    "query_string" {
      "query": "elasticsearch^3 AND \"big data\""  // 단어 뒤에 "^3"을 추가하여 3의 값으로 부스팅
    }
  }
}'

 

필드나 단어에 부스팅하는 값은 절댓값이 아닌 상대값이다. 만약 모든 단어를 같은 값으로 부스팅한 후 찾으면, 부스팅하지 않은 것과 같다. 루씬이 부스트 값을 정규화했기 때문이다. 강화 값을 4로 하더라도 4의 곱으로 점수가 나오는건 아니다.

 

질의 시점에 부스트하는 것은 굉장히 유연하다. 원하는 정보가 나올때까지 데이터 집합을 가지고 실험하는 것이 중요하겠다. 부스팅을 바꾸는 것은 엘라스틱서치에 질의를 보내는 것만큼 간단하다.

 

 

explain을 통해 어떻게 문서의 점수가 결정되는지 이해하기

문서의 점수를 사용자가 정의하는 것으로 가기 전에, 문서의 점수를 매기는 방법을 단계별로 나누어서 루씬의 실제값과 함께 다뤄보자. 엘라스틱서치 관점에서 하나의 문서가 질의에 더 잘 일치하는지 이해하는데 도움이 될것이다. 엘라스틱서치에서 "explain=true"라는 설정을 요청 URL이나 전송 내용에 지정함으로써 사용할 수 있다. 문서가 어떤 방법으로 점수가 나왔는지 설명할때 유용하다. 또 문서가 왜 질의에 일치하지 않았는지 설명할때도 사용된다. 이것은 기대하는 질의의 결과 문서가 질의의 응답으로 오지 않았을 때 유용하다.

curl -XPOST 'localhost:9200/get-together/_search?pretty' -d '{
  "query": {
    "match": {
      "description": "elasticsearch"
    }
  },
  "explain": true   // 전송 내용에 설명설정을 하는 방법
}'
{
  "hits": {
    "total": 9,
    "max_score": 0.4809364,
    "hits": [{
      "_shard": 0,
      "_node": "...",
      "_index": "get-together",
      "_type": "group",
      "_id": "3",
      "_score": 0.4809364,
      "_source": {
        "name": "Elasticsearch San Francisco",
        "organizer": "Mik",
        "description": "Elasticsearch group for ES users of all knowledge levels",
        "created_on": "2012-08-07",
        "tags": ["elasticsearch", "big data", "lucene", "open source"],
        "members": ["Lee", "Igor"],
        "location": "San Francisco, California, USA"
      },
      "_explanation": {		// "_explanation"은 문서의 점수에 대한 설명 포함
        "value": 0.4809364, 	// 문서의 최상위 점수
        "description": "weight(description:elasticsearch in 1) [PerFieldSimilarity], result of:",
        /* 최종 점수를 만드는 각 복합 부분의 결합 */
        "details": [{
          "value": 1.0,
          "description": "tf(freq=1.0), with freq of:",
          "details": [{
            "value": 1.0,
            "description": "termFreq=1.0"
          }]
        }, {
          "value": 1.5389965,
          "description": "idf(docFreq=6, maxDocs=12)"
        }, {
          "value": 0.3125,
          "description": "fieldNorm(doc=1)"
        }]
        /* 최종 점수를 만드는 각 복합 부분의 결합 */
      }
    }]
  }
}

 

응답의 추가된 부분은 새로운 _explanation 키로 점수의 각각 다른 부분을 작게 나눈 정보를 포함한다. 위 경우에는 설명 필드에서 "elasticsearch"를 찾고 있고, 설명 필드에 한번 "elasticsearch"를 사용한 문서를 찾았다. 그래서 TF는 1이다.

 

똑같이 IDF 설명에서 보듯이 "elasticsearch"는 총 12개의 문서에서 6번 나타났다. 최종적으로 루씬 내부에서 해당 필드에 대해 정규화된 것을 볼 수 있다. 최종 점수는 이 점수들을 함께 곱하면 나온다.

1.0 x 1.5389965 x 0.3125 = 0.4809364

 

explanation 기능은 더 복잡한 질의를 요청했으면 더 많이 어렵고 매우 길어질 수 있다. 게다가 explanation 기능을 사용하여 엘라스틱서치에 질의를 할 경우 추가적인 자원을 사용한다. 그래서 모든 질의에 적용하기 보다는 디버그용으로 사용하는 것이 좋다.

 

 

왜 문서가 맞지 않는지 설명

문서가 어떻게 계산되어 찾아졌는지 설명하는 것처럼, 왜 문서를 찾을수 없는지도 알 수 있다. 하지만 이 경우에는 간단히 설명 파라미터를 추가하지 않고 다른 API를 사용해야 한다. 이 API를 이용하여 문서의 ID를 안다면 문서의 점수를 가지고 올 수 있다. 이 도구를 이용하여 어떻게 문서의 점수를 결정하는지 확인할 수 있다.

curl -XPOST 'localhost:9200/get-together/group/4/_explain' -d '{
  "query": {
    "match": {
      "description": "elasticsearch"
    }
  }
}'

{
  "_id": "4",
  "_index": "get-together",
  "_type": "group",
  /* 왜 문서를 찾지 못했는지에 대한 설명 */
  "explanation": {
    "description": "no matching term",
    "value": 0.0
  },
  "matched": false   // 문서가 질의에 일치하는지를 나타내는 플래그
}

위 예제에서는 "단어를 찾을 수 없음"이고, 문서의 설명 필드에서 "elasticsearch"가 사용되지 않았기 때문이다. 또한, 이 API를 이용하여 문서의 ID를 안다면 문서의 점수를 가지고 올 수 있다.

 

 

질의 재점수로 점수에 대한 영향 줄이기 

점수에 의해 시스템의 속도에 영향을 주게 될까? 대부분의 일반 질의에서는 문서의 점수를 계산하는건 속도에 조금 영향을 주게된다. TF-IDF가 루씬팀에 의해 아주 효율적으로 최적화되었기 때문이다. 아래의 경우는 점수계산시, 컴퓨팅 자원을 더 소모한다.

  • 스크립트를 실행하여 스크립트가 색인의 각 문서의 점수를 계산하는 경우
  • pharse 질의를 이용하여 각 단어의 거리를 지정할때, 큰 slop을 설정하는 경우

수백/수천만의 문서에 점수 계산 수식 영향을 줄이고 싶게 된다. 이 문제를 해결하기 위해 엘라스틱서치는 rescoring이라고 불리는 기능을 가지고 있다. rescoring은 이름과 같이 초기 질의가 실행되고 응답받은 결과를 가지고 점수를 계산하는 것을 의미한다. 스크립트를 사용하여 잠재적으로 고비용의 질의를 상위 1,000개의 데이터만 검색해서 match 질의보다 저렴하게 사용할 수 있다. 

 

curl -XPOST 'localhost:9200/get-together/_search?pretty' -d '{
  "query": {
    "match": {
      "title": "elasticsearch"  // 모든 문서에 대한 원본 질의 실행
    }
  },
  "rescore": {
    "window_size": 20, // 재점수에 의한 결과 갯수
    "query": {
      "rescore_query": {
        "match": {
          "title": {
            "type": "phrase",
            "query": "elasticsearch hadoop",
            "slop": 5
          }
        }
      },
      "query_weight": 0.8,  // 원본 질의 점수의 가중치
      "rescore_query_weight": 1.3   // 재점수 계산한 질의의 점수 가중치
    }
  }
}'

이 예제는 모든 문서의 제목에서 "elasticsearch"가 들어간 상위 20개의 결과를 가져오고, 점수를 pharse 질의에 큰 slop 값을 지정하여 재점수를 매기는 것이다. query_weight, rescore_query_weight 파라미터를 이용하여 초기 질의와 재점수 질의에 의해 점수가 결정될 수 있도록 각각 질의에 가중치를 줄 수 있다. 여러 재점수 질의를 차례로 사용할 수 있고, 각 재점수 질의는 이전 결과를 가지고 계산한다.

 

 

function_score를 이용한 사용자 설정 점수 계산

function_score 질의는 임의의 함수에 숫자로 점수를 지정하여 초기 질의에 맞는 문서의 점수를 결정하는데 이를 세부적으로 조절할 수 있도록 한다. 각 function은 점수에 영향을 미치는 작은 JSON 조각이다. 다음은 function_score 질의의 기본 구조이다.

curl -XPOST 'localhost:9200/get-together/_search?pretty' -d '{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "description": "elasticsearch"
        }
      },
      "functions": []  // 비어있는 "Functions 목록"
    }
  }
}'

 

이는 그냥 일반적인 match 질의 안에 function_score를 넣은 간단한 예제이다. 여기에 functions라는 새로운 키가 있다. 이 목록은 function_score 함수가 문서에 동작한 질의의 결과를 보여줄 예정이다. 예를 들어, 총 30개의 문서가 있는데 이 중 설명 필드에는 "elasticsearch"가 검색되는 문서가 25개가 있다면, 이 25개의 문서가 functions 안의 배열에 나타난다. function_score 질의는 초기 질의 외에 추가로 몇개의 다른 함수를 가지고 있고, 각 함수는 다른 필터 요소를 가질 수 있다. 

 

 

가중치

가중치 함수는 제공되는 함수 중 가장 단순하다. 점수에 일정한 수를 곱하는 것이다. 정규화된 값을 증가시키는 일반적인 부스트 필드 대신 가중치는 진짜로 점수를 값에 곱한다. 다음 예제에서 "hadoop"이 있는 경우 값을 증가시켜 보겠다.

curl -XPOST 'localhost:9200/get-together/_search?pretty' -d '{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "description": "elasticsearch"
        }
      },
      "functions": [{
        /* 문서 설명 필드에 'hadoop'이 있는 경우,
           가중치 함수를 이용해서 점수를 1.5배 증가 */
        "weight": 1.5,
        "filter": {
          "term": {
            "description": "hadoop"
          }
        }
      }]
    }
  }
}'

 

문서의 설명 필드에 "hadoop"이 있는 경우 점수에 1.5를 곱하라는 뜻이다. 이와 같은 걸 여러개 설정할 수도 있다. 예를 들어, get-together 그룹에서 "logstash"를 찾은 경우에도 다음 예제와 같이 두 개의 다른 weight 함수를 지정하여 점수를 증가시킬 수 있다.

curl -XPOST 'localhost:9200/get-together/_search?pretty' -d '{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "description": "elasticsearch"
        }
      }
    },
    "functions": [{
      /* 설명 필드에 'hadoop'이 있는 경우 점수를 2배 증가 */
      "weight": 2,
      "filter": {
        "term": {
          "description": "hadoop"
        }
      }
    }, {
      /* 설명 필드에 'logstash'가 있는 경우 점수를 3배 증가 */
      "weight": 3,
      "filter": {
        "term": {
          "description": "logstash"
        }
      }
    }]
  }
}'

 

점수 결합하기

점수 결합에 관해 이야기할때 염두에 두어야할 2가지 요인이 있다.

  • 개별 함수를 어떤 방법으로 결합할지 정하는 score_mode
  • 원본 질의 점수와 functions의 점수를 어떻게 결합할지를 정하는 boost_mode

첫번째 요인인 score_mode 파라미터는 각기 다른 functions의 점수를 어떻게 결합할지를 결정한다. 이전 cURL 요청에서 두 개의 functions가 있다. 하나의 가중치는 2이고, 다른 하나는 3이다. score_mode 파라미터를 multiply, sum, avg, first, max, min으로 설정할 수 있다. 만약 지정하지 않는다면, 각 function의 값을 모두 곱하게 된다.

 

first로 지정한다면, 첫번재로 일치하는 function의 점수만 사용한다. score_mode를 first로 설정한다면, 문서는 "hadoop"과 "elasticsearch"를 가지고 있고, 오직 처음에 문서와 일치하는 문서만 2의 가중치를 적용한다.

 

두번째 점수 결합 설정은 초기 질의와 functions의 점수를 결합하는걸 조정하는 boost_mode다. 만약 설정하지 않는다면, 새로운 점수는 원본 질의와 function의 점수 곱으로 설정된다. 이걸 sum, avg, max, min, replace로 설정할 수 있다. replace로 설정한다는 것은 원본 질의의 점수를 functions의 점수와 서로 교체한다는 것이다.

 

이 설정은 필드의 값에 기초해서 수정하는데 사용된다. functions는 field_value_factor, script_score, random_score를 다루며, linear, gauss, exp와 같은 decay 함수도 다룬다.

 

 

field_value_factor

다른 질의를 기반으로 점수를 수정하는건 매우 유용하다. 문서 내부의 데이터로부터 점수에 영향을 미치도록 사용하고 싶어 하는 경우가 있다. 이 예제에서는 이벤트의 점수를 증가시키기 위해 리뷰의 숫자를 받아 사용할 수 있다. 이건 function_score 내부에 field_value_factor를 사용하여 가능하게 한다.

 

field_value_factor 함수는 숫자 필드를 포함하는 필드의 이름을 받고, 추가로 점수에 곱할 상수값을 받는다. 대수와 같은 수학함수를 값에 적용한다.

curl -XPOST 'http://localhost:9200/get-together/event/_search?pretty' -d '{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "description": "elasticsearch"
        }
      },
      "functions": [{
        "field_value_factor": {
          "field": "reviews", // 값을 사용할 숫자 필드
          "factor": 2.5,      // 리뷰 필드의 값에 곱할 상수
          "modifier": "ln"    // 추가로 계산한 수학 함수
        }
      }]
    }
  }
}'

ln 이외에도 none(default), log, log1p, log2p, ln1p, ln2p, square, sqrt, reciprocal, field_value_factor를 사용할 수 있다. 이를 사용하기 위해 기억해야 한는것, 지정한 필드의 모든 값을 메모리에 올린다는 것이다. 그래서 점수 계산을 빠르게 할 수 있다.

 

 

스크립트

우리는 사용자 정의 스크립트를 지정하여 세부적으로 점수에 영향을 미치는 다른 함수를 정의할 수 있다. 스크립트는 점수를 변경하는 것에 대하여 완벽하게 조절할 수 있다. 스크립트 안에서 점수를 정렬하는 어떤 것도 할 수 있다.

 

스크립트는 Groovy 언어로 쓰여있고, 스크립트 안에서 _score를 사용하여 문서의 원래 점수에 접근할 수 있다. 문서의 값에는 doc['fieldname']을 이용하여 접근할 수 있다.

curl -XPOST 'localhost:9200/get-together/event/_search?pretty' -d '{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "description": "elasticsearch"
        }
      },
      "functions": [{
        "script_score": {
          /* 스크립트는 각 문서의 값에 적용됨. */
          "script": "Math.log(doc['attendees'].values.size() * myweight)",
          "params": {
            /* 'myweight' 변수는 요청의 파라미터로 대체됨. */
            "myweight": 3
          }
        }
      }],
      /* 문서의 원점수는 스크립트에 의해 생성된 점수로 대체됨 */
      "boost_mode": "replace"
    }
  }
}'

 

이 예제에서 점수는 attendee 목록의 크기로 점수에 영향을 미치고, 가중치 값을 곱한 후 로그를 취하고 있다. (로그를 취한 이유?)

 

스크립트는 매우 강력하다. 하지만 일반 점수 계산에 비하면 매우 느리다. 질의에 맞는 문서마다 동적으로 스크립트를 실행해야 하기 때문이다. 파라미터화된 스크립트를 사용할때, 스크립트를 캐시하면 성능에 도움이 된다.

 

 

random

random_score 함수는 무작위점수를 문서에 할당한다. 문서를 무작위로 정렬할때 이점은 첫페이지 결과를 변화있게 할 수 있다는 것이다. get-together에서 검색할 때, 어떤 경우에는 매번 같은 상위 결과를 보지 않기를 원할 수 있다.

 

무작위 값의 초기값을 지정할 수 있다. 함수의 임의값 생성에 사용되는 숫자를 질의에 넘겨줄 수 있다. 이렇게 무작위로 문서를 정렬할 수 있지만 같은 초기값을 사용한다면, 같은 요청에 대해서는 같은 결과를 되돌려 준다. 이런 기능을 지원하는 유일한 기능이다.

curl -XPOST 'localhost:9200/get-together/event/_search?pretty' -d '{
  "query": {
    "function_score": {
      "query": {
        "match": {
          "description": "elasticsearch"
        }
      },
      "functions": [{
        "random_score": {
          "seed": 1234
        }
      }]
    }
  }
}'

 

 

decay 함수

function_score에서 decay 함수는 특정 필드를 기준으로 점수를 점진적으로 줄여준다. 예를 들어, get-together 예제에서 최근 문서의 점수를 더 높게 하고 오래된 문서일수록 점수를 낮게 하고 싶을 수 있다. 지리적인 정보와 같이 사용할때이다. decay 함수를 사용하여 지리정보와 가까다면 점수를 증가시키고, 멀다면 점수를 줄일 수 있다.

 

decay 함수는 3가지 종류가 있다. linear, gauss, exp다. 각 decay 함수는 다음과 같은 문법을 가진다.

{
  "TYPE": {
    "origin": "...",
    "offset": "...",
    "scale": "...",
    "decay": "..."
  }
}

 

설정 옵션은 곡서을 어떻게 보이냐를 조절한다. 3개의 decay 곡선에는 4가지 설정 옵션이 있다.

  • origin은 곡선의 중앙 지점이다. 그래서 점수의 가장 높은 곳이기도 하다. 지리 정보의 거리를 이용한 예제에서 origin의 위치는 사용자의 현재 위치이다. 다른 상황에서 "origin"은 날짜 또는 숫자 필드이다.
  • offset은 점수가 줄어들기 전까지 기준점에서 얼마나 멀리 있느냐이다. offset을 1km로 설정하면, 기준점에서 1km까지는 점수가 줄어들지 않는다. 기본값은 0이고, 기준점에서부터 바로 점수가 줄어든다는 것이다.
  • scale과 decay 옵션은 서로 관련이 있다. 필드에 scale 값을, 점수는 decay에 의해 감소한다고 말할 수 있다. 실제 값을 생각해보면 scale을 5km, decay를 0.25로 설정한다면, 그것은 기준점에서 5km에 있는 곳의 점수는 기준 점수에서 0.25를 곱한 값이 되어야 한다는 것이다.
curl -XPOST 'localhost:9200/get-together/event/_search?pretty' -d '{
  "query": {
    "function_score": {
      "query": {
        "match_all": {}
      },
      "functions": [{
        "gauss": {
          "location_event.geolocation": {
            "origin": "40.018528,-105.275806", // decay 함수의 기준 위치
            "offset": "100m",  // 기준점 100m 이내는 같은 점수를 유지함
            /* 기준점에서 2km에 위치한 점수는 원점수의 절반값 */
            "scale": "2km",
            "decay": 0.5
          }
        }
      }]
    }
  }
}'
  • match_all 질의를 사용하여, 모든 문서가 결과로 돌아온다.
  • 각 결과 문서에 Gaussian decay를 적용하여 점수를 구한다.
  • 기준 위치는 콜로라도의 Boulder이다. get-together에서 Boulder의 점수가 가장 높다. 그리고 Denver, get-together 데이터에서 기준점에서 점점 먼 순으로 나온다.

 

스크립트를 이용한 정렬

스크립트를 이용하여 문서의 점수를 수정하는 것과 같이 엘라스틱서치는 스크립트를 이용하여 문서의 정렬도 변경할 수도 있다. 문서에 필드가 없는 경우에도 정렬이 필요한 경우에도 유용하다.

 

예를 들어 "elasticsearch"로 검색하다고 했을때, 얼마나 많은 사람들이 attended했는지로 정렬하려고 하면, 다음과 같이 쉽게 요청할 수 있다.

curl -XPOST 'localhost:9200/get-together/event/_search?pretty' -d '{
  "query": {
    "match": {
      "description": "elasticsearch"
    }
  },
  "sort": [{
      "_script": {
        "script": "doc['attendees'].values.size()", // "attendees" 필드를 정렬 필드로 사용
        "type": "number",  // 정렬 값은 숫자 타입
        "order": "desc"  // attendee 숫자에 의한 내림차순 정렬
      }
    },
    "_score"  // 같은 attendees를 가지고 있는 경우, 문서의 _score 점수를 이용하여 정렬
  ]
}'

검색된 각 문서의 필드에 대해서 "sort":[5.0, 0.28856182]와 값을 얻는다는 것을 주의해야 한다. 이 값들은 엘라스틱서치에서 문서를 정렬할때 사용한다. 5.0의 값이 수치임을 유의하자. 스크립트의 결괏값을 숫자로 지정했기 때문이다. 배열의 두번째 값은 문서의 원래 점수이다. 왜냐하면, 참석자의 수가 여러 문서에서 찾아진다면 두번째 정렬로 지정했기 때문이다.

 

이건 매우 강력하지만, 사용자 정의 스크립트에서 _score를 이용하여 문서를 정렬하는 것보다 function_score 질의를 사용하기가 훨씬 쉽고 빠르다. 다른 옵션으로 색인된 문서의 다른 숫자 필드에서 참가자의 수를 가지고 올 수 있다. 이건 함수에서 정렬하거나 점수를 바꿀 때 훨씬 쉽게 할 수 있다.

 

필드 데이터 우회

역색인은 단어를 찾을때, 검색된 문서를 돌려받을때 좋다. 그러나 필드로 정렬하거나 결과를 그룹지을 때와 같이 엘라스틱서치가 각 검색된 문서를 빠르게 계산이 필요할 때 찾는 단어들이 정렬이나 집계에 사용되길 원한다.

 

역 색인은 이러한 작업을 잘 수행하진 못한다. 필드 데이터가 이럴때 유용하다. 우리가 필드 데이터에 대해 말할때, 필드의 모든 고유한 값에 대해 말해보자. 이 값들은 엘라스틱서치에 의해 메모리에 올라와 있다.

{"body": "quick brown fox"}
{"body": "fox brown fox"}
{"body": "slow turtle"}

메모리에 적재되는 단어는 quick, brown, fox, slow, turtle이다. 엘라스틱서치는 이 단어들을 메모리에 올릴때 다음에 알아볼 필드 데이터 캐시를 이용하여 압축한 방법으로 올린다.

 

 

필드 데이터 캐시

필드 데이터 캐시는 엘라스틱서치에서 무언가의 숫자를 세는데 사용하는 내부 메모리 캐시이다. 이 캐시는 보통 데이터가 필요로 한 시점에 만들어지고, 다양한 작업 동안 유지된다. 메모리에 올리는 작업은 많은 시간과 CPU를 소모하고, 만약 데이터의 양이 많다면, 첫번째 검색이 느릴 것이다.

 

warmers는 엘라스틱서치가 내부 캐시를 채우기 위해 자동으로 동작하는 질의다. 질의에 대한 데이터가 필요하기 전에 미리 올리는데 도움이 된다.

 

왜 field data cache가 필요한가?
엘라스틱서치는 캐시가 필요하다. 대용량 데이터에 대해 많은 비교 분석 작업을 하기 때문이다. 그리고 이러한 작업을 합리적인 시간 안에 완료하려면 데이터를 메모리에서 접근해야만 가능한 유일한 방법이기 때문이다. 엘라스틱서치는 캐시가 메모리에서 차지하는 양을 최소화하기 위해 큰 단위로 동작하지만, 여전히 JVM에서 가장 큰 힙 영역을 쓰는 것 중 하나다.

 

캐시에 의해 사용되는 메모리의 양뿐만 아니라 초기에 캐시에 로딩되는 시간도 짧지 않다. 사용시 다음을 주의해야 한다. 첫번째 집합 함수를 호출한 시점에는 완료까지 2~3초가 걸리지만, 그 다음에 호출하면 응답까지 30ms만 걸린다.

 

긴 로딩 시간은 문제가 될 수 있다. 이런 문제를 색인 시점에 미리 로딩하고 새로운 세그먼트가 검색이 가능하도록 엘라스틱서치가 자동으로 필드 데이터를 올리도록 할 수 있다. 필드의 정렬이나 집합에 이 작업을 수행하려면, fielddata를 설정해야 한다. 매핑에 loading을 eager로 해야 한다. eager로 설정함으로써, 엘라스틱서치는 첫번째 검색까지 기다리지 않고, 가능할때 바로 데이터를 올린다.

 

curl -XPOST 'localhost:9200/get-together' -d '{
  "mappings": {
    "group": {
      "properties": {
        "title": {
          "type": "string",
          "fielddata": {
            /* 제목 필드에 대해 일찍 데이터가 로드될 수 있도록 설정 */
            "loading": "eager"
          }
        }
      }
    }
  }
}'

 

필드 데이터는 엘라스틱서치의 여러 곳에서 사용된다.

  1. 필드를 정렬
  2. 필드의 집합
  3. 스크립트에서 doc['fieldname'] 표기법으로 필드의 값에 접근
  4. _score 질의에서 field_value_factor 함수 사용
  5. _score 질의에서 decay 함수를 사용
  6. 검색 요청에서 fielddata_fields를 사용하여 필드 값을 받을때
  7. 문서들 간의 부모/자식 간의 ID를 캐싱

아마도 가장 흔한 사용 방법은 필드에 대한 정렬 또는 집합을 사용할 때이다. 예를 들어, get-together의 결과에서 organizer 필드로 정렬한다고 하면, 효율적으로 정렬 순서를 비교하기 위해서는 모든 고유한 필드의 값이 메모리에 적재해야 한다.

 

정렬된 다음에 집계하면 된다. 텀 집계를 실행할 때, 엘라스틱서치는 각 고유한 텀을 계산할 수 있어야 한다. 그리고 이 고유한 텀과 개수는 분석 결과를 정렬할 때까지 메모리에 유지되어야 한다. 마찬가지로 통계 집합의 경우, 필드에 대한 수치는 결과를 계산하기 위해 메모리에 적재되어야 한다.

 

엘라스틱서치는 데이터를 압축하는 방법으로 적재한다. 이 말은 클러스터에서 어떻게 필드 데이터를 관리할지 알고 있어야 한다는 의미이기도 하다.

 

 

필드 데이터 관리

엘라스틱서치 클러스터에는 필드 데이터를 관리하는 몇가지 방법이 있다. 필드 데이터를 관리한다는건 엘라스틱서치 클러스터에서 JVM 가비지 컬렉션 이슈를 회피한다는 것이다. GC는 시간이 오래 걸리고 많은 메모리를 사용한다면 OutOfMemoryError를 발생할 수 있다. 캐시의 이탈을 방지하고, 메모리에서 제거되지 않도록 하는게 유리하다.

  • 필드 데이터에 사용할 메모리의 제한
  • 서킷 브레이커(circuit breaker)에 필드 데이터 사용하기
  • doc values로 메모리값을 무시

(1) 필드 데이터에 사용할 메모리의 제한

메모리에 너무 큰 공간을 사용하지 않도록 하는 가장 쉬운 방법은 특정 크기로 필드 데이터 공간을 제약하는 것이다. 만약 이것을 설정하지 않는다면, 엘라스틱서치는 캐시에 메모리 제약을 하지 않게 된다. 그리고 데이터는 자동으로 만료되지도 않는다.

 

메모리 크기로 제약을 걸거나 캐시의 데이터가 언제 삭제될지를 설정할 수 있다. 이 설정은 elasticsearch.yml 파일에 지정할 수 있다. 이 설정은 API를 통해 업데이트할 수 없고, 변경시 재시작을 해야한다.

indices.fielddata.cache.size: 400mb
indices.fielddata.cache.expire: 25m

 

그러나 이 설정을 할때, expire 설정을 하는 것보다 indices.fielddata.cache.size를 설정하는게 더 의미있다. 왜냐하면 캐시에 필드 데이터를 적재할 때, 메모리 제한에 도달할때까지 메모리에 유지된다. 그리고 제한에 도달하면 LRU 방법에 따라 메모리에서 제거된다. 크기 제한만 설정한 상태에서 캐시가 메모리 제한에 도달하면 오래된 데이터를 제거하는 것이다.

 

크기를 설정할때 크기 대신에 상대 크기도 설정할 수 있다. 400mb 대신에 40%라고 지정할 수 있고, 40%는 JVM 힙 크기의 40%를 필드 데이터 캐시로 사용한다. 이건 보유하고 있는 서버들이 서로 다른 물리 메모리를 가지고 있어도, elasticsearch.yml 설정 파일에 절대 사이즈 설정을 하지 않고 같이 사용할 수 있어 유용하다.

 

 

(2) 서킷 브레이커에 필드 데이터 사용하기

만약 캐시 크기를 지정하지 않을 경우, 메모리에 너무 많은 데이터가 적재되는 것을 막기 위해 엘라스틱서치는 서킷 브레이커라는 개념을 가지고 있다. 메모리에 적재되는 데이터의 양을 모니터링하여, 제한을 넘으면 발생한다.

 

필드 데이터의 경우, 매 요청 발생시 필드 데이터를 읽어야 한다. 서킷 브레이커는 얼마나 많은 메모리가 필요한지 에측하고, 최대 사이즈를 넘어서는지 확인한다. 만약 최대 크기를 넘어서면, 에러를 발생시켜 동작이 메모리를 넘지 않도록 예방한다.

 

필드 데이터 캐시의 제한에 도달할때, 필드 데이터 사이즈는 메모리에 데이터를 적재한 후 계산될 수 있다. 이건 너무 많은 데이터이고, OOM 에러가 발생할 수 있다. 반면에 서킷 브레이커는 데이터를 읽기 전에 사이즈를 예측하여, 시스템이 OOM이 발생하지 않도록 할 수 있다.

 

서킷 브레이커에 대한 다른 이점은 노드가 동작 중일때 동적으로 조절 가능하다는 것이다. 반면에 캐시의 크기를 설정 파일에 설정했다면, 설정을 변경하면 노드를 재시작해야 한다. 서킷 브레이커는 기본적으로 JVM 힙 사이즈의 60%로 필드 데이터 크기를 지정하고 있다.

 

curl -XPUT 'localhost:9200/_cluster/settings' -d '{
  "transient": {
    "indices.breaker.fielddata.limit": "350mb"
  }
}'

이 설정은 350mb와 같은 절댓값과 45%와 같은 백분율을 모두 지원한다. Nodes의 Status API를 이용해서 한도나 현재 얼마나 메모리를 사용하고 있는지를 볼 수 있다. 

 

 

(3) 메모리값을 무시하고 Doc values로 디스크 활용

계속 필드 데이터가 부족하고, JVM 힙을 늘리기에는 메모리가 부족하여 필드 데이터 부족으로 느린 성능에서 운영하기는 어렵다. 이것이 doc values가 생겨난 원인이다.

 

Doc values는 메모리에 적재된 문서를 색인하는 대신에, 색인 데이터와 함께 디스크에 저장하고 그 값을 사용한다. 이 뜻은 일반적으로 필드 데이터는 메모리에서 데이터를 읽을때, 메모리 대신 디스크에서 읽을 수도 있도록 한다. 이는 아래와 같은 장점을 가지고 있다. 

 

  • 성능이 부드럽게 저하: 한번에 JVM 힙에 모든 내용이 적재되어야 하는 기본 필드 데이터와 달리, doc value는 색인의 나머지 부분처럼 디스크에서 읽을 수 있다. 만약 OS가 RAM 캐시에 모든 것을 맞출 수 없다면, 더 많은 디스크 탐색이 필요하다. 하지만 고비용의 메모리 적재나 제거는 없고, OOM 에러에 대한 위험도 없다. 그리고 서킷 브레이커 예외도 없다. 왜냐하면, 서킷 브레이커는 필드 데이터 캐시가 너무 많은 메모리를 사용하는 걸 예방하기 때문이다.
  • 더 나은 메모리 관리: 문서 값을 커널에 기반을 둬서 캐시하여 사용하면, 힙사용에 대한 가비지 컬렉션의 비용을 회피할 수 있다.
  • 빠른 로딩: 문서값으로 uninvert 구조는 색인 시점에 계산된다. 그래서 첫번째 질의를 실행하더라도, 엘라스틱서치는 즉시 uninvert할 필요가 없다. 이건 초기 요청을 빠르게 한다. 왜냐하면, 이미 uninvert 처리가 실행되었기 때문이다.

다음은 Doc Value의 단점이다.

 

  • 더 큰 색인 사이즈: 모든 문서 값을 디스크에 저장해서 색인의 크기를 키운다.
  • 색인속도가 느려짐: 문서 값을 계산하기 위해 색인 속도가 느려진다.
  • 문서 값을 읽을때 일반적으로 이미 메모리에 적재된 데이터 캐시를 사용하는 경우에 조금 느려진다.
  • 분석되지 않은 필드에서만 동작한다. 엘라스틱 서치에 색인된 로그 메시지의 timestamp 필드와 같이 분석되지 않은 대규모 데이터에도 잘 동작한다.

doc values와 인 메모리 필드 데이터 캐시를 조합하여 사용할 수 있다. 따라서 이벤트의 타임스탬프 필드에는 doc values를 사용하고, 제목 필드는 메모리를 사용할 수 있다. doc values는 색인 시점에 생성되기 때문에, doc values를 사용하기 위해서는 특정 필드에 대한 매핑이 필요하다. 문자열 필드가 분석되지 않고, 그 필드의 값을 사용하고 싶다면 다음과 같이 색인을 생성할 때 매핑을 설정할 수 있다.

 

curl -XPOST 'localhost:9200/myindex' -d '{
  "mappings": {
    "document": {
      "properties": {
        "title": {
          "type": "string",
          "index": "not_analyzed",
          "doc_values": true // 'doc_values'를 사용하여 제목 필드에 설정하기
        }
      }
    }
  }
}'



출처: https://12bme.tistory.com/479?category=737765 [길은 가면, 뒤에 있다.]