엘라스틱서치 검색 쿼리 짜보기

Jaejun Kim·2022년 12월 18일
0

정보전달보다는 일지에 가깝습니다.

스팀게임 검색 및 추천 프로젝트의 내용입니다. https://github.com/SteamReviewSearch/search

검색쿼리.. 라고 하기에는 조촐하다 사실. 정교한 함수를 통해 score을 도출하는 것이 아닌, 그저 쿼리를 짜집기하고 중요한 field에 부스팅을 한게 전부이기 때문이다. 검색쿼리 자체를 신경쓰기 전에 물밑작업(데이터 타입 관리, 맵핑관리)을 공부하고 안정화시키는게 우선이었기 때문에 쿼리 자체는 평범한 find문과 다르지 않다.

다만 일반적인 find로는 검색이라 부를수 없고 부르고 싶지도 않았기에 몇가지 기준을 두고 쿼리를 짜려고 노력했다.
1. 게임명으로 검색을 하는데 DLC가 먼저 나오지는 않게 하기
2. 풀네임으로 검색했는데 요상한거 안나오게 하기
3. 로마자로 검색 (GTA 5, GTA V, GTA Ⅴ) 했을 때 같은 결과가 나오게 하기
4. 붙여쓰거나 띄어쓰는 경우에도 검색이 가능하게 하기(battlefield === battle field)

이를 위한 맵핑과 쿼리가 필요했다.

reindex

처음 넣을 때 동적 맵핑이 들어간 데이터들을 reindex 해줄 필요도 있었다.

// 인덱스를 만들었다면 데이터를 옮기는건 이 쿼리 하나로 해결
POST _reindex
{
  "source": {
    "index": "game_data"
  },
  "dest": {
    "index": "games_data"
  }
}

reindex는 기존 인덱스의 복사이며 기존 인덱스가 사라지거나 하지는 않는다. 뭔가 당연한 부분같지만 겨우겨우 데이터를 엘라스틱서치에 집어넣은 그때만큼은 이게 혹시라도 원본을 훼손시킬까봐 전전긍긍했었다.

reindex를 진행하는김에 맵핑을 더 찾아보고 적용점을 더 찾아보게 되었다.

  1. 동의어가 신경쓰였던 로마숫자 부분을 해결했다. 파일로 대체하는 방식도 있지만 클라우드라 어디에 파일을 지정해야 할지도 모를 뿐더러 다른 거슬리는 부분들은 우선순위에서 멀었기에(그나마 중요한 것을 들면 PUBG => Playerunknowns Battleground, GTA => Grand Theft Auto 정도) 로마자를 인식시켜주는 것으로 만족했다.
  • 사실 이 단순한 작업에도 함정이 있어 시간을 좀 소비하는 부분이 있었는데, 바로 표기 방식이다. 로마자는 실제로 지원되는 글자이다. 하지만 생김새가 알파벳으로 대체해도 감쪽같은 부분이 많아 알파벳 대문자를 사용하기도 하는 것을 깜빡했다. 특히 5 같은 경우는 대체로 대문자 브이 V 를 사용하더라. 때문에 "Ⅰ, 1, I", "Ⅱ, 2, II" 식으로 매핑을 해야 변환이 되었다.
  1. 필드 하나에 여러 역색인을 넣을 수 있는 다중필드를 시험해 넣어보았다. 나같은 경우는 ngam 애널라이저 하나와 standard 애널라이저 총 두개의 다중필드를 만들었는데, 생각보다 너무 간단하게 되어서 깜짝 놀랐다.
  • 이부분도 물먹은 부분이 있었는데, 처음에 이것을 어떻게 사용해야 할지 몰라 한시간 정도 헤맸던 기억이 있다. 이렇게 다중필드인 필드를 사용할 때는 field name.analyzer name 순으로 필드를 지정해주어야 해당 애널라이저가 적용된 역색인 탐색에 들어간다.

  • 또한 ngram을 적용한 뒤 키워드로 검색을 하면 알아서 사용했던 애널라이저를 써서 작업을 할 줄 알았는데 다중필드라 먹히지 않았던 것인지 잘 되지 않았다. 때문에 name.ngrams 필드를 작성할 때 search_analyzer 를 따로 옵션에 넣어 ngrams 애널라이저를 지정해두었고, 그 이후로 name.ngrams 필드로 검색을 할 때면 알맞게 분석이 되어 검색이 되는듯 했다.

-- 여담이지만 그 이전에는 nodejs에서 직접 ngram에 맞게 분해를 해줘야 하는줄 알고 특수문자를 없애고 ngram으로 직접 쪼개는 등의 헛짓을 하기도 했다.

