동의?어 분석기~
복합명사 동의어로부터 나온 일반명사 동의어도 검색되게 만들기
안녕하세요. 검색추천팀 검색파트 이상민입니다.
오늘은 기존에 저희팀에서 사용하던 analyzer의 동의어 이슈와 이를 해결하기 위한 과정, 그리고 적용 방식과 분석 결과의 변화에 대해 공유해보겠습니다.
저희 검색추천팀에서는 Elasticsearch 검색엔진을 사용해 고객에게 검색 서비스를 제공하고 있습니다. Elasticsearch 자체로 일반적인 검색 서비스를 구현할 수 있지만, 이커머스에서 다양한 고객의 요구 사항을 만족시키기 위해 elasticsearch filter나 analyzer의 customizing이 필요합니다.
그 중에서도 이번엔 입력한 단어와 같은 의미를 지니고 있는 단어를 포함시켜서 확장시키는 동의어 필터에 대해 중점적으로 이야기해보려고 합니다.
Analyzer (분석기)
간단하게 분석기가 어떠한 역할을 하는지 알아보고 가겠습니다.
분석기는 말 그대로 입력으로 들어온 텍스트를 분석해서 필요한 정보들만 형태소로 만들어 검색에 용이하게 만들어주는 역할을 합니다. 이러한 분석기는 Tokenizer와 TokenFilters로 이루어져있고 이들의 조합으로 새로운 분석기가 구성되곤 합니다.
예를 들어 “My favorite food is apple”이라는 문장이 들어온다면 분석기에 들어오고 분석기의 Tokenizer가 “My”, “favorite”, “food”, “is”, “apple”로 토큰화시켜줍니다. 해당 토큰들은 TokenFilters를 통해 불용어를 없애기도하고 소문자로 만들기도 해서 “my”, “favorite”, “food”, “apple”과 같이 처리하여 색인을 하게합니다.
TokenFilter
분석기의 TokenFilters에는 필요에 따라 여러가지 다양한 필터가 존재할 수 있습니다. 모든 단어를 소문자로 만들어주는 LowerCaseFilter, 중복된 단어를 없애주는 DuplicateFilter, 불용어를 처리해주는 StopFilter 등 다양한 기본필터가 존재하고 필요에 따라 customizing한 필터를 사용합니다.
그 중에서도 저희 검색추천팀이 과거에 사용했었던 상품 index할 때 사용한 분석기의 필터들의 목록을 보여드리겠습니다.
"lowercase"
"graph_synonyms"
"korean_pos_stop"
"typo_correction" -> 오타수정 필터 이름변경
각각의 필터는 다음과 같습니다.
lowercase
: 이름에서 아시다시피 소문자로 만들어주는 LowerCaseFilter입니다.ssg_pos_allow
: 저희가 정한 pos(Part Of Speech: 품사)의 토큰만 허락하게 하는 Filter입니다.graph_synonyms
: 토큰의 동의어를 같이 색인하도록 하는 SynonymGraphFilter로 더욱 자세히 다뤄보겠습니다.korean_pos_stop
: ssg_pos_allow와는 반대로 저희가 정한 pos의 토큰을 제거하는 Filter입니다.typo_correction
: 사전에 등록되있는 오타이거나 편집거리 계산을 활용해 오타를 판단하고 해당 단어를 표제어로 바꿔주는 역할을 하는 Filter입니다.
이 중에서 저희는 SynonymGraphFilter에 대해 알아보고 저희의 필터 목록이 어떻게 바뀌었는지 확인해보겠습니다.
SynonymGraphFilter (동의어 필터)
SynonymGraphFilter는 그래프를 이용해 동의어를 처리합니다. 이때 저희가 관리하는 사전파일을 통해서 처리합니다. 예를 들어 사전에서 신생아,아기,baby,유아,어린 애,키즈
를 동의어로 설정해 두었고 “아기가 타고 있어요”라는 문장이 들어올 경우 그래프는 아래와 같이 만들어지게 됩니다.
따라서 “아기가 타고 있어요”라는 문장만 색인이 되어있다해도 “신생아가 타고 있어요”라는 검색으로 결과가 나올 수 있게 됩니다. Elasticsearch에 POST쿼리를 날려 Token의 PositionIncrementAttribute와 PositionLength 등을 이용해 아래와 같이 그래프가 이뤄짐을 알 수있습니다.
(”어린”은 position=0, positionLength=1이므로 0 → 1의 간선.
“아기”는 position=0, positionLength=2이므로 0 → 2의 간선)
POST /dictionary/_analyze
{
"explain": true,
"analyzer" : "ssg_index_analyzer",
"text" : "아기가 타고 있어요"
}------------------------------------------------------------------"tokens": [
{
"token": "어린",
"start_offset": 0,
"end_offset": 2,
"type": "word",
"position": 0,
"leftPOS": "NNG(General Noun)",
"posType": "MORPHEME",
"positionLength": 1
},
{
"token": "애",
"start_offset": 0,
"end_offset": 2,
"type": "word",
"position": 1,
"leftPOS": "NNG(General Noun)",
"posType": "MORPHEME",
"positionLength": 1
},
{
"token": "아기",
"start_offset": 0,
"end_offset": 2,
"type": "word",
"position": 0,
"leftPOS": "NNG(General Noun)",
"posType": "MORPHEME",
"positionLength": 2
},
...
]
하지만 그럼에도 한계가 있었습니다. “어린”의 동의어로 “young” 같은게 있다고 가정했을 때 SynonymGraphFilter 를 적용해도 위 그래프처럼 young은 분석되지 않습니다. 그 이유는 동의어 필터를 적용시 동의어가 “어린”+”애” 처럼 복합명사임에도 따로 추가적인 처리를 하지 않기 때문입니다.
저희는 이커머스 특성상 고객이 원하는 상품을 최대한 표시할 수 있도록 해야하기 때문에 복합명사의 구성요소에 대해서도 동의어가 적용되어 색인하고 싶어했습니다. 즉, 복합명사 동의어 적용 → 복합명사 동의어에서 토큰화된 각각의 일반명사에 대한 동의어 적용
을 하기위한 방법을 찾아보았습니다.
기존의 이슈 사항
그래서 저희는 먼저 복합명사 동의어 사전과 일반명사 동의어 사전을 각각 따로 두고 복합명사 동의어에 대해 SynonymGraphFilter를 한번 적용하고 이후 일반명사 동의어에 대해 SynonymGraphFilter를 다시 적용하여 토큰화된 일반명사에 대한 동의어 적용을 해보았습니다.
"lowercase",
"graph_synonym1", // 복합명사 동의어 필터
"flatten_graph",
"remove_duplicates",
"graph_synonym2", // 일반명사 동의어 필터
"korean_pos_stop",
"typo_correction"------------------------------------------------------------------[복합명사 동의어]
자판기,자동판매기,자판기기,밴딩머신,자동판매기기,벤딩머신[일반명사 동의어]
자동,오토,auto,자판
밴딩,banding,벤딩
머신,machine
위와 같이 설정하고 해당 분석기에 “자판기”라는 단어를 분석하도록 만들었더니 만들어진 Graph는 아래와 같이 되었습니다.
그래프만 보더라도 알다시피 분석되는 내용이 굉장히 이상하게 나온걸 확인할 수 있습니다. 가령 “자판기” 입력이 “자동밴딩banding기머신”라는 이상한 키워드까지도 색인이 될 수 있는 것입니다.
이에 대한 원인은 tokenizer 설정에서 복합명사를 decompound할 때 mode옵션을 “discard”로 두면 원본단어는 삭제되고 나눠진 단어들이 토큰화되는데 이 토큰들에 대해 SynonymGraphFilter를 적용하면 lucene core의 고질적인 이슈가 나타나게 됩니다.
SynonymGraphFilter cannot consume a graph token stream
Issue Navigator — ASF JIRA
여전히 open issue로 남아있는 복합명사 동의어 그래프 문제
복합명사 decompound된 토큰의 SynonymGraphFilter는 위 lucene 프로젝트 이슈에서 확인하다시피 2022년인 지금까지도 unresolved된 open issue로 남아있는 상태입니다.
하지만 저희는 복합명사 동의어로부터 나온 일반명사 동의어도 색인
되도록 하는 것이 검색 품질을 향상 시킬 수 있다고 생각했기때문에 이를 해결하거나 우회할 수 있는 방법을 고안해보았습니다.
아이디어
SynonymGraphFilter를 한번 더 적용했을 때 position이 뒤틀리는 이유를 분석해본 결과, 같은 position에 있었던 복합명사의 일반명사에 대한 동의어의 position이 한칸씩 밀리기 때문입니다. 글만 보면 저도 이해못하겠는데 예시를 들어보면 간단합니다.
위에서 들었던 “자판기” 예시에서 “graph_synonym2”가 적용되기 바로 직전까지 필터를 돌리고 이를 그래프로 표현하면 다음과 같습니다.
아직 일반명사에 대한 동의어 필터 “graph_synonym2”가 적용되지 않아서 “자동”의 동의어인 “auto”, 밴딩의 동의어인 “banding”, 머신의 동의어인 “machine”이 위 그래프에서 나타나지 않고 있습니다.
여기서 이제 “graph_synonym2”가 적용된다면 가장 먼저 position 0의 “자동”의 동의어는 0으로 동일하게 들어가고 position 0의 “밴딩”의 동의어는 한칸 밀려서 1로 들어가며 position 0의 “벤딩”의 동의어는 또한칸 밀려서 2로 들어가며 기존 position 1부터 있던 것들은 3까지 밀려나가 위에서 봤었던 그래프가 되버립니다.
즉, 같은 position의 단어가 동의어 필터를 타면 position이 밀려서 발생하는 문제이므로 이를 해결하기 위해선 동의어 필터를 타더라고 position이 밀리지 않도록 만들면 됩니다.
진행
position이 밀리는 것은 일반명사 동의어 필터를 적용할 때 발생하므로 SynonymGraphFilter를 수정하여 일반명사용 동의어 필터를 customizing 하였습니다. 아래는 코드레벨로 어떻게 변경했는지 보여주고 있는데 lucene core의 개념을 모르시면 이해하기 어려우실 수 있으니 결과로 넘기셔도 됩니다.
로직을 간단하게 설명하면 같은 position에 있는 토큰들의 동의어를 모두 parseBuffer에 넣어두고 position이 바뀔때 parseBuffer를 buffer의 맨 첫번째부터 요소를 poll해서 처리합니다.
public final class SsgSynonymMorphemeFilter extends TokenFilter { ...@Override
public boolean incrementToken() throws IOException {
if (!parseBuffer.isEmpty()) {
releaseBufferedToken();
return true;
}
while (true) {
if (state != null) {
restoreState(state);
state = null;
parseBuffer.clear();
} else {
if (!input.incrementToken()) {
break;
}
}
...
if (!parseBuffer.isEmpty() && posIncrement > 0) {
state = captureState();
releaseBufferedToken();
return true;
}
...
}
...
}
TokenFilter는 모두 incrementToken()
이라는 메서드를 override 하고 있습니다. 각 TokenFilter에서 incrementToken()
메서드를 사용해 이전 필터에서 처리된 단어를 가져와 처리하고 반환시켜줍니다. incrementToken()
를 수행하며 맨 먼저 parseBuffer에 요소가 있다는 것은 아래 무한루프에서 빠져나갔다는 것이고 이는 position이 변경되었다는 것입니다. 즉, [그림 4]에서 position 0의 “자판, 벤딩, 밴딩, 자동”의 동의어를 처리했고 이제 이것은 다음 필터로 release하게 됩니다. releaseBufferedToken()
는 기존 SynonymGraphFilter의 것과 차이가 없습니다.
while (true) {
if (state != null) {
restoreState(state);
state = null;
parseBuffer.clear();
}
...
}
releaseBufferedToken()
으로 데이터를 가져와 반환시키면 현재 상태가 변화하기 때문에 position이 변경될 때 state를 capture해서 현재 상태의 토큰정보를 모두 저장시켜놓습니다. 무한 루프를 돌며 처음에 state의 존재여부를 파악하고 state가 저장되어있다면 이를 이용해 다시 읽어들인 곳부터 시작하도록 할 수 있게 합니다.
(position 0의 “자판, 벤딩, 밴딩, 자동” 동의어 처리시 이때 다음 읽어들일 token 상태는 “판매기”이며 이를state capture. releaseBufferedToken()
하며 position 0의 동의어 처리시 다음 읽어들일 token 상태가 동의어들로 바뀌므로 다시 무한루프에 들어왔을 때 state가 존재하면 이를 읽어들임)
private int pos = 0;
@Override
public boolean incrementToken() throws IOException {
...
final int posIncrement = posIncrAttr.getPositionIncrement();
if (posIncrement > 0) {
pos++;
}
같은 position의 단어들은 동의어 확장이 되도 동일한 position을 유지해야하므로 position를 클래스 멤버변수 pos로 두어 관리하며 이 pos값을 이용해 각 token의 position을 지정할 수 있습니다.
결과
위 내용을 토대로 저희의 customizing한 SynonymGraphFilter를 graph_synonyms_morpheme로 명명하고 아래와 같은 필터목록으로 TokenFilters를 지정하였습니다.
"lowercase",
"graph_synonyms_compound",
"flatten_graph",
"remove_duplicates",
"graph_synonyms_morpheme", // customizing한 SynonymGraphFilter
"korean_pos_stop",
"typo_correction"
그러면 이제 이전과 동일한 사전이 등록되있고 “자판기”를 분석하면 그래프는 최종적으로 아래와 같이 나옵니다.
“자판기”는 동의어인 “밴딩+머신”과 “자동+판매기”로 확장되며 이 확장된 토큰들에 대해서도 다시한번 동의어 필터를 돌게 되어 “밴딩”의 동의어 “벤딩”, “banding”이 같은 position에 추가되고 “머신”의 동의어인 “machine” 또한 동일한 position에 추가된 것을 확인해 볼 수 있습니다.
[그림 3]과 비교했을 때 position이 밀려나지 않고 같은 position으로 존재하는 것 또한 확인 가능하죠.
향후 방향성
코드레벨에서 보셨다시피, 일반명사 동의어 필터는 FlattenGraphFilter와 DuplicateFilter가 합쳐져서 있다고 봐도 무방합니다. 그래서 안그래도 어려운 코드가 더욱 지저분하고 가독성이 떨어지며 SRP와 같은 설계 원칙이 깨졌습니다. 기존의 로직을 건들지 않으며 코드를 리팩토링하는 과정이 반드시 필요할 것입니다.
마무리
이상으로 복합명사 동의어로부터 나온 일반명사 동의어도 색인되게 만들었던 과정을 설명드려보았습니다. 분석기 업무를 진행할 때, 이해하기 굉장히 어려웠고 현재도 아직 공부하는 과정이지만, 제가 생각한 대로 분석이 되는 것이 재미있고 검색에 중요한 역할을 했다는 성취감을 느낄 수 있었습니다.
저희는 분석기뿐 아니라 오타보정, 자동완성, 부스팅, 추천 등등 다양한 업무들을 좋은 팀원분들과 함께 하고 있습니다. 관심있으신 분들은 입사하셔서 더욱 좋은 서비스 만들어보면 좋을 것 같습니다!!
말재주 없는 긴 글 읽어주셔서 정말 감사합니다!