Elasticsearch Synonym 검색 이슈케이스

JunMyung Lee·2023년 4월 7일
1

Elasticsearch

목록 보기
16/37

검색서비스를 운영 중에, 다음과 같은 시나리오 케이스가 존재 하였고 검색이 이루어지지 않는 이슈가 발생하였다. 그 이슈 시나리오는 다음과 같다.

  • [푸켓]의 동의어가 [뿌켓]으로 운영 중이다
  • [푸켓]의 동의어로 [푸켕]을 추가하였다
  • 사전 배포와 재색인이 수행되었다
  • [푸켓]이라는 단어로 검색이 이루어 지지 않는다

검색이 왜 안되는걸까?


푸켕이라는 단어는 형태소분석시에 Nori가 분석할 수 없는 단어다. 형태소분석이 이루어지지 않으면 음절단위로 잘라버리는데, 이때 [푸], [켕]으로 분석이 이루어졌다.

검색어가 [푸켕]이라고 검색을 했으면 검색이 안될 수도 있구나 했을 것이다. 하지만 검색어는 [푸켓]이였고 [푸켕]이든, [푸케케케케ㅔㅋㅇ]이든 동의어만을 추가한것이므로 검색이 이루어졌어야 했다.

고로 정상적인 행위가 아니다!

원인

결론부터 말하자면,

복합명사로 이루어진 (또는 형태소분석이 제대로 이루어지지 않는) 단어를 Lucent에서 색인할때, PositionLengthAttribute가 지정된 토큰을 종료해야하는 위치를 무시하고 토큰 그래프를 효과적으로 다음과 같이 평탄화 하기 때문에 일부 그래프 구조가 손실된다.

무슨말인지 어려우므로 좀더 설명을 해보면서 이해해보자

동의어 형태소 분석 원리

문장 ➡전처리 ➡형태소분석 ➡후처리 ➡분석기
SentensCharacter FilterTokenizerToken FilterAnalyzer

여기서 동의어처리는 Token Filter에서 처리가 된다. 즉, 후처리
후처리는 분석된 토큰을 추가, 삭제 및 변경의 행위를 하는 부분이다.

Example

아버지가 방에 들어가신다 ( 동의어 : 아버지 father)

일반
[아버지] [가] [방] [에] [들어가] [신다]

동의어
[아버지] [father] [가] [방] [애] [들어가] [신다]

Synonym_graph

Lucene에는 Synonym FilterSynonym Graph Filter가 존재한다.

색인어의 동의어들중에서, 복합어가 동의어가 된 경우, 일반적인 synonym_filter를 사용할 경우, 다음과 같은 문제가 발생한다.

포지션 값이 domain name system값까지 dns값 까지 잡혀야 하는데 domain에 대해서만 포지션값을 잡아버려서 dns로 검색시, 다음과 같은 문서가 검색이 되어 버린다.

domain is pretty

정상적으로 검색이 가능하게 하려면 synonym_graph_filter를 적용해야한다.

dns에 대한 포지션 값을 3번까지 (3단어 이므로) 부여 함으로써 domain is pretty는 검색이 되질 않는다.

그래서 검색이 안되는 이유는?

현재 이슈가 발생한 동의어 필터는 Synonym Filter다. 그리고 푸켓에 대한 단어들은 전부 사용자 사전에 등록이 되어 있으므로 Position은 1이였다. 하지만 사용자 사전에 등록되지 않은 [푸켕]이라는 단어가 동의어로 추가가 되었고, 그 단어는 질의시점에 (동의어 확장은 search_analyzer만) 확장이 되었고, 분석이 [푸]와 [켕]으로 나뉜 형태가 되었다.

그렇게 됨으로써 동의어 구조가 깨지게 되었으며, [푸켓]의 색인 Position이 1이 증가하게 되었으며(푸,켕 2개 토큰이므로), 기존 푸켓의 Position인 0이 [푸]위치게 엮이게 되었으며, [푸켓]의 Position 1이 생겨버린것이다.

