diff --git a/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java b/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java index 9f3fcc4..e45e3b4 100644 --- a/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java +++ b/backend-java/src/main/java/com/tasteby/controller/RestaurantController.java @@ -524,25 +524,12 @@ public class RestaurantController { * 식당 이름과 검색 결과 제목의 유사도 검사. * 한쪽 이름이 다른쪽에 포함되거나, 공통 글자 비율이 40% 이상이면 유사하다고 판단. */ + /** + * #348 — 한국어 자모 분해 + Sørensen-Dice bigram 유사도(임계값 0.45). + * 짧은 한국어 이름에서 이전 Jaccard-like(set 비율) 방식보다 정확. + */ private boolean isNameSimilar(String restaurantName, String resultTitle) { - String a = normalize(restaurantName); - String b = normalize(resultTitle); - if (a.isEmpty() || b.isEmpty()) return false; - - // 포함 관계 체크 - if (a.contains(b) || b.contains(a)) return true; - - // 공통 문자 비율 (Jaccard-like) - var setA = a.chars().boxed().collect(java.util.stream.Collectors.toSet()); - var setB = b.chars().boxed().collect(java.util.stream.Collectors.toSet()); - long common = setA.stream().filter(setB::contains).count(); - double ratio = (double) common / Math.max(setA.size(), setB.size()); - return ratio >= 0.4; - } - - private String normalize(String s) { - if (s == null) return ""; - return s.replaceAll("[\\s·\\-_()()\\[\\]【】]", "").toLowerCase(); + return com.tasteby.util.HangulSimilarity.similarity(restaurantName, resultTitle) >= 0.45; } private void emit(SseEmitter emitter, Map data) { diff --git a/backend-java/src/main/java/com/tasteby/util/HangulSimilarity.java b/backend-java/src/main/java/com/tasteby/util/HangulSimilarity.java new file mode 100644 index 0000000..77f1a7a --- /dev/null +++ b/backend-java/src/main/java/com/tasteby/util/HangulSimilarity.java @@ -0,0 +1,67 @@ +package com.tasteby.util; + +import java.text.Normalizer; +import java.util.HashMap; +import java.util.Map; + +/** + * #348 — 한국어 자모 분해(Unicode NFD) + Sørensen-Dice bigram 유사도. + * + * 음절 단위 Jaccard보다 짧은 한국어 이름에 정확. 예: + * similarity("스타벅스 강남", "스타벅스 강남점") ≈ 0.85+ + * similarity("스타벅스 강남", "스타벅스 종로") ≈ 0.55~0.85 + * similarity("스타벅스", "맥도날드") < 0.20 + * + * 공백/구두점은 제거하고 소문자화한 뒤 NFD 분해. + */ +public final class HangulSimilarity { + + private HangulSimilarity() {} + + /** 공백/구두점 제거 + 소문자화 + NFD 분해(한글 음절 → 자모). */ + public static String decompose(String s) { + if (s == null || s.isEmpty()) return ""; + String stripped = s.replaceAll("[\\s·\\-_()()\\[\\]【】]", "").toLowerCase(); + return Normalizer.normalize(stripped, Normalizer.Form.NFD); + } + + /** + * Sørensen-Dice 계수 (bigram multiset 기반). 0.0~1.0. + * 동일 문자열 → 1.0. 빈 입력 → 0.0. + */ + public static double similarity(String a, String b) { + String da = decompose(a); + String db = decompose(b); + if (da.isEmpty() || db.isEmpty()) return 0.0; + if (da.equals(db)) return 1.0; + + // 포함 관계는 강한 신호로 1.0 처리 (기존 동작과 일관) + if (da.contains(db) || db.contains(da)) return 1.0; + + if (da.length() < 2 || db.length() < 2) { + return 0.0; + } + + Map bigramsA = bigrams(da); + Map bigramsB = bigrams(db); + int common = 0; + for (var e : bigramsA.entrySet()) { + Integer countB = bigramsB.get(e.getKey()); + if (countB != null) { + common += Math.min(e.getValue(), countB); + } + } + int sizeA = da.length() - 1; + int sizeB = db.length() - 1; + return (2.0 * common) / (sizeA + sizeB); + } + + private static Map bigrams(String s) { + Map map = new HashMap<>(); + for (int i = 0; i < s.length() - 1; i++) { + String gram = s.substring(i, i + 2); + map.merge(gram, 1, Integer::sum); + } + return map; + } +}