feat(util): #348 isNameSimilar 한국어 자모 + Sørensen-Dice
- HangulSimilarity 유틸 신규 - decompose: Unicode NFD 분해 (한글 음절 → 초성/중성/종성) - 공백·구두점 제거 + 소문자화 - bigram multiset 기반 Sørensen-Dice 계수 - 빈 입력/포함 관계 가드 - RestaurantController.isNameSimilar 임계값 0.45 (이전 Jaccard 0.4와 유사 보수성) - 기존 normalize 헬퍼 제거 (HangulSimilarity 내부로 이동) DDG/DTO/UNIQUE는 별도 후속: - 외부 검색 API 선정 (Naver/Kakao/Google CSE) - RestaurantUpdateDTO + @Valid - google_place_id 중복 정리 후 UNIQUE 제약 설계서: docs/design/348-name-similarity/README.md Refs: #348 (Developer 단계)
This commit is contained in:
@@ -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<String, Object> data) {
|
||||
|
||||
@@ -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<String, Integer> bigramsA = bigrams(da);
|
||||
Map<String, Integer> 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<String, Integer> bigrams(String s) {
|
||||
Map<String, Integer> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user