Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions docs/2026-09-06-검색-전환-후-측정.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# 검색 전환 후(after) 측정 — Elasticsearch 이관 결과

기준선: [2026-09-05-검색-기준선-측정.md](./2026-09-05-검색-기준선-측정.md)

## 0. 측정 방법

기준선 문서는 raw SQL 로 `LIKE '%키워드%'` 를 셌다. 그대로 ES 와 비교하면 **마감·노출 필터
적용 여부가 달라 숫자가 오염된다** — ES 경로는 그 필터를 다 통과하므로 마감된 정책이 빠진
만큼 건수가 줄어 ES 가 불리해진다.

그래서 **같은 API 엔드포인트에 `SEARCH_ES_ENABLED` 만 토글**해 두 번 측정했다.
필터 조건이 완전히 동일해지고, 남는 차이는 "문자열 부분일치 vs 형태소 색인" 뿐이다.

```bash
SEARCH_ES_ENABLED=false ./gradlew bootRun # → python es/helper/measure_search.py --out mysql.json
SEARCH_ES_ENABLED=true ./gradlew bootRun # → python es/helper/measure_search.py --out es.json
python es/helper/measure_search.py --compare mysql.json es.json
```

## 1. 요약

| 지표 | before (LIKE) | after (ES) |
|---|---:|---:|
| 0건 검색어 | **9 / 28** | **2 / 28** |
| 평균 응답 | 339ms | 101ms |

남은 0건 2종(`청년도약계좌`, `도약계좌`)은 검색 문제가 아니다 — 해당 정책이
`applicationEndDate = 2025-12-05` 로 이미 마감되어 노출 대상이 아니다.

> 응답 시간은 이번 도입의 근거가 아니다. 2,691건 규모에서는 MySQL 풀스캔도 충분히 빠르고,
> 첫 호출(약 900ms)은 양쪽 모두 JIT 워밍업이다. 도입 이유는 **검색 품질**이다.

## 2. 유형별 결과

기준선에서 정의한 실패 5유형 그대로.

| 유형 | 검색어 | before | after |
|---|---|---:|---:|
| **A 동의어** | `일거리` | **0** | 407 |
| | `채용` | 63 | 407 |
| | `주택` | 96 | 182 |
| **B 띄어쓰기** | `청년 일자리` | 9 | 340 |
| | `청년일자리` | 21 | 86 |
| **C 어순** | `월세 지원 청년` | **0** | 764 |
| | `일자리 청년` | **0** | 340 |
| | `사업 지원` | 8 | 516 |
| **D 자연어** | `서울 청년 취업` | **0** | 440 |
| | `청년 취업 서울` | **0** | 440 |
| | `일자리를 찾고 있어요` | **0** | 87 |
| **E 표기변형** | `K디지털` | **0** | 2 |

## 3. 건수가 줄어든 검색어 — 오탐 제거

| 검색어 | before | after | 사라진 것 |
|---|---:|---:|---|
| `지원금` | 92 | 50 | **42건 전부 "지원금액"** |
| `임대` | 57 | 47 | `임대차` 오분석 |
| `월세` | 31 | 27 | |

`지원금액` 은 nori 가 `[지원, 금액]` 으로 끊는다. `지원금` 토큰이 없으므로 걸리지 않는 것이
맞다. LIKE 는 글자 세 개(지·원·금)가 이어져 있다는 이유로 매칭했다 —
**"지원금액 : 월 20만원" 문구가 든 정책이 "지원금" 검색에 나오는 것은 오탐이다.**

검색 품질을 건수로만 재면 안 되는 이유다. 재현율(빠뜨리지 않기)만 보면 LIKE 가 이기고,
정밀도(엉뚱한 걸 안 넣기)를 보면 ES 가 이긴다. 이번 전환은 **방향이 반대인 두 지표가
동시에 좋아졌다.**

## 4. 실측으로 정한 값 4개

감으로 정한 값이 하나도 없도록, 후보를 여러 개 두고 같은 검색어에 대해 결과를 비교했다.
비교 도구는 `es/helper/` 에 남겨 두었다.

### 4-1. 동의어 7그룹 (`synonym_candidates.py`)

단어별 매칭 건수와 문서 집합의 겹침(자카드)을 재서 골랐다.

