AI로 장소 검색 서비스 만드는 중입니다 (기술 3) — 하이브리드 검색: 임베딩이 놓친 '콩국수'를 형태소로 잡다

t3-hybrid-colnut

3편에서 "임베딩 튜닝이 멋져 보였지만 실패했다"고 했습니다. 이번 글은 그 임베딩의 구체적 약점과, 그걸 형태소 색인(BM25)과 융합(RRF) 으로 메운 이야기입니다. 검색 품질편의 첫 번째입니다. 수치는 유추카 자체 실측(2026-06~07). 리랭크 적합 건수는 라이브 트레이스 기준입니다.

그 전에 — 임베딩이 대체 뭔가

이 글 내내 "임베딩"이 나오니 먼저 짚고 갑니다.

임베딩(embedding)은 글을 숫자 좌표로 바꾸는 것입니다. "한강 보이는 조용한 카페"라는 문장을 넣으면 2048개의 숫자 묶음이 나옵니다. 이 좌표에는 한 가지 성질이 있습니다 — 뜻이 비슷한 문장끼리는 좌표도 가깝다는 것. 지도 위에 글을 흩뿌려 놓았다고 생각하면 됩니다. 카페 이야기들은 이쪽 동네에, 고깃집 이야기들은 저쪽 동네에 모입니다.

임베딩: 문장을 고차원 공간의 한 점(벡터)으로 변환하는 것. 두 문장의 유사도는 두 점 사이의 각도(코사인 유사도, 0~1)로 잽니다. 1에 가까울수록 "비슷한 뜻". 이 방식을 밀집 벡터(dense) 검색이라 부릅니다.

그래서 검색은 이렇게 돌아갑니다. 미리 모든 장소 문서를 좌표로 바꿔 저장해두고, 사용자가 검색어를 입력하면 검색어도 같은 방식으로 좌표로 바꾼 뒤, 가장 가까운 점들을 꺼내옵니다. 단어가 하나도 안 겹쳐도 됩니다. 좌표가 가까우면 그만입니다.

유추카가 임베딩을 쓴 이유 — 단어가 안 겹치니까

유추카는 유튜브 영상에서 장소를 캐내 문서로 쌓습니다. 그 문서에 실제로 적혀 있는 말은 이런 식입니다.

"여기 창가 자리가 진짜 미쳤어요, 강 전망이 시원하게 뚫려 있고요"

그런데 사용자는 이렇게 검색합니다.

"한강뷰 좋은 카페"

겹치는 단어가 하나도 없습니다. "한강뷰"도 "좋은"도 문서엔 없죠. 키워드 검색만 쓰면 이 문서는 영영 안 나옵니다. 하지만 임베딩 좌표로는 두 문장이 아주 가깝습니다 — "강 전망이 뚫려 있다"와 "한강뷰가 좋다"는 사실상 같은 말이니까요.

유추카가 다루는 텍스트가 영상 자막과 리뷰라는 게 결정적이었습니다. 정제된 카탈로그가 아니라 사람들이 각자 마음대로 쓴 말입니다. 같은 뜻을 "분위기 좋다 / 감성 있다 / 인테리어가 예쁘다 / 사진 잘 나온다"로 제각각 표현하고, 사용자도 마찬가지로 제각각 검색합니다. 이 표현의 격차를 단어 매칭으로 메우려면 동의어 사전을 무한히 쌓아야 하는데, 임베딩은 그걸 학습된 의미로 한 번에 넘어갑니다.

그래서 초기 유추카의 검색은 임베딩 단독이었습니다. 그리고 실제로 "분위기 좋은 카페", "데이트하기 좋은 곳" 같은 뭉뚱그린 검색에선 잘 동작했습니다. 문제는 그 반대편에서 터졌습니다.

임베딩은 '의미'는 잡지만 '희소 단어'에 약하다

