using AxCopilot.Services; namespace AxCopilot.Core; /// /// 부분 입력·오타도 유사 항목을 찾아주는 Fuzzy 검색 엔진. /// 한글 초성 검색, 자모 분리 검색, 비연속 매칭을 지원합니다. /// public class FuzzyEngine { private readonly IndexService _index; public FuzzyEngine(IndexService index) { _index = index; } /// /// 쿼리에 대해 유사도 순으로 정렬된 결과를 반환합니다. /// 300개 이상 항목은 PLINQ 병렬 처리로 검색 속도를 개선합니다. /// public IEnumerable Search(string query, int maxResults = 7) { if (string.IsNullOrWhiteSpace(query)) return Enumerable.Empty(); var normalized = query.Trim().ToLowerInvariant(); var entries = _index.Entries; // 쿼리 언어 타입 1회 사전 분류 — 항목마다 재계산하지 않음 bool queryHasKorean = false; foreach (var c in normalized) { if ((c >= 0xAC00 && c <= 0xD7A3) || ChosungSet.Contains(c)) { queryHasKorean = true; break; } } // 300개 초과 시 PLINQ 병렬 처리 if (entries.Count > 300) { return entries.AsParallel() .Select(e => new FuzzyResult(e, CalculateScoreFast(normalized, e, queryHasKorean))) .Where(r => r.Score > 0) .OrderByDescending(r => r.Score) .Take(maxResults); } return entries .Select(e => new FuzzyResult(e, CalculateScoreFast(normalized, e, queryHasKorean))) .Where(r => r.Score > 0) .OrderByDescending(r => r.Score) .Take(maxResults); } /// 미리 계산된 캐시 필드를 활용하는 빠른 점수 계산. private static int CalculateScoreFast(string query, IndexEntry entry, bool queryHasKorean) { // 캐시가 없으면(구버전 호환) 기존 방식으로 폴백 var targetLower = string.IsNullOrEmpty(entry.NameLower) ? entry.Name.ToLowerInvariant() : entry.NameLower; if (query.Length == 0) return 0; if (targetLower == query) return 1000 + entry.Score; if (targetLower.StartsWith(query)) return 800 + entry.Score; if (targetLower.Contains(query)) return 600 + entry.Score; // 순수 ASCII 쿼리 — 한글 검색 로직(자모·초성) 전체 스킵 if (!queryHasKorean) { var fs = FuzzyMatch(query, targetLower); return fs > 0 ? fs + entry.Score : 0; } // 자모 분리 검색 (캐시된 NameJamo 활용) var jamoScore = JamoContainsScoreFast( string.IsNullOrEmpty(entry.NameJamo) ? DecomposeToJamo(targetLower) : entry.NameJamo, query); if (jamoScore > 0) return jamoScore + entry.Score; // 초성 검색 (캐시된 NameChosung 활용) var chosungScore = ChosungMatchScoreFast( string.IsNullOrEmpty(entry.NameChosung) ? null : entry.NameChosung, targetLower, query); if (chosungScore > 0) return chosungScore + entry.Score; // Fuzzy 매칭 var fuzzyScore = FuzzyMatch(query, targetLower); if (fuzzyScore > 0) return fuzzyScore + entry.Score; return 0; } /// /// 점수 계산: 정확 일치 > 시작 일치 > 포함 일치 > 자모 포함 > 초성 > Fuzzy /// internal static int CalculateScore(string query, string target, int baseScore) { if (query.Length == 0) return 0; if (target == query) return 1000 + baseScore; // 완전 일치 if (target.StartsWith(query)) return 800 + baseScore; // 시작 일치 if (target.Contains(query)) return 600 + baseScore; // 부분 일치 // 한글 자모 분리 후 부분 일치 ("모장" → 메모장) var jamoScore = JamoContainsScore(target, query); if (jamoScore > 0) return jamoScore + baseScore; // 한글 초성 검색 (순수 초성 + 혼합 쿼리 모두 지원) var chosungScore = ChosungMatchScore(target, query); if (chosungScore > 0) return chosungScore + baseScore; // 문자 순서 포함 (Fuzzy) var fuzzyScore = FuzzyMatch(query, target); if (fuzzyScore > 0) return fuzzyScore + baseScore; return 0; } // ─── Fuzzy Match ──────────────────────────────────────────────────────── /// /// 쿼리의 모든 문자가 target에 순서대로 포함되는지 확인 (subsequence) /// internal static int FuzzyMatch(string query, string target) { int qi = 0, ti = 0; int score = 0; int lastMatchIdx = -1; while (qi < query.Length && ti < target.Length) { if (query[qi] == target[ti]) { if (lastMatchIdx == ti - 1) score += 30; // 연속 매칭 보너스 else score += 10; // 비연속 매칭 if (ti == 0) score += 15; // 시작 위치 보너스 lastMatchIdx = ti; qi++; } ti++; } return qi == query.Length ? Math.Max(score, 50) : 0; // 최소 50점 보장 } // ─── 한글 자모 분리 ───────────────────────────────────────────────────── private static readonly char[] Chosungs = ['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ']; /// 초성 O(1) 조회용 HashSet — HasChosung/IsChosung/MixedMatch에서 사용. private static readonly HashSet ChosungSet = new(['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ']); private static readonly char[] Jungsungs = ['ㅏ','ㅐ','ㅑ','ㅒ','ㅓ','ㅔ','ㅕ','ㅖ','ㅗ','ㅘ','ㅙ','ㅚ','ㅛ','ㅜ','ㅝ','ㅞ','ㅟ','ㅠ','ㅡ','ㅢ','ㅣ']; private static readonly char[] Jongsungs = ['\0','ㄱ','ㄲ','ㄳ','ㄴ','ㄵ','ㄶ','ㄷ','ㄹ','ㄺ','ㄻ','ㄼ','ㄽ','ㄾ','ㄿ','ㅀ','ㅁ','ㅂ','ㅄ','ㅅ','ㅆ','ㅇ','ㅈ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ']; /// 한글 음절을 자모로 분리 (초성+중성+종성). 비한글은 그대로 반환. internal static string DecomposeToJamo(string text) { var result = new System.Text.StringBuilder(text.Length * 3); foreach (var c in text) { if (c >= 0xAC00 && c <= 0xD7A3) { int offset = c - 0xAC00; int cho = offset / (21 * 28); int jung = (offset % (21 * 28)) / 28; int jong = offset % 28; result.Append(Chosungs[cho]); result.Append(Jungsungs[jung]); if (jong > 0) result.Append(Jongsungs[jong]); } else { result.Append(c); } } return result.ToString(); } /// 한글 음절에서 초성만 추출. 비한글은 '\0'. internal static char GetChosung(char hangul) { if (hangul < 0xAC00 || hangul > 0xD7A3) return '\0'; int offset = hangul - 0xAC00; return Chosungs[offset / (21 * 28)]; } // ─── 자모 기반 포함 검색 ──────────────────────────────────────────────── /// /// 쿼리를 자모 분리 후 target의 자모에 연속 부분 문자열로 포함되는지 확인. /// "모장" → "ㅁㅗㅈㅏㅇ" 이 "ㅁㅔㅁㅗㅈㅏㅇ"(메모장)에 포함 → 550점 /// internal static int JamoContainsScore(string target, string query) { if (!HasKorean(query)) return 0; var targetJamo = DecomposeToJamo(target); var queryJamo = DecomposeToJamo(query); if (queryJamo.Length == 0 || targetJamo.Length == 0) return 0; // 자모 연속 포함 if (targetJamo.Contains(queryJamo)) { // 시작 위치가 음절 경계(초성)에 가까울수록 높은 점수 int idx = targetJamo.IndexOf(queryJamo); return idx == 0 ? 580 : 550; } // 자모 subsequence 매칭 (비연속이지만 순서 유지) int qi = 0; for (int ti = 0; ti < targetJamo.Length && qi < queryJamo.Length; ti++) { if (queryJamo[qi] == targetJamo[ti]) qi++; } if (qi == queryJamo.Length) return 400; return 0; } // ─── 초성 검색 ────────────────────────────────────────────────────────── /// 쿼리에 초성 문자가 하나라도 포함되어 있는지. internal static bool HasChosung(string text) => text.Any(c => ChosungSet.Contains(c)); /// 문자열의 모든 문자가 초성인지. internal static bool IsChosung(string text) => text.Length > 0 && text.All(c => ChosungSet.Contains(c)); /// 문자열에 한글(완성형)이 포함되어 있는지. private static bool HasKorean(string text) => text.Any(c => c >= 0xAC00 && c <= 0xD7A3); /// /// 초성 매칭 점수. 순수 초성 쿼리 + 혼합 쿼리(초성+완성형) 모두 지원. /// 비연속 매칭도 허용 ("ㅁㅊ" → 메모장 OK). /// internal static int ChosungMatchScore(string target, string query) { // 초성이 하나도 없으면 초성 검색 아님 if (!HasChosung(query)) return 0; // 타겟에서 각 글자의 초성 추출 var targetChosungs = new List(); var targetChars = new List(); foreach (var c in target) { var cho = GetChosung(c); if (cho != '\0') { targetChosungs.Add(cho); targetChars.Add(c); } else if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { targetChosungs.Add(c); targetChars.Add(c); } } if (targetChosungs.Count == 0) return 0; // 순수 초성 쿼리 ("ㅁㅁㅈ", "ㅁㅊ") if (IsChosung(query)) { // 연속 매칭 시도 if (ContainsChosungConsecutive(targetChosungs, query)) return 520; // 비연속(subsequence) 매칭 if (ContainsChosungSubsequence(targetChosungs, query)) return 480; return 0; } // 혼합 쿼리 ("ㅁ장", "계ㅅ기") — 초성+완성형 혼합 return MixedChosungMatch(targetChars, targetChosungs, query); } /// 연속 초성 매칭 (기존 로직 유지) private static bool ContainsChosungConsecutive(List targetChosungs, string query) { for (int i = 0; i <= targetChosungs.Count - query.Length; i++) { bool match = true; for (int j = 0; j < query.Length; j++) { if (targetChosungs[i + j] != query[j]) { match = false; break; } } if (match) return true; } return false; } /// 비연속 초성 매칭 (subsequence) private static bool ContainsChosungSubsequence(List targetChosungs, string query) { int qi = 0; for (int ti = 0; ti < targetChosungs.Count && qi < query.Length; ti++) { if (targetChosungs[ti] == query[qi]) qi++; } return qi == query.Length; } /// /// 혼합 쿼리 매칭: 초성은 초성끼리, 완성형은 완성형끼리 비교. /// "ㅁ장" → target[i]의 초성이 'ㅁ'이고, target[j>i]가 '장'인지. /// private static int MixedChosungMatch(List targetChars, List targetChosungs, string query) { int qi = 0, ti = 0; while (qi < query.Length && ti < targetChars.Count) { var qc = query[qi]; if (ChosungSet.Contains(qc)) { // 쿼리 문자가 초성 → 타겟 초성과 비교 if (targetChosungs[ti] == qc) qi++; } else { // 쿼리 문자가 완성형 → 타겟 원본 문자와 비교 if (targetChars[ti] == qc) qi++; } ti++; } return qi == query.Length ? 460 : 0; } // ─── 캐시 기반 빠른 검색 메서드 ──────────────────────────────────────────── /// 미리 계산된 자모 문자열을 사용하는 빠른 자모 포함 검색. private static int JamoContainsScoreFast(string targetJamo, string query) { if (!HasKorean(query)) return 0; var queryJamo = DecomposeToJamo(query); // 쿼리는 짧으므로 매번 분해해도 빠름 if (queryJamo.Length == 0 || targetJamo.Length == 0) return 0; if (targetJamo.Contains(queryJamo)) { int idx = targetJamo.IndexOf(queryJamo, StringComparison.Ordinal); return idx == 0 ? 580 : 550; } int qi = 0; for (int ti = 0; ti < targetJamo.Length && qi < queryJamo.Length; ti++) { if (queryJamo[qi] == targetJamo[ti]) qi++; } return qi == queryJamo.Length ? 400 : 0; } /// 미리 계산된 초성 문자열을 사용하는 빠른 초성 검색. private static int ChosungMatchScoreFast(string? targetChosung, string targetLower, string query) { if (!HasChosung(query)) return 0; if (IsChosung(query)) { if (string.IsNullOrEmpty(targetChosung)) return 0; // 연속 매칭: 단순 Contains if (targetChosung.Contains(query, StringComparison.Ordinal)) return 520; // 비연속 매칭 (subsequence) int qi2 = 0; for (int ti2 = 0; ti2 < targetChosung.Length && qi2 < query.Length; ti2++) { if (targetChosung[ti2] == query[qi2]) qi2++; } if (qi2 == query.Length) return 480; return 0; } // 혼합 쿼리(초성+완성형): List 할당 없는 인라인 매칭 { int qi2 = 0, ti2 = 0; while (qi2 < query.Length && ti2 < targetLower.Length) { var qc = query[qi2]; var tc = targetLower[ti2]; if (ChosungSet.Contains(qc)) { // 쿼리가 초성 → 타겟 문자의 초성과 비교 var cho = GetChosung(tc); if (cho == '\0' && ((tc >= 'a' && tc <= 'z') || (tc >= '0' && tc <= '9'))) cho = tc; // 영문/숫자는 초성 = 자신 if (cho == qc) qi2++; } else { // 쿼리가 완성형 → 타겟 원본 문자와 비교 if (tc == qc) qi2++; } ti2++; } return qi2 == query.Length ? 460 : 0; } } // ─── 하위 호환 ────────────────────────────────────────────────────────── /// 기존 API 호환용 — ContainsChosung(연속+비연속) internal static bool ContainsChosung(string target, string chosungQuery) { var targetChosungs = target.Select(GetChosung).Where(c => c != '\0').ToList(); if (targetChosungs.Count < chosungQuery.Length) return false; return ContainsChosungConsecutive(targetChosungs, chosungQuery) || ContainsChosungSubsequence(targetChosungs, chosungQuery); } } public record FuzzyResult(IndexEntry Entry, int Score);