1. 배경: 조사가 붙으면 검색되지 않는 한국어
로브로스 온디바이스 로봇에 RAG를 붙이던 중이었습니다. 로봇 제원과 매뉴얼을 문서로 넣어두고, 관람객이 "배터리 얼마나 가?" 같은 질문을 하면 문서를 찾아 근거 있는 답을 하도록 만드는 작업입니다.
의미 기반 검색(Dense)만으로는 부족했습니다. 모델명이나 전화번호처럼 정확히 그 단어여야 하는 질의에서 임베딩 검색은 자꾸 비슷한 다른 문서를 물어왔습니다. 이에 따라 키워드 검색(BM25)을 함께 쓰기로 했습니다.
다만 BM25를 붙이고 나니, 분명 문서에 있는 단어를 못 찾는 일이 생겼습니다.
2. 문제 정의: 색인된 단어가 검색되지 않는 현상
증상은 이랬습니다.
문서에는 로봇은 5200mAh 배터리를 탑재하고 있습니다 라고 적혀 있습니다. 사용자가 "로봇이 얼마나 오래 가?" 라고 묻습니다. 인간 관찰자에게는 명백히 같은 이야기입니다. 다만 BM25 점수가 0이 나왔습니다.
초기에는 색인 손상을 의심하여 인덱스를 재구축했습니다. 그래도 같았습니다. 토큰을 직접 출력한 뒤에야 원인을 확인했습니다.
문서 토큰 : ["로봇은", "5200mAh", "배터리를", "탑재하고", "있습니다"]
질의 토큰 : ["로봇이", "얼마나", "오래", "가"]
교집합 : 0개"로봇은"과 "로봇이"가 서로 다른 토큰이었습니다.
3. 기술적 난점
BM25는 기본적으로 공백으로 자릅니다. 영어에서는 이것이 대체로 통합니다. robot은 어디에 나와도 robot입니다. 기껏해야 복수형 robots 정도를 어간 추출로 맞춰주면 됩니다.
한국어는 다릅니다. 교착어라서 명사 뒤에 조사가 그대로 붙습니다. 같은 "로봇"이 문맥에 따라 이렇게 변합니다.
로봇은 / 로봇이 / 로봇을 / 로봇의 / 로봇에 / 로봇으로 / 로봇과 / 로봇도 / 로봇만
공백 기준으로 자르면 이것이 전부 다른 단어입니다. 사용자가 쓴 조사와 문서에 적힌 조사가 우연히 일치할 때만 검색이 되는, 실질적으로 운에 기대는 검색이 되어버립니다.
동사와 형용사는 더 심합니다. "탑재하고 / 탑재했습니다 / 탑재된 / 탑재하는" 이 전부 갈라집니다.
4. 대안 검토
1안. 형태소 분석기를 붙인다 (KoNLPy, Mecab 등)
한국어 처리의 정석입니다. 문장을 형태소 단위로 쪼개서 "로봇" + "은" 으로 분리한 뒤 명사만 색인합니다.
- 장점: 언어학적으로 정확합니다. 품사 정보까지 얻을 수 있습니다.
- 단점: 온디바이스 환경에서 부담이 큽니다. Mecab은 별도 사전과 바이너리 설치가 필요한데, Jetson 위에 이미 llama.cpp 프로세스 3개가 메모리를 5.9GB 쓰고 있는 상황이었습니다. 여기에 사전을 통째로 올리는 건 내키지 않았습니다. 설치 의존성이 늘어나면 "YAML 프로파일 교체만으로 4가지 배포 구성 전환"이라는 이식성 목표도 깨집니다. 게다가 분석기는 신조어나 제품명에서 종종 이상하게 자릅니다.
2안. 조사 목록을 만들어서 잘라낸다
"은/는/이/가/을/를/의/에/으로..." 목록을 만들고 접미사로 매칭되면 제거하는 방식입니다.
- 장점: 가볍고 의존성이 없습니다.
- 단점: 오탐이 납니다. "가방"에서 "가"를 자르면 "방"이 되고, "이유"에서 "이"를 자르면 "유"가 됩니다. 예외 목록을 계속 늘려야 하는데 끝이 없습니다.
3안. 문자 단위 bigram으로 색인합니다
단어를 두 글자씩 겹쳐서 쪼개 색인합니다. "로봇은" → ["로봇", "봇은"]
- 장점: 사전도 규칙도 필요 없습니다. 구현이 몇 줄입니다.
- 단점: 토큰 수가 늘어 인덱스가 커지고, 짧은 조각이 우연히 겹쳐 잡음이 생길 수 있습니다.
5. 설계 결정 및 근거
3안, bigram 색인을 골랐습니다.
결정적인 이유는 이 문제의 성격이었습니다. 완벽한 형태소 분석이 필요했던 게 아니라, 조사가 달라도 같은 명사를 매칭시키기만 하면 됐습니다. 목표를 이 수준으로 낮춰서 보면 bigram으로 충분했습니다.
핵심은 이겁니다. "로봇은"과 "로봇이"를 bigram으로 쪼개면 이렇게 됩니다.
| 원문 | 공백 분리 | 문자 bigram |
|---|---|---|
| 로봇은 | 로봇은 | 로봇, 봇은 |
| 로봇이 | 로봇이 | 로봇, 봇이 |
| 매칭 결과 | 공통 0개 ✗ | `로봇` 공유 ✓ |
조사가 바뀌어도 어근 부분의 bigram은 그대로 남습니다. 조사가 만들어내는 변형은 뒤쪽 bigram 하나에만 영향을 주고, 앞쪽은 살아남아 매칭됩니다. 사전 없이 조사 변형을 흡수했습니다.
잡음 문제는 구조로 해결했습니다. BM25를 단독으로 쓰는 게 아니라 의미 검색(bge-m3)과 RRF로 융합하는 구조였기 때문입니다.
질의 → ┬ Dense 검색 (bge-m3) → 순위 리스트 A ┐
│ ├ RRF 융합 (k=60) → 최종 순위
└ Sparse 검색 (BM25+bigram) → 순위 리스트 B ┘RRF(Reciprocal Rank Fusion)는 점수가 아니라 순위를 기준으로 두 결과를 합칩니다. 이에 따라 bigram이 어쩌다 엉뚱한 문서를 상위로 올려도, 의미 검색이 그 문서를 낮게 보면 최종 순위에서 자연히 밀려납니다. 두 방식이 서로의 약점을 덮어주는 구조라 bigram의 잡음을 감당할 수 있었습니다.
6. 실험 결과
| 측정 항목 | 값 | 비고 |
|---|---|---|
| 조사 변형 매칭 | 실패 → 정상 | 로봇은 ↔ 로봇이 매칭 |
| Hybrid 검색 전체 | 36.8ms | RRF 융합 포함 |
| Dense 단독 | 39.5ms | 기준값 |
| BM25 추가 비용 | +0.1ms | bigram 색인 포함 |
| RAG 추가 비용 (종단) | +0.02초 | 0.17초 → 0.19초 |
가장 주목할 수치는 +0.1ms 입니다. 형태소 분석기를 올렸다면 메모리와 초기화 시간을 상당히 지불해야 했을 텐데, bigram 색인은 비용이 실질적으로 발생하지 않았습니다.
이 검색이 실제로 환각을 막는지는 대조 실험으로 확인했습니다. 같은 질문을 RAG를 끄고 켜면서 비교했습니다.
| 질문 | RAG OFF | RAG ON |
|---|---|---|
| 서비스 센터 번호는? | 02-485-9311 ✗ | 1588-0000 ✓ |
| 배터리 용량은? | 1000mAh · 1시간 ✗ | 5200mAh · 4시간 ✓ |
RAG를 끄면 모델은 그럴듯한 번호와 사양을 지어냈습니다. 형식은 완벽하고 내용은 전부 틀렸습니다. 검색이 되어야 근거 있는 답이 나온다는 걸, 그리고 그 검색이 조사 하나 때문에 조용히 실패할 수 있다는 걸 눈으로 확인한 실험이었습니다.
7. 결론 및 시사점
한국어 RAG에서는 검색 실패가 조용히 일어납니다. 검색이 0건이면 에러가 나는 게 아니라, LLM이 근거 없이 그럴듯한 답을 만들어냅니다. 겉으로는 잘 동작하는 것처럼 보입니다. 이에 따라 이후로는 RAG를 붙일 때 먼저 검색 단계만 따로 떼어내 토큰과 히트 문서를 찍어보는 습관이 생겼습니다. 생성 결과만 보고 있으면 검색이 죽은 걸 모릅니다.
정답인 도구보다 문제에 맞는 도구를 고르는 게 낫습니다. 형태소 분석기는 더 정확하고 더 "제대로 된" 해법입니다. 하지만 제 문제는 "조사 변형 흡수" 하나였고, 제 환경은 메모리가 제한적인 온디바이스였습니다. 그 조건에서는 bigram이 옳은 선택이었습니다. 만약 서버 환경이었고 품사 정보까지 필요했다면 형태소 분석기를 썼을 겁니다.
약점이 있는 방법도 다른 방법과 묶으면 쓸 만해집니다. bigram 단독이었다면 잡음 때문에 못 썼을 것입니다. Dense 검색과 RRF로 묶었기 때문에 단점을 감수하고 장점만 취할 수 있었습니다. 하이브리드 검색의 진짜 이점은 정확도 그 자체보다, 각 방법의 실패 모드가 서로 다르다는 점에 있다고 생각하게 됐습니다.
