AI로 장소 검색 서비스 만드는 중입니다 (기술 7) — 평가를 어떻게 믿나: 정답셋(gold)과 recall 귀인

t7-funnel

이 연재에는 "좋아 보였지만 재보니 손해였다"가 계속 나옵니다. 로컬 리랭커, 청크 정렬, LLM 재정렬… 그런데 그걸 무엇으로 판정했을까요? 이번 글은 그 자(尺) 이야기입니다. 그리고 그 자가 흔들려서 아무것도 못 재던 시절과, 자를 고정하다 발견한 정답셋 자체의 오류들을 함께 다룹니다.

"결과 괜찮은데?"는 측정이 아니다

검색 품질에는 고약한 성질이 하나 있습니다. 눈으로 보면 대체로 다 좋아 보입니다.

새 기법을 넣고 검색어 서너 개를 쳐봅니다. 결과가 그럴듯합니다. "오, 좋아졌네." 그런데 이 판단에는 문제가 셋 있습니다. 내가 고른 검색어로, 내가 아는 정답을, 개선했다고 믿고 싶은 마음으로 본 겁니다. 확증 편향이 세 겹으로 쌓입니다.

실제로 이 연재에서 "좋아 보였던" 기법들 — 로컬 리랭커, 청크 정렬(78.8 → 71.0), 피처 가중치 상향, LLM 점수 재정렬 — 은 재보니 전부 손해였습니다. 측정 인프라가 없었다면 넷 다 그대로 들어갔을 거고, 저는 품질을 깎으면서 개선했다고 믿었을 겁니다.

그래서 검색 자체보다 자를 만드는 데 먼저 공을 들였습니다. 그리고 그게 생각보다 어려웠습니다.

처음엔 자를 만들었는데, 그 자가 흔들렸다

정답셋을 만들고 평가 러너를 돌렸습니다. 그리고 곧바로 이상한 일이 벌어졌습니다.

코드를 하나도 안 바꾸고 두 번 돌렸는데 결과가 달랐습니다.

같은 코드, 같은 정답셋 1회차 2회차
오탐 위반 36건 43건
capped Recall@10 81.2% 79.9%

오탐이 ±7건, 재현율이 ±1.3%p씩 왔다 갔다 했습니다. 개선하려는 기법의 효과보다 측정 노이즈가 더 컸습니다. 이 상태에서는 "+1%p 올랐다"가 아무 의미가 없습니다. 그냥 주사위를 굴린 것과 구분이 안 되니까요.

원인은 파이프라인 곳곳에 LLM이 박혀 있기 때문이었습니다. 검색어를 해석하는 파서도 LLM이고, 후보를 판정하는 리랭커도 LLM입니다. LLM은 같은 입력에 매번 같은 답을 하지 않습니다. 그러니 파이프라인 전체가 매번 조금씩 다른 시스템이 됩니다.

측정을 하려면 이걸 먼저 잡아야 했습니다. 세 단계로 고정했습니다.

① 판정을 캐시한다

리랭커의 판정 결과를 저장해 재사용했습니다. 키는 프롬프트 버전 + 검색 조건 + 장소 + 문서 버전입니다. 같은 조건이면 첫 판정을 그대로 씁니다. "노을 카페"와 "노을 보기 좋은 카페"처럼 표현만 다른 검색어는 정규화해서 같은 키를 쓰게 했고요.

효과가 두 방향으로 났습니다.

캐시 전 캐시 후
55쿼리 평가 러너 479초 26초 (18배)
오탐 위반 (2회 반복) 요동 45 = 45 (동일)

부수 효과로 운영에서도 인기 검색어는 리랭크 LLM 호출이 0회가 됐습니다. 측정하려고 만든 장치가 서비스 속도까지 개선한 셈입니다.

② 파싱을 결정화한다

판정을 고정해도 미세한 요동이 남았습니다. 범인은 검색어 파서였습니다.

"가평 근처 카페"의 '근처'를 파서가 자유롭게 해석하고 있었습니다. 어떤 날은 "반경 50km 이동시간"으로, 어떤 날은 "가평 행정구역"으로. 두 해석은 완전히 다른 검색이 됩니다.

프롬프트에 명시적인 규칙을 추가하고, 파싱 결과도 캐시했습니다. 결과:

두 번 연속 실행한 리포트가 바이트 단위로 동일해졌습니다. 러너는 101초 → 7초.

③ 판정을 3표 다수결로

