> ## 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로 장소 검색 서비스 만드는 중입니다 (기술 7) — 평가를 어떻게 믿나: 정답셋(gold)과 recall 귀인
- URL: https://www.popit.kr/ai-place-search-tech-7-trusting-eval-gold-recall/
- Published: 2026-08-06T22:24:22.000Z
- Updated: 2026-08-06T22:24:22.000Z
- Author: 김형준
- Tags: ai, 검색, #서비스소개, 유추카, 라벨링

![t7-funnel](https://www.popit.kr/content/images/wp/2026/07/t7-funnel-600x282.png)

> 이 연재에는 "좋아 보였지만 재보니 손해였다"가 계속 나옵니다. 로컬 리랭커, 청크 정렬, 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 이동시간"으로, 어떤 날은 "가평 행정구역"으로. 두 해석은 완전히 다른 검색이 됩니다.

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

### ③ 판정을 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)에서는 이 평가가 걸러낸 **"기각의 박물관"** — 좋아 보였지만 데이터가 버리게 한 기법들 — 을 모아 봅니다.

- [유튜버추천카페로 이동하기](https://youchuca.popit.kr/?ref=popit.kr)