Position 0Position 1
푸켓Empty

여기서 Match query시, operation의 값이 지정되어 있지 않거나, "OR"로 되어있는 경우 검색이 이루어진다. [푸켓] 질의가 들어왔을 때, Position 0에 해당하는 질의어가 들어와서 매칭이 되고, Position 1에 해당하는 특정 빈값은 들어오지 않았지만, OR 조건이기 때문에 Position 0값만 매칭이 되어도 검색이 된다.
즉, 해당 이슈는 operation이 "AND"인 경우다.

synonym_graph는 토큰의 Position이 서로 안겹치도록 하고, 또한 가장 긴 복합명사의 토큰분해결과의 Position과 같은 길이로 하여 동의어들간의 Position이 같은 길이로 하도록 한다.

이때 다른 크기의 동의어가 추가되면 다른 토큰들의 Position도 변경

synonym_graph인 경우 다음처럼 변경

|Position 0|Position 1|
|         푸켓         |
|    푸    |    켓     |

synonym 인경우 추가 예제

|Position 0|Position 1|Position 2|
|노동       |    자     |          | 
|노동       |    계     |          |
|손오공      |          |          |

synonym_graph인 경우 추가 예제

|Position 0|Position 1|Position 2|
|    노동   |          자          |
|         노동         |    계     |
|               손오공             |

elasticsearch에서 autogenerateSynonymsphrasequery란?
autogenerateSynonymsphrasequery는 Elasticsearch의 쿼리 매개변수 중 하나입니다. 이 매개변수를 사용하면 검색어와 연관된 동의어를 자동으로 생성하여 동의어를 포함하는 검색어로 쿼리를 확장할 수 있습니다.
예를 들어, "big apple"이라는 검색어가 있다면, autogenerateSynonymsphrasequery를 사용하면 "large apple", "giant apple", "big apple", "nyc"와 같은 동의어를 자동으로 생성하여 검색어를 "big apple"을 포함하는 문장으로 쿼리를 수행할 수 있습니다.
이 기능을 사용하면 검색어와 일치하는 결과물을 더 많이 찾을 수 있습니다. 그러나 이 기능이 잘못 구성된 경우, 쿼리가 너무 일반화되어 부적절한 결과를 반환할 수도 있으므로 사용 전에 신중하게 검토해야 합니다.

# autoGenerateSynonymsPhraseQuery과 search_analyzer의 동의어 설정시
autoGenerateSynonymsPhraseQuery 사용 시:

장점:

간단하게 설정할 수 있어 초기 구현이나 특정 상황에서 빠른 동의어 처리를 제공합니다.
특정한 쿼리에 대해 자동으로 정확한 구문을 생성하여 정확한 일치를 유도할 수 있습니다.
단점:

동적으로 쿼리를 생성하므로 미리 정의된 동의어와 다르게 동작할 수 있습니다.
모든 검색어에 대해 항상 정확한 구문을 유도할 수 없는 경우가 있습니다.
search_analyzer 사용 시:

장점:

보다 세밀한 제어가 가능합니다. 사용자는 직접적으로 분석기와 동의어 필터를 정의할 수 있습니다.
정확한 동의어 목록을 지정하여 미리 예측 가능한 동작을 유도할 수 있습니다.
단점:

세부적인 설정이 필요하므로 초기 구현이나 간단한 동의어 처리에 비교적 복잡할 수 있습니다.

예제

테스트용 인덱스 생성

PUT synonym-analyzer
{
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "nori_custom",
        "search_analyzer": "synonym_custom"
      }
    }
  },
  "settings": {
    "analysis": {
      "filter": {
        "synonym_filtering": {
          "type": "synonym",
          "synonyms": [
            "푸켓,뿌켓,pucket,푸켕"
          ]
        }
      },
      "analyzer": {
        "nori_custom": {
          "filter": [
            "lowercase",
            "trim"
          ],
          "tokenizer": "nori_user_dict"
        },
        "synonym_custom": {
          "filter": [
            "lowercase",
            "trim",
            "synonym_filtering"
          ],
          "tokenizer": "nori_user_dict"
        }
      },
      "tokenizer": {
        "nori_user_dict": {
          "type": "nori_tokenizer",
          "user_dictionary_rules": [
            "푸켓",
            "뿌켓",
            "pucket"
          ],
          "decompound_mode": "discard"
        }
      }
    }
  }
}