마지막 요동은 리랭커의 판정 자체가 경계선에 있는 경우였습니다. 캐시가 첫 판정을 고정하니 재현은 되는데, 그 첫 판정이 우연히 나쁜 쪽이면 그게 그대로 고착됩니다.

그래서 중요한 판정은 3표를 병렬로 받아 과반으로 정했습니다(동률이면 부적합 — 보수적으로). 표는 병렬로 받으니 지연은 그대로고, 비용 3배는 판정 캐시가 (검색어 × 장소)당 1회로 상각해 줍니다.

1표 3표 다수결
오탐 (독립 2회 측정) 36 ↔ 43 36 = 36 (±0)
capped R@10 79.9 ↔ 81.2 79.6 ↔ 79.8 (±0.2p)

분산이 사실상 사라졌습니다. 이제 개입 하나를 넣고 한 번만 돌려도 효과를 판정할 수 있습니다.

단, 캐시는 재현성만 고정한다

여기서 중요한 단서가 있습니다. 캐시로 얻은 건 "같은 조건이면 같은 답" 이지, "그 답이 맞다" 가 아닙니다.

실제로 이걸 확인할 기회가 있었습니다. 판정 기준(rubric)을 고치면 프롬프트 버전이 바뀌면서 판정 캐시가 통째로 무효화됩니다. 캐시가 빈 상태에서 다시 재면 어떻게 될까요. 다시 ±7의 세계로 돌아갑니다.

그래서 규칙을 하나 더 세웠습니다. 판정 기준을 건드리는 개입은 단일 측정으로 결론 내지 않는다. 표적 사례를 직접 트레이스로 확인하고, 요동 대역(±7)을 넘어서는 효과만 채택합니다. "재현 가능"과 "믿을 수 있다"는 다른 말입니다.

결정성(determinism)이 왜 그렇게 중요한가: A/B 측정의 전제는 "바꾼 것 말고는 다 같다"입니다. 파이프라인이 매번 조금씩 다르면 그 전제가 깨지고, 관측된 차이가 개입 때문인지 운 때문인지 영영 알 수 없습니다. LLM을 파이프라인에 넣는 순간, 결정성은 공짜가 아니라 따로 만들어야 하는 기능이 됩니다.

정답셋 만들기

자를 고정했으니 이제 눈금이 필요합니다. 정답셋(gold) 은 "이 검색어에는 이 장소들이 적합/부적합"을 사람이 판정해 둔 표입니다.

이건 한 번에 만들어지지 않고 계속 자랐습니다.

버전 규모 비고
초기 hand 19쿼리 모드별 2~6개, 눈대중
eval v3 254 = 손 19 + 생성 100 + 합성 135 자동 생성 쿼리 추가
gold v2 55쿼리 · 330 라벨 사람 검수 시작
gold v3 55쿼리 · 1,254 라벨 918건은 모델 자문 판정
gold v4 55쿼리 · 1,261 라벨 (적합 865 / 부적합 396) 사람 검수 완료

라벨을 만들 때 지킨 원칙이 하나 있습니다. 판정 모델을 검색 모델과 다른 계열로 분리했습니다.

자기 편애(self-enhancement bias): LLM에게 자기가 만든 결과를 평가시키면 후하게 줍니다. 연구에 따르면 자기 출력 채점 편향이 16.1% 인 모델이 다른 모델 출력에는 1.16% 였습니다. 자기가 쓴 답안을 자기가 채점하는 셈이라 당연한 결과입니다.

그래서 검색은 우리 파이프라인이 하고, 판정은 다른 계열(Claude) + 사람이 했습니다. 다만 모델이 1차로 붙인 판정은 자문(advisory) 으로 표시해 두고, 사람 검수를 거쳐야 확정으로 올렸습니다. 이 구분이 나중에 중요해집니다.

정답을 다는 도구

앞의 표에서 "330 → 1,254 → 1,261"이라고 한 줄로 썼는데, 그 라벨들을 누가 어떻게 달았는지가 사실 이 글에서 제일 실용적인 부분입니다. 정답셋은 그냥 생기지 않거든요.

라벨링은 지루하고, 지루한 작업은 도구가 나쁘면 사람이 대충 합니다. 그리고 대충 단 라벨은 조용히 자를 망가뜨립니다. 그래서 여기에 시간을 좀 썼습니다.

라벨은 "검색하던 그 화면"에서 단다

