프로젝트/여행지 오픈 API

[ElasticSearch] 최종 인덱스, 중복 문제와 오탈자 검색의 고민

블랑v 2023. 11. 13. 17:44

 

인덱스 최종 수정

 

기존에는 필터 유무로 인덱스를 나눴는데. 이는 매우 비효율적인 짓이었다.

그냥 커스텀 분석기를 하나 추가하고, 하나의 인덱스에 적용하면 된다. 특히 match_term 등의 토큰화 과정에서만 2글자 이상 토크나이징 분석기를 사용하면 될 것이다.

그리고 후에 설명할 로직으로 인해 ngram 분석기를 추가하였다.

 

길이 제한 없는 기본 nori 검색기 사용

 

2글자 이상 토큰화 사용

 

 

reindex 효율적으로 사용하기

 

와중에 데이터를 마이그레이션하는 과정에서 reindex가 timeout되는 것을 확인했다.

이는 큰 사이즈의 데이터일 경우 kibana에서 일정 시간 이상 할당한 경우 자체적으로 block하는 것으로 보인다.

 

POST _reindex
{
  "source": {
    "index": "scrap_wiki_limited_term_length_1109",
    "size": 1000,
    "timeout": "2m"
  },
  "dest": {
    "index": "scrap_wiki_final"
  }
}

 

https://aky123.tistory.com/23

 

[Elasticsearch] reindex

index가 서비스에 사용되고 있을 때 일반적인 방법으로 reindex를 실행하면 오류가 발생한다. 기존 index의 document가 10만개가 넘어가기 때문에 너무 큰 나머지 timeout error가 발생 #news index의 내용을 dic

aky123.tistory.com

혹은 다음과 같이 비동기 처리를 사용하자.

 

 

 

 

 

1. 통합 내용 검색(전문 검색)

전체 내용 검색(multi-match)과 필드별 가중치 다르게 부여

 

이슈 : 중복 문제 -> Collapse 함수 사용

 

