AWS OpenSearch 1.2 버전에서 정확도 순으로 정렬하기

moongyu·2024년 3월 4일

Elasticsearch

목록 보기
3/3
post-thumbnail

Introduction

해당 글은 AWS OpenSearch 1.2 버전 환경에서 정확도 점수(relevance score)로 정렬 및 페이지네이션을 구현하면서 트러블 슈팅한 경험을 담은 글이다.

OpenSearch를 활용해서 검색을 구현하면, 아래와 같이 document 조회를 위한 search request를 호출할 때 track_scores 프로퍼티에 document 정확도 점수(relevance score) 계산 여부를 지정하여 보낼 수 있다.

const result = await this._openSearchClient
   .search({
     index,
     body: {
       query: {
         ...
       },
     },
     track_scores: true, // boolean
 })

특히 match query를 사용하는 경우 document가 검색어에 얼마나 잘 일치하는 지에 따라 정확도 점수가 다르게 매겨진다.

만약 유저가 검색어를 입력했는데 최신순으로 document를 정렬할 경우, 일치율이 높은 document가 뒤로 밀리고, 오히려 무관한 document가 앞쪽에 나오는 문제가 있을 수 있다.
특히 페이지네이션을 하게 되는 경우 원하는 결과를 찾기까지 페이지를 많이 넘겨야하기 때문에 유저 경험이 더욱 안 좋아지게 된다.

따라서 검색어를 입력받은 경우 (match 쿼리를 사용한 경우)는 정확도순으로 정렬을 해주는 것이 좋다.

Pagenation By Relevance

기본적으로 아래와 같은 구조로 페이지네이션 및 정확도순 정렬을 구현할 수 있다.

private async searchDocuments({
    index,
    size,
    searchAfter,
    trackScores,
  }: {
    index: string
    size: number
    sort: {
      createdAt: 'asc' | 'desc'
      id: 'asc' | 'desc'
      _score: 'asc' | 'desc'
    }
    searchAfter: { createdAt: string; id: string; _score: number }
    trackScores: boolean
  }): Promise<ApiResponse<Record<string, any>, unknown> | null> {
    const result = await this._openSearchClient
      .search({
        index,
        body: {
          query: {
            ...
          },
        },
        size,
        sort: [
          { _score: 'desc' },
          { createdAt: 'desc' },
          { id: 'desc' },
        ],
        search_after: [
          searchAfter._score,
          searchAfter.createdAt,
          searchAfter.id,
        ],
        track_scores: trackScores,
      })

    return result
  }

여기서 sortsearch_after 프로퍼티를 보면, _score, createdAt, id 세 가지가 페이지네이션을 위한 cursor로 사용된 것을 알 수 있다. 차례대로 document의 정확도 점수, 생성일자, ID를 나타낸다.

세 가지의 필드의 순서도 의미가 있는데, 정확도 점수로 먼저 sorting을 하고, 정확도 점수가 동일한 경우 createdAt으로 sorting, 그마저도 동일한 경우 id로 sorting하게 된다.

즉, (_score, createdAt) 두 값의 조합만으로는 해당 cursor가 unique함을 보장해주지 못한다. 따라서 unique한 document ID를 tie-breaker로 사용했다. 꼭 document ID가 아니더라도 각각의 document를 구분하기만 할 수 있다면 어떤 값이든 상관없다.

Problem

Bouncing Results

여기서 문제가 발생했는데, 매번 동일한 쿼리를 호출함에도 document의 _score 값이 계속 달라지는 경우가 있었다. 다음은 공식 문서에 나와있는 bouncing results 문제에 대한 설명이다.

Imagine that you are sorting your results by a timestamp field, and two documents have the same timestamp. Because search requests are round-robined between all available shard copies, these two documents may be returned in one order when the request is served by the primary, and in another order when served by the replica.

This is known as the bouncing results problem: every time the user refreshes the page, the results appear in a different order. The problem can be avoided by always using the same shards for the same user, which can be done by setting the preference parameter to an arbitrary string like the user’s session ID.

요약하자면, search request를 처리할 때 primary shard 혹은 replica shard 중 어떤 곳에서 스코어 값을 계산하여 반환하는 지에 따라 매번 다른 결과값이 반환될 수 있다. 그리고 이 문제를 해결하기 위해서는 preference query string에 임의의 string값을 지정하여 매번 같은 샤드로 라우팅되도록 할 수 있다.

스코어 기반 페이지네이션에서 스코어가 매 번 달라지게되면 페이지네이션할 때 데이터가 중복되거나, 혹은 생략되는 등의 문제가 발생할 수 있다.

따라서 공식 문서에 나온대로 임의의 string으로 preference 값을 지정하여 request를 보내보았으나, 여전히 라운드로빈 형태로 두 개의 다른 결과값이 번갈아가며 나오는 현상이 있었다.