가장 먼저 정한 건 별도의 라벨링 화면을 만들지 않는다였습니다.

라벨링 전용 페이지를 만들면 (검색어, 장소) 쌍이 표로 나열되고, 라벨러는 그 표만 보게 됩니다. 그런데 "이 장소가 이 검색어에 맞나"는 장소를 실제로 봐야 판단할 수 있습니다. 사진, 리뷰 발췌, 어떤 유튜버가 뭐라고 소개했는지까지요.

그래서 등록 UI를 장소 상세 화면 안에 넣었습니다(어드민에게만 보입니다). 검색하고, 결과를 열어보고, 그 자리에서 정답/오답을 누릅니다. 판단에 필요한 재료가 이미 화면에 있는 상태에서 라벨이 달립니다.

검색어를 고정하지 않으면 라벨이 흩어진다

여기서 실제로 겪은 문제가 하나 있습니다.

정답셋에서 가장 값진 라벨은 "정답인데 검색 결과에 안 나온 장소" 입니다. 그게 있어야 재현율을 잴 수 있으니까요. 그런데 그 장소를 등록하려면 어떻게 해야 할까요.

결과에 없으니 장소명으로 따로 찾아 열어야 합니다. 그 순간 검색창의 원래 검색어가 날아갑니다.

"아이들과 가기 좋은 곳" 검색 → 은아목장이 안 나오네

"은아목장" 으로 검색해서 상세를 연다

등록하려는데… 원래 검색어가 "은아목장" 으로 바뀌어 있다

여기서 손으로 다시 타이핑하면, 오타 하나에 그 라벨은 존재하지 않는 검색어에 붙습니다. 그리고 아무도 모릅니다. 라벨은 늘었는데 평가에는 안 잡히죠.

그래서 검색어 고정(pin) 을 넣었습니다. "이 검색어로 정답을 모으는 중"이라고 못을 박아두면, 어느 장소를 열든 등록 기본값이 그 검색어로 유지됩니다. 화면 위에 📌 정답 수집 중: 아이들과 가기 좋은 곳 이 떠 있고, 해제 버튼이 옆에 있습니다.

한 검색어의 정답을 몰아서 다는 게 실제 작업 방식인데, 도구가 그 방식을 몰랐던 겁니다.

오답도 1급 시민이다

버튼이 두 개입니다 — 정답으로 등록 / 오답으로 등록.

오답 라벨을 따로 두는 게 중요했습니다. 앞에서 본 오탐 위반 지표의 재료가 이거고, 그게 없으면 "많이 보여주기"로 최적화하는 걸 막을 수 없습니다.

그리고 이 버튼 덕에 특이한 항목이 생겼습니다.

"용인 야경 좋은 식당" — 정답 0건, 오답 24건

사람이 24곳을 열어보고 전부 "아니다"라고 찍은 겁니다. "용인에는 야경 맛집이 없다" 는 판정이죠. 이 검색어의 이상적인 결과는 0건 + 안내입니다. 정답 버튼만 있었으면 이런 라벨은 만들 수 없었습니다.

같은 걸 두 번 눌러도 안전하게

라벨링은 여러 날에 걸쳐 하니, 같은 조합을 다시 만나게 됩니다. 그래서 (검색어, 장소)에 유니크 제약을 걸고, 이미 있으면 새로 만들지 않고 정답/오답만 갱신합니다.

이게 없으면 중복 라벨이 쌓여서 재현율 분모가 부풀고, 어느 날 "정답이 왜 이렇게 많지?" 하며 원인을 찾게 됩니다.

라벨과 "평가할 검색어"를 따로 관리한다

작지만 유용했던 구분이 하나 더 있습니다.

  • 라벨을 하나 달면 그 검색어가 평가 대상 목록에 자동으로 편입됩니다.
  • 반대로 라벨이 0건인 검색어도 목록에 먼저 넣을 수 있습니다. "이건 앞으로 평가하고 싶다"를 선언해 두는 거죠.
  • 목록에서 검색어를 빼도 라벨은 지우지 않습니다. 회귀 데이터니까요.

각 검색어 옆에 정답/오답 건수가 보여서, 어느 검색어가 아직 얇은지가 한눈에 보입니다. 앞에서 "약체 쿼리: 호수뷰 22%, 카공 56%"를 짚을 수 있었던 것도 이 화면 덕입니다.

1,261건을 모아놓고, 채점기가 없었다

