diff --git a/docs/kangcheolung/issue-92-search-quality.md b/docs/kangcheolung/issue-92-search-quality.md new file mode 100644 index 0000000..7c1ca4a --- /dev/null +++ b/docs/kangcheolung/issue-92-search-quality.md @@ -0,0 +1,173 @@ +# 이슈 #92 — 약 검색 품질 재설계 (초성·edge n-gram·오타·관련도) + +> 브랜치: `feature/92` · 관련 이슈: [PIUDAProject/Backend#92](https://github.com/PIUDAProject/Backend/issues/92) +> +> 전제: #90(무중단 재색인 인프라) — 매핑을 바꾼 뒤 `POST /api/admin/drugs/reindex` 한 번으로 반영 + +--- + +## 1. 배경 + +### 기존 + +`DrugDocument.itemName` = `text` 필드 1개(기본 애널라이저), 쿼리는 `match_phrase_prefix` 하나. + +```text +"타이레놀정500밀리그램(아세트아미노펜)" + → standard 토크나이저 → ["타이레놀정500밀리그램", "아세트아미노펜"] (한글 런은 통짜) +``` + +### 안 되던 것 + +| 입력 | 기존 | 이유 | +|---|---|---| +| `ㅌㅇㄹㄴ` | ❌ 0건 | 초성 개념이 색인에 없음 | +| `타이레올` | ❌ 0건 | 통짜 토큰이라 fuzzy가 무력 | +| `서방정` (중간 단어) | ❌ 0건 | prefix만 매칭 | +| `아세트아미노펜` (괄호 속 성분명) | △ | standard가 괄호는 분리하나 정확 토큰만 매칭 | +| 결과 순서 | 무작위 | `match_phrase_prefix`는 스코어 변별력 약함 | + +--- + +## 2. 설계 — `itemName` 한 값을 여러 방식으로 색인 + bool.should 조합 + +### 멀티필드 + +| 필드 | 색인 애널라이저 | 검색 애널라이저 | 용도 | +|---|---|---|---| +| `itemName` | `drug_search_analyzer` (standard + lowercase) | 동일 | 시작 매칭, 관련도 기준 | +| `itemName.keyword` | (keyword) | (keyword) | 이름 전체 완전 일치 | +| `itemName.autocomplete` | `drug_edge_ngram_analyzer` (edge_ngram 1~20) | `drug_search_analyzer` | 앞에서부터 타이핑하는 자동완성 | +| `itemName.ngram` | `drug_ngram_analyzer` (ngram 2~4) | `drug_ngram_analyzer` | 중간 단어·성분명·끝자리 오타 (ngram 겹침) | +| `itemNameChosung` | `keyword` (통짜 토큰) | `keyword` | 초성 검색 (`match_phrase_prefix` 로 접두 매칭 → 길이 제한 없음) | + +- 애널라이저 정의: `src/main/resources/elasticsearch/drug-info-settings.json` (`@Setting`) +- `number_of_replicas: 0` 명시 (single-node → 클러스터 green) +- `edge_ngram`/`ngram` 토크나이저의 `token_chars: [letter, digit]` → `(`, `)`, `,`, `:` 는 토큰 경계 + → `"타치온정(글루타티온)"` 에서 `글루타티온` 이 독립적으로 ngram 색인됨 + +### 초성 처리 + +ES에 한글 자모 분해기가 없으므로 **색인 시점에 Java로** 생성한다. + +```text +HangulChosungExtractor.extract("타이레놀정500") → "ㅌㅇㄹㄴㅈ500" +``` + +`DrugInfoConverter.toDocument` 에서 `itemNameChosung` 필드에 주입한다. +검색 시점에는 변환이 필요 없다 — 사용자가 입력한 `ㅌㅇㄹㄴ` 을 그대로 `itemNameChosung` 에 `match_phrase_prefix`(접두) 매칭한다. +`itemNameChosung` 을 통짜 토큰(`keyword`)으로 색인하므로 **입력 길이 제한이 없다**. +사용자가 완성형(`타이레`)을 입력하면 이 필드엔 매칭 0이라, 쿼리에 항상 포함해도 무해하다. + +### 검색 쿼리 (`DrugSearchRepository.searchByItemName`) + +```text +bool.should (minimum_should_match: 1): + term(itemName.keyword) boost 20 ← 이름 전체 완전 일치 (최우선) + match_phrase_prefix(itemName) boost 5 ← "입력한 그대로 시작" + match(itemName.autocomplete) boost 2 ← edge n-gram 자동완성 + match(itemName, fuzziness=AUTO) boost 2 ← 짧은 약품명의 1글자 오타 + match(itemName.ngram, minimum_should_match=50%) boost 1 ← 중간 단어·성분명·끝자리 오타 + match_phrase_prefix(itemNameChosung) boost 3 ← 초성 +``` + +boost로 완전/시작 일치가 상단에 오고, `ElasticsearchRepository` `@Query` 는 기본 `_score` 내림차순이라 서비스는 순서 그대로 매핑한다. + +--- + +## 3. 변경 파일 + +### 신규 + +| 파일 | 역할 | +|---|---| +| `global/util/HangulChosungExtractor` | 완성형 한글 → 초성 문자열, `isChosungOnly` 판별 | +| `resources/elasticsearch/drug-info-settings.json` | 커스텀 애널라이저 3개 + replica 0 | +| `scripts/drug-search-bench.py` | before/after Recall@10 / MRR / latency 벤치 | + +### 수정 + +| 파일 | 변경 | +|---|---| +| `DrugDocument` | `@Setting` 추가 / `itemName` → `@MultiField`(keyword, autocomplete, ngram) / `itemNameChosung` 필드 추가 | +| `DrugInfoConverter.toDocument` | `itemNameChosung = HangulChosungExtractor.extract(itemName)` | +| `DrugSearchRepository.searchByItemName` | `match_phrase_prefix` 단일 → `bool.should` 6절 | +| `DrugSearchQueryService.search` | 주석만 (시그니처·흐름 동일) | + +폴백 경로(`DrugInfoController` → MySQL `LIKE`)와 색인 파이프라인(#90 `DrugIndexManager`)은 그대로. +`DrugIndexManager.createTimestampedIndex()`가 `@Setting`/`@MultiField`를 그대로 반영하므로 매핑 배포는 재색인 API 한 번. + +--- + +## 4. before/after 측정 (로컬, 실제 4,745건) + +`scripts/drug-search-bench.py` — 동일 4,745건으로 old(기존 매핑 + `match_phrase_prefix`) / new(신규 매핑 + `bool.should`) 벤치 인덱스를 만들어 라벨링 테스트셋 24개로 비교. + +**정답 판별**: 결과 `itemName` 에 해당 쿼리의 기대 부분문자열 포함 여부. +**Recall@10** = (상위 10건 중 정답 수) / min(전체 정답 수, 10) · **MRR** = 1 / (첫 정답 순위). + +| 지표 | old | new | +|---|---|---| +| **Recall@10 (평균)** | **0.471** | **0.978** | +| **MRR (평균)** | **0.521** | **0.979** | + +| 유형 | old R@10 | new R@10 | 비고 | +|---|---|---|---| +| 정확/시작 (타이레놀, 게보린 등 10개) | 0.83 | 1.00 | new는 완전 일치가 1위 | +| 초성 (ㅌㅇㄹㄴ, ㄱㅂㄹ 등 4개) | 0.00 | 0.89 | 신규 | +| 오타 (타이레올, 게보른 등 4개) | 0.00 | 1.00 | 신규 | +| 중간 단어 (서방정, 현탁액) | 0.00 | 1.00 | 신규 | +| 성분명 (아세트아미노펜 등 4개) | 0.75 | 0.98 | ngram으로 개선 | + +### latency (동일 쿼리 50회, size=20) + +| 쿼리 | old p50 / p95 | new p50 / p95 | +|---|---|---| +| `타이레` | 1.5 / 2.9 ms | 2.1 / 3.0 ms | +| `게보린` | 1.4 / 1.8 ms | 1.9 / 2.7 ms | +| `아세트아미노펜` | 1.6 / 2.5 ms | 4.1 / 5.3 ms | + +- should 절이 6개로 늘어 쿼리당 **약 +1~3ms** (ngram 매칭이 많은 성분명 쿼리가 가장 큼). +- 절대값은 여전히 한 자릿수 ms. 로컬 단일 노드 + 캐시된 상태 기준. +- 부팅 시 재색인 4,745건 ≈ 1.6초. + +### 알려진 한계 + +**한국어 형태소 분석(nori) 미도입** → 약품명이 통짜 토큰이라 `타이래놀`(레→래, 중간 음절 오타)처럼 **중간 위치 오타**는 잘 못 잡는다. + +- prefix 오타, 끝자리 오타, 짧은 이름 오타는 커버됨 +- nori를 넣으면 `타이레놀정` → `타이레놀`+`정` 형태소 분리 → 중간 오타도 fuzzy로 잡힘 +- nori는 ES 8.x 기본 이미지에 없어 커스텀 Docker 이미지 필요 → **2단계로 분리** (측정 후 필요하면) + +`마그네슘`(성분명) 은 R@10 0.90 이지만 MRR 0.50 — 첫 결과가 마그네슘 제품이 아님(ngram 노이즈). `function_score` 로 조정 여지. + +--- + +## 5. 수동 검증 절차 (로컬) + +```bash +# 1. 새 매핑으로 재색인 (alias 있는 상태면 API, 없으면 부팅 시 자동) +# 로컬에서 강제로 새로 만들려면: +curl -s -XDELETE "localhost:9200/$(curl -s 'localhost:9200/_cat/aliases/drug_info?h=index')" +# → 앱 재기동 시 DrugIndexingInitializer가 새 매핑으로 부트스트랩 + +# 2. 매핑 확인 +curl -s localhost:9200/drug_info/_mapping | jq '.[].mappings.properties.itemName, .[].mappings.properties.itemNameChosung' + +# 3. 검색 +for q in 타이레놀 ㅌㅇㄹㄴ 타이레올 게보른 서방정 아세트아미노펜; do + echo "$q:"; curl -s "localhost:8080/api/search/drugs?keyword=$q" | jq -r '.data[:3][].itemName' +done + +# 4. before/after 벤치 (drug_info 가 신규 매핑이어야 함) +python3 scripts/drug-search-bench.py +``` + +--- + +## 6. 후속 + +- **nori 2단계**: 커스텀 ES 이미지(`elasticsearch-plugin install analysis-nori`) + `nori_tokenizer` 애널라이저 추가. 중간 음절 오타 + 관련도 개선 +- **동의어**: 성분명 ↔ 대표 제품명 사전 (별도 이슈) +- **관련도 미세조정**: `function_score` 로 필드 길이 정규화 완화 (짧은 이름이 과하게 상위로 오는 경우) +- **테스트셋 확장**: 현재 24개 → 50개+, 실제 검색 로그 기반 diff --git a/scripts/drug-search-bench.py b/scripts/drug-search-bench.py new file mode 100644 index 0000000..cc64e20 --- /dev/null +++ b/scripts/drug-search-bench.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +""" +약 검색 품질 before/after 벤치마크 (이슈 #92). + +로컬 Elasticsearch의 `drug_info` 인덱스에서 문서를 뽑아 두 벤치 인덱스를 만든다. + - drug_bench_old : 기존 매핑(itemName: text 기본 애널라이저), match_phrase_prefix + - drug_bench_new : 신규 매핑(멀티필드 + 초성), bool.should 조합 +라벨링 테스트셋으로 Recall@10 / MRR / latency 를 비교 출력한다. + +사용: 앱을 한 번 띄워 drug_info 를 신규 매핑으로 재색인한 뒤 + python3 scripts/drug-search-bench.py +""" +import json +import statistics +import sys +import time +import urllib.request + +ES = "http://localhost:9200" +SETTINGS_PATH = "src/main/resources/elasticsearch/drug-info-settings.json" + +# (query, 정답 판별용 부분문자열, 유형) +TESTSET = [ + ("타이레놀", "타이레놀", "정확/시작"), + ("타이레", "타이레놀", "시작"), + ("타이레놀정", "타이레놀정", "정확/시작"), + ("게보린", "게보린", "정확/시작"), + ("판피린", "판피린", "시작"), + ("겔포스", "겔포스", "시작"), + ("이지엔", "이지엔", "시작"), + ("훼스탈", "훼스탈", "시작"), + ("부루펜", "부루펜", "시작"), + ("까스활명수", "까스활명수", "정확/시작"), + ("ㅌㅇㄹㄴ", "타이레놀", "초성"), + ("ㄱㅂㄹ", "게보린", "초성"), + ("ㅍㅍㄹ", "판피린", "초성"), + ("ㄱㅍㅅ", "겔포스", "초성"), + ("타이레올", "타이레놀", "끝오타"), + ("게보른", "게보린", "짧은이름오타"), + ("판피링", "판피린", "끝오타"), + ("겔포수", "겔포스", "끝오타"), + ("서방정", "서방정", "중간단어"), + ("현탁액", "현탁액", "중간단어"), + ("아세트아미노펜", "아세트아미노펜", "성분명"), + ("이부프로펜", "이부프로펜", "성분명"), + ("클로르페니라민", "클로르페니라민", "성분명"), + ("마그네슘", "마그네슘", "성분명"), +] + +OLD_MAPPING = {"properties": {"itemName": {"type": "text"}, "itemNameChosung": {"type": "text"}}} +NEW_MAPPING = {"properties": { + "itemName": {"type": "text", "analyzer": "drug_search_analyzer", "fields": { + "keyword": {"type": "keyword"}, + "autocomplete": {"type": "text", "analyzer": "drug_edge_ngram_analyzer", + "search_analyzer": "drug_search_analyzer"}, + "ngram": {"type": "text", "analyzer": "drug_ngram_analyzer", + "search_analyzer": "drug_ngram_analyzer"}}}, + "itemNameChosung": {"type": "text", "analyzer": "keyword", "search_analyzer": "keyword"}}} + + +def req(method, path, body=None): + r = urllib.request.Request( + ES + path, method=method, + data=json.dumps(body).encode() if body is not None else None, + headers={"Content-Type": "application/json"}) + return json.load(urllib.request.urlopen(r)) + + +def bulk_load(index, docs): + lines = [] + for d in docs: + lines.append('{"index":{}}') + lines.append(json.dumps({"itemName": d["itemName"], + "itemNameChosung": d.get("itemNameChosung", "")}, ensure_ascii=False)) + raw = ("\n".join(lines) + "\n").encode() + r = urllib.request.Request(f"{ES}/{index}/_bulk?refresh", method="POST", data=raw, + headers={"Content-Type": "application/x-ndjson"}) + if json.load(urllib.request.urlopen(r)).get("errors"): + print("bulk errors", file=sys.stderr) + + +def old_query(kw): + return {"match_phrase_prefix": {"itemName": {"query": kw, "max_expansions": 50}}} + + +def new_query(kw): + return {"bool": {"minimum_should_match": 1, "should": [ + {"term": {"itemName.keyword": {"value": kw, "boost": 20.0}}}, + {"match_phrase_prefix": {"itemName": {"query": kw, "boost": 5.0}}}, + {"match": {"itemName.autocomplete": {"query": kw, "boost": 2.0}}}, + {"match": {"itemName": {"query": kw, "fuzziness": "AUTO", "boost": 2.0}}}, + {"match": {"itemName.ngram": {"query": kw, "minimum_should_match": "50%", "boost": 1.0}}}, + {"match_phrase_prefix": {"itemNameChosung": {"query": kw, "boost": 3.0}}}, + ]}} + + +def evaluate(index, qfn, docs): + recalls, rrs = [], [] + for kw, needle, _ in TESTSET: + hits = req("POST", f"/{index}/_search", + {"size": 10, "_source": ["itemName"], "query": qfn(kw)})["hits"]["hits"] + names = [h["_source"]["itemName"] for h in hits] + rel = [needle in n for n in names] + total_rel = sum(1 for d in docs if needle in d["itemName"]) + recalls.append(sum(rel) / min(total_rel, 10) if total_rel else 0.0) + rrs.append(next((1.0 / (i + 1) for i, ok in enumerate(rel) if ok), 0.0)) + return recalls, rrs + + +def latency(index, qfn, kw, n=50): + ts = [] + for _ in range(n): + s = time.perf_counter() + req("POST", f"/{index}/_search", {"size": 20, "query": qfn(kw)}) + ts.append((time.perf_counter() - s) * 1000) + ts.sort() + return statistics.median(ts), ts[int(n * 0.95)] + + +def main(): + docs = [] + r = req("POST", "/drug_info/_search?scroll=2m", {"size": 1000, "query": {"match_all": {}}}) + sid = r["_scroll_id"] + while r["hits"]["hits"]: + docs += [h["_source"] for h in r["hits"]["hits"]] + r = req("POST", "/_search/scroll", {"scroll": "2m", "scroll_id": sid}) + print(f"수집: {len(docs)}건") + + settings = json.load(open(SETTINGS_PATH)) + for name, s, m in [("drug_bench_old", None, OLD_MAPPING), ("drug_bench_new", settings, NEW_MAPPING)]: + try: + req("DELETE", "/" + name) + except urllib.error.HTTPError: + pass + body = {"mappings": m} + if s: + body["settings"] = s + req("PUT", "/" + name, body) + bulk_load(name, docs) + + or_, orr = evaluate("drug_bench_old", old_query, docs) + nr, nrr = evaluate("drug_bench_new", new_query, docs) + + print("\n%-16s %-13s | %-9s %-9s | %-8s %-8s" + % ("query", "유형", "old R@10", "new R@10", "old RR", "new RR")) + print("-" * 78) + for (kw, _, typ), a, b, c, d in zip(TESTSET, or_, nr, orr, nrr): + print("%-16s %-13s | %-9.2f %-9.2f | %-8.2f %-8.2f" % (kw, typ, a, b, c, d)) + print("-" * 78) + print("%-16s %-13s | %-9.3f %-9.3f | %-8.3f %-8.3f" + % ("평균", "", statistics.mean(or_), statistics.mean(nr), + statistics.mean(orr), statistics.mean(nrr))) + + print("\n--- latency (동일 쿼리 50회, size=20) ---") + for kw in ["타이레", "게보린", "아세트아미노펜"]: + op50, op95 = latency("drug_bench_old", old_query, kw) + np50, np95 = latency("drug_bench_new", new_query, kw) + print(f" '{kw}': old p50={op50:.1f}ms p95={op95:.1f}ms | new p50={np50:.1f}ms p95={np95:.1f}ms") + + for name in ["drug_bench_old", "drug_bench_new"]: + req("DELETE", "/" + name) + print("\n벤치 인덱스 정리 완료") + + +if __name__ == "__main__": + main() diff --git a/src/main/java/com/piuda/callcare/domain/druginfo/converter/DrugInfoConverter.java b/src/main/java/com/piuda/callcare/domain/druginfo/converter/DrugInfoConverter.java index 4c3b767..26b94a8 100644 --- a/src/main/java/com/piuda/callcare/domain/druginfo/converter/DrugInfoConverter.java +++ b/src/main/java/com/piuda/callcare/domain/druginfo/converter/DrugInfoConverter.java @@ -6,6 +6,7 @@ import com.piuda.callcare.domain.druginfo.dto.response.DrugSyncHistoryResponse; import com.piuda.callcare.domain.druginfo.entity.DrugInfo; import com.piuda.callcare.domain.druginfo.entity.DrugSyncHistory; +import com.piuda.callcare.global.util.HangulChosungExtractor; import org.springframework.stereotype.Component; @Component @@ -25,10 +26,12 @@ public DrugSyncHistoryResponse toSyncHistoryResponse(DrugSyncHistory history) { } // DrugInfo(MySQL) → DrugDocument(Elasticsearch 색인용) + // itemNameChosung: 초성 검색용 문자열을 색인 시점에 생성 (ES에 한글 자모 분해기가 없어 Java에서 만든다) public DrugDocument toDocument(DrugInfo drugInfo) { return DrugDocument.builder() .itemSeq(drugInfo.getItemSeq()) .itemName(drugInfo.getItemName()) + .itemNameChosung(HangulChosungExtractor.extract(drugInfo.getItemName())) .entpName(drugInfo.getEntpName()) .prductType(drugInfo.getPrductType()) .spcltyPblc(drugInfo.getSpcltyPblc()) diff --git a/src/main/java/com/piuda/callcare/domain/druginfo/document/DrugDocument.java b/src/main/java/com/piuda/callcare/domain/druginfo/document/DrugDocument.java index b2f4e90..d79e1d6 100644 --- a/src/main/java/com/piuda/callcare/domain/druginfo/document/DrugDocument.java +++ b/src/main/java/com/piuda/callcare/domain/druginfo/document/DrugDocument.java @@ -8,11 +8,16 @@ import org.springframework.data.elasticsearch.annotations.Document; import org.springframework.data.elasticsearch.annotations.Field; import org.springframework.data.elasticsearch.annotations.FieldType; +import org.springframework.data.elasticsearch.annotations.InnerField; +import org.springframework.data.elasticsearch.annotations.MultiField; +import org.springframework.data.elasticsearch.annotations.Setting; // indexName("drug_info")은 물리 인덱스가 아니라 alias다 (DrugIndexManager.ALIAS와 반드시 일치). // 물리 인덱스는 DrugIndexManager가 타임스탬프로 생성하고 alias를 스왑한다. // createIndex=false로 두어 Spring Data가 alias 이름의 물리 인덱스를 자동 생성하지 못하게 한다. +// 애널라이저 정의는 @Setting 파일에 있으며, DrugIndexManager.createTimestampedIndex()가 색인 시 적용한다. @Document(indexName = "drug_info", createIndex = false) +@Setting(settingPath = "/elasticsearch/drug-info-settings.json") @Getter @Builder @NoArgsConstructor @@ -22,8 +27,26 @@ public class DrugDocument { @Id private String itemSeq; // 품목기준코드 - @Field(type = FieldType.Text) - private String itemName; // 약품명 + // 약품명: 한 값을 3가지로 색인 + // - itemName : standard + lowercase (정확/시작 매칭, 관련도 기준) + // - itemName.autocomplete : edge n-gram (앞에서부터 타이핑하는 자동완성) + // - itemName.ngram : n-gram 2~4 (제품명 중간 단어·괄호 속 성분명) + @MultiField( + mainField = @Field(type = FieldType.Text, analyzer = "drug_search_analyzer"), + otherFields = { + @InnerField(suffix = "keyword", type = FieldType.Keyword), + @InnerField(suffix = "autocomplete", type = FieldType.Text, + analyzer = "drug_edge_ngram_analyzer", searchAnalyzer = "drug_search_analyzer"), + @InnerField(suffix = "ngram", type = FieldType.Text, + analyzer = "drug_ngram_analyzer", searchAnalyzer = "drug_ngram_analyzer") + } + ) + private String itemName; + + // 약품명의 초성 문자열 (색인 시 HangulChosungExtractor로 생성). "ㅌㅇㄹㄴ" 같은 초성 검색용. + // 통짜 토큰(keyword)으로 색인하고 검색은 match_phrase_prefix로 접두 매칭 → 입력 길이 제한 없음. + @Field(type = FieldType.Text, analyzer = "keyword", searchAnalyzer = "keyword") + private String itemNameChosung; @Field(type = FieldType.Keyword) private String entpName; // 제조사명 diff --git a/src/main/java/com/piuda/callcare/domain/druginfo/repository/DrugSearchRepository.java b/src/main/java/com/piuda/callcare/domain/druginfo/repository/DrugSearchRepository.java index c42d9ea..bd8b753 100644 --- a/src/main/java/com/piuda/callcare/domain/druginfo/repository/DrugSearchRepository.java +++ b/src/main/java/com/piuda/callcare/domain/druginfo/repository/DrugSearchRepository.java @@ -9,7 +9,33 @@ public interface DrugSearchRepository extends ElasticsearchRepository { - // 약품명 자동완성 검색 (match_phrase_prefix: 입력한 prefix로 시작하는 약품명 반환) - @Query("{\"match_phrase_prefix\": {\"itemName\": {\"query\": \"?0\", \"max_expansions\": 50}}}") + /** + * 약품명 검색. bool.should 조합으로 자동완성·오타·중간 단어·초성을 한 번에 처리하고 + * boost로 관련도를 정렬한다 (완전/시작 일치가 상단). + * + * 한계: 한국어 형태소 분석(nori) 미도입이라 약품명이 통짜 토큰이라 "타이래놀" 같은 중간 음절 오타는 잘 못 잡는다. + */ + @Query(""" + { + "bool": { + "minimum_should_match": 1, + "should": [ + { "term": { "itemName.keyword": { "value": "?0", "boost": 20.0 } } }, + { "match_phrase_prefix": { "itemName": { "query": "?0", "boost": 5.0 } } }, + { "match": { "itemName.autocomplete": { "query": "?0", "boost": 2.0 } } }, + { "match": { "itemName": { "query": "?0", "fuzziness": "AUTO", "boost": 2.0 } } }, + { "match": { "itemName.ngram": { "query": "?0", "minimum_should_match": "50%", "boost": 1.0 } } }, + { "match_phrase_prefix": { "itemNameChosung": { "query": "?0", "boost": 3.0 } } } + ] + } + } + """) List searchByItemName(String keyword, Pageable pageable); } diff --git a/src/main/java/com/piuda/callcare/domain/druginfo/service/query/DrugSearchQueryService.java b/src/main/java/com/piuda/callcare/domain/druginfo/service/query/DrugSearchQueryService.java index f50ee2f..6643627 100644 --- a/src/main/java/com/piuda/callcare/domain/druginfo/service/query/DrugSearchQueryService.java +++ b/src/main/java/com/piuda/callcare/domain/druginfo/service/query/DrugSearchQueryService.java @@ -23,7 +23,7 @@ public class DrugSearchQueryService { private final DrugInfoQueryService drugInfoQueryService; private final DrugInfoConverter drugInfoConverter; - // Elasticsearch로 약품명 자동완성 검색 (match_phrase_prefix, 최대 20건) + // Elasticsearch 약품명 검색 (자동완성·오타·중간 단어·초성 통합, 관련도 순, 최대 20건) public List search(String keyword) { if (keyword == null || keyword.isBlank()) { throw new CallCareException(ErrorCode.INVALID_PARAMETER); diff --git a/src/main/java/com/piuda/callcare/global/util/HangulChosungExtractor.java b/src/main/java/com/piuda/callcare/global/util/HangulChosungExtractor.java new file mode 100644 index 0000000..e1f229a --- /dev/null +++ b/src/main/java/com/piuda/callcare/global/util/HangulChosungExtractor.java @@ -0,0 +1,57 @@ +package com.piuda.callcare.global.util; + +import java.util.regex.Pattern; + +/** + * 완성형 한글에서 초성만 뽑아낸다. 초성 검색(예: "ㅌㅇㄹㄴ" → "타이레놀")을 위해 + * 색인 시점에 별도 필드로 저장할 문자열을 만드는 용도. + *