Shard allocation awareness

해당 문제의 원인은 다음과 같다.

ElasticSearch는 shard를 노드에 할당하는 방식이 여러가지 존재하는데, 그 중 하나가 장애 상황에서의 리스크를 최소화하기 위해 물리적으로 다른 하드웨어(ex. AWS zone)에 있는 노드에 primary shard와 replica shard를 나눠서 분배하는 방식이다.

예를 들어, 각 노드마다 node.attr.rack_id 라는 config를 설정해주고, node.attr.rack_id: rack_one, node.attr.rack_id: rack_two 등 물리적 위치를 구분할 수 있는 정보를 저장해준다. 그 후 cluster.routing.allocation.awareness.attributes: rack_id 와 같이 여러 노드에서 공통적으로 사용되는 config 값을 awareness attribute로 지정해주면, 이를 참조하여 shard를 분배한다. 보다 자세한 내용은 공식 문서에서 확인할 수 있다.

이는 원래 shard 할당을 위한 config인데, ElasticSearch 버전 8.0 미만에서는 search request를 라우팅할 때도 해당 값을 참조하여 문제가 발생했다. coordinating 노드가 동일 awareness attribute 값을 가진 노드를 우선시하여 라우팅하기 때문에 preference string을 지정했음에도 계속해서 다른 결과값이 나온 것이다 (관련 Github PR).

ElasticSearch 버전 8.0 이상부터는 해당 이슈가 해결되어서 search request에서 해당 값을 참조하지 않도록 하는 es.routing.search_ignore_awareness_attributes 값이 default true로 적용되어 있다. 또한 ElasticSearch 7.5 버전 이상, 8.0 버전 미만인 경우는 수동으로 해당 값을 변경할 수 있다.

그러나 AWS OpenSearch의 경우는 ElasticSearch 7.1 버전에서 fork되어 나온 프로젝트이기도 하고, 실제로 OpenSearch 버전 1.2에서 허용된 오퍼레이션 목록을 보았을 때 해당 설정값을 변경할 수는 없었다.

Solution

Point In Time

그럼 다른 해결 방안은 어떤게 있을까?
결론부터 말하자면 해당 방법 역시 버전 이슈로 사용하지 못했지만, 일정 시간 동안 세션을 유지하며 동일한 dataset에 request를 보내는 PIT 방식이 있다.

이 방식은 꼭 bouncing results 문제 때문만이 아니라, 동일한 shard 내에서도 데이터는 계속해서 업데이트 될 수 있기 때문에 일정 시간 동안의 쿼리에 대해 일관성 있는 결과를 얻기 위해 사용된다.

동작 방식은, POST API로 PIT를 생성하고 해당 pit_id 값을 search request 시 같이 보내는 방식으로 동작한다. AWS OpenSearch는 버전 2.5 이상부터 해당 기능을 제공한다.

Prefer Nodes

그래서 또 다른 해결 방안을 찾아야만 했다.
물론, 버전업을 하고 PIT를 쓰거나, preference query string에 임의의 string을 주는 방식을 사용할 수 있다면 베스트였을 것이다. 그러나 당시 회사에서 사용하고 있던 AWS OpenSearch 도메인은 우리 팀뿐만 아니라 다른 팀이 공용으로 사용하고 있었고, 비교적 우선 순위가 낮은 어드민 검색에서 사용될 용도였기 때문에, 이 작업만을 위해서 다른 팀에게까지 버전업을 요청드리기는 어려운 상황이었다.

최종적으로 해결한 방법은 preference 쿼리스트링에 _prefer_nodes 값을 지정하여, 계속해서 특정 노드로 라우팅되도록 한 것이다.

예를 들어, A~H까지 8개 노드가 존재하고, 특정 인덱스의 shard가 5개, 각 shard의 replica가 1개인 상황이라고 하자. 이러한 경우에, 총 5개의 primary shard와 5개의 replica가 노드들에 분포하게 된다.

GET {index-name}/_search_shards 쿼리로 인덱스의 샤드 정보를 받아올 수 있는데, 해당 결과를 아래와 같이 정리해볼 수 있다.

총 10개의 shard가 A~H까지 8개 노드에 분포해 있고,

Shard01234
PrimaryNode ANode BNode CNode DNode E
ReplicaNode FNode GNode HNode ANode B

노드들은 두 개의 zone에 각각 4개씩 나누어져있다.

AWS Availability ZoneNode
ap-northeast-2aA, E, G, H
ap-northeast-2cB, C, D, F

따라서, 이러한 경우에 아래와 같이 ap-northeast-2a에 해당하는 'A, E, G, H' 노드를 지정하여 request를 보내면, 계속해서 동일한 샤드에만 요청이 가는 것을 보장할 수 있기 때문에 일관성 있는 결과값을 얻을 수 있다.