여기까지 읽으면 꽤 잘 갖춰진 것 같은데, 한동안 이런 상태였습니다.

데이터 1,261건과 등록 UI는 있는데, 그걸 채점하는 프로그램이 없었다.

평가는 별도의 파이썬 러너로 돌리고 있었고, "정답셋 회귀가 통과 기준이다"라는 규정은 문서에만 있었습니다. 이름뿐인 게이트였죠. 색인 재료를 바꾸는 변경이 들어와도 통과 여부를 말할 수가 없었습니다.

그래서 명령 하나로 도는 채점기를 붙였습니다. 만들면서 정한 것들이 이 글의 다른 교훈과 그대로 이어집니다.

deepRecall을 따로 두는 이유

Recall@10에는 앞에서 말한 상한 문제가 있습니다. 정답이 27개인 검색어는 상위 10개를 다 맞혀도 최대 0.37입니다.

그래서 조회 깊이 전체에서 정답이 살아남았는지를 따로 잽니다.

무엇을 구분하나
Recall@10 정답이 상위 10개에 있나
deepRecall 정답이 후보 안에 남아는 있나

이 둘을 나눈 이유는 회귀의 성질이 다르기 때문입니다.

  • 순위가 밀린 것 → 나중에 정렬을 고치면 되살릴 수 있습니다.
  • 후보에서 아예 사라진 것 → 되살릴 방법이 없습니다. 순위를 아무리 잘 매겨도 없는 걸 올릴 순 없으니까요.

지표를 하나만 보면 이 둘이 섞여서, 훨씬 나쁜 회귀를 못 알아챕니다.

정밀도는 재지 않는다

이 채점기는 정밀도를 아예 안 잽니다. 의도적으로요.

이유는 기술 5편에서 호되게 배운 것과 같습니다. 라벨은 전수가 아니라 표본입니다. 1,261건은 10,499곳 중 일부고, 라벨이 없는 장소가 훨씬 많습니다. 그러니 "라벨 없는 결과 = 오답"으로 셀 근거가 없습니다.

그렇게 세면 청크 임베딩 때처럼 개선했는데 점수가 떨어지는 자를 또 만들게 됩니다.

대신 명시적으로 오답이라고 찍힌 것이 상위에 뜨는지만 봅니다. 그건 라벨이 있는 것에 대한 판단이라 근거가 확실합니다.

표본 라벨로 재현율은 잴 수 있지만 정밀도는 못 잽니다. 재현율의 분모는 "라벨된 정답"이라 표본 안에서 닫히는데, 정밀도의 분모는 "보여준 결과 전부"라 표본 밖으로 나가거든요. 이걸 구분 안 하면 자가 거짓말을 합니다.

그리고 빈 색인을 조용히 채점했다

마지막으로, 이 채점기를 만들자마자 낸 사고 하나.

첫 구현은 앱이 뜨자마자 돌게 해뒀습니다. 그런데 유추카의 검색 인덱스들은 앱 준비가 끝난 뒤에 순차로 메모리에 올라옵니다(기술 9편의 "재시작 후 40~60초"가 그겁니다).

그래서 이런 일이 벌어졌습니다.

12:00:37  평가 러너 시작   ← 색인이 아직 비어 있음

12:01:19 BM25 색인 적재 완료

빈 색인을 채점하고 있었습니다.

무서운 건 실패하지 않았다는 점입니다. 에러도 안 나고, 그럴듯한 숫자가 나옵니다. 그냥 낮게요. 이걸 "이번 변경이 품질을 깎았네"로 읽었으면 멀쩡한 코드를 되돌렸을 겁니다.

색인 적재와 평가 러너에 명시적인 실행 순서를 박아 고쳤습니다. 기술 8편의 "결과가 이상하면 성공도 실패도 아닌 버그를 의심하라"가, 이번엔 나쁜 쪽 숫자로 나타난 경우였습니다.

통과 기준은 관대하게

판정은 세 지표 중 하나라도 기준치를 넘어 나빠지면 실패입니다. 그 기준치를 0.01로 꽤 관대하게 잡았습니다.

검색 경로에 LLM이 두 군데(파싱·리랭크) 끼어 있어서 완전히 결정적이지는 않기 때문입니다. 이 글 앞부분에서 캐시와 다수결로 요동을 없앴다고 했지만, 캐시가 비는 순간(판정 기준을 바꿀 때) 요동은 돌아옵니다.

