ABOUT ME

-

Total
-
  • Go: Bleve 인덱싱 한국어 주소 검색 서버 만들기 (Apache Lucene-like)
    컴퓨터/Go language 2024. 6. 2. 14:09
    728x90
    반응형

     

     

    GitHub - blevesearch/bleve: A modern text/numeric/geo-spatial/vector indexing library for go

    A modern text/numeric/geo-spatial/vector indexing library for go - blevesearch/bleve

    github.com

     

    ZincSearch (ElasticSearch-like)를 이용해 한국 주소 검색 시스템을 이용하고 있었다.

    웹 서버는 단일 서버로 돌리다보니 굳이 검색 서버를 따로 열어서 호출하는 것이 마음에 들진 않았다.

    프로젝트가 Go언어이다보니 모든 것을 Go언어로 된 것을 이용하려고 노력하다가 Bleve를 찾게 되었다.

     

    DoorDash 개발자 블로그를 읽다가 ElasticSearch를 이용하다가 Apache Lucene을 직접 이용해서

    조금 더 커스터마이징 (랭킹 기능, DoorDash만의 독특한 인덱싱 설정,  scalability 등)을 생각해서 이전했다고 한다.

    (ElasticSearch도 기반은 Apache Lucene)

     

    그러다가 Go언어에서 직접 인덱싱을 하고 검색을 이용할 수 없을까 궁금해서 Bleve를 이용하게 되었다.

     

    깃헙에 가도 공식 예제도 없고 많이 불편하다. 어떻게 분석기/토크나이저/필터 등을 만드는지는 깃헙 이슈에서 보고 만들어야 했다.

    그리고 한글 검색은 상당히 힘들어서 "경기 영통"을 검색하더라도 "경기도 수원시 영통구 ~~" 주소를 전부 얻고 싶었다.

     

    # db에 있는 주소들
    경기도 이천시 대월면 대평로178번길 48, 대월초등학교
    울산광역시 북구 염포로 700, 현대자동차
    서울 동작구 대방동 23-181
    서울 중구 회현동2가 53-2
    대구 동구 율하동 1623

     

    일단 json으로 데이터를 저장했다.

    [{"markerId": 1, "address": "경북 포항시 북구 창포동 655"}, {"markerId": 2, "address": "서울 노원구 하계동 250"}, ... ]

     

    처음에는 다음과 같이 인덱싱 데이터를 만들었는데

    이러면 문제가 province/distinct/city 단위로 "영통", "수원", "이태원"처럼 검색할 때 문제가 많았다.

    type Marker struct {
    	MarkerID    int    `json:"markerId"`
    	Address     string `json:"address"
    }

     

    그렇게 여러 고민을 하다가 full address에서 province, city, 나머지 주소를 split 해서 전부 인덱싱을 하게 되었다.

    type Marker struct {
    	MarkerID    int    `json:"markerId"`
    	Province    string `json:"province"`
    	City        string `json:"city"`
    	Address     string `json:"address"` // such as Korean: 경기도 부천시 소사구 경인로29번길 32, 우성아파트
    	FullAddress string `json:"fullAddress"`
    }
    
    func standardizeProvince(province string) string {
    	switch province {
    	case "경기", "경기도":
    		return "경기도"
    	case "서울", "서울특별시":
    		return "서울특별시"
    	case "부산", "부산광역시":
    		return "부산광역시"
    	case "대구", "대구광역시":
    		return "대구광역시"
    	case "인천", "인천광역시":
    		return "인천광역시"
    	case "제주", "제주특별자치도", "제주도":
    		return "제주특별자치도"
    	case "대전", "대전광역시":
    		return "대전광역시"
    	case "울산", "울산광역시":
    		return "울산광역시"
    	case "광주", "광주광역시":
    		return "광주광역시"
    	case "세종", "세종특별자치시":
    		return "세종특별자치시"
    	case "강원", "강원도", "강원특별자치도":
    		return "강원특별자치도"
    	case "경남", "경상남도":
    		return "경상남도"
    	case "경북", "경상북도":
    		return "경상북도"
    	case "전북", "전북특별자치도":
    		return "전북특별자치도"
    	case "충남", "충청남도":
    		return "충청남도"
    	case "충북", "충청북도":
    		return "충청북도"
    	case "전남", "전라남도":
    		return "전라남도"
    	default:
    		return province
    	}
    }
    
    func splitAddress(address string) (string, string, string) {
    	parts := strings.Fields(address)
    	if len(parts) < 2 {
    		return "", "", address
    	}
    	province := standardizeProvince(parts[0])
    	city := parts[1]
    	rest := strings.Join(parts[2:], " ")
    	return province, city, rest
    }

     

    어떤 필터를 만들고 분석기를 이용할까 많은 테스트를 했다.

    필터들은 순서도 중요해서 시간이 많이 걸린 것 같다.

     

    1 정규화

    "github.com/blevesearch/bleve/v2/analysis/token/unicodenorm"

    우선 bleve에 있는 unicodenorm 중 NFKC 정규화를 선택했다. (NFC, NFD, NFKC, NFKD 지원하고 있음)

    한국어 텍스트에는 다양한 유니코드 문자가 사용되므로 ('한글' -> "한", 조합자 "ㅎㅏㄴ"...)

    NFKC를 쓰면 "한글"과 "ㅎㅏㄴㄱㅡㄹ"을 동일하게 인식할 수 있게 된다.

     

    2 BIGRAM

    다행히도 Bleve 는 CJK (chinese-japanese-korean)을 지원한다.

    문서가 하나도 없어서 무슨 일을 하는지는 코드를 봐야 한다...

    https://github.com/blevesearch/bleve/tree/master/analysis/lang/cjk

     

    "cjk_bigram" (빅그램) 필터를 살펴보면, 텍스트를 두 글자씩 겹쳐서 자르는 방식으로 처리한다.

    "서울역"이라면 "서", "서울", "울", "울역"과 같이 나눌 수 있다.

     

    3 edge ngram

    https://github.com/blevesearch/bleve/blob/v2.4.0/analysis/token/ngram/ngram.go

    처음에 ngram만 썼다가 edgengram이라는 필터가 있어서 사용해 보았다. (min 1.0, max 4.0)

    공백으로 단어가 구분되지 않은 경우가 많아 분석에 어렵지만, edge ngram는 시작이나 끝에서 일정 길이만큼 문자 조합을 생성한다. (접두어, 접미어에 좋음)

     

    ngram과의 차이는

    • edge는 텍스트의 FRONT 또는 BACK에서부터 n-gram을 생성함 (모든 위치에서 n-gram 생성하는 기본과 다름)
    • minLength와 maxLength 사이의 길이는 갖는 n-gram 생성
    • ex) "서울역", min=1, max=4
    • - FRONT: "서", "서울", "서울역"
    • - BACK: "역", "울역", "서울역"

     

    결과

    "서울특별시 송파구 오금로 551, e편한세상 송파 파크센트럴"

    위와 같은 한글은 어떤 순서로 처리가 될까

     

    normalize 단계에서는 큰 변화 없고

    CJK bigram과 edge ngram이 상당히 겹칠 수 있다.

    1. 텍스트 입력
    서울특별시 송파구 오금로 551, e편한세상 송파 파크센트럴
    
    2. Normalize (Unicode Normalizer)
    유니코드 정규화는 텍스트를 표준 형태로 변환, NFKC 정규화는 텍스트를 호환성 있는 형태로 변환.
    - 입력 텍스트: `서울특별시 송파구 오금로 551, e편한세상 송파 파크센트럴`
    - 결과 텍스트: `서울특별시 송파구 오금로 551, e편한세상 송파 파크센트럴`
    
    3. CJK Bigram
    CJK Bigram 필터는 텍스트를 두 글자씩 겹쳐서 자른다.
    - 입력 텍스트: `서울특별시 송파구 오금로 551, e편한세상 송파 파크센트럴`
    - 결과 토큰:
      - `서울`
      - `울특`
      - `특별`
      - `별시`
      - `시 `
      - ` 송`
      - `송파`
      - `파구`
      - `구 `
      - ` 오`
      - `오금`
      - `금로`
      - `로 `
      - ` 55`
      - `551`
      - `1,`
      - `, e`
      - `e편`
      - `편한`
      - `한세`
      - `세상`
      - `상 `
      - ` 송`
      - `송파`
      - `파 `
      - ` 파`
      - `파크`
      - `크센`
      - `센트`
      - `트럴`
    
    4. Edge Ngram (min: 1, max: 4)
    Edge Ngram 필터는 텍스트의 시작 부분에서 일정 길이의 n-gram을 생성
    - 입력 텍스트 (각 CJK Bigram 토큰에 대해 처리):
      - `서울`: `서`, `서울`
      - `울특`: `울`, `울특`
      - `특별`: `특`, `특별`
      - `별시`: `별`, `별시`
      - `시 `: `시`
      - ` 송`: `송`
      - `송파`: `송`, `송파`
      - `파구`: `파`, `파구`
      - `구 `: `구`
      - ` 오`: `오`
      - `오금`: `오`, `오금`
      - `금로`: `금`, `금로`
      - `로 `: `로`
      - ` 55`: `55`
      - `551`: `5`, `55`, `551`
      - `1,`: `1`
      - `, e`: `e`
      - `e편`: `e`, `e편`
      - `편한`: `편`, `편한`
      - `한세`: `한`, `한세`
      - `세상`: `세`, `세상`
      - `상 `: `상`
      - ` 송`: `송`
      - `송파`: `송`, `송파`
      - `파 `: `파`
      - ` 파`: `파`
      - `파크`: `파`, `파크`
      - `크센`: `크`, `크센`
      - `센트`: `센`, `센트`
      - `트럴`: `트`, `트럴`
    
    5. Lowercase
    Lowercase 필터는 모든 텍스트를 소문자로 변환
    
    - 입력 텍스트:
      - `서`, `서울`, `울`, `울특`, `특`, `특별`, `별`, `별시`, `시`, `송`, `송파`, `파`, `파구`, `구`, `오`, `오금`, `금`, `금로`, `로`, `55`, `5`, `551`, `1`, `e`, `e편`, `편`, `편한`, `한`, `한세`, `세`, `세상`, `상`, `파`, `파크`, `크`, `크센`, `센`, `센트`, `트`, `트럴`
    - 결과 텍스트:
      - `서`, `서울`, `울`, `울특`, `특`, `특별`, `별`, `별시`, `시`, `송`, `송파`, `파`, `파구`, `구`, `오`, `오금`, `금`, `금로`, `로`, `55`, `5`, `551`, `1`, `e`, `e편`, `편`, `편한`, `한`, `한세`, `세`, `세상`, `상`, `파`, `파크`, `크`, `크센`, `센`, `센트`, `트`, `트럴`
      - (이 예제에서는 소문자로 변환할 문자가 X)

     

    Edge ngram은 접두어나 접미어 매칭이 주요고, cjk bigram은 단어 경계를 인식할 때 좋다.

    그래서 그냥 일단은 두 개를 동시에 쓰고 있다.

     

    검색 함수는 많은 쿼리를 써야 했다. (그래도 5ms 내 검색)

     

    "서울 이태원"이면 ["서울", "이태원"]으로 분리하고

    # 1. 인덱스 open
    
    # 2. 검색어 분리
    terms := strings.Fields(t)
    입력된 검색어 `t`를 공백을 기준으로 분리하여 `terms` 리스트에 저장,
    예를 들어, `t = "서울 이태원"`이면 `terms = ["서울", "이태원"]`
    
    # 3. 쿼리 생성
    
    검색어의 각 부분에 대해 다양한 유형의 쿼리를 생성하고 `queries` 리스트에 추가
    
    var queries []query.Query
    
    for _, term := range terms {
        standardizedProvince := standardizeProvince(term)
        if standardizedProvince != term {
            // If the term is a province, use a lower boost
            matchQuery := query.NewMatchQuery(standardizedProvince)
            matchQuery.SetField("province")
            matchQuery.Analyzer = "koCJKEdgeNgram"
            matchQuery.SetBoost(1.5)
            queries = append(queries, matchQuery)
        } else {
            // Use PrefixQuery for cities and regions
            prefixQueryCity := query.NewPrefixQuery(term)
            prefixQueryCity.SetField("city")
            prefixQueryCity.SetBoost(10.0)
            queries = append(queries, prefixQueryCity)
    
            // Use MatchPhraseQuery for detailed matches in full address
            matchPhraseQueryFull := query.NewMatchPhraseQuery(term)
            matchPhraseQueryFull.SetField("fullAddress")
            matchPhraseQueryFull.Analyzer = "koCJKEdgeNgram"
            matchPhraseQueryFull.SetBoost(5.0)
            queries = append(queries, matchPhraseQueryFull)
    
            // Use WildcardQuery for more flexible matches
            wildcardQueryFull := query.NewWildcardQuery("*" + term + "*")
            wildcardQueryFull.SetField("fullAddress")
            wildcardQueryFull.SetBoost(2.0)
            queries = append(queries, wildcardQueryFull)
    
            // Additional PrefixQuery and WildcardQuery for other fields
            prefixQueryAddr := query.NewPrefixQuery(term)
            prefixQueryAddr.SetField("address")
            prefixQueryAddr.SetBoost(5.0)
            queries = append(queries, prefixQueryAddr)
    
            wildcardQueryAddr := query.NewWildcardQuery("*" + term + "*")
            wildcardQueryAddr.SetField("address")
            wildcardQueryAddr.SetBoost(2.0)
            queries = append(queries, wildcardQueryAddr)
    
            // Use MatchQuery for city and district to catch all matches
            matchQueryCity := query.NewMatchQuery(term)
            matchQueryCity.SetField("city")
            matchQueryCity.Analyzer = "koCJKEdgeNgram"
            matchQueryCity.SetBoost(5.0)
            queries = append(queries, matchQueryCity)
    
            matchQueryDistrict := query.NewMatchQuery(term)
            matchQueryDistrict.SetField("district")
            matchQueryDistrict.Analyzer = "koCJKEdgeNgram"
            matchQueryDistrict.SetBoost(5.0)
            queries = append(queries, matchQueryDistrict)
        }
    }
    
    여기서 각각의 `term`에 대해 다음과 같은 쿼리를 생성:
    
    - `서울`:
      - Province 필드에서 "서울"을 검색하는 `MatchQuery`
      - City 필드에서 "서울"로 시작하는 `PrefixQuery`
      - FullAddress 필드에서 "서울"을 포함하는 `MatchPhraseQuery`와 `WildcardQuery`
      - Address 필드에서 "서울"로 시작하는 `PrefixQuery`와 "서울"을 포함하는 `WildcardQuery`
      - City 필드에서 "서울"을 검색하는 `MatchQuery`
      - District 필드에서 "서울"을 검색하는 `MatchQuery`
    
    - `이태원`:
      - City 필드에서 "이태원"으로 시작하는 `PrefixQuery`
      - FullAddress 필드에서 "이태원"을 포함하는 `MatchPhraseQuery`와 `WildcardQuery`
      - Address 필드에서 "이태원"으로 시작하는 `PrefixQuery`와 "이태원"을 포함하는 `WildcardQuery`
      - City 필드에서 "이태원"을 검색하는 `MatchQuery`
      - District 필드에서 "이태원"을 검색하는 `MatchQuery`
    
    # 4. 쿼리 조합
    disjunctionQuery := bleve.NewDisjunctionQuery(queries...)
    searchRequest := bleve.NewSearchRequest(disjunctionQuery)
    
    생성된 모든 쿼리를 `DisjunctionQuery`로 결합하여, 여러 조건 중 하나라도 만족하는 문서를 검색
    
    # 5. 검색 요청 설정
    searchRequest.Fields = []string{"fullAddress", "address", "province", "city"}
    searchRequest.Size = 10 // Limit the number of results
    searchRequest.SortBy([]string{"_score", "markerId"})
    
    검색 요청에 포함될 필드를 설정하고, 검색 결과의 최대 개수를 10개로 제한하며, 결과를 `_score`와 `markerId`로 정렬

     

    @풀소스 commit

    728x90

    댓글