Files
AX-Copilot/src/AxCopilot/Core/FuzzyEngine.cs

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);