임베딩의 강점은 방금 본 대로 뭉뚱그린 의미입니다. 그런데 바로 그 강점이 약점이 되는 지점이 있습니다 — 드물고 구체적인 단어입니다.

대표 사례가 "콩국수 맛있는 식당"이었습니다. 임베딩은 "콩국수"라는 희소 어휘의 신호를 "맛있는 식당"이라는 흔한 의미에 희석해버려서, 정작 콩국수 전문점이 후보 창에 도달을 못 하고 유명한 (콩국수 아닌) 식당들이 상위를 채웠습니다. 리랭크에 도달한 콩국수 적합 결과가 단 1건이었습니다.

이건 임베딩 모델이 부실해서가 아니라 밀집 벡터(dense) 검색의 근본 특성입니다. 문장 하나를 좌표 하나로 뭉갠다는 건, 그 문장의 모든 단어를 평균 내 섞는다는 뜻입니다. "콩국수 맛있는 식당"에서 정체성을 결정하는 단어는 "콩국수" 하나뿐인데, 좌표상으로는 "맛있는", "식당"이라는 흔하디흔한 신호에 파묻힙니다. 결과적으로 좌표는 그냥 "맛집"이라는 동네에 찍히고, 그 동네엔 콩국수집보다 유명 맛집이 훨씬 많습니다.

벡터는 "전체적으로 비슷한가"는 판단하지만, "이 정확한 단어가 이 문서에 있는가"는 판단하지 못합니다.

반대편 검색기 — 키워드 검색(BM25)

임베딩이 나오기 20년도 전부터 검색엔진이 쓰던 방식이 정확히 그 반대편에 있습니다. BM25, 키워드 색인입니다.

BM25는 의미를 전혀 모릅니다. 오로지 단어가 실제로 박혀 있는지만 봅니다. 세 가지를 조합해 점수를 냅니다.

  • TF(Term Frequency) — 그 단어가 이 문서에 몇 번 나오나. 많이 나올수록 높은 점수. 단, 무한정 오르진 않습니다 — 10번 나온 문서가 5번 나온 문서보다 2배 관련 있진 않으니 점수가 완만하게 포화되도록 눌러줍니다.
  • IDF(Inverse Document Frequency) — 그 단어가 전체 문서 중 몇 개에만 나오나. 이게 핵심입니다. "식당"은 수만 개 문서에 나오니 IDF가 거의 0 — 있으나 마나 한 단어로 취급됩니다. 반면 "콩국수"는 몇백 개 문서에만 나오니 IDF가 크게 붙습니다. 즉 BM25는 희귀한 단어일수록 세게 반응합니다. 임베딩이 희석시킨 바로 그 신호를요.
  • 문서 길이 정규화 — 문서가 길면 아무 단어나 우연히 들어 있을 확률이 높으니, 평균보다 긴 문서는 점수를 깎습니다. 안 그러면 리뷰가 200개 달린 장소가 무조건 이깁니다.

BM25 점수식: Σ IDF(단어) × TF포화(단어, 문서) × 길이보정. 유추카는 Lucene 표준값 k1=1.2, b=0.75를 그대로 씁니다. 인메모리 인덱스로 전 문서의 단어→빈도와 전역 문서빈도(df)를 기동 시 적재하고, 1시간마다 리로드합니다.

두 검색기를 나란히 놓으면 성격이 정반대입니다.

의미 검색 (dense/임베딩) 키워드 검색 (BM25)
보는 것 문장 전체의 정확한 단어의 존재와 희귀성
강한 검색 "분위기 좋은 카페", "데이트 코스" "콩국수", "피양콩할마니", "루프탑"
약한 검색 희소 어휘 (신호가 평균에 희석됨) 표현이 다른 동의어 ("강 전망" ↔ "한강뷰")
단어가 안 겹치면 찾아냄 못 찾음
흔한 단어에 휘둘림 면역 (IDF≈0)