엄격한 게이트는 거짓 실패를 만들고, 거짓 실패가 반복되면 사람이 게이트를 무시하기 시작합니다. 그게 게이트가 없는 것보다 나쁩니다.

정답셋이 자기 자신을 정답으로 삼을 때

gold v3로 확장하고 다시 쟀더니 capped Recall@10이 88.5% 가 나왔습니다. 목표가 90%였으니 거의 달성입니다.

축하할 일이 아니었습니다.

정답셋을 확장할 때 후보를 어디서 가져왔는지 보면 이유가 보입니다. 두 소스에서 가져왔습니다.

  • 현행 API 상위 12개 — 적합률 86%
  • 오프라인 코사인 상위 8개 — 적합률 54%

새 라벨의 상당수가 "지금 시스템이 이미 상위에 올려놓은 장소" 였습니다. 그 장소들이 정답으로 등록되면, 지금 시스템의 상위 10개 적합률이 그대로 Recall@10 점수가 됩니다. 자기가 낸 답을 정답지에 적고 자기를 채점한 겁니다.

88.5%는 개선이 아니라 구성 변화였습니다. 이걸 못 알아챘다면 "목표 달성"을 선언하고 개선을 멈췄을 겁니다.

그럼 이 확장이 쓸모없었냐 하면 아닙니다. 진짜 신호는 다른 칸에 있었습니다.

  • 오탐 70건 — 새로 계량된 정밀도 부채. 노을 7, 용인 야경 7, 한강뷰 식당 6, 카공 5…
  • raw Recall@10 58.3% — 캡을 안 씌우면 아직 절반 수준
  • 약체 쿼리 — 호수뷰 22%, 아기랑 브런치 50%, 카공 56%

그리고 오탐 70건을 분해해 보니 하나의 패턴이 보였습니다. 전부 "직접 근거 없이 인접 신호로 완화 판정" 이었습니다.

  • 시티뷰·마운틴뷰가 있으니 → "노을도 볼 수 있겠지"
  • 분위기가 좋으니 → "카공하기도 좋겠지" (콘센트·와이파이 근거는 전무)
  • 한강 근처니까 → "한강뷰겠지"

점수 하나로는 못 봤을 패턴을, 오답 목록을 직접 읽어서 찾았습니다. 지표는 어디가 아픈지 알려주고, 진단은 목록을 읽어야 나옵니다.

정답셋도 틀린다 — 세 번의 사고

정답셋을 "정답"이라고 부르지만, 사람이 만든 물건이라 틀립니다. 실제로 세 번 크게 틀렸습니다.

① 판정자에게 잘린 입력을 줬다

오탐 목록에 티라미수 3건, 베이글 1건이 있었습니다. "디저트 카페" 검색에 메뉴 근거 없이 걸린 오답으로 라벨돼 있었죠. 이걸 막는 게이트를 만들려고 조사에 들어갔는데, 네 곳 다 실제로는 메뉴 근거가 있었습니다. 티라미수라떼, 떠먹는 티라미수, 티라미수소금빵, 어니언베이글.

오답이 아니라 라벨이 틀린 거였습니다.

원인은 판정자에게 준 입력이었습니다. 텍스트를 1,500자에서 잘라서 넣었고, 메뉴 목록은 아예 안 넣었습니다. 판정 모델은 근거를 못 봤으니 "언급 없음"이라고 답한 겁니다. 모델은 정직했고, 우리가 잘못 물어봤습니다.

판정자 입력을 3,000자 + 메뉴 포함으로 고치고, 네 건은 사람 재검토로 돌렸습니다. 정답셋을 만드는 파이프라인도 파이프라인입니다. 거기 버그가 있으면 정답이 오염됩니다.

② 도메인 정의를 모델에게 맡겼다

자문 판정 모델이 "북한강은 한강이 아니다" 라고 판정했고, 그 라벨들이 검수에서 무수정으로 통과됐습니다. 그 결과 북한강변 카페들의 한강뷰 태그가 오태깅으로 정리되고, 정답셋에서도 부적합으로 내려갔습니다.

나중에 서비스 주인에게 확인하니 답은 명확했습니다. "한강뷰는 북한강·남한강을 포함한다." 유추카 사용자가 "한강뷰 카페"를 찾을 때 가평 북한강변 카페를 기대하지 않을 이유가 없으니까요.

