324 lines
12 KiB
TypeScript
324 lines
12 KiB
TypeScript
/**
|
|
* ============================================================
|
|
* Scoring Engine — TF-IDF + Bilingual Tokenizer
|
|
*
|
|
* 단순 includes() 키워드 매칭을 넘어서,
|
|
* TF-IDF 가중치 기반의 문서 스코어링을 제공합니다.
|
|
* 한국어/영어 양국어 토크나이저를 포함합니다.
|
|
* ============================================================
|
|
*/
|
|
|
|
// ─── Bilingual Tokenizer ───
|
|
|
|
// ─── Scoring Engine Configuration ───
|
|
|
|
const SCORING_CONFIG = {
|
|
STOP_WORDS_EN: new Set([
|
|
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
|
|
'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been',
|
|
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
|
|
'should', 'may', 'might', 'can', 'this', 'that', 'these', 'those',
|
|
'it', 'its', 'not', 'no', 'what', 'how', 'when', 'where', 'which',
|
|
'who', 'whom', 'why', 'if', 'then', 'than', 'so', 'as', 'just',
|
|
'about', 'also', 'more', 'some', 'very', 'all', 'each', 'every',
|
|
'such', 'please', 'write', 'use', 'using', 'used'
|
|
]),
|
|
STOP_WORDS_KO: new Set([
|
|
'그리고', '그런데', '그래서', '하지만', '또한', '또는', '해서', '하는',
|
|
'있어', '없어', '아래', '위에', '어떻게', '이것', '저것', '그것',
|
|
'이런', '저런', '그런', '여기', '거기', '필요', '사용', '관련',
|
|
'대한', '대해', '통해', '따라', '위해', '대로', '만큼'
|
|
]),
|
|
SYNONYM_DATA: [
|
|
['성능', ['performance', 'optimization', '최적화', 'speed']],
|
|
['performance', ['성능', '최적화', 'optimization', 'speed']],
|
|
['아키텍처', ['architecture', '구조', 'structure', 'design']],
|
|
['architecture', ['아키텍처', '구조', 'structure', 'design']],
|
|
['메모리', ['memory', '기억', 'cache', 'storage']],
|
|
['memory', ['메모리', '기억', 'cache', 'storage']],
|
|
['버그', ['bug', 'error', '오류', 'issue', 'defect']],
|
|
['bug', ['버그', 'error', '오류', 'issue']],
|
|
['설계', ['design', '아키텍처', 'architecture', 'pattern']],
|
|
['design', ['설계', '아키텍처', 'architecture', 'pattern']],
|
|
['배포', ['deploy', 'deployment', 'release', 'ci', 'cd']],
|
|
['deploy', ['배포', 'deployment', 'release']],
|
|
['테스트', ['test', 'testing', 'spec', 'jest', 'mocha']],
|
|
['test', ['테스트', 'testing', 'spec']],
|
|
['프로젝트', ['project', '프로그램', 'repo', 'repository']],
|
|
['project', ['프로젝트', '프로그램', 'repo']],
|
|
['방향', ['direction', '전략', 'strategy', '목표', 'goal']],
|
|
['direction', ['방향', '전략', 'strategy', '목표']]
|
|
] as [string, string[]][],
|
|
DENSITY_THRESHOLD: 0.15, // 발췌문 추출 시 최소 키워드 밀도
|
|
TITLE_MULTIPLIER: 3.0, // 제목 일치 가중치
|
|
GLOBAL_CACHE_LIMIT: 2000,
|
|
CONFLICT_INDICATORS: new Set([
|
|
'반대', '충돌', '오류', '논란', '반박', '차이', '대조',
|
|
'conflict', 'contradict', 'dispute', 'controversy', 'error', 'mismatch', 'vs'
|
|
])
|
|
};
|
|
|
|
// ─── Global Search State & Cache ───
|
|
const TOKEN_CACHE = new Map<string, string[]>();
|
|
const IDF_CACHE = new Map<string, number>();
|
|
|
|
/**
|
|
* 캐시를 명시적으로 비웁니다. 문서 집합이 크게 변경되었을 때 사용합니다.
|
|
*/
|
|
export function clearScoringCache() {
|
|
TOKEN_CACHE.clear();
|
|
IDF_CACHE.clear();
|
|
}
|
|
|
|
/**
|
|
* 한국어/영어 혼합 텍스트를 정규화하고 토큰으로 분리합니다.
|
|
*/
|
|
export function tokenize(text: string): string[] {
|
|
if (!text) return [];
|
|
if (TOKEN_CACHE.has(text)) return TOKEN_CACHE.get(text)!;
|
|
|
|
const normalized = text
|
|
.toLowerCase()
|
|
.replace(/[\u200B-\u200D\uFEFF]/g, '')
|
|
.replace(/[^\w\s가-힣_.-]/g, ' ')
|
|
.trim();
|
|
|
|
const tokens = normalized
|
|
.split(/[^a-z0-9가-힣_.-]+/g)
|
|
.map((t) => t.trim())
|
|
.filter((t) => t.length >= 2)
|
|
.filter((t) => !SCORING_CONFIG.STOP_WORDS_EN.has(t) && !SCORING_CONFIG.STOP_WORDS_KO.has(t));
|
|
|
|
if (TOKEN_CACHE.size >= SCORING_CONFIG.GLOBAL_CACHE_LIMIT) TOKEN_CACHE.clear();
|
|
TOKEN_CACHE.set(text, tokens);
|
|
return tokens;
|
|
}
|
|
|
|
const synonymMap = new Map<string, string[]>(SCORING_CONFIG.SYNONYM_DATA);
|
|
|
|
/**
|
|
* 동의어/관련어 확장을 수행합니다.
|
|
*/
|
|
export function expandQuery(tokens: string[]): string[] {
|
|
const synonymMap = new Map<string, string[]>([
|
|
['성능', ['performance', 'optimization', '최적화', 'speed']],
|
|
['performance', ['성능', '최적화', 'optimization', 'speed']],
|
|
['아키텍처', ['architecture', '구조', 'structure', 'design']],
|
|
['architecture', ['아키텍처', '구조', 'structure', 'design']],
|
|
['메모리', ['memory', '기억', 'cache', 'storage']],
|
|
['memory', ['메모리', '기억', 'cache', 'storage']],
|
|
['버그', ['bug', 'error', '오류', 'issue', 'defect']],
|
|
['bug', ['버그', 'error', '오류', 'issue']],
|
|
['설계', ['design', '아키텍처', 'architecture', 'pattern']],
|
|
['design', ['설계', '아키텍처', 'architecture', 'pattern']],
|
|
['배포', ['deploy', 'deployment', 'release', 'ci', 'cd']],
|
|
['deploy', ['배포', 'deployment', 'release']],
|
|
['테스트', ['test', 'testing', 'spec', 'jest', 'mocha']],
|
|
['test', ['테스트', 'testing', 'spec']],
|
|
['프로젝트', ['project', '프로그램', 'repo', 'repository']],
|
|
['project', ['프로젝트', '프로그램', 'repo']],
|
|
['방향', ['direction', '전략', 'strategy', '목표', 'goal']],
|
|
['direction', ['방향', '전략', 'strategy', '목표']]
|
|
]);
|
|
|
|
const expanded = new Set(tokens);
|
|
for (const token of tokens) {
|
|
const synonyms = synonymMap.get(token);
|
|
if (Array.isArray(synonyms)) {
|
|
for (const syn of synonyms) {
|
|
expanded.add(syn);
|
|
}
|
|
}
|
|
}
|
|
return Array.from(expanded);
|
|
}
|
|
|
|
// ─── TF-IDF Scoring ───
|
|
|
|
/**
|
|
* TF (Term Frequency): 문서 내 용어 빈도
|
|
*/
|
|
function termFrequency(term: string, documentTokens: string[]): number {
|
|
if (documentTokens.length === 0) return 0;
|
|
const count = documentTokens.filter((t) => t === term).length;
|
|
return count / documentTokens.length;
|
|
}
|
|
|
|
/**
|
|
* IDF (Inverse Document Frequency): 전체 문서 대비 희소도
|
|
* (Stability Enhancement: Smoothing 적용 및 최소 문서 수 대응)
|
|
*/
|
|
function inverseDocumentFrequency(
|
|
term: string,
|
|
allDocumentTokenSets: Array<Set<string>>
|
|
): number {
|
|
const N = allDocumentTokenSets.length;
|
|
if (N === 0) return 1.0;
|
|
|
|
const containing = allDocumentTokenSets.filter((doc) => doc.has(term)).length;
|
|
|
|
// N이 매우 작을 때(예: 5개 이하) 스코어 편향 방지를 위한 최소 분모 보정
|
|
const smoothN = N < 5 ? N + 5 : N;
|
|
const smoothContaining = containing;
|
|
|
|
// Standard Smooth IDF: log((N+1) / (containing+1)) + 1
|
|
// containing이 0일 경우에도 안전하게 동작하도록 설계
|
|
return Math.log((smoothN + 1) / (smoothContaining + 1)) + 1;
|
|
}
|
|
|
|
export interface ScoredDocument {
|
|
index: number;
|
|
score: number;
|
|
titleBoost: number;
|
|
recencyBoost: number;
|
|
matchedTerms: string[];
|
|
conflictDetected: boolean;
|
|
informationDensity: number;
|
|
}
|
|
|
|
/**
|
|
* TF-IDF 기반으로 문서 집합을 스코어링합니다.
|
|
*/
|
|
export function scoreTfIdf(
|
|
queryTokens: string[],
|
|
documents: Array<{
|
|
title: string;
|
|
content: string;
|
|
lastModified?: number;
|
|
}>
|
|
): ScoredDocument[] {
|
|
if (documents.length === 0 || queryTokens.length === 0) return [];
|
|
|
|
// Pre-tokenize all documents
|
|
const docTokenArrays = documents.map((doc) =>
|
|
tokenize(`${doc.title} ${doc.content}`)
|
|
);
|
|
const docTokenSets = docTokenArrays.map((tokens) => new Set(tokens));
|
|
|
|
// Expand query with synonyms
|
|
const expandedQuery = expandQuery(queryTokens);
|
|
|
|
// Compute IDF for each query term
|
|
const idfCache = new Map<string, number>();
|
|
for (const term of expandedQuery) {
|
|
if (!idfCache.has(term)) {
|
|
idfCache.set(term, inverseDocumentFrequency(term, docTokenSets));
|
|
}
|
|
}
|
|
|
|
const now = Date.now();
|
|
|
|
return documents.map((doc, index) => {
|
|
const docTokens = docTokenArrays[index];
|
|
const titleTokens = new Set(tokenize(doc.title));
|
|
let score = 0;
|
|
const matchedTerms: string[] = [];
|
|
|
|
// Conflict Detection: 문서 내 상충 지표 확인
|
|
const conflictDetected = docTokens.some(t => SCORING_CONFIG.CONFLICT_INDICATORS.has(t));
|
|
|
|
for (const term of expandedQuery) {
|
|
const tf = termFrequency(term, docTokens);
|
|
const idf = idfCache.get(term) || 1;
|
|
const tfidf = tf * idf;
|
|
|
|
if (tfidf > 0) {
|
|
matchedTerms.push(term);
|
|
}
|
|
|
|
// Title match bonus
|
|
const titleMultiplier = titleTokens.has(term) ? SCORING_CONFIG.TITLE_MULTIPLIER : 1.0;
|
|
score += tfidf * titleMultiplier;
|
|
}
|
|
|
|
// Information Density: 쿼리 관련 토큰의 밀도 측정
|
|
const informationDensity = docTokens.length > 0 ? matchedTerms.length / docTokens.length : 0;
|
|
|
|
// Recency boost
|
|
let recencyBoost = 0;
|
|
if (doc.lastModified) {
|
|
const daysAgo = (now - doc.lastModified) / (1000 * 60 * 60 * 24);
|
|
if (daysAgo < 1) recencyBoost = 0.3;
|
|
else if (daysAgo < 7) recencyBoost = 0.2;
|
|
else if (daysAgo < 30) recencyBoost = 0.1;
|
|
}
|
|
|
|
// Title match bonus for exact query term presence
|
|
const titleBoost = queryTokens.some((t) => titleTokens.has(t)) ? 0.2 : 0;
|
|
|
|
return {
|
|
index,
|
|
score: score + recencyBoost + titleBoost,
|
|
titleBoost,
|
|
recencyBoost,
|
|
matchedTerms: [...new Set(matchedTerms)],
|
|
conflictDetected,
|
|
informationDensity
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 텍스트에서 가장 관련성 높은 구간(excerpt)을 추출합니다.
|
|
* 단순 paragraph 단위가 아니라, 키워드 밀도가 높은 윈도우를 찾습니다.
|
|
*/
|
|
export function extractBestExcerpt(
|
|
content: string,
|
|
queryTokens: string[],
|
|
maxLength = 500
|
|
): string {
|
|
const expanded = expandQuery(queryTokens);
|
|
const expandedSet = new Set(expanded);
|
|
|
|
// 1. Sentence splitting & Initial filtering
|
|
const sentences = content
|
|
.split(/(?<=[.!?。!?\n])\s*/)
|
|
.map((s) => s.trim())
|
|
.filter((s) => s.length > 10);
|
|
|
|
if (sentences.length === 0) return content.slice(0, maxLength);
|
|
|
|
// 2. Phase 1: Density-based filtering (Multi-stage)
|
|
// 최소 정보 밀도를 충족하지 못하는 문장은 후보군에서 제외하거나 가중치를 낮춤
|
|
const scored = sentences.map((sentence, idx) => {
|
|
const tokens = tokenize(sentence);
|
|
const matchCount = tokens.filter((t) => expandedSet.has(t)).length;
|
|
const density = tokens.length > 0 ? matchCount / tokens.length : 0;
|
|
|
|
// 정보 밀도가 임계값 미만이면 점수를 크게 깎음
|
|
const densityMultiplier = density >= SCORING_CONFIG.DENSITY_THRESHOLD ? 1.5 : 0.5;
|
|
|
|
return { sentence, idx, matchCount, density, score: (matchCount + density * 2) * densityMultiplier };
|
|
});
|
|
|
|
// 3. Phase 2: Optimal window search
|
|
let bestStart = 0;
|
|
let bestScore = -1;
|
|
let bestLen = 0;
|
|
|
|
for (let i = 0; i < scored.length; i++) {
|
|
let windowText = '';
|
|
let windowScore = 0;
|
|
let j = i;
|
|
|
|
while (j < scored.length && windowText.length < maxLength) {
|
|
windowText += scored[j].sentence + ' ';
|
|
windowScore += scored[j].score;
|
|
j++;
|
|
}
|
|
|
|
if (windowScore > bestScore) {
|
|
bestScore = windowScore;
|
|
bestStart = i;
|
|
bestLen = j - i;
|
|
}
|
|
}
|
|
|
|
const excerptSentences = scored
|
|
.slice(bestStart, bestStart + bestLen)
|
|
.map((s) => s.sentence);
|
|
|
|
const result = excerptSentences.join(' ');
|
|
return result.length > maxLength ? result.slice(0, maxLength - 3) + '...' : result;
|
|
}
|