diff --git "a/docs/2026-09-06-\352\262\200\354\203\211-\354\240\204\355\231\230-\355\233\204-\354\270\241\354\240\225.md" "b/docs/2026-09-06-\352\262\200\354\203\211-\354\240\204\355\231\230-\355\233\204-\354\270\241\354\240\225.md" new file mode 100644 index 0000000..95d1d82 --- /dev/null +++ "b/docs/2026-09-06-\352\262\200\354\203\211-\354\240\204\355\231\230-\355\233\204-\354\270\241\354\240\225.md" @@ -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` 변경만 재색인이 필요하다. diff --git a/es/config/synonym_ko.txt b/es/config/synonym_ko.txt index 8b6fdd1..cb257f9 100644 --- a/es/config/synonym_ko.txt +++ b/es/config/synonym_ko.txt @@ -2,7 +2,7 @@ # # 형식 두 가지 # 일자리, 취업, 채용, 구직 등가형 — 서로 모두 동의어 -# 알바, 아르바이트 => 아르바이트 지정형 — 왼쪽을 오른쪽으로 치환 +# 알바 => 아르바이트 지정형 — 왼쪽을 오른쪽으로 치환 # # 이 사전은 search_analyzer(검색 시점)에만 적용된다. # 색인 시점에 넣으면 사전을 고칠 때마다 전체 재색인이 필요하기 때문이다. @@ -10,9 +10,68 @@ # 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% 다. 대부분 같은 문서를 잡고 있어 묶을 실익이 없다. +# +# 월세 / 전세 / 임대 +# 상품이 다르다. 사용자가 '월세'를 칠 때는 월세를 원하는 것이다. diff --git a/es/config/userdict_ko.txt b/es/config/userdict_ko.txt index ccbbcab..7587283 100644 --- a/es/config/userdict_ko.txt +++ b/es/config/userdict_ko.txt @@ -112,3 +112,24 @@ 병점구 병점 구 전북특별자치도 전북 특별자치도 강원특별자치도 강원 특별자치도 + +# ─── 사용자가 치지만 nori 가 쪼개버리는 말 (2026-09-06 추가) ────── +# 검색 품질 재측정에서 발견. 아래 셋은 등록 전 토큰이 뜻과 무관하게 쪼개져 +# 엉뚱한 문서가 걸리거나(취준생 → [취, 생]) 우연히만 동작했다(응시료 → [시료]). +# 정책 본문에는 안 나오는 말이라, 사전 등록으로 한 덩어리로 만든 뒤 +# synonym_ko.txt 에서 실제 표현으로 이어준다. +취준생 +취준 +응시료 응시 료 + +# ─── 동의어 사전이 잡을 수 있게 한 토큰으로 묶는 말 (2026-09-06 추가) ─── +# synonym_graph 는 사전에 적은 단어를 같은 분석기로 한 번 돌려본 뒤 규칙을 만든다. +# 그때 여러 토큰이 나오면 구(phrase)로 취급되어 단일 토큰과 묶이지 못한다. +# 실제로 "스타트업" 이 [스타트업, 스타트, 업] 으로 쪼개져 창업과 묶이지 않았고, +# 같은 그룹의 학자금까지 통째로 무효가 됐다(에러 없이 조용히). +# 아래 단어들은 쪼개도 얻을 게 없어(등록/금) 통짜로 둔다. +일거리 +스타트업 +등록금 +장학금 +부트캠프 diff --git a/es/helper/measure_search.py b/es/helper/measure_search.py new file mode 100644 index 0000000..034adfd --- /dev/null +++ b/es/helper/measure_search.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +"""검색 품질 측정 (개발용 도구 — 서비스 코드 아님). + +기준선 문서(docs/2026-09-05-검색-기준선-측정.md)의 검색어 26종을 실제 API로 호출해 +건수와 1위 결과를 기록한다. + +before/after 를 공정하게 비교하려면 같은 경로로 재야 한다. LIKE 시절 raw SQL 과 +ES 질의를 직접 비교하면 마감·노출 필터 적용 여부가 달라 숫자가 오염된다. 그래서 +SEARCH_ES_ENABLED 만 바꿔 앱을 두 번 띄우고, 같은 엔드포인트를 두 번 호출한다. + +사용: + python es/helper/measure_search.py --out mysql.json # SEARCH_ES_ENABLED=false 로 띄운 뒤 + python es/helper/measure_search.py --out es.json # SEARCH_ES_ENABLED=true 로 띄운 뒤 + python es/helper/measure_search.py --compare mysql.json es.json +""" +import argparse +import json +import sys +import time +import urllib.parse +import urllib.request + +API = "http://localhost:8080/api/v1/policies" + +# 기준선 문서의 26종. 유형별로 묶어 표에서 바로 읽히게 한다. +TERMS = [ + ("A 동의어", ["일자리", "취업", "채용", "구직", "일거리"]), + ("A 동의어", ["주거", "주택", "월세", "임대"]), + ("A 동의어", ["창업", "지원금", "보조금", "수당"]), + ("B 띄어쓰기", ["청년 일자리", "청년일자리"]), + ("C 어순", ["지원사업", "사업 지원", "청년 월세 지원", "월세 지원 청년", "일자리 청년"]), + ("D 자연어", ["서울 청년 취업", "청년 취업 서울", "일자리를 찾고 있어요"]), + ("E 표기변형", ["k-디지털", "K디지털", "디지털", "청년도약계좌", "도약계좌"]), +] + + +def search(keyword): + qs = urllib.parse.urlencode({"keyword": keyword, "page": 1, "size": 1}) + started = time.perf_counter() + with urllib.request.urlopen(f"{API}?{qs}", timeout=10) as res: + body = json.loads(res.read().decode("utf-8")) + elapsed_ms = (time.perf_counter() - started) * 1000 + items = body["data"] + return { + "total": body["meta"]["totalCount"], + "top": items[0]["title"] if items else None, + "ms": round(elapsed_ms, 1), + } + + +def measure(out_path): + results = {} + for group, terms in TERMS: + for term in terms: + try: + results[term] = {"group": group, **search(term)} + except Exception as e: # 서버가 죽었으면 나머지도 의미 없다 + print(f"실패: {term} — {e}", file=sys.stderr) + sys.exit(1) + row = results[term] + print(f" {term:<16} {row['total']:>6}건 {row['ms']:>6}ms {row['top'] or '-'}") + with open(out_path, "w", encoding="utf-8") as f: + json.dump(results, f, ensure_ascii=False, indent=2) + print(f"\n저장: {out_path} ({len(results)}종)") + + +def compare(before_path, after_path): + before = json.load(open(before_path, encoding="utf-8")) + after = json.load(open(after_path, encoding="utf-8")) + + print("| 유형 | 검색어 | LIKE | ES | 변화 |") + print("|---|---|---:|---:|---|") + zero_before = zero_after = 0 + for term, b in before.items(): + a = after.get(term, {}) + bt, at = b["total"], a.get("total", 0) + zero_before += bt == 0 + zero_after += at == 0 + if bt == 0 and at > 0: + change = f"**0건 해소 → {at}건**" + elif bt > 0 and at == 0: + change = "**회귀(0건)**" + elif bt == at: + change = "동일" + else: + change = f"{at - bt:+d}" + print(f"| {b['group']} | `{term}` | {bt} | {at} | {change} |") + + total = len(before) + print() + print(f"0건 검색어: {zero_before}/{total} → {zero_after}/{total}") + b_ms = sum(v["ms"] for v in before.values()) / total + a_ms = sum(after[t]["ms"] for t in before if t in after) / total + print(f"평균 응답: {b_ms:.1f}ms → {a_ms:.1f}ms") + + print("\n### 1위 결과가 바뀐 검색어") + for term, b in before.items(): + a = after.get(term, {}) + if b.get("top") != a.get("top") and a.get("top"): + print(f"- `{term}`: {b.get('top') or '(없음)'} → **{a['top']}**") + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--out") + p.add_argument("--compare", nargs=2, metavar=("BEFORE", "AFTER")) + args = p.parse_args() + if args.compare: + compare(*args.compare) + elif args.out: + measure(args.out) + else: + p.error("--out 또는 --compare 중 하나가 필요합니다") + + +if __name__ == "__main__": + sys.stdout.reconfigure(encoding="utf-8") + main() diff --git a/es/helper/synonym_candidates.py b/es/helper/synonym_candidates.py new file mode 100644 index 0000000..c53fa7f --- /dev/null +++ b/es/helper/synonym_candidates.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +"""동의어 후보 검증 (개발용 도구 — 서비스 코드 아님). + +동의어는 재현율을 올리는 대신 정밀도를 깎는다. 감으로 넣으면 "취업"을 찾는 사람에게 +관계없는 정책이 섞이므로, 후보마다 실제 색인에서 다음을 재고 넣을지 판단한다. + + - 각 단어가 몇 건을 잡는가 (0건이면 그 단어는 데이터에 없는 것 — 사전에 넣어도 의미 없음) + - 두 단어의 문서 집합이 얼마나 겹치는가 + 겹침이 이미 높다 → 동의어로 묶을 실익이 적다 + 겹침이 낮다 → 묶으면 그만큼 새로 걸린다(= 살리는 건수) + +사용: + python es/helper/synonym_candidates.py # 후보 그룹 전부 검증 + python es/helper/synonym_candidates.py 취업 채용 # 임의의 단어들만 검증 +""" +import itertools +import json +import sys +import urllib.request + +ES = "http://localhost:9200" +INDEX = "policy" +FIELDS = ["title^3", "keywords^2", "organizationName^2", "description", "supportContent"] + +# 검증할 후보 그룹. 기준선 측정(docs/2026-09-05-검색-기준선-측정.md)의 유형 A 와 +# 실사용 검색 로그에서 뽑았다. +CANDIDATES = [ + ["일자리", "취업", "채용", "구직", "일거리"], + ["주거", "주택", "월세", "임대", "전세"], + ["지원금", "보조금", "수당", "장려금"], + ["창업", "스타트업", "창업자"], + ["학자금", "등록금", "장학금"], + ["대출", "융자", "이자"], + ["알바", "아르바이트"], + ["자격증", "응시료", "시험"], + ["교육", "훈련", "연수"], + ["상담", "컨설팅", "멘토링"], +] + + +def post(path, body): + req = urllib.request.Request( + ES + path, + data=json.dumps(body, ensure_ascii=False).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + return json.loads(urllib.request.urlopen(req).read().decode("utf-8")) + + +def doc_ids(word): + """그 단어가 걸리는 문서 id 집합. 동의어 적용 전 상태를 본다.""" + body = { + "size": 2000, + "_source": False, + "query": {"multi_match": {"query": word, "fields": FIELDS, "type": "best_fields"}}, + } + return {h["_id"] for h in post(f"/{INDEX}/_search", body)["hits"]["hits"]} + + +def tokens(word): + body = {"analyzer": "ko_index", "text": word} + return [t["token"] for t in post(f"/{INDEX}/_analyze", body)["tokens"]] + + +def report(group): + print(f"\n### {' / '.join(group)}") + ids = {} + for word in group: + ids[word] = doc_ids(word) + mark = " ⚠ 데이터에 없음" if not ids[word] else "" + print(f" {word:<8} {len(ids[word]):>5}건 토큰={tokens(word)}{mark}") + + print(" 겹침(자카드) / 묶으면 새로 걸리는 건수") + for a, b in itertools.combinations(group, 2): + sa, sb = ids[a], ids[b] + if not sa or not sb: + continue + jaccard = len(sa & sb) / len(sa | sb) + print(f" {a:>6} ↔ {b:<6} 겹침 {jaccard:5.0%} {a}→+{len(sb - sa):<4} {b}→+{len(sa - sb)}") + + +def main(): + groups = [sys.argv[1:]] if len(sys.argv) > 1 else CANDIDATES + for group in groups: + report(group) + + +if __name__ == "__main__": + sys.stdout.reconfigure(encoding="utf-8") + main() diff --git a/es/helper/tune_msm.py b/es/helper/tune_msm.py new file mode 100644 index 0000000..ab49b8d --- /dev/null +++ b/es/helper/tune_msm.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +"""minimum_should_match 값 비교 (개발용 도구 — 서비스 코드 아님). + +여러 단어를 입력했을 때 몇 개가 맞아야 통과시킬지를 정하는 값이다. +느슨하면 '청년' 하나만 맞아도 전부 걸려 필터 구실을 못 하고, +빡빡하면 기준선의 0건 문제로 되돌아간다. 그 사이를 실측으로 고른다. + +같은 검색어 묶음에 값만 바꿔가며 건수를 재고, 다음 둘을 함께 본다. + - 0건이 되는 검색어 수 (너무 빡빡한지) + - 전체 대비 비율이 큰 검색어 (너무 느슨한지 — 사실상 목록 조회) + +사용: + python es/helper/tune_msm.py +""" +import json +import sys +import urllib.request + +ES = "http://localhost:9200" +INDEX = "policy" +TODAY = "2026-09-06" +FIELDS = ["title^3", "keywords^2", "organizationName^2", "description", "supportContent", "sidoNames.text"] + +CANDIDATES = ["1", "2<70%", "2<-1", "3<70%", "75%", "100%"] + +QUERIES = [ + "서울 청년 취업", + "청년 취업 서울", + "청년 월세 지원", + "월세 지원 청년", + "청년 일자리", + "사업 지원", + "일자리를 찾고 있어요", + "면접 정장 지원", + "K디지털", + "청년", +] + + +def post(path, body): + req = urllib.request.Request( + ES + path, + data=json.dumps(body, ensure_ascii=False).encode("utf-8"), + headers={"Content-Type": "application/json"}, + method="POST", + ) + return json.loads(urllib.request.urlopen(req).read().decode("utf-8")) + + +def count(keyword, msm): + """서비스와 같은 필터를 걸고 센다 — 필터 없이 재면 마감된 정책까지 섞여 숫자가 오염된다.""" + body = { + "size": 0, + "track_total_hits": True, + "query": { + "bool": { + "filter": [ + {"term": {"visibility": "VISIBLE"}}, + {"term": {"adminHidden": False}}, + {"bool": {"should": [ + {"bool": {"must_not": {"exists": {"field": "applicationEndDate"}}}}, + {"range": {"applicationEndDate": {"gte": TODAY}}}], "minimum_should_match": 1}}, + {"bool": {"should": [ + {"exists": {"field": "applicationEndDate"}}, + {"bool": {"must_not": {"exists": {"field": "businessPeriodEnd"}}}}, + {"range": {"businessPeriodEnd": {"gte": TODAY}}}], "minimum_should_match": 1}}, + ], + "must": [{"multi_match": { + "query": keyword, "fields": FIELDS, + "type": "best_fields", "minimum_should_match": msm}}], + } + }, + } + return post(f"/{INDEX}/_search", body)["hits"]["total"]["value"] + + +def main(): + total = count("", "1") if False else None + # 필터만 통과하는 전체 모수 — '너무 느슨한지' 판단의 분모 + body = {"size": 0, "track_total_hits": True, "query": {"match_all": {}}} + corpus = post(f"/{INDEX}/_search", body)["hits"]["total"]["value"] + + header = "검색어".ljust(22) + "".join(m.rjust(9) for m in CANDIDATES) + print(header) + print("-" * len(header)) + zeros = {m: 0 for m in CANDIDATES} + floods = {m: 0 for m in CANDIDATES} + for q in QUERIES: + row = q.ljust(22) + for m in CANDIDATES: + c = count(q, m) + zeros[m] += c == 0 + floods[m] += c > corpus * 0.3 + row += str(c).rjust(9) + print(row) + + print("-" * len(header)) + print("0건 검색어".ljust(22) + "".join(str(zeros[m]).rjust(9) for m in CANDIDATES)) + print("전체30%초과".ljust(22) + "".join(str(floods[m]).rjust(9) for m in CANDIDATES)) + print(f"\n색인 문서 {corpus}건 기준") + + +if __name__ == "__main__": + sys.stdout.reconfigure(encoding="utf-8") + main() diff --git a/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java b/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java index 435a8f0..3f9341c 100644 --- a/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java +++ b/src/main/java/com/bop/youthpick/policy/controller/PolicyController.java @@ -5,6 +5,7 @@ import com.bop.youthpick.policy.dto.PolicyCardResponse; import com.bop.youthpick.policy.dto.PolicyDetailResponse; import com.bop.youthpick.policy.service.PolicyService; +import com.bop.youthpick.search.dto.PolicyFacets; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import java.util.List; @@ -54,6 +55,29 @@ public ApiResponse> getCards( return ApiResponse.ok(page.getContent(), page); } + /** + * 필터 UI 에 붙일 카테고리·지역별 건수. 목록 조회와 같은 파라미터를 그대로 받는다(page/size 만 없다). + * + *

각 목록은 자기 자신의 필터를 뺀 조건에서 센 값이라, 카테고리를 고른 상태에서도 다른 분류의 건수가 보인다. + */ + @Operation( + summary = "정책 필터 건수 조회", + description = + "필터 UI 에 붙일 카테고리·지역별 건수(비회원 허용). 목록 조회와 같은 파라미터를 받는다. 각 목록은 자기 자신의 필터를 뺀" + + " 조건에서 세므로 카테고리를 고른 상태에서도 다른 분류의 건수가 보인다. 빈 목록은 0건이 아니라 집계 불가(ES 미사용/장애)를" + + " 뜻한다.") + @GetMapping("/facets") + public ApiResponse getFacets( + @RequestParam(required = false) String category, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String region, + @RequestParam(required = false) Integer ageMin, + @RequestParam(required = false) Integer ageMax, + @RequestParam(required = false) String jobCode) { + return ApiResponse.ok( + policyService.getFacets(category, keyword, region, ageMin, ageMax, jobCode)); + } + /** 정책 상세 조회 (비회원 허용). 로그인 사용자의 조회는 최근 본 정책으로 기록된다. */ @Operation(summary = "정책 상세 조회", description = "정책 상세 조회(비회원 허용). 로그인 사용자의 조회는 최근 본 정책으로 기록된다.") @GetMapping("/{policyId}") diff --git a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java index cd487be..81708f4 100644 --- a/src/main/java/com/bop/youthpick/policy/service/PolicyService.java +++ b/src/main/java/com/bop/youthpick/policy/service/PolicyService.java @@ -11,6 +11,7 @@ import com.bop.youthpick.policy.repository.PolicyRegionRepository; import com.bop.youthpick.policy.repository.PolicyRepository; import com.bop.youthpick.search.config.PolicySearchProperties; +import com.bop.youthpick.search.dto.PolicyFacets; import com.bop.youthpick.search.dto.PolicySearchQuery; import com.bop.youthpick.search.dto.PolicySearchResult; import com.bop.youthpick.search.service.PolicySearchService; @@ -92,17 +93,15 @@ private Page searchViaElasticsearch( Pageable pageable) throws Exception { String searchKeyword = trimToNull(keyword); - String regionFilter = trimToNull(region); PolicySearchResult result = policySearchService.search( - new PolicySearchQuery( - searchKeyword, - trimToNull(category), - NATIONWIDE_REGION.equals(regionFilter) ? null : regionFilter, + toSearchQuery( + category, + keyword, + region, ageMin, ageMax, - trimToNull(jobCode), - LocalDate.now(), + jobCode, pageable.getPageNumber(), pageable.getPageSize())); @@ -123,6 +122,57 @@ private Page searchViaElasticsearch( return new PageImpl<>(toCards(policies), pageable, result.total()); } + /** + * 필터 UI 에 붙일 카테고리·지역별 건수. 목록과 같은 파라미터를 받아 같은 조건에서 센다. + * + *

목록 응답에 끼워 넣지 않고 따로 뺀 이유: 이미 쓰이고 있는 응답 형태를 바꾸지 않고, 필터 바를 다시 그릴 때만 부르면 되기 때문이다. + * + *

ES 가 죽으면 빈 결과를 준다. MySQL 로 같은 집계를 하려면 8개 조건이 걸린 {@code GROUP BY} 를 두 벌 더 만들어야 하는데, + * 목록은 폴백으로 계속 뜨고 건수 배지만 사라지는 정도라 그 값을 하지 않는다. + */ + @Transactional(readOnly = true) + public PolicyFacets getFacets( + @Nullable String category, + @Nullable String keyword, + @Nullable String region, + @Nullable Integer ageMin, + @Nullable Integer ageMax, + @Nullable String jobCode) { + if (!searchProperties.enabled()) { + return PolicyFacets.empty(); + } + try { + return policySearchService.facets( + toSearchQuery(category, keyword, region, ageMin, ageMax, jobCode, 0, 0)); + } catch (Exception e) { + log.warn("ES 패싯 집계 실패 — 건수 없이 응답합니다", e); + return PolicyFacets.empty(); + } + } + + /** 컨트롤러 파라미터를 검색 조건으로 옮긴다. 목록과 패싯이 같은 조건에서 돌아야 해서 한 곳에서만 만든다. */ + private PolicySearchQuery toSearchQuery( + @Nullable String category, + @Nullable String keyword, + @Nullable String region, + @Nullable Integer ageMin, + @Nullable Integer ageMax, + @Nullable String jobCode, + int page, + int size) { + String regionFilter = trimToNull(region); + return new PolicySearchQuery( + trimToNull(keyword), + trimToNull(category), + NATIONWIDE_REGION.equals(regionFilter) ? null : regionFilter, + ageMin, + ageMax, + trimToNull(jobCode), + LocalDate.now(), + page, + size); + } + private Page searchViaMysql( @Nullable String category, @Nullable String keyword, diff --git a/src/main/java/com/bop/youthpick/search/dto/PolicyFacets.java b/src/main/java/com/bop/youthpick/search/dto/PolicyFacets.java new file mode 100644 index 0000000..991b59f --- /dev/null +++ b/src/main/java/com/bop/youthpick/search/dto/PolicyFacets.java @@ -0,0 +1,20 @@ +package com.bop.youthpick.search.dto; + +import java.util.List; + +/** + * 필터 UI 에 함께 보여줄 항목별 건수. + * + *

각 목록은 자기 자신의 필터를 뺀 조건에서 센 값이다. 카테고리=주거를 고른 상태에서 카테고리 건수까지 주거로 걸러 세면 나머지 넷이 전부 0이 되어 + * 사용자가 다른 분류로 옮겨갈 수 없다(막다른 골목). 지역 건수는 반대로 주거 조건을 적용한 값이라야 "주거 정책 중 서울 139건"이 된다. + * + *

빈 목록은 "0건"이 아니라 집계하지 못했다는 뜻이다 — ES 가 죽으면 폴백 경로에는 집계가 없다. + */ +public record PolicyFacets(List categories, List regions) { + + public record FacetCount(String key, long count) {} + + public static PolicyFacets empty() { + return new PolicyFacets(List.of(), List.of()); + } +} diff --git a/src/main/java/com/bop/youthpick/search/service/PolicySearchService.java b/src/main/java/com/bop/youthpick/search/service/PolicySearchService.java index 7f44f26..21b77b7 100644 --- a/src/main/java/com/bop/youthpick/search/service/PolicySearchService.java +++ b/src/main/java/com/bop/youthpick/search/service/PolicySearchService.java @@ -10,6 +10,7 @@ import com.bop.youthpick.policy.entity.Policy; import com.bop.youthpick.policy.entity.PolicyVisibility; import com.bop.youthpick.search.config.PolicySearchProperties; +import com.bop.youthpick.search.dto.PolicyFacets; import com.bop.youthpick.search.dto.PolicySearchQuery; import com.bop.youthpick.search.dto.PolicySearchResult; import java.time.LocalDate; @@ -39,6 +40,18 @@ public class PolicySearchService { // 지역명으로도 검색되게 한다(#199). keyword 필드는 정확 일치라 형태소 분석된 하위 필드를 쓴다. "sidoNames.text"); + /** 검색어가 지역명과 맞을 때 그 지역 전용 정책에 주는 가산점. 실측으로 고른 값이다. */ + private static final float REGION_SPECIFIC_BOOST = 5.0f; + + /** filter 집계 안에 들어가는 terms 집계 이름. 두 패싯이 같은 모양이라 이름도 공유한다. */ + private static final String VALUES = "values"; + + /** 표준 5분류. 늘어날 것을 대비해 여유를 뒀다. */ + private static final int CATEGORY_BUCKETS = 10; + + /** 시도 17개. 기본값 10을 그대로 두면 7개가 말없이 잘린다. */ + private static final int REGION_BUCKETS = 20; + private final ElasticsearchClient client; private final PolicySearchProperties properties; @@ -53,7 +66,7 @@ public long count() { } public PolicySearchResult search(PolicySearchQuery query) throws Exception { - BoolQuery bool = bool(query); + BoolQuery bool = bool(query, true); SearchResponse response = client.search( request -> @@ -83,11 +96,74 @@ public PolicySearchResult search(PolicySearchQuery query) throws Exception { return new PolicySearchResult(policyIds, total); } + /** + * 필터 UI 에 붙일 카테고리·지역별 건수. 목록 조회와 같은 조건에서 세되, 각 항목은 자기 자신의 필터만 뺀다. + * + *

구현은 "기준 질의 + 패싯별 filter 집계"다. 기준 질의에서 카테고리·지역 필터를 빼 두고, 카테고리 건수를 셀 때만 지역 필터를 다시 씌우고 + * 지역 건수를 셀 때만 카테고리 필터를 다시 씌운다. 이렇게 해야 "주거를 고른 상태에서도 다른 분류의 건수가 보인다". + * + *

hit 은 필요 없으므로 {@code size(0)} 이다 — 문서를 한 건도 실어 나르지 않고 숫자만 받는다. + */ + public PolicyFacets facets(PolicySearchQuery query) throws Exception { + BoolQuery base = bool(query, false); + Query categoryFilter = query.category() == null ? matchAll() : categoryFilter(query.category()); + Query regionFilter = query.sidoName() == null ? matchAll() : regionFilter(query.sidoName()); + + SearchResponse response = + client.search( + request -> + request.index(properties.alias()) + .size(0) + .query(q -> q.bool(base)) + .aggregations( + "categories", + a -> + a.filter(regionFilter) + .aggregations( + VALUES, + sub -> + sub.terms( + t -> + t.field( + "category") + .size( + CATEGORY_BUCKETS)))) + .aggregations( + "regions", + a -> + a.filter(categoryFilter) + .aggregations( + VALUES, + sub -> + sub.terms( + t -> + t.field( + "sidoNames") + .size( + REGION_BUCKETS)))), + Void.class); + + return new PolicyFacets(buckets(response, "categories"), buckets(response, "regions")); + } + + /** + * filter 집계 안의 terms 결과를 꺼낸다. terms 집계는 기본 상위 10개만 돌려주고 나머지는 말없이 버리므로 버킷 수를 명시했다 — + * 시도는 17개라 기본값이면 7개가 조용히 사라진다. + */ + private static List buckets(SearchResponse response, String name) { + return response.aggregations().get(name).filter().aggregations().get(VALUES).sterms() + .buckets().array().stream() + .map(b -> new PolicyFacets.FacetCount(b.key().stringValue(), b.docCount())) + .toList(); + } + /** * 질의 조립. filter 절은 점수에 영향을 주지 않고 걸러내기만 하고, should 절은 걸러내지 않고 점수만 올린다 — MySQL 이 * {@code case when ... then 1 else 0} 정렬로 "후순위"를 표현했던 것을 ES 에서는 "가산점"으로 뒤집어 표현한다. + * + * @param withDrilldown 카테고리·지역 필터를 포함할지. 패싯 집계는 이 둘을 뺀 기준 집합에서 시작한다. */ - private BoolQuery bool(PolicySearchQuery query) { + private BoolQuery bool(PolicySearchQuery query, boolean withDrilldown) { BoolQuery.Builder bool = new BoolQuery.Builder(); // ── 노출 조건 ── 삭제된 정책은 애초에 색인되지 않으므로 조건이 없다. @@ -103,12 +179,11 @@ private BoolQuery bool(PolicySearchQuery query) { missing("businessPeriodEnd"), onOrAfter("businessPeriodEnd", query.today()))); - if (query.category() != null) { - bool.filter(f -> f.term(t -> t.field("category").value(query.category()))); + if (withDrilldown && query.category() != null) { + bool.filter(categoryFilter(query.category())); } - if (query.sidoName() != null) { - // MySQL 의 EXISTS 서브쿼리가 배열 필드 term 하나로 줄었다. - bool.filter(f -> f.term(t -> t.field("sidoNames").value(query.sidoName()))); + if (withDrilldown && query.sidoName() != null) { + bool.filter(regionFilter(query.sidoName())); // 전국 정책은 모든 시도에 걸려 있어 지역 검색에 항상 잡힌다. 지역 특화 정책을 위로 올린다. bool.should(s -> s.term(t -> t.field("nationwide").value(false))); } @@ -142,16 +217,59 @@ private BoolQuery bool(PolicySearchQuery query) { mm -> mm.query(query.keyword()) .fields(SEARCH_FIELDS) - // best_fields: 한 필드에 몰려 맞은 문서를 여러 필드에 흩어져 - // 맞은 문서보다 높게 본다. - .type(TextQueryType.BestFields) + // cross_fields: 여러 필드를 한 덩어리처럼 본다. + // best_fields 는 "한 필드 안에서" 조건을 세기 때문에, + // "서울 청년 취업"처럼 지역(sidoNames)과 제목에 단어가 + // 나뉘어 있으면 어떤 필드도 전부 갖지 못해 걸러졌다. + .type(TextQueryType.CrossFields) // 단어가 3개 이상이면 70% 이상 맞아야 한다. 기본값(OR)은 // '청년'처럼 흔한 한 단어만 맞아도 전부 걸려 필터 구실을 못 한다. .minimumShouldMatch("2<70%"))); + // 검색어에 지역명이 들어 있으면 그 지역 전용 정책을 전국 정책보다 위로 올린다(#199 후속). + // 전국 정책은 모든 시도에 지역 행이 걸려 있어 지역명 검색에 항상 잡히는데, 이 규칙이 없으면 + // "서울 청년 취업" 상위가 전부 전국 정책으로 채워진다. + // 가산점 5는 실측으로 정했다 — 없으면 전국이 상위를 독점하고, 15면 여러 시도를 걸친 정책이 + // 해당 지역 전용 정책보다 앞선다(es/helper 로 상위 결과를 비교). + bool.should( + s -> + s.bool( + b -> + b.must( + m -> + m.match( + mt -> + mt.field( + "sidoNames.text") + .query( + query + .keyword()))) + .must( + m -> + m.term( + t -> + t.field( + "nationwide") + .value( + false))) + .boost(REGION_SPECIFIC_BOOST))); } return bool.build(); } + /** 목록 조회의 filter 절과 패싯 집계의 filter 가 같은 조건이어야 해서 따로 뽑았다. */ + private static Query categoryFilter(String category) { + return Query.of(q -> q.term(t -> t.field("category").value(category))); + } + + /** MySQL 의 EXISTS 서브쿼리가 배열 필드 term 하나로 줄었다. */ + private static Query regionFilter(String sidoName) { + return Query.of(q -> q.term(t -> t.field("sidoNames").value(sidoName))); + } + + private static Query matchAll() { + return Query.of(q -> q.matchAll(m -> m)); + } + /** 여러 조건 중 하나만 맞으면 통과하는 filter 절(SQL 의 OR). */ private static Query anyMatch(Query... alternatives) { return Query.of( diff --git a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java index 6dd3485..f2e997a 100644 --- a/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java +++ b/src/test/java/com/bop/youthpick/policy/service/PolicyServiceTest.java @@ -33,6 +33,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import com.bop.youthpick.search.config.PolicySearchProperties; +import com.bop.youthpick.search.dto.PolicyFacets; import com.bop.youthpick.search.service.PolicySearchService; import org.springframework.beans.BeanUtils; import org.springframework.dao.DataAccessResourceFailureException; @@ -325,6 +326,34 @@ void setUp() { assertThat(ageMaxCaptor.getValue()).isEqualTo(34); } + @Test + void 검색_전환이_꺼져_있으면_패싯은_ES를_부르지_않고_빈_결과다() throws Exception { + PolicyFacets facets = policyService.getFacets(null, null, null, null, null, null); + + assertThat(facets.categories()).isEmpty(); + assertThat(facets.regions()).isEmpty(); + verify(policySearchService, never()).facets(any()); + } + + @Test + void 패싯_집계가_실패해도_예외를_던지지_않고_빈_결과를_준다() throws Exception { + // 건수 배지는 부가 정보다. 여기서 예외가 새면 목록까지 못 그리게 되므로 삼키고 빈 결과를 준다. + PolicyService esEnabled = + new PolicyService( + policyRepository, + policyRegionRepository, + policyRecentViewService, + searchLogService, + policySearchService, + new PolicySearchProperties(true, "policy")); + when(policySearchService.facets(any())).thenThrow(new java.io.IOException("ES down")); + + PolicyFacets facets = esEnabled.getFacets("주거", null, null, null, null, null); + + assertThat(facets.categories()).isEmpty(); + assertThat(facets.regions()).isEmpty(); + } + private Policy newPolicy(Long id, String title) { Policy policy = BeanUtils.instantiateClass(Policy.class); ReflectionTestUtils.setField(policy, "id", id);