SHIFTONE

사내 문서 RAG가 엉뚱한 걸 물어와요 — 범인은 임베딩이 아니라 토크나이저였어요

사내 문서 RAG가 엉뚱한 문서를 물어올 때 범인이 임베딩이 아닌 경우가 많아요. 한국어 복합명사와 띄어쓰기 탓에 BM25가 정답 문서에 0점을 주는 과정을 순수 파이썬으로 재현하고, nori 형태소 분석기와 RRF 융합으로 어디까지 되돌릴 수 있는지 정리했어요.

오래된 도서관 목록함 서랍에서 손이 색인 카드 한 장을 꺼내는 모습 — 검색 품질은 결국 색인에 무엇이 들어 있느냐의 문제다

"결제 취소 방법 알려줘." 사내 문서 검색 챗봇에 물었는데 근거로 달려 나온 문서가 엉뚱했어요. 정작 답이 적힌 안내문은 「결제취소는 마이페이지 주문내역에서 신청합니다」인데, 상위 다섯 건 안에 없었고요.

RAG를 붙이면 대개 이 장면부터 만나요. 먼저 떠오르는 가설은 "임베딩이 한국어를 잘 못 잡는다"입니다. 그런데 벡터 검색만 떼어 재 보면 정답 문서가 상위권에 멀쩡히 올라와 있곤 해요. 문제는 그 다음이에요. 하이브리드 검색이라면 어휘 검색 쪽이 같은 문서에 0점을 주고 있을 수 있거든요.

어휘 검색이 0점을 주는 이유는 띄어쓰기였어요

확인하려고 순수 파이썬 BM25(k1=1.2, b=0.75)를 짜서 한국어 안내문 10건에 돌려봤어요. 질의는 "결제 취소 방법", 정답은 "결제취소는 마이페이지…" 문서입니다. 토크나이저만 바꿔 비교했어요.

def tok_space(t):              # 공백·구두점 기준
    return re.findall(r"[가-힣A-Za-z0-9]+", t)

def tok_ngram(t, n=2):         # 문자 2-gram
    s = re.sub(r"[^가-힣A-Za-z0-9]", "", t)
    return [s[i:i+n] for i in range(len(s)-n+1)]

쪼개는 단위만 바꿨는데 결과는 이렇게 갈렸어요.

[공백] 질의 토큰 = ['결제', '취소', '방법']
  1위 4.097  주문 취소 방법 문의가 많아 FAQ에 정리했습니
  정답(결제취소 문서) 점수 0.000  → 4위

[2-gram] 질의 토큰 = ['결제', '제취', '취소', '소방', '방법']
  2위 3.796  결제취소는 마이페이지 주문내역에서 신청합니다
  정답(결제취소 문서) 점수 3.796  → 2위

점수가 낮은 게 아니라 0점이라는 게 핵심이에요. 색인에 결제취소라는 토큰 하나만 있으니 질의의 결제취소가 어디에도 걸리지 않아요.

n-gram은 리콜을 살리는 대신 없던 단어를 만들어요

문자 2-gram으로 바꾸니 정답이 4위에서 2위로, 점수도 0에서 3.796이 됐어요. 그런데 질의 토큰에 소방이 섞여 있죠. "취소 방법"을 글자 단위로 자르다 생긴, 원문에 없는 단어예요. 수만 건 규모가 되면 이런 토큰이 상위 결과를 지저분하게 만들고요.

그래서 형태소 분석기 쪽으로 갑니다

Elasticsearch의 nori 플러그인은 mecab-ko-dic 사전으로 한국어를 형태소 단위로 분석하고, 복합명사 처리를 decompound_mode로 고르게 해줘요. 기본값 discard가곡역가곡, 역으로 쪼개며 원형을 버리고, mixed가곡역, 가곡, 역을 모두 남깁니다.

"tokenizer": {
  "ko": { "type": "nori_tokenizer", "decompound_mode": "mixed" }
}

mixed결제취소 문서가 "결제 취소" 질의에도 걸리고, 2-gram과 달리 유령 토큰도 만들지 않아요.

융합 단계에서 한 번 더 갈려요

두 검색기를 합칠 때 흔히 쓰는 건 RRF(Reciprocal Rank Fusion)예요. Azure AI Search 문서에 계산식이 나와 있습니다. 각 목록에서 문서 순위를 받아 1/(순위 + k)로 점수를 매겨 더하고, k는 60쯤이 가장 잘 동작한다고 해요.

여기서 RRF는 점수가 아니라 순위를 봐요. 상한 없는 BM25 점수와 0~1인 코사인 유사도를 그대로 더하면 한쪽이 다른 쪽을 삼키니까요. 대신 앞 단계 순위가 틀리면 그 오류도 실려 옵니다. 토크나이저를 고치기 전엔 정답 문서가 BM25 목록에 없었으니, RRF가 아무리 잘 합쳐도 나올 수 없었어요.

질의결제 취소 방법 어휘 검색 · BM25nori 형태소 · decompound mixed 벡터 검색임베딩 유사도 상위 k 순위만 RRF 융합1/(순위+60) 합산 리랭커질의+문서 동시 채점 정답 문서가 왼쪽 두 검색기에서 후보로 뽑히지 못하면, RRF도 리랭커도 되돌리지 못해요

재발을 막은 건 눈금을 따로 둔 거였어요

이 일을 겪고 바꾼 건 도구가 아니라 순서예요. 답변 품질을 먼저 보지 않고 검색만 따로 잽니다.

AS-ISTO-BE
무엇을 보나최종 답변이 그럴듯한가후보 안에 정답 문서가 있었나
지표사람이 읽고 판단recall@10, 리랭킹 후 nDCG@5

질의 30~50개와 정답 문서 id를 고정한 회귀 세트면 충분해요. 눈금이 없으면 답이 나아졌을 때 검색이 좋아진 건지 모델이 더 잘 둘러댄 건지 구분이 안 되니까요. 단계마다 측정 지점을 두는 이야기는 파일럿은 통과했는데 운영에서 멈춘다에서도, 도입 전체 흐름은 6단계 가이드 1편에서 다뤘어요.

답이 이상할 때, 저희는 모델부터 보지 않아요

제일 먼저 볼 건 모델이 아니라 후보 안에 정답 문서가 있었는지예요. 없었다면 생성이 아니라 색인의 문제고, 프롬프트를 다듬어도 해결되지 않아요. RAG는 한 번 세팅하고 끝나는 레시피가 아니라, 실패한 질의를 회귀 세트에 하나씩 넣으며 계속 다듬는 파이프라인입니다. 저희가 일하는 방식도 그 위에 있고요.

자주 묻는 질문

Q. 벡터 검색만 쓰면 토크나이저 문제는 없는 거 아닌가요? A. 벡터 검색은 표현이 달라도 의미로 잡지만 사번·모델명·약어 같은 고유 문자열엔 약해요. 그래서 어휘 검색을 함께 두고, 두는 순간 토크나이저가 품질을 좌우합니다.

Q. 리랭커를 붙이면 이 문제가 해결되나요? A. 아니요. 리랭커는 넘어온 후보 안에서 순서를 고칠 뿐, 후보에 없는 문서를 만들지 못해요. recall 먼저, 정밀도는 그다음입니다.

Q. 사내 용어가 많아 형태소 사전에 없는 말이 대부분인데요? A. nori는 user_dictionary 파일이나 user_dictionary_rules 규칙으로 사용자 명사를 추가할 수 있어요. 사전을 처음부터 채우기보다, 실패한 질의가 나올 때마다 그 단어를 사전과 회귀 세트에 같이 넣는 편이 현실적입니다.

Sources