> ## Content Index
> Fetch the complete content index at: https://www.popit.kr/llms.txt
> Use this file to discover other available public pages before exploring further.

# AI로 장소 검색 서비스 만드는 중입니다 (기술 3) — 하이브리드 검색: 임베딩이 놓친 '콩국수'를 형태소로 잡다
- URL: https://www.popit.kr/ai-place-search-tech-3-hybrid-search-morpheme/
- Published: 2026-07-29T22:21:03.000Z
- Updated: 2026-09-11T00:38:42.000Z
- Author: 김형준
- Tags: ai, 검색, #서비스소개, 유추카

![t3-hybrid-colnut](https://www.popit.kr/content/images/wp/2026/07/t3-hybrid-colnut-600x198.png)

> 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를 **별도 검색기로 붙여 후보를 더 데려오는 대신, 이미 뽑아둔 후보 풀의 순서만 바꾸는 데** 썼습니다. 코드로는 딱 한 줄입니다.  

```kotlin
// 의미 관련도 순위와 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)에서는 "감자탕집이 한옥으로 나오던" 문제를 근본에서 막은 **구조화 하드게이트** — '비슷함'을 '조건'으로 못 박는 이야기입니다.

- [유튜버추천카페로 이동하기](https://youchuca.popit.kr/?ref=popit.kr)
- 실제 장소 소개는 블로그에 올리고 있습니다 — [https://blog.naver.com/popitt](https://blog.naver.com/popitt?ref=popit.kr)