Shard01234
PrimaryNode ANode BNode CNode DNode E
ReplicaNode FNode GNode HNode ANode B

반대로, ap-northeast-2c에 속하는 'B, C, D, F' 노드만 지정하는 것도 가능하다.

Shard01234
PrimaryNode ANode BNode CNode DNode E
ReplicaNode FNode GNode HNode ANode B

또는, 아래와 같은 해결방안도 있다.
shard 번호를 기준으로 하나씩만 속하면 되기 때문에 'A, B, C'를 지정하거나, 'D, E, F, G, H'를 지정하여 request를 보낼 수 있다.

ShardNode ID (Primary Shard)Node ID (Replica Shard)
0Node ANode F
1Node BNode G
2Node CNode H
3Node DNode A
4Node ENode B

이렇게 노드를 지정하는 방식은, 일부 노드에만 부하가 몰린다는 점에서 단점이 존재한다.

따라서 최대한 많은 노드를 사용하는 것이 유리하므로, _prefer_nodes=D,E,F,G,H와 같은 형태로 요청을 보내게 되면, 0번부터 4번까지 모든 shard를 포괄하면서, 동일한 shard에만 지속적으로 요청을 보내고, 가장 많이 부하를 분산시킬 수 있게 된다.

최종적인 코드는 아래와 같다.

private async searchDocuments({
    index,
    condition,
    size,
    sort,
    searchAfter,
    trackScores,
  }: {
    index: string
    condition: {
      must?: Record<string, any>[]
      mustNot?: Record<string, any>[]
      filter?: Record<string, any>[]
      should?: Record<string, any>[]
      minimumShouldMatch?: number
    }
    size?: number
    sort?: {
      createdAt: 'asc' | 'desc'
      id?: 'asc' | 'desc'
      _score?: 'asc' | 'desc'
    }
    searchAfter?: { createdAt: string; id?: string; _score?: number }
    trackScores?: boolean
  }): Promise<ApiResponse<Record<string, any>, unknown> | null> {
    const {
      must,
      mustNot: must_not,
      filter,
      should,
      minimumShouldMatch: minimum_should_match,
    } = condition

    const result = await this._openSearchClient
      .search({
        index,
        preference: _prefer_nodes: 'D,E,F,G,H',
        body: {
          query: {
            bool: {
              must,
              must_not,
              filter,
              should,
              minimum_should_match,
            },
          },
          size,
          ...(sort
            ? sort.id
              ? sort._score
                ? {
                    sort: [
                      { _score: 'desc' },
                      { createdAt: 'desc' },
                      { id: 'desc' },
                    ],
                  }
                : { sort: [{ createdAt: sort.createdAt }, { id: sort.id }] }
              : { sort: [{ createdAt: sort.createdAt }] }
            : {}),
          ...(searchAfter
            ? searchAfter.id
              ? searchAfter._score
                ? {
                    search_after: [
                      searchAfter._score,
                      searchAfter.createdAt,
                      searchAfter.id,
                    ],
                  }
                : { search_after: [searchAfter.createdAt, searchAfter.id] }
              : { search_after: [searchAfter.createdAt] }
            : {}),
        },
        track_scores: trackScores,
      })
      .catch((error) => {
        // handle error

        return null
      })

    return result
  }

_only_nodes로 완전히 특정한 노드에만 request를 보내는 방식도 존재하는데, _prefer_nodes는 이와 달리 지정된 노드가 active하지 않을 경우, 다른 노드에 쿼리를 보낸다는 점에서 안정성이 보장된다.

이러한 해결 방안은 cluster가 rebalancing되는 상황이 생기면 또 다시 노드 ID를 변경해야하는 문제가 생길 수 있다는 점에서 한계가 있다.

그러나 현재까지 해당 도메인이 문제 없이 운영되고 있었고, 위와 같이 rebalancing되더라도 _prefer_nodes를 사용하기 때문에 에러가 발생하지는 않는다는 점, 어드민에서 검색하기 위한 용도라는 점을 고려해서 위와 같은 방법을 사용했다.

추후 OpenSearch를 버전업하여 PIT 방식 등을 사용할 수 있다면 그것이 가장 안정적인 방향이라고 생각한다. 최적의 해결 방안은 아니지만 동일한 버전 이슈를 겪고 있다면 고려해볼만한 방법인듯 하다.

References

[ElasticSearch] Search Options : preference
[ElasticSearch] Modules Cluster : shard-allocation-awareness
[Github] shard-allocation-awareness Issue
[Github] Document that awareness attributes override custom preferences
[AWS] OpenSearch supported operations
[Opensearch] Search : the preference query parameter

0개의 댓글