home

아이들나라의 신규 검색 서버를 구축해주세요!

안녕하세요! 아이들나라에서 CMS파트(Content Management System)를 맡고 있는 임형준이라고 합니다. 이번 글에서는 아이들나라 신규 검색서버를 어떻게 구축하고 있는지, 구축하는 과정에서 어떤 문제점이 있었고 해당 문제사항들을 어떻게 해결하였는 지를 중점으로 해당 글을 작성하려고 합니다.

검색 서버 구축 Step0 - Opensearch & 기본 용어

검색엔진은 역색인(Inverted Index)을 이용한 Lucene 기반의 서비스를 이용해서 주로 구축합니다. 역색인 기반 검색엔진은 여러 개가 있지만, 가장 대중적이고 많이 사용되는 검색엔진은 Elasticsearch입니다. 이 때, AWS에서는 Elasticsearch를 편하게 운영하게 하기 위해 Elasticsearch의 Managed Service인 Opensearch를 제공합니다. 운영적인 측면과 비용적인 측면을 고려해봤을 때, Elasticsearch대신 Opensearch를 선택하였습니다.
Opensearch는 es의 7.10.2버전의 fork버전으로, es에서 제공하는 대부분의 기능을 똑같이 제공합니다. 단, 특이점이 있는데 Opensearch에는 미리 설치된 플러그인 외 다른 플러그인 설치가 불가능합니다. 이는 es에서 새로운 플러그인 설치시 모든 노드에 각자 설치 및 클러스터 재실행을 해주어야 하는데, 관리 포인트를 복잡하게 두지 않고 싶어서라고 추측하고 있습니다.
Opensearch에서 제공하는 한글 형태소 분석 플러그인은 은전한닢 플러그인입니다.
(23년 10월초부터 nori plugin도 제공해주기 시작했습니다.)
아래부터 글이 시작되기 전, es 관련 기본 개념을 간단히 정리하고 시작하겠습니다.
HTTP 통신
es는 API 통신을 함
Index
RDBMS에서 table의 개념
Document
RDBMS에서 row
Field
RDBMS에서 column
Node
실행중인 Opensearch 서버
Segment
실제 데이터가 저장되는 단위

검색 서버 구축 Step1 - 검색어 추천 키워드 색인

1-1) 요구사항 파악

개발쪽에 요청된 검색 요구사항은 아래와 같았습니다.
인기검색어 제공
캐릭터 검색
오탈자 보정 검색
띄어쓰기 보정 검색
초성검색
검색시 사용자의 기대값만큼 검색어 추천과 컨텐츠 검색 기능이 제공될 것
외 등등등…

1-2) 검색어 추천

모든 검색 서버에는 검색어 추천 기능과 해당 검색어로 실제 데이터를 조회하는 부분이 분리돼 있습니다.
저희 역시 검색어 추천 부분과 컨텐츠 검색 부분을 나눠서 생각하기로 하였습니다. 그러면 검색어 추천 창에 어떤 데이터들이 색인돼야 하는 지에 대해서 고민해보겠습니다. 저희는 컨텐츠의 제목과 제목으로부터 추출할 수 있는 명사 토큰들을 검색어 추천에 색인하기로 결정하였습니다.
예를 들어서, 뽀로로와 여행을 신나게 가자! 라는 컨텐츠가 있다고하면, 아래와 같은 데이터들이 검색어 추천 index에 색인될 것입니다.
word: 뽀로로와 여행을 신나게 가자!
type: ORIGINAL
word: 뽀로로
type: TOKEN, ORIGINAL
word: 여행
type: TOKEN
색인시 각 word를 UNIQUE값으로 색인되게 설정하였습니다. type을 추가로 준 이유는 해당 검색어 추천 색인이 distinct를 한 갯수만큼 제대로 색인이 됐는 지 확인하기 위함입니다. 추가로 만약 이후에 무신사처럼 색인 데이터와 키워드를 분리하는 요구사항이 있을 경우 쉽게 구현할 수 있습니다.
이 때, 뽀로로라는 word의 type은 TOKENORIGINAL을 둘 다 갖고 있습니다. 왜냐하면 제목에서 추출한 값의 토큰값이 다른 컨텐츠의 제목이 되는 경우가 있기 때문입니다. (뽀로로가 제목인 컨텐츠가 존재)

1-3) 검색어 추천 색인시 문제점 → 바른 형태소 분석기

