/** * LLM Semantic Re-ranking — TF-IDF / 임베딩이 놓치는 *의도* 매치를 작은 LLM 호출 * 한 번으로 잡는다. * * 동작: * 1. 1차 검색(TF-IDF + embedding + 부스트들) 결과의 *상위 K* (기본 15) 후보를 추출 * 2. 가벼운 프롬프트로 LLM 에게 "이 중 query 의도에 가장 부합하는 순서로 ID 나열" 요청 * 3. LLM 응답을 파싱해 순서 적용 — 응답 실패/누락 ID 는 원순서 유지 * * 비용·위험 관리: * - 기본 OFF (g1nation.semanticRerankEnabled). 사용자가 latency 감수할 의지 있을 때만. * - 짧은 timeout (기본 8초) — 초과 시 원순서 그대로 반환, 검색 실패 안 됨. * - 후보 K 제한 — 토큰 비용 cap. * - 별도 빠른 모델 지정 가능 (`g1nation.semanticRerankModel`) — 메인 모델 외 작은 모델. * * 인터페이스: input chunks 순서는 *원본 score 내림차순* 으로 들어와야 함. * 반환: re-rank 가 성공하면 새 순서의 RetrievalChunk[], 실패하면 원순서. */ import { RetrievalChunk } from './types'; export interface SemanticRerankOptions { ollamaUrl: string; /** Re-rank 전용 모델 ID. 비면 fallback model 사용. */ model: string; /** 후보로 LLM 에 넘길 최대 chunk 개수. 기본 15. */ candidateK: number; /** LLM 호출 타임아웃 (ms). 기본 8000. */ timeoutMs: number; /** 각 chunk 미리보기 길이. 기본 240 chars. */ excerptLength: number; } export const DEFAULT_SEMANTIC_RERANK_OPTIONS: Omit = { candidateK: 15, timeoutMs: 8000, excerptLength: 240, }; export interface SemanticRerankResult { rerankedChunks: RetrievalChunk[]; /** true 면 LLM 응답으로 순서 변경됨. false 면 원순서 (실패/타임아웃/파싱 실패). */ success: boolean; durationMs: number; /** 디버그·footer 표시용 — re-rank 가 어떻게 동작했는지. */ note: string; } function shortExcerpt(text: string, n: number): string { if (!text) return ''; const cleaned = text.replace(/\s+/g, ' ').trim(); return cleaned.length <= n ? cleaned : cleaned.slice(0, n) + '…'; } function buildRerankPrompt(query: string, candidates: RetrievalChunk[], excerptLength: number): { system: string; user: string } { const lines: string[] = []; for (let i = 0; i < candidates.length; i++) { const c = candidates[i]; lines.push(`[C${i + 1}] (${c.source}) ${c.title || '(제목 없음)'}`); lines.push(` ${shortExcerpt(c.content, excerptLength)}`); } const system = [ '당신은 검색 결과 재정렬기 (re-ranker). 사용자 질의의 *의도* 와 각 후보 문서의 *내용 부합도* 를 평가해 가장 유용한 순서로 정렬.', '', '[규칙]', '1. 응답은 *반드시* 한 줄의 JSON: `{"ranking":[3,1,5,2,4,...]}` 형식.', '2. ranking 배열 원소 = 입력 [C1], [C2] 의 *번호* (1-based).', '3. 모든 입력 후보를 한 번씩만 포함. 누락·중복·번호 외 값 금지.', '4. 다른 설명·코드 블록·텍스트 출력 절대 금지 — JSON 한 줄만.', '5. 평가 기준: (a) 질의 의도와의 직접 부합도 > (b) 키워드 매치 > (c) 문맥 풍부도.', ].join('\n'); const user = [ `[사용자 질의]\n${query}`, '', `[후보 ${candidates.length}개]`, ...lines, '', '위 후보를 가장 부합도 높은 순서로 정렬한 ranking 배열만 JSON 한 줄로 출력.', ].join('\n'); return { system, user }; } /** * Ollama / OpenAI 호환 endpoint 로 단발 호출. agents/factory.ts 의 BaseAgent.callLLM * 패턴 단순화. timeout, retry 1회만. */ async function callLlmForRerank( ollamaUrl: string, model: string, system: string, user: string, timeoutMs: number, ): Promise { const isOllama = ollamaUrl.includes(':11434') || ollamaUrl.includes('ollama'); const endpoint = isOllama ? `${ollamaUrl}/api/chat` : `${ollamaUrl}/v1/chat/completions`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const body = isOllama ? { model, stream: false, messages: [ { role: 'system', content: system }, { role: 'user', content: user }, ], options: { temperature: 0.0, num_predict: 256 }, } : { model, messages: [ { role: 'system', content: system }, { role: 'user', content: user }, ], stream: false, temperature: 0.0, max_tokens: 256, }; const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: controller.signal, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data: any = await res.json(); const content = data?.message?.content ?? data?.choices?.[0]?.message?.content ?? data?.choices?.[0]?.text ?? data?.response ?? ''; return String(content || ''); } finally { clearTimeout(timer); } } /** LLM 응답에서 ranking 배열 추출 + 검증. 실패 시 null. */ function parseRanking(raw: string, expectedSize: number): number[] | null { if (!raw) return null; // JSON 한 줄 추출 — { ... } 안에 ranking const match = raw.match(/\{[\s\S]*?\}/); if (!match) return null; try { const parsed = JSON.parse(match[0]); const arr = parsed?.ranking; if (!Array.isArray(arr)) return null; const seen = new Set(); const out: number[] = []; for (const v of arr) { const n = typeof v === 'number' ? v : parseInt(String(v), 10); if (!Number.isFinite(n) || n < 1 || n > expectedSize) continue; if (seen.has(n)) continue; seen.add(n); out.push(n); } // 누락 보충 — LLM 이 일부 빠뜨렸으면 원순서로 뒤에 붙임. for (let i = 1; i <= expectedSize; i++) { if (!seen.has(i)) out.push(i); } return out.length === expectedSize ? out : null; } catch { return null; } } export async function semanticRerank( query: string, chunks: RetrievalChunk[], options: SemanticRerankOptions, ): Promise { const start = Date.now(); const k = Math.max(2, Math.min(options.candidateK, chunks.length)); if (chunks.length < 2 || k < 2) { return { rerankedChunks: chunks, success: false, durationMs: 0, note: 'too few candidates' }; } // 입력은 score 내림차순 가정 — 상위 K 가 re-rank 대상, 나머지는 그대로 꼬리. const candidates = chunks.slice(0, k); const tail = chunks.slice(k); const { system, user } = buildRerankPrompt(query, candidates, options.excerptLength); let raw = ''; try { raw = await callLlmForRerank(options.ollamaUrl, options.model, system, user, options.timeoutMs); } catch (e: any) { return { rerankedChunks: chunks, success: false, durationMs: Date.now() - start, note: `LLM call failed: ${e?.name || e?.message || 'unknown'}`, }; } const ranking = parseRanking(raw, candidates.length); if (!ranking) { return { rerankedChunks: chunks, success: false, durationMs: Date.now() - start, note: 'unparseable LLM response', }; } const reranked = ranking.map((i) => candidates[i - 1]); return { rerankedChunks: [...reranked, ...tail], success: true, durationMs: Date.now() - start, note: `re-ranked top ${k} (changed positions: ${ranking.filter((v, i) => v !== i + 1).length})`, }; }