var reg = /[\{\}\[\]\/?.,;:|\)*~`!^\-_+<>@\#$%&\\\=\(\'\"]/gi;
const keywords_patched = keywords.replace(reg, '').replace(/\s/g, '');

let tokens = keywords.split(' '),
    let ngrams = [];
for (let i = 0; i < ((keywords_patched.length - 2) + 1); i++) {
  let subset = [];
  for (let j = i; j < (i + 3); j++) {
    subset.push(keywords_patched[j]);
  }
  ngrams.push(subset.join(''))
}

물론 현재는 죽은 코드가 되었다.


mapping

PUT games_data_copy
{
"mappings": {
  "properties": {
    "@timestamp": {
      "type": "date"
    },
    "@version": {
      "type": "keyword"
    },
    "appid": {
      "type": "long"
    },
    "categories": {
      "properties": {
        "description": {
          "type": "keyword"
        },
        "id": {
          "type": "long"
        }
      }
    },
    "createdat": {
      "type": "date"
    },
    "genres": {
      "properties": {
        "description": {
          "type": "keyword"
        },
        "id": {
          "type": "long"
        }
      }
    },
    "img_url": {
      "type": "keyword"
    },
    "metacritic": {
      "properties": {
        "score": {
          "type": "long"
        },
        "url": {
          "type": "keyword"
        }
      }
    },
    "name_eng": {
      "type": "text",
      "fields": {
        "autocomplete": {
          "type": "text",
          "analyzer": "autocomplete",
          "search_analyzer": "autocomplete_search"
        },
        "ngram_filter": {
          "type": "text",
          "analyzer": "ngram_analyzer_filter"
          //"search_analyzer": "standard_analyzer"
        },
        "standard": {
          "type": "text",
          "analyzer": "standard_analyzer"
        }
      }
    },
    "name": {
      "type": "text",
      "fields": {
        "autocomplete": {
          "type": "text",
          "analyzer": "autocomplete",
          "search_analyzer": "autocomplete_search"
        },
        "ngram_filter": {
          "type": "text",
          "analyzer": "ngram_analyzer_filter"
          //"search_analyzer": "standard_analyzer"
        },
        "standard": {
          "type": "text",
          "analyzer": "standard_analyzer"
        }
      }
    },
    "review_score": {
      "type": "long"
    },
    "review_score_desc": {
      "type": "keyword"
    },
    "short_description": {
      "type": "keyword"
    },
    "short_description_eng": {
      "type": "keyword"
    },
    "supported_languages": {
      "type": "keyword"
    },
    "total_negative": {
      "type": "long"
    },
    "total_positive": {
      "type": "long"
    },
    "total_reviews": {
      "type": "long"
    },
    "type": {
      "type": "keyword"
    },
    "updatedat": {
      "type": "date"
    },
    "pass": {
      "type": "boolean"
    },
    "platforms": {
      "properties": {
        "linux": {
          "type": "boolean"
        },
        "mac": {
          "type": "boolean"
        },
        "windows": {
          "type": "boolean"
        }
      }
    },
    "price_overview": {
      "properties": {
        "currency": {
          "type": "keyword"
        },
        "discount_percent": {
          "type": "long"
        },
        "final": {
          "type": "long"
        },
        "final_formatted": {
          "type": "keyword"
        },
        "initial": {
          "type": "long"
        },
        "initial_formatted": {
          "type": "keyword"
        },
        "recurring_sub": {
          "type": "long"
        },
        "recurring_sub_desc": {
          "type": "keyword"
        }
      }
    },
    "recommendations": {
      "properties": {
        "total": {
          "type": "long"
        }
      }
    },
    "release_date": {
      "properties": {
        "coming_soon": {
          "type": "boolean"
        },
        "date": {
          "type": "keyword"
        }
      }
    }
  }
},
"settings": {
    "index": {
      "number_of_shards": 3,
      "number_of_replicas": 1,
      "max_ngram_diff" : "20"
    },
    "analysis": {
      "filter": {
        "ngram": {
          "type": "ngram",
          "min_gram": "2",
          "max_gram": "20"
        },
        "edge_ngram": {
          "type": "edge_ngram",
          "min_gram": 2,
          "max_gram": 20
        },
        "stop_simbols": {
          "type": "stop",
          "stopwords": [
            "®",
            "™",
            "©"
            ]
        },
        "syn_roma_number": {
          "type": "synonym",
          "synonyms": [
            "Ⅰ, 1, I",
            "Ⅱ, 2, II", 
            "Ⅲ, 3, III", 
            "Ⅳ, 4, IV", 
            "Ⅴ, 5, V", 
            "Ⅵ, 6, VI", 
            "Ⅶ, 7, VII", 
            "Ⅷ, 8, VIII", 
            "Ⅸ, 9, IX", 
            "Ⅹ, 10, X"
          ]
        }
      },
      "analyzer": { 
        // 일반
          "standard_analyzer": {
            "filter": [
              "lowercase",
              "stop_simbols",
              "syn_roma_number"
            ],
            "tokenizer": "standard"
          },
          //ngram 필터
          "ngram_analyzer_filter": {
            "filter": [
              "lowercase",
              "stop_simbols",
              "syn_roma_number",
              "ngram"
            ],
            "tokenizer": "standard"
          },
          // edge_ngram 자동완성용
          "autocomplete": {
            "tokenizer": "standard",
            "filter": [
              "lowercase",
              "stop_simbols",
              "syn_roma_number",
              "edge_ngram"
            ]
          },
          // 자동완성 검색용
          "autocomplete_search": {
            "tokenizer": "standard",
            "filter": [
              "lowercase",
              "stop_simbols",
              "syn_roma_number"
            ]
          }
      }
    }
  }
}

한창 재맵핑을 하던 중에는 ngram의 갯수로 고민을 했었었다.

처음에는 시작과 끝을 3 과 4로, 나중에는 2와 20으로 설정을 해보기도 했다. 결과는 '크게 차이가 나지 않는다' 였다.

2와 20으로 두면 그나마 standard 애널라이저를 검색 분석기로 따로 두어 score 인플레이션을 해결할 수 있었기에 나쁘지 않다고 생각했다. ngram이 검색분석기까지 겸할 경우 단순 매칭으로 생기는 엄청난 score 인플레이션이 감당이 안될 것이다.

단 standard를 검색분석기로 쓰면 callofduty 같이 붙어서 검색되는 키워드를 대할 때 형태소 분석기를 쓰는 등 추가적인 노력이 들어간다. 혼자 생각하기로는 각각 장단점이 있기에 현재는 search_analyzer도 ngram으로 두어 써 보고도 있다.

고민을 통해 조금씩 적용을 해보니 검색또한 조금씩 더 유연해졌고 정확해졌다.

검색쿼리(NodeJS)

let option_keywords = {
	from: slice_start, size: 30,
	index: process.env.GAME,
	explain: true,
	body: {
	    query: {
	        bool: {
	            filter: [
	                {
	                    multi_match: {
	                        "query": keywords,
	                        // "fuzziness": 1, // multi_search 적용시 2~3배 느려짐
	                        "fields": [
	                            "name_eng.ngram_filter",
	                            "name.ngram_filter"
	                        ]
	                    },
	                },
	                { exists: { field: "img_url" } }
	            ],
	            should: [
	                { match_phrase: { "name.standard": keywords } }, // 구문 검색 up
	                { match: { "name.standard": keywords } }, // 노말 검색 up
	                { match: { "name_eng.ngram_filter": keywords } }, // ngram 키워드가 맞으면 up
	                { match: { type: { query: 'game', boost: 50 } } },// type이 game이면 + 
	            ]
	        }
	    }
	}
}
const game_list = await this.gamesRepository.findWithES(option_keywords);

should 는 검색을 할때 요소를 거르는 결정적인 요소가 되지는 않지만, should 쿼리에 맞는 doc이냐 아니냐에 따라 해당 doc의 점수가 유지되거나 더 올라간다.

처음에는 match_phrase_prefix: name 과도 적용하여 추가점수를 주었으나, 첫번째는 아무래도 구문 마지막을 와일드카드로 쓰기 때문에 정확도에서 큰 효과를 기대할 수 없을 것 같았고,  ngram은 구문이 맞는경우가 너무 많아서 점수가 쓸데없이 많이 올라가게 되는 문제가 있다.  그렇게 되면 시리즈번호같은 한글자 맞는 정도는 점수를 추가하더라도 순위변동이 크게 일어나지 않아, 결과적으로 검색 결과가 가지는 민감도가 크게 떨어지게 된다.

때문에 "전체구문이 맞을 경우에만 점수를 획득하는 match_phrase, 가장 일반적인 방식으로 점수획득이 가능한 match, 해당 정체가 게임인 경우 점수를 높이는 type: game 만 넣게 되었고 결과는 나름 만족스러운 것 같다.

참고

reindex
https://xodwkx2.tistory.com/m/entry/Elasticsearch-reindex-%EC%9E%AC%EC%83%89%EC%9D%B8

매핑
https://esbook.kimjmin.net/07-settings-and-mappings/7.2-mappings/7.2.1

다중필드
https://esbook.kimjmin.net/07-settings-and-mappings/7.3-multi-field

ngram 토큰필터
https://esbook.kimjmin.net/06-text-analysis/6.6-token-filter/6.6.4-ngram-edge-ngram-shingle

0개의 댓글