한쪽이 약한 곳에서 정확히 다른 쪽이 강합니다. 그러니 답은 둘 중 하나를 고르는 게 아니라 둘 다 쓰는 것 — 하이브리드 검색입니다.

한국어라서 필요한 한 단계 — 형태소 분석

그런데 한국어에 BM25를 그냥 쓰면 안 됩니다. 한국어는 교착어 — "콩국수를", "콩국수가", "콩국수집"처럼 조사·접미가 붙어 같은 단어가 다르게 보입니다. 띄어쓰기도 제각각이고요. 그래서 색인 전에 형태소 분석(morphological analysis) 으로 "콩국수 + 를"을 쪼개 어근만 색인해야 "콩국수"로 검색했을 때 "콩국수를"도 걸립니다.

유추카는 Lucene의 한국어 형태소 분석기 Nori를 씁니다(순수 JVM, 별도 모델서버 불필요). 이걸로 문서·검색어를 형태소로 쪼갠 뒤 BM25(IDF) 색인을 만듭니다. (기술 1편에서 "강릉감성카페"와 "강릉 감성 카페"의 결과가 달랐던 문제도, 붙여쓴 덩어리를 제대로 분해하지 못한 데서 나왔습니다 — 형태소·분류기 단계의 정합이 이래서 중요합니다.)

두 검색을 어떻게 합치나 — RRF

이제 의미 검색(dense)키워드 검색(BM25), 두 개의 순위 목록이 생겼습니다. 이걸 하나로 합쳐야 합니다.

가장 먼저 떠오르는 방법 — 점수를 더한다. 안 됩니다. 두 점수는 아예 다른 단위입니다.

  • 코사인 유사도: 0~1 사이. 게다가 실제로는 0.5~0.8 좁은 구간에 몰려 있습니다.
  • BM25 점수: 0에서 수십까지. 상한이 없고, 검색어가 몇 단어냐에 따라 스케일이 통째로 바뀝니다.

그냥 더하면 BM25가 코사인을 압도해버립니다. 그렇다고 가중치를 붙이자니(0.7×cos + 0.3×bm25 같은 식) 그 가중치를 검색어마다 다시 튜닝해야 합니다 — 단어 수, 코퍼스, 검색 유형이 바뀔 때마다요. 실무에서 이 정규화는 악명 높게 잘 깨집니다.

그래서 점수를 아예 버리고 순위(rank)만 씁니다. 그게 RRF입니다.

RRF(Reciprocal Rank Fusion): 각 목록에서 어떤 문서가 r등이면 1/(k+r) 점수를 주고(k는 상수, 유추카는 k=60 — 원논문 권장값), 목록별로 받은 이 점수를 합산해 재정렬합니다. "몇 점이었나"는 안 보고 "몇 등이었나"만 봅니다.

1/(k+r)은 순위가 내려갈수록 점수가 줄되, 줄어드는 폭이 점점 완만해집니다. 1등↔5등의 격차가 50등↔55등의 격차보다 큽니다 — "상위권일수록 확신이 크다"는 검색의 상식이 수식에 들어 있는 셈이죠.

여기서 k의 역할이 재밌습니다. k=60이면 1등은 1/61 = 0.0164, 10등은 1/70 = 0.0143 — 겨우 13% 차이입니다. 즉 k를 크게 잡으면 1등의 특권이 의도적으로 약해집니다. 왜 그렇게 하냐면, RRF가 진짜로 보상하려는 건 "한 검색기가 1등으로 꼽은 것"이 아니라 "두 검색기가 나란히 상위권에 올린 것" 이기 때문입니다. 한 곳에서 1등이지만 다른 곳에선 흔적도 없는 문서보다, 양쪽에서 고루 10~20등인 문서가 더 믿을 만하다는 판단입니다. k가 0에 가까우면 어느 한쪽의 1등이 모든 걸 독식하고, 너무 크면 순위 차이 자체가 무의미해집니다. 60은 원논문(Cormack et al., 2009)이 실험으로 찾은 균형점이고, Elasticsearch·Qdrant 등도 이 값을 기본값으로 씁니다.

