/** * ============================================================ * RetrievalOrchestrator — Unified RAG Pipeline * * Astra의 모든 검색 소스를 통합 관리하는 오케스트레이터입니다. * * 검색 흐름: * ① Query Planning — 의도 분류 + 검색 전략 결정 * ② Parallel Search — Brain + Memory + Project + Episode 동시 검색 * ③ Result Fusion — 통합 스코어링 + 중복 제거 * ④ Context Budget — 토큰 예산 내에서 최종 선택 * ============================================================ */ import * as fs from 'fs'; import * as path from 'path'; import { BrainProfile } from '../config'; import { findBrainFiles, summarizeText } from '../utils'; import { isInside } from '../lib/paths'; import { MemoryManager } from '../memory'; import { RetrievalChunk, RetrievalResult, ContextBudgetConfig } from './types'; import { tokenize, expandQuery, scoreTfIdfPreTokenized, extractBestExcerpt } from './scoring'; import { selectWithinBudget, assembleContext, estimateTokens } from './contextBudget'; import { getBrainTokenIndex } from './brainIndex'; export { tokenize, expandQuery, scoreTfIdf, scoreTfIdfPreTokenized, extractBestExcerpt } from './scoring'; export { selectWithinBudget, assembleContext, estimateTokens } from './contextBudget'; export { getBrainTokenIndex, clearBrainTokenIndex } from './brainIndex'; export * from './types'; interface RetrievalOptions { brain: BrainProfile; memoryManager: MemoryManager; workspacePath?: string; chatHistory?: Array<{ role: string; content: string }>; contextBudget?: Partial; brainFileLimit?: number; includeRawConversations?: boolean; /** * Optional absolute folder paths constraining brain-file search to those * subtrees. When provided and non-empty, only brain files inside one of * the folders are considered. Empty / undefined preserves whole-brain * search (legacy behavior). Folders that escape the brain root are * silently dropped by the caller (see `agentKnowledgeMap.resolveScopeForAgent`). */ scopeFolders?: string[]; } export class RetrievalOrchestrator { /** * 통합 검색을 수행합니다. * 모든 소스에서 검색 → TF-IDF 스코어링 → 중복 제거 → 예산 내 선택 */ public retrieve(query: string, options: RetrievalOptions): RetrievalResult { const fusionLog: string[] = []; const allChunks: RetrievalChunk[] = []; const queryTokens = tokenize(query); const expandedTokens = expandQuery(queryTokens); fusionLog.push(`Query tokens: [${queryTokens.slice(0, 10).join(', ')}]`); fusionLog.push(`Expanded tokens: [${expandedTokens.slice(0, 15).join(', ')}]`); // ── ① Brain File Search (TF-IDF enhanced) ── const scopeFolders = options.scopeFolders ?? []; const brainChunks = this.searchBrainFiles( query, expandedTokens, options.brain, options.brainFileLimit || 8, options.includeRawConversations || false, scopeFolders ); allChunks.push(...brainChunks); fusionLog.push( scopeFolders.length > 0 ? `Brain search (scoped to ${scopeFolders.length} folder(s)): ${brainChunks.length} chunks` : `Brain search: ${brainChunks.length} chunks found` ); // ── ② Memory Layers ── const memoryChunks = this.searchMemoryLayers( query, options.memoryManager, options.chatHistory || [], options.workspacePath ); allChunks.push(...memoryChunks); fusionLog.push(`Memory search: ${memoryChunks.length} chunks found`); // ── ③ Result Fusion — normalize scores across sources ── this.normalizeScores(allChunks); fusionLog.push(`Total chunks before budget: ${allChunks.length}`); // ── ④ Context Budget Selection ── const { selected, dropped, tokensUsed } = selectWithinBudget( allChunks, options.contextBudget ); fusionLog.push(`Selected: ${selected.length}, Dropped: ${dropped.length}, Tokens: ${tokensUsed}`); return { query, totalChunks: allChunks.length, selectedChunks: selected, droppedChunks: dropped, totalTokensUsed: tokensUsed, contextBudget: options.contextBudget?.totalBudget || 8000, fusionLog }; } /** * 검색 결과를 최종 컨텍스트 문자열로 변환합니다. */ public buildContextString(result: RetrievalResult): string { return assembleContext(result.selectedChunks); } // ─── Brain File Search ─── private searchBrainFiles( query: string, expandedTokens: string[], brain: BrainProfile, limit: number, includeRaw: boolean, scopeFolders: string[] = [] ): RetrievalChunk[] { try { const scoped = (file: string) => scopeFolders.length === 0 || scopeFolders.some((folder) => isInside(folder, file)); const allFiles = findBrainFiles(brain.localBrainPath) .filter(scoped) .filter((file) => includeRaw || !this.isRawConversation(path.relative(brain.localBrainPath, file))); if (allFiles.length === 0) return []; // Tokenized docs from the persistent mtime-keyed index — unchanged files are not re-read // or re-tokenized, so per-query work over a large brain drops from O(total content) to O(files) stats. const indexed = getBrainTokenIndex(brain.localBrainPath, allFiles); if (indexed.length === 0) return []; const scored = scoreTfIdfPreTokenized( expandedTokens, indexed.map((d) => ({ tokens: d.tokens, titleTokens: d.titleTokens, lastModified: d.mtimeMs, conflictCount: d.conflictCount, })) ); const topResults: RetrievalChunk[] = []; for (const s of scored.filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, limit)) { const doc = indexed[s.index]; // Only the top `limit` files are actually read off disk (for excerpt extraction). let content = ''; try { content = fs.readFileSync(doc.filePath, 'utf8'); } catch { /* deleted just now — skip */ continue; } const excerpt = extractBestExcerpt(content, expandedTokens, 400); topResults.push({ id: `brain-${s.index}`, source: 'brain-memory' as const, title: doc.relativePath, content: summarizeText(excerpt, 400), score: s.score, tokenEstimate: estimateTokens(excerpt), metadata: { filePath: doc.filePath, category: this.inferCategory(doc.relativePath), isProjectEvidence: this.isProjectEvidence(doc.relativePath, content), lastUpdated: doc.mtimeMs, // Phase 5: Scoring Intelligence Integration conflictDetected: s.conflictDetected, conflictSeverity: s.conflictSeverity, informationDensity: s.informationDensity, }, }); } return topResults; } catch { return []; } } // ─── Memory Layer Search ─── private searchMemoryLayers( query: string, memoryManager: MemoryManager, chatHistory: Array<{ role: string; content: string }>, workspacePath?: string ): RetrievalChunk[] { const chunks: RetrievalChunk[] = []; // Long-Term Memory const ltm = memoryManager.getLongTermMemory(); const ltmContext = ltm.buildContext(query); if (ltmContext) { chunks.push({ id: 'ltm-context', source: 'long-term-memory', title: ltmContext.label, content: ltmContext.content, score: ltmContext.relevance, tokenEstimate: estimateTokens(ltmContext.content), metadata: { category: 'long-term' } }); } // Project Memory if (workspacePath) { const pm = memoryManager.getProjectMemory(workspacePath); const pmContext = pm.buildContext(query); if (pmContext) { chunks.push({ id: 'pm-context', source: 'project-memory', title: pmContext.label, content: pmContext.content, score: pmContext.relevance, tokenEstimate: estimateTokens(pmContext.content), metadata: { category: 'project', isProjectEvidence: true } }); } } // Procedural Memory const proc = memoryManager.getProceduralMemory(); const procContext = proc.buildContext(query); if (procContext) { chunks.push({ id: 'proc-context', source: 'procedural-memory', title: procContext.label, content: procContext.content, score: procContext.relevance, tokenEstimate: estimateTokens(procContext.content), metadata: { category: 'procedural' } }); } // Episodic Memory const ep = memoryManager.getEpisodicMemory(); const epContext = ep.buildContext(query); if (epContext) { chunks.push({ id: 'ep-context', source: 'episodic-memory', title: epContext.label, content: epContext.content, score: epContext.relevance, tokenEstimate: estimateTokens(epContext.content), metadata: { category: 'episodic' } }); } return chunks; } // ─── Score Normalization ─── /** * 서로 다른 스코어 스케일을 가진 소스들의 점수를 0~1로 정규화합니다. */ private normalizeScores(chunks: RetrievalChunk[]): void { // Group by source const groups = new Map(); for (const chunk of chunks) { if (!groups.has(chunk.source)) groups.set(chunk.source, []); groups.get(chunk.source)!.push(chunk); } // Normalize each group independently for (const [, group] of groups) { const maxScore = Math.max(...group.map((c) => c.score), 0.001); for (const chunk of group) { chunk.score = chunk.score / maxScore; } } // Source priority boost (some sources are inherently more valuable for RAG) const sourceBoost: Record = { 'brain-trace': 1.0, 'brain-memory': 0.9, 'project-memory': 0.85, 'long-term-memory': 0.8, 'procedural-memory': 0.95, // Procedural is highly specific 'episodic-memory': 0.7, 'project-scan': 0.6, 'recent-knowledge': 0.75 }; for (const chunk of chunks) { const boost = sourceBoost[chunk.source] || 0.5; chunk.score *= boost; } } // ─── Helpers ─── private isRawConversation(relativePath: string): boolean { return /(^|[\\/])(00_Raw|raw-data|conversations?|transcripts?)([\\/]|$)/i.test(relativePath); } private inferCategory(relativePath: string): string { const normalized = relativePath.toLowerCase(); if (/(decisions?|adr|planning)/i.test(normalized)) return 'decision'; if (/(records|development|bugs)/i.test(normalized)) return 'project-record'; if (/(architecture|design|pattern)/i.test(normalized)) return 'architecture'; if (/(knowledge|wiki|topics)/i.test(normalized)) return 'knowledge'; return 'general'; } private isProjectEvidence(relativePath: string, content: string): boolean { const normalized = relativePath.toLowerCase(); if (/(records|planning|development|bugs|retrospectives|projectchronicle)/i.test(normalized)) return true; if (/adr-\d+|(^|[\\/])decisions?([\\/]|$)/i.test(normalized)) return true; return false; } }