검색어 추천을 색인할 때 원본과 명사 토큰들을 색인한다고 말씀드렸습니다. 명사 토큰을 추출할 때 아래와 같은 문제점들이 존재했습니다.
만약 마술사 라는 단어가 있다면, 마술사라는 명사가 그대로 색인돼야 할 것이나 은전한닢 plugin의 분석결과에 의하면 마술가 분리된 형태로 명사가 분리됩니다.
(nori plugin에서도 같은 문제가 발생합니다.)
{ "tokens": [ { "token": "마술/N", "start_offset": 0, "end_offset": 2, "type": "N", "position": 0 }, { "token": "사/N", "start_offset": 2, "end_offset": 3, "type": "N", "position": 1 } ] }
JSON
복사
다른 예로, 실제로 아이들나라에서 제공되고 있는 꾀돌이 다람이 라는 컨텐츠가 있습니다. 이 역시 꾀돌이 / 다람이 로 분리될 것을 기대했지만, 아래와 같이 / 돌이다람으로 분리되는 것을 볼 수 있습니다.
{ "tokens": [ { "token": "꾀/N", "start_offset": 0, "end_offset": 1, "type": "N", "position": 0 }, { "token": "돌이/N", "start_offset": 1, "end_offset": 3, "type": "N", "position": 1 }, { "token": "다람/N", "start_offset": 4, "end_offset": 6, "type": "N", "position": 2 } ] }
JSON
복사
위와 같은 문제를 해결하고자 최초엔 chatgpt를 이용해서 해당 문장에서 고유명사가 되는 키워드들을 추출해내려고 했습니다. 추출된 고유명사들을 사용자 정의사전에 등록해놓으면 tokenizer에서 분리될 때 해당 단어로 저장이 될 테니까요. 그러나 chatgpt를 이용했을 때에는 추출되는 속도도 너무 느리고, 부정확한 데이터가 추출되는 케이스가 많았습니다.
여러 고민을 하던차, 아이들나라 어린이 사전 기능 구현을 위해 바른 형태소 분석기라는 tool을 구매하였고, 해당 tool에서는 마술사 / 꾀돌이 다람이 등이 제대로 분리되는 것을 확인할 수 있었습니다.
{ "sentences": [ { "text": { "content": "꾀돌이 다람이", "beginOffset": 0, "length": 7 }, "tokens": [ { "text": { "content": "꾀돌이", "beginOffset": 0, "length": 3 }, "morphemes": [ { "text": { "content": "꾀돌이", "beginOffset": 0, "length": 3 }, "tag": "NNP", "probability": 0.349184066, "outOfVocab": "OUT_OF_VOCAB" } ], "lemma": "꾀돌이", "tagged": "꾀돌이/NNP", "modified": "" }, { "text": { "content": "다람이", "beginOffset": 4, "length": 3 }, "morphemes": [ { "text": { "content": "다람이", "beginOffset": 4, "length": 3 }, "tag": "NNG", "probability": 0.335078388, "outOfVocab": "OUT_OF_VOCAB" } ], "lemma": "다람이", "tagged": "다람이/NNG", "modified": "" } ], "refined": "꾀돌이 다람이" } ], "language": "ko_KR" }
JSON
복사
(NNP는 고유명사, NNG는 일반명사를 의미함)
해당 단어들을 사용자 정의 사전에 넣는 것으로 문제를 해결했습니다.
만약 마술사라는 단어를 사용자 정의 사전에 넣어놓으면, analyzer가 해당 단어를 분석할 때 마술사라는 단어를 한 단어로 인식해서 토큰을 분리할 것입니다.

검색서버 Step2 - 정교한 검색을 위한 자소분리, ngram 저장!

Prefix search(접두어 검색)

