/** * Hierarchical Context Window — 질의·문서의 *추상도 레벨* 매칭으로 검색 노이즈 감소. * * 사용자 제안: "사용자가 '배포해줘' 라고 하면 L1(실행) 우선, '전략 검토' 라고 하면 * L3(전략) 우선". 같은 키워드 매치 점수여도 추상도가 안 맞으면 noise. * * v1 — 3-level 휴리스틱 (LLM 호출 없음, 결정적): * - `concrete` — 코드, 로그, 디버그, 실행 명령 * - `operational` — 작업, 일정, 운영 절차, 회의록 (기본/기본값) * - `strategic` — 전략, 비전, 의사결정 근거, 아키텍처 방향 * * 매칭 정책: * - 같은 레벨 → 보너스 (× 1.15) * - 인접 레벨 (concrete↔operational, operational↔strategic) → 변화 없음 * - 양 끝 mismatch (concrete↔strategic) → 페널티 (× 0.7) * * 약한 시그널 — TF-IDF dominant 유지, 동점 깨기 역할. 검색 결과를 *제외* 하지 않음. */ import { RetrievalChunk } from './types'; export type AbstractionLevel = 'concrete' | 'operational' | 'strategic'; const QUERY_STRATEGIC_KEYWORDS = [ '전략', '방향', '비전', '미션', '목표', '의사결정', '아키텍처', '설계 방향', '왜 이렇게', '왜 그렇게', '뭐가 맞', '어떤 게 좋', '어떻게 가야', '어떤 방향', '판단', '결정', '관점', '평가', '검토', 'strategy', 'vision', 'mission', 'roadmap', 'direction', 'goal', 'why', 'rationale', 'pros and cons', 'tradeoff', 'evaluate', ]; const QUERY_CONCRETE_KEYWORDS = [ '코드', '함수', '버그', '에러', '로그', '실행', '명령어', '스크립트', '디버그', '고쳐', '수정', '리팩토링', '리팩터', '커밋', '머지', '배포해', '돌려', '에러 메시지', '스택 트레이스', 'syntax', 'compile', 'code', 'function', 'bug', 'error', 'log', 'execute', 'command', 'script', 'debug', 'fix', 'refactor', 'commit', 'merge', 'deploy', 'run', ]; const FOLDER_STRATEGIC_HINTS = ['strategy', 'vision', 'mission', 'roadmap', 'decision', 'principle', '전략', '비전']; const FOLDER_OPERATIONAL_HINTS = ['playbook', 'runbook', 'operation', 'process', 'sop', '운영', '절차', 'meeting', '회의']; const FOLDER_CONCRETE_HINTS = ['code', 'snippet', 'log', 'debug', 'fix', 'patch', '디버그', 'commit']; const TITLE_STRATEGIC_HINTS = ['strategy', 'vision', 'rationale', 'direction', 'decision', 'plan', '전략', '계획', '방향', '결정', '평가']; const TITLE_CONCRETE_HINTS = ['fix', 'bug', 'error', 'log', 'script', 'command', '버그', '에러', '로그', '커밋']; function countMatches(text: string, keywords: string[]): number { const lower = text.toLowerCase(); let n = 0; for (const k of keywords) if (lower.includes(k.toLowerCase())) n++; return n; } /** * 질의 추상도 분류. 키워드 카운트 우열로 결정, 동률·없음이면 'operational' (기본). */ export function classifyQueryLevel(query: string): AbstractionLevel { if (!query) return 'operational'; const s = countMatches(query, QUERY_STRATEGIC_KEYWORDS); const c = countMatches(query, QUERY_CONCRETE_KEYWORDS); if (s > c && s >= 1) return 'strategic'; if (c > s && c >= 1) return 'concrete'; return 'operational'; } /** * 한 chunk 의 추상도 분류 — 폴더 경로 → 파일명/제목 → 본문 순으로 강도 감소. * 어느 신호도 없으면 'operational' (기본). */ export function classifyChunkLevel(chunk: RetrievalChunk): AbstractionLevel { // 1. 폴더 경로 (가장 강함) const fp = (chunk.metadata?.filePath || '').toLowerCase(); if (fp) { for (const h of FOLDER_STRATEGIC_HINTS) if (fp.includes(`/${h}`) || fp.includes(`\\${h}`)) return 'strategic'; for (const h of FOLDER_CONCRETE_HINTS) if (fp.includes(`/${h}`) || fp.includes(`\\${h}`)) return 'concrete'; for (const h of FOLDER_OPERATIONAL_HINTS) if (fp.includes(`/${h}`) || fp.includes(`\\${h}`)) return 'operational'; } // 2. 제목 const t = (chunk.title || '').toLowerCase(); if (t) { let strat = 0, conc = 0; for (const h of TITLE_STRATEGIC_HINTS) if (t.includes(h.toLowerCase())) strat++; for (const h of TITLE_CONCRETE_HINTS) if (t.includes(h.toLowerCase())) conc++; if (strat > conc && strat >= 1) return 'strategic'; if (conc > strat && conc >= 1) return 'concrete'; } return 'operational'; } const LEVEL_INDEX: Record = { concrete: 0, operational: 1, strategic: 2, }; export interface HierarchicalWeights { /** 같은 레벨 매치 multiplier. 기본 1.15. */ sameLevelBonus: number; /** 양 끝 mismatch (concrete↔strategic) multiplier. 기본 0.70. */ farMismatchPenalty: number; } export const DEFAULT_HIERARCHICAL_WEIGHTS: HierarchicalWeights = { sameLevelBonus: 1.15, farMismatchPenalty: 0.70, }; /** * 질의 레벨에 따라 chunks 의 score 를 hierarchical 매칭으로 재가중. in-place. * metadata 에 분류 결과 기록 (debug/UI 노출). */ export function applyHierarchicalReweight( chunks: RetrievalChunk[], queryLevel: AbstractionLevel, weights: HierarchicalWeights = DEFAULT_HIERARCHICAL_WEIGHTS, ): { sameLevel: number; farMismatch: number } { let sameLevel = 0; let farMismatch = 0; const qi = LEVEL_INDEX[queryLevel]; for (const c of chunks) { const cl = classifyChunkLevel(c); (c.metadata as any).abstractionLevel = cl; const ci = LEVEL_INDEX[cl]; const diff = Math.abs(qi - ci); if (diff === 0) { c.score *= weights.sameLevelBonus; sameLevel++; } else if (diff === 2) { c.score *= weights.farMismatchPenalty; farMismatch++; } // diff === 1: 인접 레벨 → 변화 없음 } return { sameLevel, farMismatch }; }