한국어 RAG를 만들 때에 임베딩 모델을 활용한 유사도 검색만 활용하기 보다는
BM25를 꼭 고려해 보아야 하며, 경우에 따라서 굉장한 성능 향상을 보일 수 있다.
그런데, 무작정 한국어 문서에 BM25를 적용하면 안된다. Langchain이나 LlamaIndex의 BM25 Retriever을 한국어 문서에 적용해보면, 그 처참한 성능에 "뭐야 BM25 별로잖아"라는 생각을 할 것이다. 왜일까?
라마인덱스 문서에 가보면 BM25 Retriever를 볼 수 있다. 이것을 쓰면 잘 동작할 것 같지만, 그것은 '영어'에만 해당되는 소리다.
class BM25Retriever(BaseRetriever):
def __init__(
self,
nodes: List[BaseNode],
tokenizer: Optional[Callable[[str], List[str]]],
similarity_top_k: int = DEFAULT_SIMILARITY_TOP_K,
callback_manager: Optional[CallbackManager] = None,
objects: Optional[List[IndexNode]] = None,
object_map: Optional[dict] = None,
verbose: bool = False,
) -> None:
self._nodes = nodes
self._tokenizer = tokenizer or tokenize_remove_stopwords
위는 LlamaIndex의 BM25Retriver 코드다. tokenizer을 선언한 부분이 보이는가? 직접 함수를 받아주던, tokenize_remove_stopwords
함수를 사용한다.
그럼 원리를 알아보기 전에 일단 실행 해보자.
아래 문장을 tokenize_remove_stopwords
로 토큰화 해 보았다.
문장 : 혜진이가 엄청 혼났던 그날 지원이가 여친이랑 헤어진 그날 걔는 언제나 네가 없이 그날
tokenize 결과 : ['그날', '헤어진', '혼났던', '지원이가', '없이', '여친이랑', '걔는', '엄청', '언제나', '혜진이가', '네가']
뭔가 이상하지 않은가? BM25의 원리를 이해하고 있다면, 이런 식으로 토큰화가 되었을 때 좋은 성능이 나올 수 없다는 것을 알 수 있다.
먼저 BM25의 원리를 복기해보자.
기본적으로 '질문(쿼리)과 겹치는 단어가 많은 단락'을 고른다. 여기서 흔한 단어의 영향을 줄이기 위해, 단락들에서 자주 등장한 단어들은 적게 고려하도록 한다.
결국, '겹치는 단어'를 구분하는 것이 BM25에서 얼마나 중요한 지 알 수 있다. 그렇기에 '단어' 구분을 해줄 수 있는 토크나이저의 역할이 매우 중요하다.
다시 위의 tokenize_remove_stopwords
결과를 보며 설명하겠다.
만약 내가 '혜진아, 너가 혼난 날 지원이는 여친과 헤어졌나?'라는 질문을 했다고 생각해보자. 해당 질문을 tokenize_remove_stopwords 함수를 이용해 토큰화 하면 다음과 같은 결과가 나온다.
tokenize 결과 : ['날', '혜진아', '지원이는', '너가', '여친과', '혼난', '헤어졌나']
분명히 질문은 위의 문장과 매우 유사하지만, 실제 토큰화된 결과에서는 겹치는 단어가 '하나'도 없다.
정리하자면 아래와 같다.
단락 : 혜진이가 엄청 혼났던 그날 지원이가 여친이랑 헤어진 그날 걔는 언제나 네가 없이 그날
질문: 혜진아, 너가 혼난 날 지원이는 여친과 헤어졌나?
=> 질문과 단락에서 겹치는 단어가 많으므로, BM25 점수가 높아야 한다.
단락의 tokenize 결과 : ['그날', '헤어진', '혼났던', '지원이가', '없이', '여친이랑', '걔는', '엄청', '언제나', '혜진이가', '네가']
질문의 tokenize 결과 : ['날', '혜진아', '지원이는', '너가', '여친과', '혼난', '헤어졌나']
=> 겹치는 토큰이 없으므로, 둘의 BM25 점수는 0이 된다.
tokenize_remove_stopwords
의 문제가 뭔지 살펴보자. 해결하는 방법만 알고 싶으신 분들은 쿨하게 이 단락을 넘어가도 좋다.
아래는 tokenize_remove_stopwords
함수 내부이다.
def tokenize_remove_stopwords(text: str) -> List[str]:
# lowercase and stem words
text = text.lower()
stemmer = PorterStemmer()
words = list(simple_extract_keywords(text))
return [stemmer.stem(word) for word in words]
여기서 PorterStemmer
라는 것이 등장한다. 이것은 영어 문장에서 동사 등을 기본형으로 바꾸어주는 유틸이다. 'walks', 'walking', 'walked' 등을 'walk'같은 원형으로 바꾸어 주는 것이다.
그런데 이 유틸, 당연히 한국어는 안된다.
여기에 더해, simple_extract_keywords
라는 함수도 볼 수 있는데, 이것도 한 번 내부를 들여다보자.
def simple_extract_keywords(
text_chunk: str, max_keywords: Optional[int] = None, filter_stopwords: bool = True
) -> Set[str]:
"""Extract keywords with simple algorithm."""
tokens = [t.strip().lower() for t in re.findall(r"\w+", text_chunk)]
if filter_stopwords:
tokens = [t for t in tokens if t not in globals_helper.stopwords]
value_counts = pd.Series(tokens).value_counts()
keywords = value_counts.index.tolist()[:max_keywords]
return set(keywords)
첫줄의 정규식은 문장을 띄어쓰기 단위로 잘라주며, 그 과정에서 문장부호도 제거해준다.
그리고 거기서 stopword(불용어)를 제거해주는데, 이 stopword는 nltk 라이브러리에서 가져와 사용하고 있다.
그렇다면 이 stopword는 어떤 언어 기준일까? 그렇다. 당연히 영어 기준이다.
한국어는 안된다.
(정확하게는 nltk 라이브러리에서 한국어 stopword를 사용할 수 있으나, 라마인덱스는 기본적으로 영어로 설정되어 있다)
해결책은? 한국어 버전의 PorterStemmer
와 같은 것을 사용하는 것이다. 그러기에 좋은 것이 바로 '형태소 분석기'이다.
한국어를 형태소 형태로 잘 분리해서, 단어를 원형으로 분리해준다. 그러면 잘 검색될 수 있겠지?
필자는 다양한 한국어 형태소 분석기 중 kiwi를 사용했다. Python 라이브러리도 있어 AutoRAG에도 적용해 두었다.
이제 kiwi를 사용해서 위의 예시를 다시 토큰화해보자.
단락 : 혜진이가 엄청 혼났던 그날 지원이가 여친이랑 헤어진 그날 걔는 언제나 네가 없이 그날
단락 tokenize 결과 : ['혜진', '이', '가', '엄청', '혼나', '었', '던', '그날', '지원', '이', '가', '어', '여친', '이랑', '헤어지', 'ᆫ', '그', '날', '걔', '는', '언제나', '너', '가', '없이', '그', '날']
질문 : 혜진아, 너가 혼난 날 지원이는 여친과 헤어졌나?
질문 tokenize 결과 : ['혜진', '아', ',', '너', '가', '혼나', 'ᆫ', '날', '지원이', '는', '여친', '과', '헤어지', '었', '나', '?']
이제 두 단락과 질문이 겹치는 단어는 10개나 된다!
겹치는 단어 : {'ᆫ', '가', '날', '너', '는', '었', '여친', '헤어지', '혜진', '혼나'}
혜진, 여친, 혼나, 헤어지 와 같은 핵심 단어들이 겹치는 단어로 성공적으로 검출되었음을 알 수 있다.
이제 여기에 한국어 불용어, 문장부호 제거 등을 결합하며 더 좋은 BM25용 한국어 토크나이저가 될 것이다.
AutoRAG에서 바로 kiwi 기반 토크나이저를 사용해 BM25의 성능을 다른 방법들과 비교할 수 있다.
설정 YAML 파일에 토크나이저 옵션만 'ko_kiwi'로 설정해주면 된다.
node_lines:
- node_line_name: retrieve_node_line
nodes:
- node_type: retrieval
strategy:
metrics: [retrieval_recall, retrieval_ndcg, retrieval_map]
top_k: 5
modules:
- module_type: vectordb
embedding_model: openai
- module_type: bm25
bm25_tokenizer: ko_kiwi # 이곳이 오늘의 핵심!
아주 간단하게 '한국어에 최적화된' 토크나이저를 활용한 BM25를 이용해서 RAG 성능 비교를 수행할 수 있다!