+ * 검색 시점에는 사용자가 입력한 초성 문자열을 그대로 초성 필드에 매칭하면 되므로 + * 이 유틸이 필요 없다. + */ +public final class HangulChosungExtractor { + + // 유니코드 완성형 한글의 초성 19개 (U+AC00 조합 순서) + private static final char[] CHOSUNG = { + 'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ', + 'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ' + }; + + private static final char HANGUL_SYLLABLE_BASE = 0xAC00; // '가' + private static final char HANGUL_SYLLABLE_END = 0xD7A3; // '힣' + private static final int JUNGSUNG_COUNT = 21; + private static final int JONGSUNG_COUNT = 28; + + // 초성 자모(U+3131~U+314E: ㄱ~ㅎ, 겹자음 포함). 모음(U+314F~)은 제외. + private static final Pattern CHOSUNG_ONLY = Pattern.compile("[\\u3131-\\u314E]+"); + + private HangulChosungExtractor() { + } + + /** + * 완성형 한글은 초성으로 치환하고, 그 외 문자(자모 단독·숫자·영문·기호)는 그대로 둔다. + * 예: {@code "타이레놀정500"} → {@code "ㅌㅇㄹㄴㅈ500"} + */ + public static String extract(String text) { + if (text == null || text.isEmpty()) { + return text; + } + StringBuilder sb = new StringBuilder(text.length()); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c >= HANGUL_SYLLABLE_BASE && c <= HANGUL_SYLLABLE_END) { + int syllableIndex = c - HANGUL_SYLLABLE_BASE; + int chosungIndex = syllableIndex / (JUNGSUNG_COUNT * JONGSUNG_COUNT); + sb.append(CHOSUNG[chosungIndex]); + } else { + sb.append(c); + } + } + return sb.toString(); + } + + /** 입력 전체가 초성 자모로만 이루어졌는지 (초성 검색 의도인지 판단할 때 사용). */ + public static boolean isChosungOnly(String text) { + return text != null && !text.isEmpty() && CHOSUNG_ONLY.matcher(text).matches(); + } +} diff --git a/src/main/resources/elasticsearch/drug-info-settings.json b/src/main/resources/elasticsearch/drug-info-settings.json new file mode 100644 index 0000000..5023e8a --- /dev/null +++ b/src/main/resources/elasticsearch/drug-info-settings.json @@ -0,0 +1,39 @@ +{ + "index": { + "number_of_replicas": 0, + "max_ngram_diff": 19 + }, + "analysis": { + "tokenizer": { + "drug_edge_ngram_tokenizer": { + "type": "edge_ngram", + "min_gram": 1, + "max_gram": 20, + "token_chars": ["letter", "digit"] + }, + "drug_ngram_tokenizer": { + "type": "ngram", + "min_gram": 2, + "max_gram": 4, + "token_chars": ["letter", "digit"] + } + }, + "analyzer": { + "drug_search_analyzer": { + "type": "custom", + "tokenizer": "standard", + "filter": ["lowercase"] + }, + "drug_edge_ngram_analyzer": { + "type": "custom", + "tokenizer": "drug_edge_ngram_tokenizer", + "filter": ["lowercase"] + }, + "drug_ngram_analyzer": { + "type": "custom", + "tokenizer": "drug_ngram_tokenizer", + "filter": ["lowercase"] + } + } + } +} diff --git a/src/test/java/com/piuda/callcare/global/util/HangulChosungExtractorTest.java b/src/test/java/com/piuda/callcare/global/util/HangulChosungExtractorTest.java new file mode 100644 index 0000000..3b883ba --- /dev/null +++ b/src/test/java/com/piuda/callcare/global/util/HangulChosungExtractorTest.java @@ -0,0 +1,60 @@ +package com.piuda.callcare.global.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("HangulChosungExtractor 단위 테스트") +class HangulChosungExtractorTest { + + @ParameterizedTest + @CsvSource({ + "타이레놀, ㅌㅇㄹㄴ", + "타이레놀정500, ㅌㅇㄹㄴㅈ500", + "겔포스현탁액, ㄱㅍㅅㅎㅌㅇ", + "글루타티온(환원형), ㄱㄹㅌㅌㅇ(ㅎㅇㅎ)", + "ABC정, ABCㅈ" + }) + @DisplayName("완성형 한글은 초성으로, 그 외 문자는 그대로") + void extract(String input, String expected) { + assertThat(HangulChosungExtractor.extract(input)).isEqualTo(expected); + } + + @Test + @DisplayName("겹받침 음절도 초성만 추출한다") + void extract_with_jongsung() { + assertThat(HangulChosungExtractor.extract("닭")).isEqualTo("ㄷ"); + assertThat(HangulChosungExtractor.extract("값")).isEqualTo("ㄱ"); + } + + @Test + @DisplayName("null/빈 문자열은 그대로 반환") + void extract_null_or_empty() { + assertThat(HangulChosungExtractor.extract(null)).isNull(); + assertThat(HangulChosungExtractor.extract("")).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"ㅌㅇㄹㄴ", "ㄱ", "ㄲㅄ"}) + @DisplayName("초성 자모로만 이루어진 입력은 초성 검색 의도로 판단") + void isChosungOnly_true(String input) { + assertThat(HangulChosungExtractor.isChosungOnly(input)).isTrue(); + } + + @ParameterizedTest + @ValueSource(strings = {"타이레", "ㅏㅣ", "타ㅇ", "abc", "ㅌㅇ ㄹㄴ", ""}) + @DisplayName("완성형 한글·모음·공백·영문이 섞이면 초성 검색 의도가 아님") + void isChosungOnly_false(String input) { + assertThat(HangulChosungExtractor.isChosungOnly(input)).isFalse(); + } + + @Test + @DisplayName("null은 초성 검색 의도가 아님") + void isChosungOnly_null() { + assertThat(HangulChosungExtractor.isChosungOnly(null)).isFalse(); + } +}