[
    {
        "id": "zA5nxosBAhFhWCZbZsXi",
        "pkId": "65489e8ba9b4a124c9196d37",
        "contentId": "134223",
        "attractionName": "통나무집",
        "number": "0",
        "wiki_title": "통나무집",
        "wiki_content": null,
        "overview": "실내 분위기가 토속적이며 고풍스럽다. 여러가지 한약재를 넣고 삶은 돼지고기를 배추에 싸먹는 한방보쌈 맛이 각별하며 육질이 이색적이어서 보쌈이나 양념 없이 그대로 먹어도 색다른 맛을 느낄 수 있다. 직접 집에서 뽑은 메밀국수를 자체 개발한 생과일 소스에 찍어 먹는 쟁반국수는 별미이다. 버섯전골은 직접 재배한 버섯과 인근 재배농가 버섯을 사용하여 항상 싱싱한 버섯을 제공한다.",
        "matchTerm": -1,
        "totalTerm": 1
    },
    {
        "id": "zQ5nxosBAhFhWCZbZsXi",
        "pkId": "65489e8ba9b4a124c9196d38",
        "contentId": "134223",
        "attractionName": "통나무집",
        "number": "null",
        "wiki_title": "null",
        "wiki_content": "null",
        "overview": "실내 분위기가 토속적이며 고풍스럽다. 여러가지 한약재를 넣고 삶은 돼지고기를 배추에 싸먹는 한방보쌈 맛이 각별하며 육질이 이색적이어서 보쌈이나 양념 없이 그대로 먹어도 색다른 맛을 느낄 수 있다. 직접 집에서 뽑은 메밀국수를 자체 개발한 생과일 소스에 찍어 먹는 쟁반국수는 별미이다. 버섯전골은 직접 재배한 버섯과 인근 재배농가 버섯을 사용하여 항상 싱싱한 버섯을 제공한다.",
        "matchTerm": -1,
        "totalTerm": 1
    },
   
   .. 생략

 

중복 문제. 데이터 수집 과정에서 부분적으로 겹치는 부분이 생긴다.

number 단위가 다른 개념의 검색이었지만, 내용 자체는 똑같다. (contentId와 overview가 동일하다)

이는 같은 contentId여도 스플릿에 따른 여러 검색결과가 존재하기에 발생하는 일이다.

이를 독립적으로 하나의 결과값으로 인지하고 가장 스코어가 높은 값만 산출하여야 한다.

 

collapse 기능은 검색 결과에서 주어진 필드(여기서는 contentId)를 기준으로 문서를 축소하여, 각 고유값에 대해 가장 관련성 높은 단일 문서만 반환하도록 하였다.

 

GET scrap_wiki_final/_search
{
  "size": 10, 
  "query": {
    "multi_match": {
      "query": "통나무집",
      "fields": ["attraction_name^4", "overview^3", "wiki_title^2", "wiki_content"]
    }
  },
  "collapse": {
    "field": "content_id"
  }
}


//이를 비스니스 로직으로 구현하면 다음과 같다.
  public List<Wiki> searchAll(SearchAllDTO searchAllDTO) {
        try {
            QueryBuilder multiyMatchQuery = new MultiMatchQueryBuilder(
                    searchAllDTO.getSearchContent(), // 검색어
                    "attraction_name", "overview", "wiki_title", "wiki_content"
            )
                    .field("attraction_name", searchAllDTO.getAttractionNameCorrectionFactor())
                    .field("overview", searchAllDTO.getOverviewCorrectionFactor())
                    .field("wiki_title", searchAllDTO.getWikiTitleCorrectionFactor())
                    .field("wiki_content", searchAllDTO.getWikiContentCorrectionFactor());
            //NativeQuery 생성
            NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
                    .withQuery(multiyMatchQuery)
                    .withCollapseField("content_id")
                    .withPageable(PageRequest.of(0, searchAllDTO.getMaxNum())) // 결과 개수 제한
                    .build();

            // Elasticsearch에서 쿼리 실행 후 결과값 가져오기
            SearchHits<Wiki> searchHits = elasticsearchRestTemplate.search(searchQuery, Wiki.class);
            return toolsForWikiService.getListBySearchHits(searchHits);
        } catch (Exception e) {
            throw new CommonException(ExceptionType.CALCULATE_SIMILARY_TERMS);
        }
    }

 

 

신뢰성 로직

이전에 사용했던 용어 단위의 매치 분석을 통해 연관(신뢰성) 있는 검색 결과의 사용 여부를 추가하였다.

이 경우 wiki title과 content 제공 과정에서 20% 퍼센테이지 이상 연관 있을 경우만 검색 결과에 포함되고, 아닐 경우 Null을 반환한다.

 

    public List<Wiki> getListBySearchHits(SearchHits<Wiki> result, boolean useReliable) {
        // SearchHits 내부 Content를 List<Wiki>로 변환
        List<Wiki> wikiList = new ArrayList<>();
        for (SearchHit<Wiki> hit : result) {
            Wiki wiki = hit.getContent();
            wiki.setScore(hit.getScore());

            //신뢰성 판단 로직 사용
            if(useReliable && (wiki.getMatchTerm() / (float)wiki.getTotalTerm()) <= 0.2f) {
                wiki.setWikiContentAndWikiTitleNull();
            }
            wikiList.add(wiki);
        }
        return wikiList;
    }

 

 

오탈자 검색 고민

 

제목의 오탈자를 검색해서 가장 올바른 결과를 뽑으려고 한다.

 

 

1. Fuzzy

 

Fuzzy쿼리는 단어 사이 거리로 기본적으로 유사도를 판단한다.

https://csg1353.tistory.com/35

 

[ElasticSearch] Nori 분석기, 오타 보정(fuzzy), 로그스태시(logStash)

노리 분석기 레퍼런스1 : https://esbook.kimjmin.net/06-text-analysis/6.7-stemming/6.7.2-nori 6.7.2 노리 (nori) 한글 형태소 분석기 - Elastic 가이드북 이번 장에서는 elasticsearch가 데이터를 저장하는 색인 과정에서

csg1353.tistory.com

이전 포스팅을 참조한다. 

 

    public List<Wiki> searchTitleUseFuzzy(String title, int maxResults, int fuzziness, boolean reliable) {
        try {
            // fuzziness 설정
            Fuzziness fuzzinessLevel;
            if (fuzziness > 0) {
                fuzzinessLevel = Fuzziness.build(fuzziness);
            } else {
                fuzzinessLevel = Fuzziness.AUTO;
            }

            NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
                    .withQuery(
                            new FuzzyQueryBuilder("attraction_name.keyword", title)
                                    .fuzziness(fuzzinessLevel)
                    )
                    .withPageable(PageRequest.of(0, maxResults)) // 결과 개수 제한
                    .withCollapseField("content_id")
                    .build();

            SearchHits<Wiki> searchHits = elasticsearchRestTemplate.search(searchQuery, Wiki.class);
            return toolsForWikiService.getListBySearchHits(searchHits, reliable);
        } catch (Exception e) {
            log.error("[ERR LOG]{}", e);
            throw new CommonException(ExceptionType.ATTRACTION_NAME_FUZZYSEARCH_FAIL);
        }
    }

 

문제는 현재 토큰 단위로 잘라 fuzzy를 측정하는 것이 아닌, keyword 단위의 fuzziness 측정인데, 이는 정확한 값을 산출하지 않을 가능성이 있다.

 

예를 들어 "남산공원(서울)" 이라면, "남산공원"을 검색했을 경우 단순 거리는 4가 차이나고, 이는 전혀 관계없는 "광산남원" 등보다 밀릴 가능성이 존재한다.

조금 더 정확한 로직을 확보해야 했다.

 

2. ngram 추가 보정

 

따라서 추가적으로 N-Gram 토크나이저를 사용해 전체 검색어의 부분 검색어가 존재하는지 확인하고, 슬라이스 값 중 입력어가 있을 경우 이를 확보하려 한다.

 

N-Gram 토크나이저를 사용하는 경우, "유성온천족욕체험장"과 같은 긴 문자열은 여러 개의 부분 문자열(그램)로 나뉜다. min_gram과 max_gram 설정에 따라, 이 문자열로부터 생성되는 그램의 범위가 결정된다.

예를 들어, min_gram이 2이고 max_gram이 3인 경우에 "유성온천족욕체험장" 문자열에 대해 N-Gram 토크나이저가 생성하는 그램은 다음과 같다

  • 2-그램(2개 문자 그램): "유성", "성온", "온천", "천족", "족욕", "욕체", "체험", "험장" 
  • 3-그램(3개 문자 그램): "유성온", "성온천", "온천족", "천족욕", "족욕체", "욕체험", "체험장" 

만약 "유성온천"을 검색했을 경우, N-Gram 토크나이저는 "유성온천"을 다음과 같이 분해할 것이다.

  • 2-그램: "유성", "성온", "온천"
  • 3-그램: "유성온", "성온천"

검색 시, 이러한 그램은 "유성온천족욕체험장"에서 생성된 그램을 비교한다.

"유성온", "성온천", "온천" 등의 그램이 원본 문자열 "유성온천족욕체험장"에서도 발견되므로, 검색어 "유성온천"은 원본 문자열과 부분적으로 일치하는 것으로 간주하고, 유사도가 높은 결과값을 N개 반환할 것이다.

   //제목 Ngram 검색
    public List<Wiki> searchTitleUseNgram(String title, int maxResults, boolean reliable) {
        try {
            QueryBuilder queryBuilder = new QueryStringQueryBuilder(title)
                    .defaultField("attraction_name")
                    .analyzer(ngramAnalyzer);
            NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
                    .withQuery(queryBuilder)
                    .withPageable(PageRequest.of(0, maxResults)) // 결과 개수 제한
                    .withCollapseField("content_id")
                    .build();

            SearchHits<Wiki> searchHits = elasticsearchRestTemplate.search(searchQuery, Wiki.class);
            return toolsForWikiService.getListBySearchHits(searchHits, reliable);
        } catch (Exception e) {
            log.error("[ERR LOG]{}", e);
            throw new CommonException(ExceptionType.ATTRACTION_NAME_NGRAMSEARCH_FAIL);
        }
    }
 
 

결론

1. 제목명이 일치하는지를 확인한다. 

있다면 가장 먼저 이 결과를 반환할 것이다.

 

2. ngram + fuzzy 전부 검색하여 리스트화 한다.

이 결과값을 content_id별로 score를 합산하여 매핑한 뒤 가장 높은 점수의 결과를 산출하려고 한다.

분명 중복된 값이 List에 담길 것이고, 이는 충분히 유의미한 결과를 보여줄 것이다.