Step1에서 예시로 들었던 뽀로로와 신나게 여행을 가자! 라는 컨텐츠를 사용자가 검색한다고 해보겠습니다.
Opensearch 은전한닢 plugin을 사용하면 analyzer가 해당 input을 위와 같이 분해합니다. 그리고 분해한 단어들을 색인된 document의 segment가 포함하고 있다면 해당 컨텐츠가 검색됩니다.
예를 들어서, 뽀로로와 신나게 여행을 가자!라는 제목의 컨텐츠는 위 그림에서 보는 것과 같이 여러 토큰으로 분해됩니다. 사용자가 input값으로 뽀로로, 신나 와 같이 해당 토큰값으로 검색하거나, 뽀로로와 신나와 같이 토큰값을 포함하는 문장으로 검색했을 경우 위 컨텐츠가 검색되게 됩니다.
(뽀로로와 신나는 analyzer에 의해 뽀로로, 뽀로로와, 신나 로 분해될 것이기 때문)
그런데 한글의 경우 뽀로로를 검색하려고 할 때, 한글의 자소분리 특성때문에 ㅃ → 뽀 → 뽈 → 뽀로 → 뽀롤 → 뽀로로 의 순서로 검색할 것입니다. 이 때, 사용자는 뽀로로라고 쳤을 때 뿐 아니라 뽀로로를 치는 앞 과정들에서도 뽀로로 관련 컨텐츠가 나올 것이라 기대할 것입니다.
Opensearch에서는 prefix search(접두어 검색)을 지원해줍니다. 그래서 , 뽀로 등을 입력했을 때 뽀로로 관련 값들이 찾아질 수 있습니다. 해당 prefix pearch는 lucene의 segement단위로 동작하기 때문에 토큰으로 분리된 다른 단어들에 대해서도 prefix search를 지원합니다.
좀 더 직관적으로 이해할 수 있는 예제는 아래와 같습니다.
POST /animals/_doc/1 { "sentence": "The cat and horse ate food." } GET /animals/_search { "query": { "prefix": { "sentence": "hor" } } }
JSON
복사
그러면 위 prefix search로 ㅃ → 뽀 → 뽈 → 뽀로 → 뽀롤 → 뽀로로 를 검색창에 쳤을 때 사용자가 각 step에 도달했을 때 원하는 기대값을 가질 수 있을까요?
위의 경우 뽀로의 경우 뽀로로 컨텐츠가 나올 것이지만, , , 뽀롤 의 경우에는 아무 컨텐츠도 나오지 않을 것입니다. 즉, 검색어 추천 단계에서 타이핑을 하면서 아래 검색어 추천이 나왔다가 안 나왔다가 하는 현상이 생길 것이라는 얘기입니다.
한글의 경우 데이터 색인시 자소분리라는 추가 step이 필요합니다.

자소분리

자소(Grapheme)란 문자를 이루는 최소 단위입니다. 영어의 경우 26자의 알파벳이 자소입니다. 한글의 경우 각 자소들이 합쳐서 하나의 글자를 만드는 형태이기 때문에 검색이 잘 되게 하기 위해서는 추가적인 작업이 필요합니다.
한글의 경우 자모는 유니코드 0x1100 ~ 0x11FF의 범위를 가지며, 총 256자로 구성됩니다. 그러나 해당 글자들에는 현대에는 사용되지 않는 글자들이 포함돼있으므로, 현대 한글에서는 초성 19자, 중성 21자, 종성 28자로 구성돼있다고 보면 됩니다. 해당 초성, 중성, 종성을 이용해서 표현할 수 있는 한글의 글자 개수는 총 11172자입니다.
위의 경우에서처럼 사용자가 을 입력하는 경우 ㅃㅗㄹ 을 입력한 값이 로 합쳐져서 화면에 표시된 것입니다. 그러면 검색 서버에서도 뽀로로ㅃㅗㄹㅗㄹㅗ로 분해해서 저장해놓고 입력값이 들어올 때 한글을 자소분리된 형태로 찾게 해놓으면 검색이 될 것입니다.
그래서 뽀로로차차라는 컨텐츠가 존재할 때, 해당 컨텐츠의 자소를 모두 분리해서 자소 단위로 검색했을 때도 끊임없이 검색이 되게 설정하였습니다.
위의 경우 로 입력했을 때 뽀로로 관련 컨텐츠가 나오는 것을 확인할 수 있습니다.
추가적으로 오타보정 검색이 되기 위해서도 자소분리가 필수입니다. 사용자가 오타를 내서 뾰로로 라던가, 뽀ㄹ로와 같이 검색했을 때도 뽀로로 컨텐츠가 나오는 것을 기대할 것입니다.
Opensearch에서는 fuzziness라는 오타보정 API를 제공해줍니다. fuzziness(1) 이라고 설정하면 한 글자까지 차이나는 것을 같은 글자로 인식해서 검색 결과를 찾아줍니다. 그러나 만약 자소분리를 하지 않고 fuzziness를 설정하면 너무 광범위한 결과의 값이 반환될 것입니다.
자소분리를 해서 저장한 결과, 그리고 fuzziness API를 사용한 결과 위와 같이 오타가 있음에도 보정해서 결과가 검색된 것을 확인할 수 있습니다.

ngram