"콩국수 맛있는 식당"에서 벌어지는 일을 숫자로 보면(순위는 설명용 예시):

장소 dense 순위 BM25 순위 RRF 점수 (k=60) 융합 후
명인콩국수 30등 1등 1/90 + 1/61 = 0.0275 1등
피양콩할마니 38등 2등 1/98 + 1/62 = 0.0263 2등
유명 한식당 A 2등 매칭 없음 1/62 + 0 = 0.0161 3등

임베딩이 30등으로 밀어놨어도 BM25가 1등으로 보면 융합 후 최상위로 올라옵니다. 반대로 임베딩만 2등으로 본 유명 한식당은 "콩국수"라는 단어가 문서에 아예 없어 BM25 기여가 0이라, 자기 힘만으로는 두 표를 받은 전문점을 못 이깁니다.

RRF의 좋은 점은 둘 중 하나만 찾아도 결과에 남는다는 것입니다. BM25가 못 찾은 문서는 dense 순위 점수만 받고 그대로 목록에 남습니다 — 어느 쪽도 상대를 탈락시키지 않습니다. 유추카 구현도 그렇게 돼 있어서, BM25 인덱스가 비어 있거나 검색어에 색인된 단어가 하나도 없으면 조용히 dense 순위 그대로 폴백합니다. 하이브리드가 기존 검색을 망가뜨릴 수 없는 구조입니다.

그래서 유추카는 어디에 이걸 끼워 넣었나

여기까지가 교과서입니다. 실제로 붙일 때는 한 가지를 먼저 정해야 했습니다 — BM25를 "새로운 검색기"로 쓸 것인가, "순서를 바꾸는 장치"로 쓸 것인가.

유추카의 검색은 깔때기 구조입니다.

전체 장소 문서

↓ 임베딩 + 구조화 조건으로 후보 소환

후보 풀 (최대 2,000곳)

↓ 상위 60곳만 LLM 리랭커가 읽고 판정 ← 여기가 병목

리랭크 창 (60)

↓ 상위 20곳에 "왜 추천하는지" 이유 생성

사용자 화면

콩국수 사건을 이 그림 위에 다시 놓아보면 진범이 보입니다. 콩국수 전문점들은 후보 풀 2,000곳 안에 이미 들어와 있었습니다. 문제는 임베딩 순위상 한참 아래에 깔려 있어서 상위 60이라는 리랭크 창에 도달하지 못한 것이었습니다. LLM 리랭커는 정확히 판정할 능력이 있었지만, 판정할 기회 자체를 못 받았던 거죠.

그래서 BM25를 별도 검색기로 붙여 후보를 더 데려오는 대신, 이미 뽑아둔 후보 풀의 순서만 바꾸는 데 썼습니다. 코드로는 딱 한 줄입니다.

// 의미 관련도 순위와 BM25(형태소) 순위를 RRF로 융합 — 점수는 보존하고 '순서'만 재배열

val fused = fuseBm25Rrf(relSorted, rawQuery, queryNorm != null && semantic)

이 선택 덕에 안전장치가 공짜로 따라왔습니다.

  • 점수를 건드리지 않습니다. 뒤에서 등급(tier)·추천 이유를 만들 때 쓰는 블렌드 점수는 그대로 두고 순서만 바꿉니다. 하이브리드를 껐다 켜도 점수 기반 로직은 영향을 안 받습니다.
  • 의미 검색 경로에만 적용합니다. 태그·메뉴로 조건이 딱 떨어지는 어휘 검색 경로는 이미 하드게이트가 걸려 있어 RRF를 안 태웁니다.
  • 실패해도 조용히 원상복귀. 인덱스가 안 떴거나 검색어에 색인된 단어가 하나도 없으면 dense 순위 그대로 내려갑니다.
  • 환경변수 하나로 OFF(YOUCHUCA_SEARCH_BM25_RRF_ENABLED). 문제가 생기면 배포 없이 끕니다.