되돌리는 데 네 단계가 필요했습니다 — 분류 프롬프트 수정, 판정 기준 재작성, 데이터 23건 복원, 정답셋 6건을 적합으로 환원. (진짜 오태깅 12건은 제외한 채로요.)

교훈은 이겁니다. "한강뷰의 범위가 어디까지인가"는 모델이 아니라 서비스가 정하는 문제입니다. 자문 판정이 도메인 경계를 임의로 그으면, 그건 판정이 아니라 제품 결정입니다. 그런 라벨은 무수정 통과시키면 안 됐습니다.

③ 평가 러너에 사각지대가 있었다

앞에서 오답 버튼 덕에 만들어졌다고 한 그 항목 — "용인 야경 좋은 식당" 정답 0 / 오답 24 를 다시 봅시다.

이 검색어의 이상적인 결과는 0건 + "이 지역엔 결과가 적어요" 안내입니다. 그런데 평가 러너는 이 쿼리를 통째로 건너뛰고 있었습니다. 재현율을 계산하려면 정답이 있어야 하는데 정답이 0개니, 분모가 0이라 계산에서 빠진 겁니다. "아무거나 24개 뱉어도 지표에 안 잡히는 쿼리" 가 정답셋 안에 있었던 셈입니다.

러너를 고쳐서 부적합만 있는 쿼리는 오탐만 집계하도록 했습니다. 기술 3편의 "분당 콩국수 0건"이 왜 좋은 결과인지, 여기서 비로소 지표로도 확인할 수 있게 됐습니다.

무엇을 재나 — 네 개의 지표

단일 "정답률"로는 부족해서 네 가지를 함께 봅니다. 각각이 다른 종류의 실패를 잡습니다.

① capped Recall@K — 상위 K개 안에 정답을 얼마나 담았나.

단순 Recall@10에는 함정이 있습니다. 정답이 30개인 쿼리는 상위 10개를 전부 정답으로 채워도 최대 33% 밖에 안 나옵니다. 수학적으로 불가능한 걸 못 했다고 감점하는 거죠. 그래서 분모를 min(K, 정답 수) 로 제한합니다. 정답이 3개면 3개를 다 담았을 때 100%가 됩니다.

② candidate-recall — 정답을 후보로 불러오기라도 했나.

이게 가장 유용한 지표였습니다. 손실을 두 종류로 분리해 주기 때문입니다.

candidate-recall Recall@10 진단
낮음 낮음 소환 문제 — 애초에 못 불러온다
높음 낮음 정렬 문제 — 불러왔는데 위로 못 올린다

기술 5편의 청크 임베딩이 이 지표를 44.6% → 93.8%로 올린 작업이었고, 그 뒤로 병목은 정렬 쪽으로 넘어갔습니다.

③ MRR — 첫 정답이 몇 등에 나오나(순위의 역수 평균). 1등에 나오면 1.0, 5등이면 0.2. "쓸 만한 결과가 첫 화면에 있나" 라는 체감에 가장 가까운 지표입니다.

④ 오탐 위반 — 부적합으로 라벨된 장소가 상위 10개에 들어오면 위반. 이상적으로는 0이어야 합니다.

이 네 번째가 안전장치 역할을 합니다. 재현율만 보면 "일단 많이 보여주자" 로 최적화하게 되는데, 그러면 오탐이 늘어납니다. 오탐 위반은 그 유혹에 브레이크를 겁니다. 기술 5편에서 청크 기능을 살린 것도 이 지표였습니다 — 재현율이 떨어져 보이는데 오탐은 안 늘었다는 사실이 "측정이 잘못됐다"를 가리켰으니까요.

정답이 어디서 죽었나 — 귀인 깔때기

지표는 얼마나 아픈지를 알려주지만 어디가 아픈지는 안 알려줍니다. 그래서 만든 게 귀인 진단입니다.

특정 장소를 지목하면(watchPlaceIds), 검색 응답에 그 장소가 깔때기의 어느 단계에서 탈락했는지가 함께 담깁니다.

버킷 어디서 죽었나
NOT_INDEXED 애초에 색인이 안 됨 (데이터 문제)
GATE_CUT 하드게이트에서 탈락 — 어떤 조건에 걸렸는지까지 특정
POOL_MISS 후보 풀에 못 들어옴 (소환 손실)
SCORE_CUT 점수가 낮아 잘림
RERANK_WINDOW_OUT 후보엔 들었지만 리랭크 창 밖 — 평가도 못 받고 탈락
RERANK_REJECTED 리랭커가 부적합 판정
ASSEMBLY_DROPPED 조립 단계 탈락 (드묾)
EXPOSED 최종 노출 성공

