AI로 장소 검색 서비스 만드는 중입니다 (기술 6) — 라벨링 데이터 공장: 6천 곳을 사람 없이 분류하기

기술 4편의 하드게이트도, 5편의 청크도 각 장소에 라벨이 붙어 있어야 작동합니다. 그런데 라벨은 누가 붙일까요. 장소는 6천 곳이 넘고 매일 늘어납니다. 이번 글은 그 라벨을 사람 없이 채우는 데이터 공장 이야기 — 그리고 LLM이 자꾸 없는 바다를 만들어내던 사건 — 입니다.
라벨이 없으면 게이트도 없다
기술 4편에서 "한옥 카페"는 venueStyles에 '한옥'이 붙은 장소만 통과시킨다고 했습니다. 깔끔한 설계입니다. 그런데 그 글에서 안 한 이야기가 있습니다. 처음엔 그 라벨이 없었습니다.
얼마나 없었냐면, 이랬습니다.
전라도 지역 식당 187곳 중, 태그에 '한옥'이 붙어 있는 곳: 1곳
전라도에 한옥 식당이 한 곳일 리 없습니다. 색인에 "이 장소가 어떤 건물·공간인가"라는 신호 자체가 없었던 겁니다. 그러니 하드게이트를 걸 근거도 없었고, 임시방편으로 "리뷰 텍스트에 '한옥'이라는 글자가 있으면 통과"를 쓰고 있었습니다. 그 결과가 기술 4편에서 본 사고들이었고요.
결국 검색 품질 문제의 상당수는 검색 알고리즘 문제가 아니라 라벨 부재 문제였습니다. 그래서 라벨을 만드는 공장을 지어야 했습니다.
첫 번째 문제 — 8,070개의 라벨은 라벨이 아니다
라벨이 아예 없던 건 아닙니다. 리뷰에서 속성(aspect)을 뽑아내는 파이프라인은 이미 있었고, 꽤 열심히 뽑고 있었습니다. 8,070종을 뽑았습니다.
그게 문제였습니다.
| 실제로 뽑힌 라벨들 | 건수 |
|---|---|
| 주차 | 2,743 |
| 주차장 | 1,157 |
| 친절 | 548 |
| 친절함 | 278 |
| 직원친절 | 279 |
같은 뜻인데 다 다른 라벨입니다. "주차 되는 곳"을 검색하면 '주차'가 붙은 2,743곳은 걸리고 '주차장'이 붙은 1,157곳은 안 걸립니다. 친절은 세 갈래로 쪼개져 각각 절반씩 놓칩니다. 라벨이 8,070종이라는 건 사실상 라벨이 없는 것과 비슷합니다 — 검색어와 라벨이 정확히 같은 글자여야만 만나니까요.
그래서 닫힌 집합으로 접근을 바꿨습니다.
닫힌 집합(closed set) 분류: LLM에게 "이 장소의 특징을 자유롭게 뽑아줘"라고 하지 않고, 미리 정한 목록을 주고 그 안에서만 고르게 하는 방식. 표현이 갈라질 여지가 없어지고, 검색어 쪽도 같은 목록으로 번역하면 항상 만납니다. 대신 목록에 없는 개념은 영영 못 잡으니, 목록을 잘 짜는 게 일의 절반입니다.
8,070개의 자유 라벨을 188개의 정규 속성으로 묶었습니다. '주차'와 '주차장'은 하나의 parking_available로, '친절/친절함/직원친절'은 하나의 friendly_staff로 모입니다. 각 정규 속성은 한글 이름과 동의어 목록을 갖고 있어서, 새 표현이 들어와도 같은 자리로 정규화됩니다.
여기엔 사소하지만 잊기 쉬운 함정이 하나 있었습니다. "웨이팅"이라는 속성은 부호가 반대입니다. 리뷰에서 웨이팅이 긍정적으로 언급되면(= 대기가 짧다) 좋은 신호지만, 부정적으로 언급되면(= 대기가 길다) 나쁜 신호입니다. 다른 속성처럼 "언급되면 가산"으로 처리하면 줄이 제일 긴 집이 '웨이팅 짧은 집'으로 검색됩니다. 이런 반전 속성은 따로 표시해 뒀습니다.
두 번째 문제 — "한옥"과 "한옥 느낌"은 다르다
공간·건물 축(venueStyles)은 33종의 닫힌 집합으로 정의했습니다 — 한옥·루프탑·테라스·정원·유럽풍·지중해풍·노포·오마카세 등. 그리고 LLM에게 각 장소를 이 목록으로 분류시켰습니다.
첫 결과에서 곧바로 문제가 나왔습니다. 감자탕집이 '한옥'으로 분류됐습니다.
리뷰를 열어보니 이런 문장이 있었습니다.
"천장에 한옥 서까래 느낌으로 인테리어를 해놔서 분위기가 좋아요"
한옥이 아니라 한옥 느낌입니다. 한옥 인테리어를 흉내 낸 현대식 건물이죠. 그런데 "한옥 식당"을 검색하는 사람은 진짜 한옥을 원합니다. 인테리어 소품이 아니라요.
그래서 분류 프롬프트에 이 구분을 첫 번째 규칙으로 박았습니다.
"한옥"과 "한옥 느낌 / 한옥풍 / 한옥 스타일"은 다르다 — 실제 건물만 분류한다. 음식 종류는 스타일이 아니다. 추측 금지. 0~2개만 고른다.
"추측 금지"와 "0~2개"가 중요합니다. LLM은 빈 답을 어색해해서, 놔두면 어떻게든 뭔가를 채워 넣으려 합니다. 아무것도 해당 없으면 아무것도 고르지 않는 게 정답이라고 명시해야 빈 배열을 냅니다.
표본 60건으로 검증한 결과입니다.
| 장소 | 분류 결과 |
|---|---|
| 굴피집(한식) | 한옥, 야외 |
| 스타벅스 더북한산 | 테라스, 대형 |
| 블루보틀 | 모던, 통유리 |
| 교동떡갈비(전주 한옥마을) | 한옥 |
| 뼈통감자탕(한옥 서까래 느낌) | (없음) ← 의도한 결과 |
LLM이 자꾸 없는 바다를 만들어냈다
뷰(전망)도 같은 방식으로 분류했습니다 — 오션뷰·한강뷰·강뷰·마운틴뷰·시티뷰·야경. 그런데 여기서 훨씬 고약한 문제를 만났습니다.
LLM은 "전망이 좋다"는 잘 맞혔지만, 그게 무슨 전망인지를 자주 틀렸습니다.
- 내륙 한복판의 교회를 오션뷰로 분류
- 한강변 카페를 오션뷰로 분류
- 산자락 카페를 오션뷰로 분류
바다가 없는 곳에 자꾸 바다를 만들어냈습니다. 그래서 프롬프트에 규칙을 추가했습니다.
"뷰는 그 장소에서 실제로 보이는 전망만. 나들이·주변·다른 장소 이야기는 제외. 오션뷰 = 바다만(호수·저수지·강은 강뷰)."
그런데도 안 지켰습니다. 규칙을 더 강하게 써도, 예시를 붙여도 마찬가지였습니다. 프롬프트로 못 고치는 종류의 오류였습니다.
그래서 접근을 바꿨습니다. 프롬프트로 안 되면 코드로 검증한다.
분류기가 LLM 출력을 받은 뒤, 뷰 라벨에 대해서만 근거를 직접 확인합니다.
LLM이 '오션뷰'라고 답함
↓
근거 텍스트(상호 + 카테고리 + 태그 + 리뷰)에 '바다·오션·해변'이 실제로 있나?
├─ 있다 → 오션뷰 유지
└─ 없다 → 그 라벨을 삭제
'한강'이 있으면 한강뷰로, '호수·저수지'가 있으면 강뷰로, '산뷰·숲뷰·계곡뷰'면 마운틴뷰로 — 근거 키워드와 라벨을 결정적으로 대조하고, 근거가 없으면 그냥 지웁니다. 재현율을 조금 잃더라도 정밀도를 택한 겁니다.
실데이터 검증 결과:
| 장소 | LLM 출력 | 교정 후 |
|---|---|---|
| 광진교 8번가 | (뷰 있음) | 한강뷰 ✓ |
| 수원제일교회 | 오션뷰 | 삭제 ✓ |
| 더숲, 1인1잔 | (뷰 있음) | 마운틴뷰 ✓ |
| 고복저수지 | 오션뷰 | 강뷰 ✓ |
| 모이핀 오션점 | 오션뷰 | 오션뷰 유지 ✓ |
이 작업에서 얻은 원칙이 하나 있습니다. LLM이 잘하는 일과 못하는 일을 나눠 맡겨라. "이 장소에 전망이 있는가"는 문맥 판단이라 LLM이 잘합니다. "그 전망이 바다인가"는 사실 확인이라 코드가 잘합니다. 둘을 한 번에 시키니 실패했고, 나눠 맡기니 됐습니다.
입력을 잘못 주면 아무리 좋은 모델도 못 맞힌다
한동안 원인을 못 찾은 누락이 하나 있었습니다. 동락식당 — 사진만 봐도 명백한 한옥인데, 분류 결과가 계속 빈 값이었습니다.
프롬프트를 의심했고, 모델을 의심했습니다. 답은 입력에 있었습니다.
분류기에 넣는 텍스트(searchText)는 리뷰의 투표 키워드로 앞부분이 채워져 있었고, 정작 "기와지붕이 멋있다" 같은 한옥 묘사는 입력 길이 제한 밖으로 밀려나 있었습니다. 모델은 한옥이라는 단서를 본 적이 없었습니다.
그래서 입력에 별도로 추출해 둔 태그("한옥/기와집", "뷰")를 함께 넣었습니다. 동락식당은 곧바로 한옥으로 분류됐고, 뼈통감자탕은 여전히 빈 값이었습니다(정밀도 유지). 모델을 바꾸기 전에 모델이 뭘 보고 있는지부터 봐야 했습니다.
말랑한 축은 참/거짓 대신 점수로
'한옥이다/아니다'는 이분법이 됩니다. 그런데 "인스타 감성", "데이트하기 좋은", "조용한" 은 안 됩니다. 정도의 문제니까요.
그래서 이 축은 점수로 라벨링합니다. 앞에서 만든 188개 정규 속성 목록을 통째로 LLM에게 메뉴판처럼 보여주고, 리뷰를 근거로 해당하는 것만 0.5~1.0으로 채점하게 합니다. 실제 프롬프트는 짧습니다.
장소 리뷰를 읽고, 아래 특성 목록 중 리뷰 근거로 '분명히 해당하는' 것만 0.5~1.0으로 채점한다. 근거 없는 특성은 출력하지 않는다(생략). 점수 = 근거 강도(1.0 = 강하게 반복 언급, 0.5 = 약하게 시사). 추측 금지 — 리뷰에 단서가 있는 것만. 출력은 JSON 객체 하나만.
여기서도 방어가 두 겹입니다. 프롬프트로 "추측 금지"를 걸고, 파싱 단계에서 목록에 없는 id와 0.5~1.0 밖의 값을 버립니다. LLM이 존재하지 않는 속성을 지어내도 코드가 걸러냅니다. 앞의 뷰 교정과 같은 철학입니다.
실제 결과는 이렇게 나옵니다.
- 산모퉁이 →
panoramic_viewpoint,rooftop_space - 예전한정식 →
korean_courtesy_meal,hanok_aesthetic
그리고 검색 시점에 concepts.<속성> ≥ 0.6으로 자릅니다. "인스타 감성 카페"라는 말랑한 문장이 딱딱한 부등식이 되는 지점이고, 그 효과가 기술 4편의 P@8 29% → 100%였습니다.
여담: 이 채점은 원래 외부 파이썬 스크립트로만 돌렸는데, 그 스크립트가 유실되는 사고가 있었습니다. 필드는 DB에 남아 있는데 새 장소를 채울 방법이 없어진 거죠. 결국 백엔드로 다시 포팅해 운영을 일원화했습니다. 핵심 파이프라인은 코드로 남겨라 — 값비싸게 배운 교훈입니다.
"모름"과 "아님"을 구분하기
공장을 매일 돌리려면 "이미 처리한 것"을 정확히 알아야 합니다. 여기서 함정이 하나 있습니다.
파인다이닝 분류를 돌렸는데 어떤 식당이 파인다이닝이 아니라고 판정됐다고 합시다. 이 장소의 품질 라벨은 비어 있습니다. 그런데 라벨이 비어 있는 상태는 두 가지 뜻일 수 있습니다.
- "평가했고, 아니었다"
- "아직 평가 안 했다"
이 둘을 구분하지 않으면, 매일 밤 스케줄러가 "라벨이 없네? 평가해야지" 하며 6천 곳 전부를 다시 LLM에 물어봅니다. 결과는 매번 똑같고 돈만 나갑니다.
그래서 "평가한 축"을 따로 기록합니다. 라벨이 비어 있어도 평가 기록이 있으면 건너뜁니다. 같은 원리가 점수 채점에도 적용됩니다 — 리뷰가 없거나 LLM 호출이 실패해서 판정 자체를 못 한 경우는 비워둬서 다음 실행에 다시 시도하고, 정상 판정 결과가 빈 목록인 경우는 빈 목록을 저장해 완료 표시를 합니다. null과 {}가 다른 뜻입니다.
배치 파이프라인에서 이 구분은 생각보다 자주 필요합니다. "결과 없음"과 "실행 안 함"을 같은 값으로 저장하는 순간, 재시도와 건너뛰기를 동시에 만족시킬 수 없게 됩니다.
공장을 돌리는 법 — 색인 즉시와 매일 새벽
라벨을 붙이는 타이밍은 둘로 나뉩니다.
① 색인 즉시 (장소가 들어오는 순간): 네이버 카테고리, 편의시설 태그, 메뉴 토큰, 속성, 임베딩, 썸네일. LLM 판단이 필요 없거나 검색에 즉시 필요한 것들입니다.
② 매일 새벽 4시 30분 (크롤·정제가 끝난 뒤): 공간/뷰, 품질 등급, 188-속성 채점. LLM이 필요한 것들입니다. 세 분류를 순서대로 돌리고, 각각은 실패해도 다음 것이 이어서 돕니다.
배치는 전부 증분입니다.
증분(incremental) + 멱등(idempotent): 증분은 "아직 처리 안 한 것만" 처리한다는 뜻이고, 멱등은 "같은 배치를 몇 번 다시 돌려도 결과가 같다"는 뜻입니다. 배치가 중간에 죽어도 그냥 다시 실행하면 되니, 긴 배치를 운영할 때 사실상 필수입니다.
전체를 처음 한 번 채우는 백필은 순서와 비용이 있었습니다. 편의시설(약 3시간) → 공간/뷰 → 품질 → 속성 채점. 가장 비쌌던 건 속성 채점 전량으로, 6,041곳에 약 $31이었습니다. 기술 2편의 "비싼 건 실시간이 아니라 일회성 배치, 그리고 그 배치는 생각보다 싸다"가 여기서도 그대로였습니다.
병렬로 돌렸더니 오히려 느려졌다
백필을 빨리 끝내려고 당연히 병렬화를 시도했습니다. 4개 동시 요청. 결과는 예상 밖이었습니다.
| 방식 | 처리량 |
|---|---|
| 순차 (1개씩) | 40건/분 |
| 4병렬 | 26건/분 |
병렬이 35% 더 느렸습니다.
원인은 당시 분류에 쓰던 로컬 LLM이 GPU 한 장짜리 Ollama였기 때문입니다. 동시 요청이 와도 실제로는 GPU에서 직렬로 처리되고, 대신 요청을 붙들어 두는 오버헤드와 컨텍스트 전환 비용만 추가됩니다. 병렬화는 뒤에 여러 일꾼이 있을 때만 이득인데, 일꾼이 하나면 줄만 복잡해지는 거죠.
병렬화를 걷어내고 순차로 돌렸습니다. 6,365건에 2.5~3시간. (같은 이유로 클라우드 모델을 쓰는 지금의 채점 서비스는 4개 동시 처리를 씁니다 — 저쪽은 일꾼이 여럿이니까요.)
새 축을 추가하는 비용
품질 등급 축을 하나 더 넣고 싶을 때, 분류기 클래스 하나만 추가하면 됩니다. 이름·후보 조건·판정 로직 세 가지만 구현하면 서비스가 자동으로 수집해서 백필·매일 스케줄·신규 장소 처리에 전부 편입시킵니다. 스케줄러 코드는 손대지 않습니다. (기술 4편에서 짧게 언급한 그 레지스트리 패턴입니다.)
"새 검색 기능 하나 = 코드 한 조각" 이 되도록 만드는 게 목표였습니다. 축을 늘릴 때마다 배포 절차가 늘어나면, 결국 축을 안 늘리게 되니까요.
애매한 건 사람에게 — 검토 큐
자동화의 목표는 사람을 없애는 게 아니라 사람이 애매한 것만 보게 하는 것이었습니다.
영상에서 장소를 추출해 네이버와 매칭하는 과정에서(기술 10편) 확신이 안 서는 후보가 쌓입니다. 한때 1,793건이 밀려 있었습니다. 사람이 전수 검토하기엔 너무 많았고, 그렇다고 자동 승인하기엔 위험했습니다.
그래서 자동 심사를 3단계로 나눴습니다.
- 네이버에서 아예 못 찾은 것(804건) — 재검색까지 해도 0건이면 좌표도 주소도 없어 지도에 올릴 방법이 없습니다. LLM을 부르지 않고 규칙으로 반려합니다. 검토할 실익이 없는 건 사람에게도 안 보냅니다.
- 찾긴 했는데 확신이 없는 것(989건) — 여기만 LLM에게 물어봅니다. 추출된 정보(이름·지역·영상 발췌)와 네이버 결과(상호·업종·주소)를 나란히 보여주고 한 단어로 답하게 합니다:
SAME/DIFFERENT/NOT_A_PLACE/UNSURE. UNSURE와 LLM 실패는 사람에게 남깁니다. 이게 핵심입니다. LLM에게 "모르겠다"고 말할 선택지를 명시적으로 주면, 모를 때 찍는 대신 모른다고 합니다. 보수적 기본값이죠.
989건을 처리하는 데 약 20분, 수십 센트였습니다. 그리고 반려할 때는 사유를 ... (was: 원래 사유) 형태로 원래 사유를 보존합니다. 나중에 "이거 왜 반려됐지"를 되짚거나 복구할 수 있게요.
관리자가 실제로 하는 일은 이제 남은 애매한 것들입니다 — 같은 이름의 다른 가게 고르기, 폐업 처리, 그리고 추출이 "샤퀴테리" 같은 업종어를 상호로 오인한 걸 바로잡기. 고치면 즉시 재색인됩니다.
남은 한계
- 재현율은 결국 리뷰가 뭘 말했느냐에 달려 있습니다. 여수 돌산의 스타벅스는 명백한 오션뷰인데, 리뷰에 바다 이야기가 충분히 없어 라벨이 안 붙었습니다. 근거 없으면 지운다는 규칙의 대가입니다 — 정밀도를 택한 대신 이런 누락을 받아들였습니다. (이 누락을 나중에 메운 게 기술 5편의 청크 임베딩입니다.)
- 드문 오분류는 남아 있습니다. 제물포구락부가 '하와이안'으로 분류된 적이 있습니다. 목록 가지치기와 프롬프트 보강으로 줄여가는 중입니다.
- 닫힌 집합의 근본 한계 — 33종 목록에 없는 공간 유형은 영영 못 잡습니다. 목록을 늘리면 잡히지만, 목록이 길어질수록 LLM의 선택 정확도가 떨어집니다. 이 균형점은 아직 감으로 잡고 있습니다.
이 글의 결론
- 검색 품질 문제의 상당수는 알고리즘이 아니라 라벨 부재였습니다. 전라도 식당 187곳 중 한옥 태그가 1곳이던 상태에선 어떤 게이트도 못 겁니다.
- 자유롭게 뽑은 8,070개 라벨은 라벨이 아닙니다('주차' 2,743 vs '주차장' 1,157). 닫힌 집합 188개로 정규화해야 검색어와 만납니다.
- LLM에게 잘하는 것만 시킵니다. "전망이 있는가"는 LLM, "그 전망이 바다인가"는 코드. 프롬프트로 세 번 실패한 걸 근거 대조 코드가 한 번에 고쳤습니다.
- 모델을 의심하기 전에 입력을 확인합니다. 동락식당이 한옥으로 안 잡힌 건 모델 탓이 아니라 단서가 입력에 안 들어가서였습니다.
- 배치는 "모름"과 "아님"을 구분해야 합니다. 안 그러면 매일 밤 6천 곳을 다시 물어봅니다.
- 병렬화가 항상 빠른 건 아닙니다 — GPU 한 장 뒤에서는 순차가 35% 빨랐습니다.
- 자동화의 목표는 사람을 없애는 게 아니라 애매한 것만 남기는 것. 1,793건 중 사람이 볼 것만 남겼습니다.
다음 글(기술 7)에서는 이 모든 개선을 무엇으로 믿었나 — 사람 라벨 정답셋(gold)과 "정답이 어느 단계에서 탈락했는지" 추적하는 recall 귀인 — 을 봅니다.