인덱스는 별도 검색 서버 없이 JVM 인메모리로 굴립니다. 전 문서의 장소명 + 태그 + 검색텍스트를 Nori로 토큰화해 기동 시 적재하고 1시간마다 리로드합니다. 서비스 규모가 수만 건 수준이라 Elasticsearch를 세우는 비용이 더 컸습니다.

그런데 RRF 하나만으로는 안 됐다

여기서 끝났으면 깔끔했을 텐데, 트레이스를 따라가 보니 콩국수가 침몰하는 지점이 한 군데가 아니었습니다. RRF로 순위를 올려놔도 그 위에서 다른 로직이 다시 밀어내리고 있었고, 애초에 "콩국수"를 메뉴로 인식조차 못 하는 단계도 있었습니다. 결국 "리터럴 의도 검색 오버홀"이라는 이름으로 6계층을 한꺼번에 손봐야 했습니다.

  1. 근거 검증 게이트 — 리랭커가 "이 집에 콩국수가 있다는 근거"를 리뷰·메뉴·특징에서 실제로 찾게 강제. 못 찾으면 부적합. (환각 컷)
  2. BM25 리터럴 recall(RRF) — 이 글의 주제.
  3. 보편 품질수식어 강등 — "맛있는", "유명한" 같은 흔한 칭찬어를 필수요건에서 제외.
  4. *메뉴 정확매칭 승격을 블렌드 이후로 이동* — 순서가 문제였습니다. 블렌드 이전에 승격시키면 그 가산점이 코사인 유사도에 섞여 희석돼 버렸습니다. 리뷰가 적고 코사인이 낮은 니치 전문점일수록 이 희석에 무너졌고요.
  5. 메뉴 사전을 손큐레이션에서 데이터로 — 원래 메뉴 용어는 사람이 적어둔 목록이었습니다. "콩국수"는 그 목록에 없었습니다. 그래서 코퍼스에서 문서빈도 20 이상인 메뉴 토큰을 자동 수집하도록 바꿨습니다(실측: 콩국수 119건·족발 45건은 메뉴로, 정원 10건·계곡 0건은 분위기어로 정확히 갈렸습니다).
  6. "지역 + 속성" 검색도 의도 검색으로 승격 + 희소 지역 안내 — "분당 콩국수"처럼 지역이 붙으면 단순 지역 브라우징으로 분류돼 리랭크를 아예 안 타던 문제.

3번에서 예상이 깨진 게 재밌었습니다. 처음엔 "IDF가 흔한 칭찬어를 알아서 눌러줄 것" 이라고 가정했습니다. BM25의 IDF는 흔한 단어에 0점을 주니까요. 그런데 실제 코퍼스를 재보니 "맛있는"의 문서빈도가 1.3%밖에 안 됐습니다. 사람들은 리뷰에 생각보다 "맛있다"라고 잘 안 씁니다. 희소하니 IDF는 오히려 이 단어를 더 세게 밀어 올리고 있었습니다. 가설과 정반대였죠. 결국 IDF에 맡기는 걸 포기하고 품질수식어를 명시적으로 제외했습니다.

결과 — 콩국수 1건 → 15건

6계층을 다 켜고 라이브 트레이스로 확인한 결과입니다.

검색어 이전 이후
콩국수 맛있는 식당 (전국) 적합 1건, 상위는 유명 비-콩국수 식당 적합 15건, 명인콩국수·피양콩할마니·유달콩물·제일콩집
콩국수 맛있는 식당 (연남 뷰포트) 누락 마포즉석모밀촌(모밀검은콩국수) 진입
분당 콩국수 일반 식당 27건으로 채워 넣음 0건 + "이 지역엔 결과가 적어요" 안내
계곡 카페 (연남) 0건 + 안내 / (경기 근교)에선 실제 계곡 카페 14건