PUT synonym-graph-analyzer
{
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "nori_custom",
        "search_analyzer": "synonym_custom"
      }
    }
  },
  "settings": {
    "analysis": {
      "filter": {
        "synonym_filtering": {
          "type": "synonym_graph",
          "synonyms": [
            "푸켓,뿌켓,pucket,푸켕"
          ]
        }
      },
      "analyzer": {
        "nori_custom": {
          "filter": [
            "lowercase",
            "trim"
          ],
          "tokenizer": "nori_user_dict"
        },
        "synonym_custom": {
          "filter": [
            "lowercase",
            "trim",
            "synonym_filtering"
          ],
          "tokenizer": "nori_user_dict"
        }
      },
      "tokenizer": {
        "nori_user_dict": {
          "type": "nori_tokenizer",
          "user_dictionary_rules": [
            "푸켓",
            "뿌켓",
            "pucket"
          ],
          "decompound_mode": "discard"
        }
      }
    }
  }
}

# Result
# PUT synonym-analyzer
{
  "acknowledged" : true,
  "shards_acknowledged" : true,
  "index" : "synonym-analyzer"
}

# PUT synonym-graph-analyzer
{
  "acknowledged" : true,
  "shards_acknowledged" : true,
  "index" : "synonym-graph-analyzer"
}

Analyzer 테스트

GET synonym-analyzer/_analyze
{
  "text": "푸켓",
  "analyzer": "synonym_custom"
}

GET synonym-graph-analyzer/_analyze
{
  "text": "푸켓",
  "analyzer": "synonym_custom"
}

# Result
# GET synonym-analyzer/_analyze
{
  "tokens" : [
    {
      "token" : "푸켓",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "뿌켓",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "SYNONYM",
      "position" : 0
    },
    {
      "token" : "pucket",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "SYNONYM",
      "position" : 0
    },
    {
      "token" : "푸",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "SYNONYM",
      "position" : 0
    },
    {
      "token" : "켕",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "SYNONYM",
      "position" : 1
    }
  ]
}

# GET synonym-graph-analyzer/_analyze
{
  "tokens" : [
    {
      "token" : "뿌켓",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "SYNONYM",
      "position" : 0,
      "positionLength" : 2
    },
    {
      "token" : "pucket",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "SYNONYM",
      "position" : 0,
      "positionLength" : 2
    },
    {
      "token" : "푸",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "SYNONYM",
      "position" : 0
    },
    {
      "token" : "푸켓",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0,
      "positionLength" : 2
    },
    {
      "token" : "켕",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "SYNONYM",
      "position" : 1
    }
  ]
}

문서 삽입

POST synonym-analyzer/_doc/1
{
  "name": "푸켓"
}

POST synonym-graph-analyzer/_doc/1
{
  "name": "푸켓"
}

# Result
# POST synonym-analyzer/_doc/1
{
  "_index" : "synonym-analyzer",
  "_type" : "_doc",
  "_id" : "1",
  "_version" : 1,
  "result" : "created",
  "_shards" : {
    "total" : 2,
    "successful" : 1,
    "failed" : 0
  },
  "_seq_no" : 0,
  "_primary_term" : 1
}

# POST synonym-graph-analyzer/_doc/1
{
  "_index" : "synonym-graph-analyzer",
  "_type" : "_doc",
  "_id" : "1",
  "_version" : 1,
  "result" : "created",
  "_shards" : {
    "total" : 2,
    "successful" : 1,
    "failed" : 0
  },
  "_seq_no" : 0,
  "_primary_term" : 1
}