GATE_CUT의 구현이 특히 유용했습니다. 게이트에 걸린 장소는 조건을 하나씩 단독으로 다시 검사해서 "정확히 어느 조건이 이 장소를 죽였는지"를 남깁니다. "게이트에 걸렸다"가 아니라 "지역 bbox 조건에 걸렸다"까지 나오니 손볼 곳이 바로 나옵니다.

그리고 이 진단이 투자 순서를 정했습니다. 기술 4편에서 인용한 그 표가 여기서 나온 겁니다.

정답이 죽은 지점 비율 그래서 한 일
리랭크 창 밖 50% 창을 넓히고 병렬 배치로 (기술 4편)
태깅 하드게이트 컷 31% 소프트게이트로 전환 (기술 4편)
기타 19%

"재현율이 낮다"로는 뭘 해야 할지 모릅니다. "정답의 절반이 리랭크 창 밖에서 죽는다"면 할 일이 정해집니다.

지표를 보고 달려들기 전에 실제로 봐라

귀인이 좋은 도구인 만큼, 귀인을 잘못 읽으면 엉뚱한 데 힘을 씁니다. 실제로 그럴 뻔했습니다.

소프트게이트 전환 후 "랭킹 버킷"(결과에 노출은 됐지만 상위 10개 밖) 이 211건으로 최대 손실 버킷이 됐습니다. 다음 작업은 당연히 랭킹 개선이라고 생각했습니다.

착수 전에 그 쿼리들의 상위 10개를 직접 열어봤습니다.

미스가 가장 많은 쿼리들의 top-10 실측: 적합 55 / 60, 부적합 1, 미라벨 4

상위 10개가 이미 정답으로 가득 차 있었습니다. 그럼 211건은 뭐냐 — 정답이 10개보다 많은 쿼리의 11등 이하 정답들이었습니다. 정답이 30개인 쿼리에서 상위 10개를 정답으로 채워도 나머지 20개는 "랭킹 밖"으로 집계되거든요. 실제로 그중 63%가 11~20위에 있었습니다.

품질 결함이 아니라 집계 아티팩트였습니다. 여기에 랭킹 개선을 투입했다면 개선할 분모 자체가 없으니 아무 성과도 못 냈을 겁니다. 버킷을 닫고 다른 데로 갔습니다.

그 뒤로는 귀인 리포트를 읽을 때 랭킹 버킷은 capped Recall과 반드시 같이 읽습니다. 그리고 규칙 하나를 추가했습니다 — 큰 버킷을 발견하면, 착수 전에 실제 결과를 눈으로 확인한다.

정밀도를 위해 재현율을 일부러 버린 날

앞에서 본 "인접 신호로 완화 판정" 패턴(뷰 좋으니 노을도 되겠지)을 고치는 작업을 했습니다. 판정 기준에 "직접 근거가 없으면 부적합" 을 뷰 계열 6종과 한옥·카공·조용에 일반화했습니다.

결과는 양쪽으로 움직였습니다.

오탐 위반 70 42 (−40%)
capped Recall@10 88.5% 82.7% (−5.8p)

재현율이 5.8%p 떨어졌습니다. 보통이면 기각할 숫자입니다. 그런데 채택했습니다. 이유가 셋이었습니다.

  1. 오탐 −40%는 대역을 한참 넘는 개선이고, 오탐은 사용자가 가장 크게 배신감을 느끼는 실패입니다. "한강뷰 카페"를 눌렀는데 한강이 안 보이는 집이 나오면, 그 검색은 실패한 겁니다.
  2. 떨어진 재현율의 일부는 관대한 라벨 탓이었습니다. 자문 판정이 "인접 신호로 완화"해서 적합으로 매긴 항목들이, 엄격해진 기준에서 떨어져 나간 거죠. 즉 정답셋도 같은 방향으로 관대했습니다.
  3. 전례가 있었습니다. 이전에도 비슷하게 재현율이 단기 하락했다가, 정답셋을 확장해 다시 재니 정당성이 확인된 적이 있었습니다.

모든 지표를 동시에 올릴 수는 없습니다. 정밀도와 재현율은 대체로 서로를 잡아먹으니, 어느 쪽을 사고 어느 쪽을 팔지 명시적으로 결정하고 기록해야 합니다. 그러지 않으면 나중에 "왜 이때 점수가 떨어졌지?"를 아무도 설명 못 합니다.