| 넣은 것 | 근거 |
|---|---|
| `일자리, 취업, 채용, 구직, 일거리` | 겹침 9~27% — 지금은 사실상 다른 검색어 |
| `주거, 주택` | 겹침 46% — 사실상 같은 말 |
| `창업, 창업자, 스타트업` | 스타트업은 외래어 표기, 겹침 7% |
| `학자금, 등록금, 장학금` | 규모 비슷(40/24/46) |
| `대출, 융자` | 융자 19건, 단독으로는 안 걸림 |
| `컨설팅, 멘토링` | 겹침 10% |
| `알바→아르바이트`, `취준생→취업` 등 | 검색창에만 있고 본문엔 없는 말 |

| 넣지 않은 것 | 이유 |
|---|---|
| `지원금 / 보조금 / 수당` | 보조금 745건 vs 지원금 143건 — **5배 차이. 작은 쪽이 잠식된다** |
| `교육 / 훈련 / 연수` | 교육 1,191건(전체의 44%). 훈련 검색이 교육 전체로 번진다 |
| `자격증 / 응시료 / 시험` | 이미 겹침 50~72% — 묶을 실익 없음 |
| `월세 / 전세 / 임대` | 상품이 다르다. '월세'를 친 사람은 월세를 원한다 |

### 4-2. `best_fields` → `cross_fields`

`best_fields` 는 **한 필드 안에서** 조건을 센다. `서울 청년 취업` 은 `서울` 이
`sidoNames` 에, `청년`·`취업` 이 `title` 에 있어 어떤 필드도 세 단어를 다 갖지 못했다.

| 검색어 | best_fields | cross_fields |
|---|---:|---:|
| `서울 청년 취업` | 291 | 440 |
| `청년 월세 지원` | 624 | 764 |

### 4-3. `minimum_should_match = 2<70%` (`tune_msm.py`)

| 검색어 | `2<70%` | `3<70%` |
|---|---:|---:|
| `서울 청년 취업` | 440 | 43 |
| `청년 월세 지원` | 764 | 20 |
| `일자리를 찾고 있어요` | 87 | **2** |

`3<70%` 는 건수가 훨씬 정확하지만 자연어 검색이 87 → 2건으로 무너진다.
**상위 5개 결과는 두 설정에서 완전히 동일했다** — BM25 순위는 안 바뀌고 꼬리 길이만
달라진다. 즉 이 선택은 정확도가 아니라 "몇 건이라고 표시할 것인가"의 문제였고,
0건 문제를 푸는 것이 이번 작업의 목적이므로 느슨한 쪽을 택했다.

### 4-4. 지역 가산점 5