마지막으로 ngram입니다. ngram, edge ngram, edge ngram back을 이용하면 어떠한 부분 일치도 찾을 수 있게 구현할 수 있습니다.
예문: 아버지가, 방에, 들어가신다
ngram
아 아버 아버지 아버지가 버 버지 버지가 지 지가 가
방 방에 에
들 들어 들어가 들어가신 들어가신다 어 어가 어가신 어가신다 …
edge ngram
아 아버 아버지 아버지가
방 방에
들 들어 들어가 들어가신 들어가신다
edge ngram back
아 / 버 아버 / 지 버지 아버지 / 가 지가 버지가 아버지가
위에서 언급한 자소분리로 분해한 ngramedge ngram입니다. 단 ngram의 경우 모든 글자를 분해해서 저장해놓으면 하나의 document 크기가 너무 비대해져서 성능저하가 일어날 수 있습니다. 그래서 저희는 띄어쓰기 단위로 각 ngram을 저장해놓았습니다.
그래서 뽀로로의 곤충 대모험이라는 컨텐츠의 경우 최종적으로 위와 같이 색인되게 됩니다.

검색서버 Step3 - 검색 Query 생성!

Opensearch에서는 검색을 잘 할수 있게 여러 API들을 제공해줍니다. 예를 들어서, 위에서 언급한 토큰 검색은 matchQuery를 사용해서 검색할 수 있습니다.
저희가 현재 검색에서 사용하고 있는 API들은 아래와 같습니다.
matchQuery, multiMatchQuery, boost
termQuery, fuzziness
prefixQuery, matchPhrasePrefixQuery, minimumShouldMatch
step by step으로, 하나하나 살펴보겠습니다.
matchQuery, multimatchQuery, boost
검색 query는 analyzer에 의해 여러 개의 토큰으로 분리될 수 있습니다. 예를 들어서, 뽀로로와 여행라는 input 값은 뽀로로여행으로 분리될 것입니다. 뽀로로와 신나게 여행을 가자! 라는 제목은 뽀로로여행이라는 토큰값을 포함하고 있으므로 해당 값으로 검색이 될 것입니다.
추가로, 검색어로 검색했을 때 제목 뿐 아니라 본문, 키워드, 등장인물 등이 같이 검색될 수 있어야 하므로 multiMatchQuery를 사용하고 있습니다.
이 때, 제목에 포함된 값의 가중치가 가장 위에 나타나야 할 것이므로 boost 값을 주어서 해당 sorting을 조절하고 있습니다.
fun findByNameMultiMatch(name: String): MultiMatchQueryBuilder { return QueryBuilders.multiMatchQuery(name, NAME_TEXT_FIELD, KEYWORD_KEYWORD_FIELD, DESCRIPTION_TEXT_FIELD) .field(NAME_TEXT_FIELD, 10.0f) .field(KEYWORD_KEYWORD_FIELD, 2.0f) .field(DESCRIPTION_TEXT_FIELD, 1.0f) .field(CAST_NAME_TEXT_FIELD, 1.0f) .type(MultiMatchQueryBuilder.Type.MOST_FIELDS) .operator(Operator.OR) }
Kotlin
복사
termQuery, fuzziness
matchQuery는 analyzer를 한 번 걸쳐서 검색 결과를 찾습니다. 그래서 analyzer를 통해 토큰 분석이 굳이 필요없을 때 matchQuery를 사용하면 비효율적이죠. 정확히 해당하는 텍스트 값을 찾을 때는 termQuery를 사용하면 됩니다. 예를 들어서, 뽀롤 이라고 입력했을 때 ㅃㅗㄹㅗㄹ에 해당하는 값을 찾을 텐데, 이 때는 ㅃㅗㄹㅗㄹ에 해당하는 값이 있는 지만 보는 것이므로 termQuery를 사용하면 됩니다.
단, 위에서 언급한 오타보정 API인 fuzziness API를 사용하기 위해서는 matchQuery 사용이 필수입니다. 그래서 자소 분리 검색에서 오타보정이 필요한 경우 matchQuery, 필요없을 땐 termQuery를 사용하고 있습니다.
prefixQuery, matchPhrasePrefixQuery, minimumShouldMatch
prefixQuery는 위에서 언급했듯, 접두어 검색 API입니다.
matchPhrasePrefixQuery는 토큰의 접두어 순서를 지키면서 검색하는 API입니다. 예를 들어서, 장수탕 선녀님이라는 제목이 있다고 하면 장수 선녀는 나오지만 선녀 장수는 해당 API의 검색 결과로는 나오지 않습니다.
minimumShouldMatch는 input값을 토큰으로 분리했을 때 document가 설정한 % 이상으로 보유하고 있다면 검색되는 API입니다.
예를 들어서, apple banana cherry라는 input값이 있고 %를 50%로 설정했다고 해보겠습니다. apple banana가 input이라면 input token의 66%를 갖고 있으므로 반환됩니다. apple grape meat computer 의 경우엔 apple만 갖고 있으므로 33%에 해당합니다. 고로 반환되지 않습니다.