질의 테스트

GET synonym-analyzer/_search
{
  "query": {
    "match": {
      "name": {
        "query": "푸켓", 
        "operator": "and", 
        "auto_generate_synonyms_phrase_query": false
      }
    }
  }
}


GET synonym-graph-analyzer/_search
{
  "query": {
    "match": {
      "name": {
        "query": "푸켓", 
        "operator": "and", 
        "auto_generate_synonyms_phrase_query": false
      }
    }
  }
}

# Result
# GET synonym-analyzer/_search
{
  "took" : 142,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 0,
      "relation" : "eq"
    },
    "max_score" : null,
    "hits" : [ ]
  }
}

# GET synonym-graph-analyzer/_search
{
  "took" : 30,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 1,
      "relation" : "eq"
    },
    "max_score" : 0.2876821,
    "hits" : [
      {
        "_index" : "synonym-graph-analyzer",
        "_type" : "_doc",
        "_id" : "1",
        "_score" : 0.2876821,
        "_source" : {
          "name" : "푸켓"
        }
      }
    ]
  }
}

바로 분석 예시


GET /_analyze
{
  "text": [
    "사조참치"
  ],
  "tokenizer": {
    "decompound_mode": "discard",
    "type": "nori_tokenizer"
  },
  "filter": [
    {
      "type": "synonym_graph",
      "synonyms": [
        "사조참치,동원참치,대양참치"
      ]
    }
  ]
} 

GET /_analyze
{
  "text": [
    "노동자"
  ],
  "tokenizer": {
    "decompound_mode": "discard",
    "type": "nori_tokenizer",
    "user_dictionary_rules": [
      "노동자",
      "노동계",
      "손오공",
      "마이타부처님",
      "사조",
      "라면",
      "삼양",
      "불닭",
      "볶음면"
    ]
  },
  "filter": [
    {
      "type": "synonym_graph",
      "synonyms": [
        "노동자,노동계,손오공,마이타부처님,사조라면,삼양불닭볶음면"
      ]
    }
  ]
}

# Result
# GET /_analyze
{
  "tokens" : [
    {
      "token" : "동원",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "SYNONYM",
      "position" : 0
    },
    {
      "token" : "대양",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "SYNONYM",
      "position" : 0,
      "positionLength" : 2
    },
    {
      "token" : "사조",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0,
      "positionLength" : 3
    },
    {
      "token" : "참치",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "SYNONYM",
      "position" : 1,
      "positionLength" : 3
    },
    {
      "token" : "참치",
      "start_offset" : 0,
      "end_offset" : 4,
      "type" : "SYNONYM",
      "position" : 2,
      "positionLength" : 2
    },
    {
      "token" : "참치",
      "start_offset" : 2,
      "end_offset" : 4,
      "type" : "word",
      "position" : 3
    }
  ]
}

# GET /_analyze
{
  "tokens" : [
    {
      "token" : "노동계",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "SYNONYM",
      "position" : 0,
      "positionLength" : 4
    },
    {
      "token" : "손오공",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "SYNONYM",
      "position" : 0,
      "positionLength" : 4
    },
    {
      "token" : "마이타부처님",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "SYNONYM",
      "position" : 0,
      "positionLength" : 4
    },
    {
      "token" : "사조",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "SYNONYM",
      "position" : 0
    },
    {
      "token" : "삼양",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "SYNONYM",
      "position" : 0,
      "positionLength" : 2
    },
    {
      "token" : "노동자",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "word",
      "position" : 0,
      "positionLength" : 4
    },
    {
      "token" : "라면",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "SYNONYM",
      "position" : 1,
      "positionLength" : 3
    },
    {
      "token" : "불닭",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "SYNONYM",
      "position" : 2
    },
    {
      "token" : "볶음면",
      "start_offset" : 0,
      "end_offset" : 3,
      "type" : "SYNONYM",
      "position" : 3
    }
  ]
}

0개의 댓글