전국 정책은 모든 시도에 지역 행이 걸려 있어 지역명 검색에 **항상** 잡힌다.
MySQL 은 이를 `case when ... then 1 else 0` 정렬로 후순위 처리했는데(#199 후속),
ES 로 옮기면서 빠뜨렸다가 재측정에서 발견했다.

`서울 청년 취업` 상위 3건:

| 가산점 없음 | 가산점 5 | 가산점 15 |
|---|---|---|
| 청년일자리도약장려금 (전국) | 미취업 청년 어학·자격증 응시료 (서울) | 미취업 청년 어학·자격증 응시료 (서울) |
| 청년취업ON (전국) | 중랑청년청 동네친구 (서울) | 중랑청년청 동네친구 (서울) |
| 취업특강 (전국) | 2026 서울시 청년인생설계학교 (서울) | 청년중독관리사업 (경기) |

15는 과교정이다 — 여러 시도를 걸친 정책이 해당 지역 전용 정책을 밀어낸다.

## 5. 조용한 실패 두 건

둘 다 **에러가 나지 않아서** 토큰을 직접 찍어보기 전에는 알 수 없었다.

### 5-1. 동의어 7그룹 중 3그룹이 무효였다

`synonym_graph` 는 사전에 적은 단어를 같은 분석기로 한 번 돌려본 뒤 규칙을 만든다.
그때 여러 토큰이 나오면 구(phrase)로 취급되어 단일 토큰과 묶이지 못한다.

```
스타트업 → [스타트업, 스타트, 업] ← 창업과 묶이지 않음
```

같은 그룹의 `학자금`(단일 토큰)까지 통째로 무효가 됐다.
→ `userdict_ko.txt` 에 단일 토큰으로 등록하고 재색인해 해결.

**사전 두 개가 서로 물려 있다.** userdict 가 만든 토큰 위에서만 synonym 이 동작한다.

### 5-2. `취준생` 이 `[취, 생]` 으로 쪼개졌다

`생` 은 학생·대학생 등 어디에나 있어 관계없는 문서가 걸렸다.
`응시료` 도 `[시료]` 가 되어 우연히만 동작하고 있었다(문서 쪽도 같은 토큰이라).

## 6. 패싯 집계 — 자기 필터를 빼고 센다

`GET /api/v1/policies/facets`. 목록과 같은 파라미터를 받아 카테고리·지역별 건수를 준다.
목록 응답에 끼워 넣지 않고 뺀 이유는 이미 쓰이는 응답 형태를 바꾸지 않기 위해서다.

여기서 한 번 틀리기 쉬운 지점이 있다. **각 항목은 자기 자신의 필터를 뺀 조건에서 세야 한다.**
`category=주거` 상태에서 카테고리 건수까지 주거로 걸러 세면 이렇게 된다.

| 자기 필터를 포함(순진) | 자기 필터를 제외(구현) |
|---|---|
| 주거 139 | 일자리 427 / 금융·복지·문화 325 / 참여·기반 159 / 교육·직업훈련 152 / 주거 139 |

나머지 넷이 **응답에서 아예 사라진다.** 0건으로 보이는 것도 아니고 버킷 자체가 없으니
프론트가 그릴 수 없고, 사용자는 다른 분류로 옮겨갈 방법이 없다(막다른 골목).

구현은 "기준 질의 + 패싯별 filter 집계"다. 기준 질의에서 카테고리·지역 필터를 빼 두고,
카테고리를 셀 때만 지역 필터를, 지역을 셀 때만 카테고리 필터를 다시 씌운다.
`post_filter` 로도 되지만 패싯이 늘면 조합이 꼬여서 filter 집계 쪽이 읽기 쉽다.

지역 버킷 크기는 20으로 명시했다 — `terms` 집계의 기본값은 **상위 10개**라 시도 17개를
그냥 두면 7개가 말없이 잘린다. `sum_other_doc_count` 가 0인 것으로 확인했다.

ES 가 죽으면 빈 목록을 준다(= 0건이 아니라 "집계 불가"). MySQL 로 같은 집계를 하려면
8개 조건이 걸린 `GROUP BY` 를 두 벌 더 만들어야 하는데, 목록은 폴백으로 계속 뜨고
건수 배지만 사라지는 정도라 그 값을 하지 않았다.

## 7. 남은 것

- 동의어는 검색 시점 적용이라 재색인 없이 `_reload_search_analyzers` 로 반영된다.
운영 중 사전을 고칠 때 이 점이 갈린다 — `userdict` 변경만 재색인이 필요하다.
69 changes: 64 additions & 5 deletions es/config/synonym_ko.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,76 @@
#
# 형식 두 가지
# 일자리, 취업, 채용, 구직 등가형 — 서로 모두 동의어
# 알바, 아르바이트 => 아르바이트 지정형 — 왼쪽을 오른쪽으로 치환
# 알바 => 아르바이트 지정형 — 왼쪽을 오른쪽으로 치환
#
# 이 사전은 search_analyzer(검색 시점)에만 적용된다.
# 색인 시점에 넣으면 사전을 고칠 때마다 전체 재색인이 필요하기 때문이다.
# 고친 뒤 재색인 없이 아래 한 줄로 반영한다:
# curl -XPOST "localhost:9200/policy/_reload_search_analyzers"
#
# 주의: nori 가 먼저 형태소로 쪼갠 뒤에 이 규칙이 걸린다. 그래서 여기 적는 단어는
# nori 가 실제로 만들어내는 토큰이어야 한다(예: "일자리" 는 userdict 에 등록돼 있어야
# nori 가 실제로 만들어내는 토큰이어야 한다(예: "취준생" 은 userdict 에 등록돼 있어야
# 하나의 토큰이 된다).
#
# 아직 비어 있다. 기준선 측정(docs/2026-09-05-검색-기준선-측정.md)에서
# "일자리 → 취업 정책 488건 누락" 이 확인됐으므로, 인덱스를 만들고 실제
# 검색 결과를 본 뒤에 데이터 근거로 채운다.
# ─────────────────────────────────────────────────────────────
# 넣을지 말지는 감이 아니라 실측으로 정했다.
# python es/helper/synonym_candidates.py
# 단어별 매칭 건수와 문서 집합의 겹침(자카드)을 재서
# 겹침이 이미 높다 → 묶을 실익이 적다
# 한쪽이 압도적으로 크다 → 묶으면 작은 쪽 검색이 큰 쪽에 잠식된다
# 는 기준으로 걸렀다. 아래 "넣지 않은 것"에 그 근거를 남긴다.
# ─────────────────────────────────────────────────────────────

# ─── 구직 계열 ────────────────────────────────────────────────
# 기준선 측정에서 "일자리로 검색하면 취업 정책 488건 누락"이 1순위 실패였다.
# 서로 겹침이 9~27% 로 낮다 = 지금은 사실상 다른 검색어처럼 동작하고 있다는 뜻.
# 청년 정책 맥락에서 이 넷은 사용자에게 같은 뜻이다.
일자리, 취업, 채용, 구직, 일거리

# ─── 주거 계열 ────────────────────────────────────────────────
# 주거 ↔ 주택 만 묶는다(겹침 46%, 사실상 같은 말).
# 월세·전세·임대는 서로 다른 상품이라 묶지 않는다 — "월세" 를 찾는 사람에게
# 전세 정책을 섞으면 그건 개선이 아니라 소음이다.
주거, 주택

# ─── 창업 계열 ────────────────────────────────────────────────
# 스타트업(45건)은 창업(455건)의 외래어 표기다. 겹침 7% 로 거의 안 겹친다.
창업, 창업자, 스타트업

# ─── 학비 계열 ────────────────────────────────────────────────
# 셋 다 40건 안팎으로 규모가 비슷하고(40/24/46) 학생 입장에서 구분이 없다.
학자금, 등록금, 장학금

# ─── 대출 계열 ────────────────────────────────────────────────
# 융자는 19건뿐이라 단독으로는 거의 안 걸린다. '이자'는 뜻이 달라 제외.
대출, 융자

# ─── 상담 계열 ────────────────────────────────────────────────
# 컨설팅 ↔ 멘토링 만 묶는다. '상담'(472건)까지 묶으면 심리상담·법률상담이
# 멘토링 검색에 섞여 들어온다.
컨설팅, 멘토링

# ─── 사용자만 쓰는 말 → 실제 표기로 ────────────────────────────
# 정책 본문에는 없고 검색창에만 등장하는 말들이다. 한 방향으로만 치환한다
# (반대로 걸면 '취업' 검색이 '취준생' 까지 끌고 와 넓어지기만 한다).
알바 => 아르바이트
취준생 => 취업
취준 => 취업
부트캠프 => 교육

# ─────────────────────────────────────────────────────────────
# 넣지 않은 것과 이유
#
# 지원금 / 보조금 / 수당 / 장려금
# 보조금이 745건으로 지원금(143건)의 5배다. 묶으면 "지원금" 검색이
# 보조금 문서에 잠식되어 900건이 되고, 사용자는 걸러진 결과를 못 본다.
# 규모 차가 큰 단어는 동의어로 묶으면 작은 쪽이 사라진다.
#
# 교육 / 훈련 / 연수
# 교육이 1,191건(전체의 44%)이다. 훈련(64건) 검색이 교육 전체로 번진다.
#
# 자격증 / 응시료 / 시험
# 이미 겹침 50~72% 다. 대부분 같은 문서를 잡고 있어 묶을 실익이 없다.
#
# 월세 / 전세 / 임대
# 상품이 다르다. 사용자가 '월세'를 칠 때는 월세를 원하는 것이다.
21 changes: 21 additions & 0 deletions es/config/userdict_ko.txt
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,24 @@
병점구 병점 구
전북특별자치도 전북 특별자치도
강원특별자치도 강원 특별자치도

# ─── 사용자가 치지만 nori 가 쪼개버리는 말 (2026-09-06 추가) ──────
# 검색 품질 재측정에서 발견. 아래 셋은 등록 전 토큰이 뜻과 무관하게 쪼개져
# 엉뚱한 문서가 걸리거나(취준생 → [취, 생]) 우연히만 동작했다(응시료 → [시료]).
# 정책 본문에는 안 나오는 말이라, 사전 등록으로 한 덩어리로 만든 뒤
# synonym_ko.txt 에서 실제 표현으로 이어준다.
취준생
취준
응시료 응시 료

# ─── 동의어 사전이 잡을 수 있게 한 토큰으로 묶는 말 (2026-09-06 추가) ───
# synonym_graph 는 사전에 적은 단어를 같은 분석기로 한 번 돌려본 뒤 규칙을 만든다.
# 그때 여러 토큰이 나오면 구(phrase)로 취급되어 단일 토큰과 묶이지 못한다.
# 실제로 "스타트업" 이 [스타트업, 스타트, 업] 으로 쪼개져 창업과 묶이지 않았고,
# 같은 그룹의 학자금까지 통째로 무효가 됐다(에러 없이 조용히).
# 아래 단어들은 쪼개도 얻을 게 없어(등록/금) 통짜로 둔다.
일거리
스타트업
등록금
장학금
부트캠프
Loading
Loading