검색서버 Step4 - Spring에서 생성한 Query를 날려보자!

Spring에서 Opensearch와 연동하기 위해서는 Spring 3.x, JDK 17 이상의 버전이 필요합니다.
Spring에서 Opensearch에 HTTP 요청을 하는 방법으로는 lowLevelClient(RestClient), HighLevelClient, ElasticsearchOperations 를 이용하는 방법 등이 있습니다. 저희는 Spring data elasticsearch에서 제공하는 ElasticsearchOperations를 주로 이용하고, updateByQuery같은 특정 쿼리에서 실행할 때에는 RestHighLevelClient를 이용하고 있습니다.
검색어 추천의 경우 여러 개의 조건이 OR조건으로 검색돼야 합니다. 여러 개의 query가 있을 때 해당 query들을 bool query 로 묶을 수 있습니다. bool query안에는 should(OR), must(AND), must_not, filter 등이 들어갈 수 있습니다.
{ "from": 0, "size": 15, "query": { "bool": { "must": [ { "bool": { "should": [ { "match": { "word": { "query": "뽀로로차", "operator": "OR", "minimum_should_match": "50%", "fuzzy_transpositions": true, "boost": 1.0 } } }, { "prefix": { "word": { "value": "뽀로로차", "boost": 1.0 } } }, { "match_phrase_prefix": { "word": { "query": "뽀로로차", "boost": 3.0 } } }, { "match": { "wordUnits": { "query": "ㅃㅗㄹㅗㄹㅗㅊㅏ", "operator": "OR", "fuzziness": "1", "fuzzy_transpositions": true, "boost": 1.0 } } }, { "term": { "wordInitialUnits": { "value": "ㅃㄹㄹㅊ", "boost": 1.0 } } } ], "minimum_should_match": "1", "boost": 2.0 } } ] } } }
JSON
복사
검색어 추천 query는 현재 위와 같이 동작하고 있습니다.
input이 들어오면 해당 검색 query를 여러 조건으로 묶어서 Opensearch에 결과를 던집니다.
BoolQueryBuilder() .should(QueryBuilders.matchQuery("word", input).minimumShouldMatch("50%")) .should(QueryBuilders.prefixQuery("word", input)) .should(QueryBuilders.matchPhrasePrefixQuery("word", input).boost(3f)) .should(QueryBuilders.matchQuery("wordUnits", units).fuzziness(Fuzziness.ONE)) .should(QueryBuilders.termQuery("wordInitialUnits", initialUnits)).boost(2f) .minimumShouldMatch(1)
Kotlin
복사
해당 query는 Spring에서 위와 같이 구현될 수 있습니다.
BoolQueryBuilder 등은 opensearch에서 제공해주는 라이브러리입니다. 실제 쿼리와 대응되게 직관적으로 사용할 수 있는 것을 확인할 수 있습니다.
해당 BoolQuery들을 묶어서 ElasticsearchOperationssearch method에 해당 nativeQuery를 인자값으로 넘겨주면 끝입니다.

Step5 - Next Step

위와 같이 검색을 설정했을 때, 일정 수준 이상으로 검색이 잘 되는 것을 확인할 수 있었습니다. 단, 아이들나라 검색 신규 구축은 이제 시작인만큼 앞으로 많은 과제들이 남았습니다. 아래 목록들은 앞으로 아이들나라 검색 고도화를 위해 고려해 볼 리스트들입니다.
gpt를 이용해서 컨텐츠를 카테고리 분류 체계에 넣기
동적으로 인기도 적용하기
검색 품질 유지 방법 고안
사용자 검색 기록을 바탕한 컨텐츠 개인화 추천
인기도 sorting 동적 조정
query 성능 최적화
딥러닝을 이용한 검색어 추천
자연어 검색
앞으로 아이들나라가 더욱 사용자 친화적인 서비스가 되기 위해서는 검색과 추천이 높은 수준으로 이뤄져야 할 것입니다. 앞으로 검색 서버를 더욱 고도화시켜서 사용자들이 원하는 컨텐츠를 쏙쏙 뽑아서 보여줄 수 있게 노력하겠습니다. 긴 글 읽어주셔서 감사합니다!