기준선이 계속 리셋된다

정직하게 경고할 게 하나 더 있습니다. 이 연재의 숫자들은 서로 비교하면 안 됩니다.

정답셋이 v2 → v4로 커지고 장소 데이터도 2배로 늘면서, 기준선이 여러 번 리셋됐습니다. 극단적인 예를 하나 들면 — 신규 장소 1,000여 곳이 유입되자 capped R@10이 78.8 → 65.0으로 떨어졌습니다.

품질이 나빠진 게 아니었습니다. 새로 들어온 장소들이 상위 10개를 채웠는데, 그 장소들은 정답셋에 라벨이 없어서 자동으로 오답 취급된 겁니다. 기술 5편에서 청크 임베딩이 겪은 것과 완전히 같은 현상입니다. 실제로 표적 쿼리("회식 고기집")의 상위 10개를 열어보니 전원 근거 있는 적합이었고, 오탐은 오히려 28 → 26으로 줄어 있었습니다.

그래서 이 연재의 모든 수치는 "같은 정답셋 안에서, 같은 시점의 before → after" 만 인용했습니다. "초기 17.5%에서 지금 83%" 같은 종단 비교는 하지 않습니다. 그건 자를 바꿔가며 잰 값들이라 이어 붙이면 안 됩니다.

넣을지 말지 — 채택 게이트

새 기법을 반영할지는 세 관문을 다 통과해야 정했습니다.

  1. 방향성 — 핵심 지표가 요동 대역을 넘어 오르는가
  2. 퇴행 가드급락하는 쿼리가 0건인가. 전체 평균이 올라도 특정 쿼리를 죽이면 기각합니다
  3. 정성 확인 — 표적 사례를 트레이스로 열어봤을 때 납득되는가

두 번째가 실제로 여러 번 일했습니다. 청크 정렬이 딱 그랬죠 — 평균은 그럴듯해 보였지만 디저트 100 → 20, 수다 100 → 30처럼 특정 쿼리를 학살하고 있었습니다. 평균만 봤다면 놓쳤을 겁니다.

이 글의 결론

  • 검색 품질은 눈으로는 다 좋아 보입니다. "좋아 보이던" 기법 넷이 재보니 전부 손해였습니다.
  • 자를 만들기 전에 자가 흔들리지 않게 해야 합니다. 같은 코드로 두 번 재서 ±7이 나오면 아무것도 못 잽니다. 판정 캐시 + 파싱 결정화 + 3표 다수결로 "두 번 돌린 리포트가 바이트 단위로 동일"까지 갔고, 덤으로 러너가 479초 → 7초가 됐습니다.
  • 단, 재현 가능 ≠ 옳다. 캐시가 무효화되는 개입은 단일 측정으로 판정하지 않습니다.
  • 라벨링 도구가 나쁘면 사람이 대충 답니다. 라벨은 검색하던 화면에서 달고, 검색어를 고정해 흩어지지 않게 하고, 오답 버튼을 1급으로 뒀습니다.
  • 표본 라벨로는 재현율만 잴 수 있습니다. 라벨 없는 결과를 오답으로 세면 개선이 하락으로 찍힙니다 — 그래서 채점기는 정밀도를 아예 안 잽니다.
  • 정답셋이 자기 자신을 정답으로 삼으면 점수가 오릅니다(88.5%). 그건 개선이 아니라 구성 변화입니다.
  • 정답셋도 틀립니다. 판정자 입력이 잘려서, 도메인 정의를 모델에게 맡겨서, 러너에 사각지대가 있어서 — 세 번 틀렸습니다.
  • 지표는 얼마나 아픈지, 귀인은 어디가 아픈지를 알려줍니다. 그리고 큰 버킷을 발견하면 착수 전에 눈으로 확인합니다 — 211건이 집계 아티팩트였던 적이 있습니다.
  • 정밀도와 재현율은 서로를 잡아먹습니다. 어느 쪽을 사고 파는지 명시적으로 결정하고 기록해야 합니다.
  • 기준선이 리셋되므로 같은 정답셋 안의 before → after만 믿습니다.

다음 글(기술 8)에서는 이 평가가 걸러낸 "기각의 박물관" — 좋아 보였지만 데이터가 버리게 한 기법들 — 을 모아 봅니다.


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