421 lines
16 KiB
C#
421 lines
16 KiB
C#
using AxCopilot.Services;
|
|
|
|
namespace AxCopilot.Core;
|
|
|
|
/// <summary>
|
|
/// 부분 입력·오타도 유사 항목을 찾아주는 Fuzzy 검색 엔진.
|
|
/// 한글 초성 검색, 자모 분리 검색, 비연속 매칭을 지원합니다.
|
|
/// </summary>
|
|
public class FuzzyEngine
|
|
{
|
|
private readonly IndexService _index;
|
|
|
|
public FuzzyEngine(IndexService index)
|
|
{
|
|
_index = index;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 쿼리에 대해 유사도 순으로 정렬된 결과를 반환합니다.
|
|
/// 300개 이상 항목은 PLINQ 병렬 처리로 검색 속도를 개선합니다.
|
|
/// </summary>
|
|
public IEnumerable<FuzzyResult> Search(string query, int maxResults = 7)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(query))
|
|
return Enumerable.Empty<FuzzyResult>();
|
|
|
|
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);
|
|
}
|
|
|
|
/// <summary>미리 계산된 캐시 필드를 활용하는 빠른 점수 계산.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 점수 계산: 정확 일치 > 시작 일치 > 포함 일치 > 자모 포함 > 초성 > Fuzzy
|
|
/// </summary>
|
|
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 ────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// 쿼리의 모든 문자가 target에 순서대로 포함되는지 확인 (subsequence)
|
|
/// </summary>
|
|
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 =
|
|
['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'];
|
|
|
|
/// <summary>초성 O(1) 조회용 HashSet — HasChosung/IsChosung/MixedMatch에서 사용.</summary>
|
|
private static readonly HashSet<char> ChosungSet =
|
|
new(['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ']);
|
|
|
|
private static readonly char[] Jungsungs =
|
|
['ㅏ','ㅐ','ㅑ','ㅒ','ㅓ','ㅔ','ㅕ','ㅖ','ㅗ','ㅘ','ㅙ','ㅚ','ㅛ','ㅜ','ㅝ','ㅞ','ㅟ','ㅠ','ㅡ','ㅢ','ㅣ'];
|
|
|
|
private static readonly char[] Jongsungs =
|
|
['\0','ㄱ','ㄲ','ㄳ','ㄴ','ㄵ','ㄶ','ㄷ','ㄹ','ㄺ','ㄻ','ㄼ','ㄽ','ㄾ','ㄿ','ㅀ','ㅁ','ㅂ','ㅄ','ㅅ','ㅆ','ㅇ','ㅈ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'];
|
|
|
|
/// <summary>한글 음절을 자모로 분리 (초성+중성+종성). 비한글은 그대로 반환.</summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>한글 음절에서 초성만 추출. 비한글은 '\0'.</summary>
|
|
internal static char GetChosung(char hangul)
|
|
{
|
|
if (hangul < 0xAC00 || hangul > 0xD7A3) return '\0';
|
|
int offset = hangul - 0xAC00;
|
|
return Chosungs[offset / (21 * 28)];
|
|
}
|
|
|
|
// ─── 자모 기반 포함 검색 ────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// 쿼리를 자모 분리 후 target의 자모에 연속 부분 문자열로 포함되는지 확인.
|
|
/// "모장" → "ㅁㅗㅈㅏㅇ" 이 "ㅁㅔㅁㅗㅈㅏㅇ"(메모장)에 포함 → 550점
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
// ─── 초성 검색 ──────────────────────────────────────────────────────────
|
|
|
|
/// <summary>쿼리에 초성 문자가 하나라도 포함되어 있는지.</summary>
|
|
internal static bool HasChosung(string text) => text.Any(c => ChosungSet.Contains(c));
|
|
|
|
/// <summary>문자열의 모든 문자가 초성인지.</summary>
|
|
internal static bool IsChosung(string text) => text.Length > 0 && text.All(c => ChosungSet.Contains(c));
|
|
|
|
/// <summary>문자열에 한글(완성형)이 포함되어 있는지.</summary>
|
|
private static bool HasKorean(string text) => text.Any(c => c >= 0xAC00 && c <= 0xD7A3);
|
|
|
|
/// <summary>
|
|
/// 초성 매칭 점수. 순수 초성 쿼리 + 혼합 쿼리(초성+완성형) 모두 지원.
|
|
/// 비연속 매칭도 허용 ("ㅁㅊ" → 메모장 OK).
|
|
/// </summary>
|
|
internal static int ChosungMatchScore(string target, string query)
|
|
{
|
|
// 초성이 하나도 없으면 초성 검색 아님
|
|
if (!HasChosung(query)) return 0;
|
|
|
|
// 타겟에서 각 글자의 초성 추출
|
|
var targetChosungs = new List<char>();
|
|
var targetChars = new List<char>();
|
|
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);
|
|
}
|
|
|
|
/// <summary>연속 초성 매칭 (기존 로직 유지)</summary>
|
|
private static bool ContainsChosungConsecutive(List<char> 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;
|
|
}
|
|
|
|
/// <summary>비연속 초성 매칭 (subsequence)</summary>
|
|
private static bool ContainsChosungSubsequence(List<char> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 혼합 쿼리 매칭: 초성은 초성끼리, 완성형은 완성형끼리 비교.
|
|
/// "ㅁ장" → target[i]의 초성이 'ㅁ'이고, target[j>i]가 '장'인지.
|
|
/// </summary>
|
|
private static int MixedChosungMatch(List<char> targetChars, List<char> 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;
|
|
}
|
|
|
|
// ─── 캐시 기반 빠른 검색 메서드 ────────────────────────────────────────────
|
|
|
|
/// <summary>미리 계산된 자모 문자열을 사용하는 빠른 자모 포함 검색.</summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>미리 계산된 초성 문자열을 사용하는 빠른 초성 검색.</summary>
|
|
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<char> 할당 없는 인라인 매칭
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
|
|
// ─── 하위 호환 ──────────────────────────────────────────────────────────
|
|
|
|
/// <summary>기존 API 호환용 — ContainsChosung(연속+비연속)</summary>
|
|
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);
|