"분당 콩국수 → 0건"이 개인적으로 가장 마음에 드는 결과입니다. 예전엔 결과가 없으면 아무 식당이나 27개 채워서 화면을 메웠습니다. 사용자 입장에선 "콩국수집 27개를 찾았다"로 보이지만 실제론 한 곳도 콩국수집이 아닌, 최악의 거짓말이었죠. 지금은 없으면 없다고 말합니다. 검색 품질은 못 찾은 걸 찾아내는 것만이 아니라, 없는 걸 없다고 말하는 것이기도 합니다.

회귀도 확인했습니다 — "맛있는 파스타", "한우 맛집", "조용한 카페", "경기 광주 데이트", "분당 맛집" 모두 정상이었습니다. "조용한"이나 "데이트" 같은 말랑한 개념 검색이 안 망가지는 게 중요했습니다. 리터럴 검색을 살리려다 원래 잘되던 뭉뚱그린 검색을 죽이면 남는 장사가 아니니까요.

방향 자체는 업계 정설과도 맞습니다 — Elastic의 하이브리드 RRF는 dense 단독 대비 nDCG를 +1.4~6% 올린다고 보고했고, 한국어 RAG 벤치(AutoRAG)에서도 형태소 토큰화가 상위였습니다.

아직 안 풀린 것 — '콩국수'를 쪼개면 '국수'가 딸려온다

자랑만 하면 반칙이니 남은 문제도 적습니다.

형태소 분석의 대가입니다. Nori는 "콩국수"를 콩 + 국수로 쪼갭니다. 조사를 떼어내려고 도입한 바로 그 성질인데, 부작용으로 "국수"라는 토큰이 살아나 잔치국수·비빔국수까지 후보 상위로 끌어올립니다. 지금은 LLM 리랭커가 뒤에서 상당수 걸러내지만, 애초에 창을 국수집으로 채워 잡아먹는다는 점에서 낭비입니다. 근본 해결은 "콩국수"를 분해하지 않는 복합 dish 토큰으로 다루는 건데, 아직 안 했습니다. 형태소 분해는 조사엔 축복이고 복합명사엔 저주입니다.

그리고 RRF만의 기여도를 숫자로 분리하지 못했습니다. "콩국수 1→15"는 6계층을 한꺼번에 켜고 잰 종합 효과입니다. 정직하게 말하면 이 15건 중 몇 건이 RRF 덕인지 저는 모릅니다. 계층을 하나씩 켜 가며 A/B를 했어야 하는데, 급한 마음에 다 켜고 재버렸습니다. 다음엔 안 그러려 합니다.

이 글의 결론

  • 임베딩(의미) 검색만으론 희소·리터럴 어휘를 놓친다. "콩국수" 같은 정확한 단어는 키워드 색인(BM25)이 잡아야 합니다.
  • 한국어는 형태소 분석(Nori) 이 선행돼야 조사·띄어쓰기 변형을 흡수합니다. 대신 복합명사가 쪼개지는 대가를 치릅니다.
  • 두 이질적 검색기는 점수가 아니라 순위로(RRF) 융합해야 안정적입니다. 유추카는 이걸 새 검색기가 아니라 "후보를 리랭크 창까지 밀어 올리는 장치" 로 썼습니다.
  • 하나만 고쳐선 안 됐습니다. RRF는 6계층 중 하나였고, 나머지 5개가 없으면 혼자선 움직이지 않았습니다.
  • IDF가 흔한 칭찬어를 눌러줄 거란 가설은 데이터가 뒤집었습니다("맛있는" df 1.3%). 검색 튜닝에서 직관은 자주 틀립니다.

다음 글(기술 4)에서는 "감자탕집이 한옥으로 나오던" 문제를 근본에서 막은 구조화 하드게이트 — '비슷함'을 '조건'으로 못 박는 이야기입니다.


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.