AI로 장소 검색 서비스 만드는 중입니다 (기술 4) — 구조화 하드게이트: '비슷함'을 '조건'으로 고정하다

기술 3편에서 "임베딩은 뜻은 잡지만 정확한 단어는 못 잡는다"를 봤습니다. 이번엔 한 걸음 더 들어갑니다 — 단어가 맞아도 여전히 틀리는 경우, 그리고 그걸 막기 위해 '비슷함'을 아예 '조건'으로 고정한 이야기입니다. 수치는 유추카 자체 A/B(사람 라벨 정답셋) 실측입니다.
감자탕집을 찾았는데 한옥 카페가 나온다
먼저 실제로 겪은 장면부터.
"감자탕집" 을 검색했더니 결과 상단에 한옥 카페가 올라왔습니다. 처음엔 버그인 줄 알았는데, 아니었습니다. 기술 3편에서 본 임베딩의 좌표 비유를 떠올려 보면 이유가 보입니다 — 감자탕집 문서와 한옥 카페 문서는 좌표상 정말로 가깝습니다. 둘 다 "한국적", "전통", "투박하고 정겨운", "어르신들이 좋아하는" 같은 말로 설명되니까요. 임베딩이 고장 난 게 아니라, 임베딩이 시키는 대로 정확하게 일한 결과가 한옥 카페였습니다.
같은 종류의 사고가 곳곳에서 났습니다.
- "인스타 감성 카페" → 평범한 동네 카페가 섞입니다. "카페"라는 거대한 의미가 "인스타 감성"이라는 미묘한 축을 덮어버립니다. 좌표는 결국 "카페 동네"에 찍히거든요.
- "스페셜티 커피" → 스페셜티가 아닌 곳이 들어옵니다. 리뷰에 "여긴 스페셜티는 아니지만 가성비가 좋아요" 라고 적혀 있어도, 텍스트로는 "스페셜티"가 있는 문서니까요. 부정문을 긍정으로 읽는 셈입니다.
- "멕시칸 음식점" → 치킨집이 나옵니다. 메뉴 목록에 "멕시칸치폴레치킨"이 있었거든요.
- "회" → 회식 장소, 회전초밥, 심지어 "OO회 정기모임" 후기가 걸립니다.
앞의 둘과 뒤의 둘은 성격이 다릅니다. 앞은 뜻이 너무 가까워서 생긴 사고고, 뒤는 글자가 우연히 겹쳐서 생긴 사고입니다. 그런데 사용자 입장에선 구분이 없습니다. 그냥 "틀린 결과" 죠.
리랭커를 붙이면 되지 않나
이쯤에서 자연스럽게 나오는 답이 있습니다. 리랭커(reranker) 를 붙이자.
리랭커: 검색이 뽑아온 후보 수십 개를 LLM에게 통째로 보여주고 "이 검색어에 이 장소가 맞습니까?"를 하나하나 판정·재정렬시키는 단계. 임베딩은 좌표만 보지만 LLM은 문장을 읽으므로, "스페셜티는 아니지만" 같은 부정문을 이해할 수 있습니다.
실제로 유추카도 리랭커를 씁니다. 그리고 잘 동작합니다. 그런데 이것만으로는 부족했습니다. 이유가 셋입니다.
- 리랭커는 자기한테 온 것만 봅니다. 후보 목록에 애초에 감자탕집이 없으면, 리랭커가 아무리 똑똑해도 감자탕집을 만들어낼 수 없습니다. 기술 3편에서 본 그 깔때기 문제죠.
- 느리고 비쌉니다. 검색할 때마다 LLM을 부르는 구조라, 후보를 무한정 늘릴 수 없습니다. 창을 60개로 제한하는 이유가 그겁니다.
- 그리고 확률적입니다. "주차 가능한 곳"이라는 요청에 대해 LLM은 대체로 맞히지만 가끔 틀립니다. 주차장 유무는 취향 문제가 아니라 사실 문제인데, 사실 문제를 확률에 맡길 이유가 없습니다.
기술 1편에서 로컬 리랭커가 코사인 유사도조차 못 이겼던 경험도 있었고요. 결론은 이랬습니다 — 리랭커는 정밀도의 주역이 아니라 보조여야 한다.
발상의 전환 — '정렬'에서 '필터'로
그래서 접근을 바꿨습니다.
기존 검색은 전부 정렬(ranking) 문제였습니다. 모든 장소에 점수를 매기고 높은 순으로 보여주는 것. 이 틀에선 감자탕집도 한옥 카페도 "점수가 조금 다를 뿐 둘 다 후보"입니다. 점수가 아슬아슬하면 언제든 뒤집힐 수 있죠.
바꾼 틀은 필터(filtering) 입니다. 조건을 만족 못 하면 점수를 볼 것도 없이 후보에서 뺍니다. 한옥 카페는 업종이 '카페'이므로 "감자탕집" 검색에선 점수 계산 자체를 시작하지 않습니다. 이게 하드게이트입니다.
하드게이트(hard gate): 통과 못 하면 후보에서 완전히 제외되는 결정적 관문. 점수를 낮추는 게 아니라 아예 없는 셈 치는 것이 핵심입니다. 반대말은 순위에 반영만 하는 소프트게이트(뒤에 다시 나옵니다).
| 정렬로 푼다 | 필터로 푼다(하드게이트) | |
|---|---|---|
| 틀린 후보 | 점수를 낮춘다 | 목록에서 없앤다 |
| 결과 | 확률적 — 가끔 올라온다 | 결정적 — 절대 안 올라온다 |
| 결과가 없으면 | 뭐라도 채워서 보여준다 | 빈 결과를 보여준다 |
| 비용 | 검색할 때마다 LLM | 색인할 때 한 번 |
여기서 세 번째 줄이 중요합니다. 하드게이트를 도입한다는 건 "조건에 맞는 게 없으면 없다고 말하겠다" 는 선언이기도 합니다. 예전엔 "분당 파인다이닝"에 결과가 없으면 분당 식당을 아무거나 채웠습니다. 지금은 빈 결과를 냅니다. 기술 3편의 "분당 콩국수 → 0건"과 같은 철학이고, 사실 그게 이 하드게이트 작업에서 먼저 자리 잡은 원칙이었습니다.
조건을 '축'으로 쪼개다
그럼 무엇을 필터할 것인가. 여기서 "검색 조건"을 축(axis) 단위로 분해했습니다.
"주차 되는 한옥 카페"라는 검색어에는 서로 성격이 완전히 다른 세 조건이 섞여 있습니다.
- 업종: 카페 — 객관적 분류. 네이버가 이미 정해놨습니다.
- 시설: 주차 — 사실 정보. 있거나 없거나 둘 중 하나입니다.
- 공간 스타일: 한옥 — 사진과 리뷰를 봐야 아는, 판단이 필요한 정보.
이걸 하나의 임베딩 벡터로 뭉뚱그려 처리하던 게 문제였습니다. 축마다 필요한 정보의 성격이 다르니, 축마다 가장 정확한 출처를 따로 쓰기로 했습니다.
| 축 | 라벨 출처 | 왜 이 출처인가 |
|---|---|---|
| 업종 | 네이버 카테고리 | 네이버가 이미 분류해 둔 권위 있는 값 |
| 요리 종류(멕시칸·일식) | 네이버 카테고리 | 메뉴명으로 판단하면 오염됨 (아래) |
| 요리명(타코·삼겹살·라멘 ~45종) | 네이버 카테고리 또는 메뉴 토큰 | 카테고리가 없는 집도 메뉴로 구제 |
| 시설(주차·반려동물·단체) | 네이버 편의시설 태그 | 사실 정보라 LLM보다 태그가 정확 |
| 품질(파인다이닝·미슐랭·스페셜티) | LLM 색인-시 검증 | 텍스트 신호는 노이즈, 문맥 판단 필요 |
| 공간/뷰(한옥·정원·루프탑·한강뷰) | LLM 분류(venueStyles) | 사진·리뷰 문맥에서 판별 |
| 추상 의도(인스타·데이트·조용) | LLM 188-feature 점수(concepts) | 미묘한 맥락 축 |
결정 규칙은 한 줄로 요약됩니다. 네이버가 구조화해서 주는 것(업종·시설)은 네이버를 쓰고, 안 주는 주관적·품질·공간 정보만 LLM이 판단한다. 그리고 그 LLM 판단은 검색할 때마다가 아니라 색인할 때 딱 한 번 합니다. 검색 시점엔 미리 저장된 라벨과 결정적으로 매칭만 하면 되니 빠르고, 같은 질문을 매번 다시 묻지 않으니 쌉니다.
"LLM이 만능"이 아니라 "출처가 정확한 데이터가 먼저"
시설 축에서 이 원칙의 효과가 가장 선명했습니다. 원래는 리뷰 텍스트에서 "주차" 신호를 뽑았는데, 990곳 중 944곳이 걸렸습니다. 거의 전부죠. 왜냐면 "주차가 불편해요", "주차장이 없어서 근처에 댔어요" 같은 부정 맥락까지 전부 걸렸기 때문입니다. 앞의 "스페셜티는 아니지만"과 정확히 같은 실패입니다.
네이버 편의시설 태그를 우선하도록 바꾸니 713곳(그중 권위 태그 572곳) 으로 정리됐습니다. "반려동물 동반"도 522 → 262곳이 됐고요. 정교한 프롬프트도, 큰 모델도 아니고 이미 정확한 데이터가 있는 곳을 찾아 쓴 것이 답이었습니다.
다만 공짜는 아니었습니다. 이 태그는 네이버 장소 페이지 안에 있어서 다시 긁어와야 했는데, 전체 재수집은 장소당 리뷰 스크롤까지 도느라 13초 × 6,138곳 ≈ 22시간에 중간 재개도 안 됐습니다. 그래서 편의시설만 필요하니 홈 화면만 파싱하는 경량 백필을 따로 만들었습니다. 약 3시간으로 줄었고, 이미 받은 장소는 건너뛰니 중간에 끊겨도 이어서 돌 수 있습니다. 최종적으로 6,138곳 중 4,844곳이 시설 정보를 갖게 됐습니다.
요리 종류에서 배운 것 — 메뉴판을 믿지 마라
"멕시칸 음식점"에 치킨집이 나오던 사고, 기억하시죠. 원인은 메뉴 토큰이었습니다. 그 치킨집 메뉴판에 "멕시칸치폴레치킨" 이 있었거든요. 글자만 보면 완벽한 "멕시칸" 매칭입니다.
그래서 요리 종류(멕시칸·이탈리안·일식…)는 메뉴를 아예 안 보고 네이버 카테고리만 쓰도록 바꿨습니다. 반대로 요리명(타코·삼겹살·라멘)은 카테고리에 안 잡히는 집이 많아 메뉴 토큰을 함께 봅니다 — 축마다 최선의 출처가 다르다는 원칙이 같은 "요리"라는 큰 덩어리 안에서도 갈린 셈입니다.
"회"는 아예 특별 취급이 필요했습니다. 회식·회전초밥·OO회까지 걸려서, 구체적인 회 요리 이름으로만 매칭하도록 좁혔습니다. 한국어 검색에서 짧은 단어는 거의 항상 이런 대가를 치릅니다.
"정밀도 0%" — 텍스트 신호를 통째로 버린 날
숫자 이야기를 하기 전에 지표부터 짚고 갑니다.
P@8 (Precision@8, 정밀도): 상위 8개 결과 중 실제로 맞는 것의 비율. P@8 = 29%면 8개 중 2~3개만 정답이라는 뜻이고, 100%면 8개 전부 정답입니다. 유추카는 사람이 라벨링한 정답셋과 Claude 판정을 함께 씁니다.
품질 축에서 가장 인상적인 실패가 파인다이닝이었습니다. 리뷰·본문 텍스트에서 "파인다이닝" 신호로 거르니 정밀도가 0% 였습니다. 하나도 못 맞혔다는 뜻입니다.
리뷰를 열어보고 이유를 알았습니다.
"여긴 파인다이닝급은 아니지만 가성비가…" "파인다이닝 부럽지 않은 플레이팅" "파인다이닝 가기 전에 여기서 한잔"
"파인다이닝"이라는 단어가 나온다는 건 오히려 그 집이 파인다이닝이 아니라는 신호에 가까웠습니다. 진짜 파인다이닝 리뷰엔 그 단어를 굳이 안 씁니다. 당연하니까요. 단어를 세는 방식으로는 절대 풀 수 없는 문제였습니다.
스페셜티 커피에서도 같은 벽에 부딪혔습니다. 처음엔 신호를 정교하게 다듬어 보려 했습니다. 키워드를 좁히고, 베이커리 카테고리를 제외하고… 두 번 시도했고 두 번 다 폐기했습니다. 빵집인데 커피가 유명한 곳(트웰브 같은), 핸드드립만 하는 동네 카페 — 이런 오탐이 구조적으로 계속 나왔기 때문입니다. 규칙을 하나 더 붙이면 다른 구멍이 열렸습니다.
그래서 텍스트 신호를 통째로 버렸습니다. 대신 색인 시점에 LLM이 문맥을 읽고 판정한 품질 라벨만 게이트로 썼습니다. 결과:
스페셜티 커피 P@8: 29% → 100%
여기서 진짜 교훈은 "LLM이 좋다"가 아닙니다. 신호가 애초에 잘못된 신호였다는 것입니다. 잘못된 신호는 아무리 정교하게 다듬어도 잘못된 신호입니다. 그럴 땐 다듬지 말고 버려야 합니다.
말랑한 검색을 숫자로 고정하기
업종·시설·품질은 그래도 경계가 뚜렷합니다. 문제는 "인스타 감성", "데이트하기 좋은", "조용한" 같은 말랑한 검색이었습니다. 이건 네이버 태그에도 없고, 참/거짓으로 딱 떨어지지도 않습니다.
여기엔 점수화로 접근했습니다.
1단계 — 미리 채점해 둡니다. 모든 장소에 대해 LLM이 188개 feature(date_spot, photo_zone_spot, quiet_space, panoramic_viewpoint, hanok_aesthetic …)를 리뷰를 근거로 0.5~1.0 점수로 채점해 저장합니다. 해당 없는 feature는 아예 안 매기고, 매긴 것만 남깁니다.
2단계 — 검색어를 feature로 번역합니다.
| 검색어에 이 말이 있으면 | 이 feature를 요구 |
|---|---|
| 인스타·인생샷·포토존·감성 | photo_zone_spot, instagrammable, unique |
| 데이트·소개팅 | date_spot, romantic_lighting |
| 조용한·한적한 | quiet_space |
3단계 — 임계값으로 자릅니다. concepts.<feature> ≥ 0.6. 여러 feature 중 하나만 넘으면 통과(OR), 검색어에 의도가 여러 개면 전부 만족해야 통과(AND).
즉 "인스타 감성 카페"라는 말랑한 문장이, 뒤에서는 photo_zone_spot ≥ 0.6이라는 딱딱한 부등식으로 바뀝니다. 결과:
인스타 카페 P@8: 29% → 100%, 데이트 29% → 50%
10곳 중 3곳만 맞던 게 거의 전부 맞게 됐습니다. (이 188-feature를 어떻게 채점하고 관리하는지는 기술 6편에서 따로 다룹니다.)
축이 직교하면 조합이 공짜다
축으로 쪼갠 덕에 예상 못 한 이득이 하나 따라왔습니다. 조합 검색이 저절로 됩니다.
"주차 되는 한옥 카페"를 처리할 때 별도 로직이 필요 없습니다. 세 축의 조건을 그냥 AND로 붙이면 끝입니다.
업종 = 카페 AND 편의시설 ∋ 주차 AND 공간스타일 ∋ 한옥
이게 가능한 건 축들이 직교(orthogonal) 하기 때문입니다 — 편의시설 어휘(주차·발렛·단체·유아의자·와이파이)와 공간스타일 어휘(한옥·정원·루프탑·한강뷰)는 단어가 하나도 겹치지 않습니다. 겹치지 않으니 서로 간섭하지 않고, 간섭하지 않으니 조합이 안전합니다. 축을 나눌 때 "어휘가 안 겹치게" 나눈 게 나중에 조합 검색을 공짜로 만들어 준 셈입니다.
새 축을 추가하는 비용을 '빈 하나'로
품질 축은 파인다이닝·미슐랭·스페셜티 커피로 시작했지만, 운영하다 보면 계속 늘어납니다. 그런데 처음엔 축 하나 추가할 때마다 분류 코드·백필 스크립트·스케줄 등록·신규 장소 처리를 전부 손봐야 했습니다.
그래서 레지스트리 패턴으로 정리했습니다. 품질 분류기는 인터페이스 하나(tierName / 후보 조건 / 판정)를 구현한 빈(bean) 하나면 되고, 서비스가 등록된 분류기를 자동으로 수집합니다. 새 축 = 클래스 하나 추가로 백필·일일 스케줄·신규 장소 자동 분류에 전부 자동 편입됩니다.
여기서 사소해 보이지만 중요한 장치가 하나 있습니다. "평가했는데 아니었다"와 "아직 평가 안 했다"를 구분해 기록합니다. 이 구분이 없으면 파인다이닝이 아닌 장소를 매일 밤 다시 LLM에 물어보게 됩니다. 6천 곳을 매일 재판정하는 비용은 농담이 아닙니다.
그런데 이 하드게이트를, 우리는 사흘 뒤에 풀었다
여기까지가 성공담입니다. 이제 반전입니다.
하드게이트를 켜고 정밀도는 확실히 올랐는데, 얼마 지나지 않아 이상한 제보가 들어왔습니다. "카페 산" 을 검색하면 분명히 산 전망이 좋은 카페인데 결과에 없다는 것이었습니다.
원인을 추적해 보니 그 카페는 마운틴뷰 태그가 안 달려 있었습니다. LLM 분류가 놓친 거죠. 그리고 하드게이트는 태그가 없으면 정답이든 아니든 무조건 자릅니다. 게이트가 제 일을 너무 충실히 한 겁니다.
이게 얼마나 큰 문제인지 재보기로 했습니다. 정답셋 19개 쿼리로 "정답인데 결과에 안 나온 장소들이 대체 어느 단계에서 죽었는지" 를 전수 귀인했습니다. 결과:
| 정답이 죽은 지점 | 비율 |
|---|---|
| 리랭크 창 밖이라 평가조차 못 받음 | 50% |
| 태깅 하드게이트에 잘림 | 31% |
| 기타 | 19% |
우리가 만든 게이트가 정답의 31%를 죽이고 있었습니다.
여기서 재현율(recall) 이라는 반대편 지표가 등장합니다.
정밀도(precision)와 재현율(recall): 정밀도는 "보여준 것 중 맞는 비율", 재현율은 "맞는 것 중 보여준 비율"입니다. 아무것도 안 보여주면 정밀도는 100%지만 재현율은 0%입니다. 둘은 대체로 서로를 잡아먹습니다. 하드게이트는 정밀도를 사는 대신 재현율을 지불하는 거래였습니다.
그래서 게이트를 소프트게이트로 바꿨습니다.
- 태그를 통과한 후보는 무조건 상위에 둡니다 (하드게이트 시절의 정밀도를 그대로 유지).
- 태그가 없는 후보는 자르지 않고 뒤에 남겨둡니다.
- 뒤에 남은 후보는 넓어진 리랭크 창에서 LLM이 근거를 직접 확인해 최종 판정합니다.
즉 "태그가 없다"를 사형이 아니라 후순위로 강등한 겁니다. 태그 누락으로 억울하게 죽던 정답은 LLM이 리뷰 원문을 읽고 구제합니다. 측정 결과:
| 지표 | 하드게이트 | 소프트게이트 |
|---|---|---|
| Recall@10 | 17.5% | 28.5% (+11.0p) |
| Recall@20 | 35.9% | 45.2% |
| 후보 단계 재현율 | 62.9% | 87.5% |
| 정밀도 위반(오탐) | 3/83 | 2/83 (악화 없음) |
재현율이 크게 오르는데 정밀도가 안 나빠졌습니다. 통과자를 여전히 위에 두기 때문입니다. 태깅 하드게이트에 잘리던 정답도 21건 → 9건으로 줄었고, 남은 9건은 대부분 "검색 영역 밖"이라는 정당한 컷이었습니다.
무엇을 하드로 남기고 무엇을 풀었나
그렇다고 전부 푼 건 아닙니다. 기준은 하나였습니다 — 라벨 출처를 얼마나 믿을 수 있나.
| 하드로 유지 | 소프트로 전환 |
|---|---|
| 업종 카테고리, 요리·메뉴 게이트 | 공간/뷰 스타일(venueStyles) |
| 편의시설(네이버 권위 태그) | 추상 의도 점수(concepts) |
| 숙박 제외, 지도 영역 |
네이버가 준 사실 정보는 하드로 남기고, LLM이 붙인 태그는 소프트로 풀었습니다. 주차장이 있다는 태그가 틀릴 확률은 낮지만, "이 카페는 한옥이다"라는 LLM 판단이 누락될 확률은 꽤 높으니까요. 즉 게이트의 강도를 그 출처의 신뢰도에 맞춘 것입니다.
한 가지 예외도 뒀습니다. 임베딩을 안 쓰는 폴백 경로(어휘 검색·평점순)에는 리랭커 검증이 없으므로 게이트를 다시 하드로 되돌립니다. 뒤에서 걸러줄 사람이 없으면 앞에서 막아야 하니까요.
남은 한계
- 데이트는 29 → 50%에 그쳤습니다. 식당의
photo_zone_spot라벨이 전국에 42건뿐이라, 게이트를 걸면 정밀해지긴 하는데 애초에 라벨된 후보가 부족합니다. 필터는 있는 걸 골라낼 뿐, 없는 걸 만들어 주지는 않습니다. - 소프트게이트는 병목을 옮겼을 뿐입니다. 게이트 컷이 줄자 다음 병목이 드러났습니다 — 정답이 결과에 들어오긴 하는데 10위 밖에 있는 경우가 12건 → 32건으로 늘었습니다. 문제가 "못 불러온다"에서 "불러왔는데 순서가 나쁘다"로 이동한 겁니다. 좋은 이동이지만, 아직 끝은 아닙니다.
- 그리고 라벨 커버리지 자체를 올리는 일이 남았습니다. 그게 다음 편입니다.
이 글의 결론
- 임베딩의 '비슷함'은 감자탕집과 한옥 카페를 구분하지 못합니다. 이런 축은 정렬이 아니라 필터로 걸러야 합니다.
- 검색 조건을 축으로 쪼개고, 축마다 가장 정확한 출처를 씁니다. 네이버가 주는 건 네이버를, 안 주는 것만 LLM을 — 모든 걸 LLM에 맡기지 않는 게 핵심입니다.
- 무거운 판단은 검색할 때가 아니라 색인할 때 한 번. 검색은 결정적 매칭만 합니다.
- 파인다이닝 정밀도 0% 가 알려준 건 "더 정교한 규칙이 필요하다"가 아니라 "이 신호는 버려야 한다" 였습니다. 텍스트를 버리고 LLM 검증 라벨만 쓰자 스페셜티·인스타 P@8 29% → 100%.
- 하드게이트는 정밀도를 사고 재현율을 지불하는 거래입니다. 실제로 정답의 31% 를 죽이고 있었고, 소프트게이트로 풀어 Recall@10 17.5% → 28.5% 를 정밀도 손실 없이 회수했습니다.
- 게이트의 강도는 라벨 출처의 신뢰도에 맞춥니다. 네이버 사실 정보는 하드, LLM 태깅은 소프트.
다음 글(기술 5)에서는 그 라벨 커버리지를 끌어올린 문서 청크 임베딩 — 리뷰 한 문장에 묻힌 정답을 살리고, 그걸 '정렬'에 쓰려다 오히려 무너진 실패까지 — 를 봅니다.