docsBasePath = '/var/www/docs'; } /** * RAG 검색 수행 * * 1. docs 폴더에서 키워드 기반 문서 검색 * 2. 관련 문서 내용을 Gemini API에 컨텍스트로 전달 * 3. 답변 생성 및 반환 */ public function search(string $query): array { $config = AiConfig::getActiveGemini(); if (! $config) { return [ 'ok' => false, 'error' => 'Gemini API 설정이 없습니다. 시스템설정 > AI 설정에서 Gemini를 추가해주세요.', ]; } // 1. 관련 문서 검색 $documents = $this->findRelevantDocuments($query); if (empty($documents)) { return [ 'ok' => false, 'error' => '관련 문서를 찾지 못했습니다. 다른 키워드로 검색해보세요.', 'searched_count' => 0, ]; } // 2. 컨텍스트 구성 $context = $this->buildContext($documents); // 3. Gemini API 호출 return $this->callGemini($config, $query, $context, $documents); } /** * docs 폴더에서 키워드 기반으로 관련 문서 검색 */ private function findRelevantDocuments(string $query): array { $keywords = $this->extractKeywords($query); $files = $this->getAllDocFiles(); $scoredFiles = []; foreach ($files as $filePath) { $content = @file_get_contents($filePath); if ($content === false || empty(trim($content))) { continue; } $score = $this->calculateRelevance($content, $filePath, $keywords); if ($score > 0) { $relativePath = str_replace($this->docsBasePath.'/', '', $filePath); $scoredFiles[] = [ 'path' => $relativePath, 'full_path' => $filePath, 'score' => $score, 'content' => $content, 'size' => strlen($content), ]; } } // 점수 내림차순 정렬 usort($scoredFiles, fn ($a, $b) => $b['score'] <=> $a['score']); // 상위 문서 선택 (토큰 제한 고려: ~100KB 이내) $selected = []; $totalSize = 0; $maxSize = 100 * 1024; // 100KB foreach ($scoredFiles as $file) { if ($totalSize + $file['size'] > $maxSize) { continue; } $selected[] = $file; $totalSize += $file['size']; if (count($selected) >= 10) { break; } } return $selected; } /** * 쿼리에서 키워드 추출 */ private function extractKeywords(string $query): array { // 한글/영문/숫자 토큰 분리 preg_match_all('/[\p{L}\p{N}]+/u', mb_strtolower($query), $matches); $tokens = $matches[0] ?? []; // 불용어 제거 $stopWords = ['은', '는', '이', '가', '을', '를', '에', '의', '에서', '으로', '로', '와', '과', '도', '하다', '있다', '없다', '되다', '것', '수', '등', '및', '때', '중', '더', '안', '해', '이런', '저런', '그런', '어떤', '무엇', '어떻게', '왜', '뭐', '좀', 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'how', 'what', 'why', 'when', 'where']; return array_values(array_filter($tokens, function ($token) use ($stopWords) { return mb_strlen($token) >= 2 && ! in_array($token, $stopWords); })); } /** * 문서 관련도 점수 계산 */ private function calculateRelevance(string $content, string $filePath, array $keywords): float { $score = 0.0; $lowerContent = mb_strtolower($content); $lowerPath = mb_strtolower($filePath); foreach ($keywords as $keyword) { // 본문 매칭 $count = mb_substr_count($lowerContent, $keyword); $score += min($count, 10) * 1.0; // 파일 경로 매칭 (가중치 높음) if (mb_strpos($lowerPath, $keyword) !== false) { $score += 5.0; } } // 파일 유형 가중치 if (str_ends_with($filePath, '.md')) { $score *= 1.2; // Markdown 문서 우선 } // INDEX.md, README.md 가중치 $basename = basename($filePath); if (in_array($basename, ['INDEX.md', 'README.md'])) { $score *= 1.1; } return $score; } /** * docs 폴더의 모든 대상 파일 목록 */ private function getAllDocFiles(): array { $files = []; $basePath = realpath($this->docsBasePath); if (! $basePath || ! is_dir($basePath)) { return []; } $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($basePath, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::LEAVES_ONLY ); foreach ($iterator as $file) { if (! $file->isFile()) { continue; } $ext = strtolower($file->getExtension()); if (! in_array($ext, $this->extensions)) { continue; } // node_modules, vendor, .git 등 제외 $path = $file->getPathname(); if (preg_match('#/(node_modules|vendor|\.git|pptx-output|assets)/#', $path)) { continue; } // 파일 크기 제한 (50KB 이하만) if ($file->getSize() > 50 * 1024) { continue; } $files[] = $path; } return $files; } /** * 검색된 문서들로 Gemini 컨텍스트 구성 */ private function buildContext(array $documents): string { $context = ''; foreach ($documents as $doc) { $context .= "=== 문서: {$doc['path']} ===\n"; $context .= $doc['content']; $context .= "\n\n"; } return $context; } /** * Gemini API 호출 */ private function callGemini(AiConfig $config, string $query, string $context, array $documents): array { $systemPrompt = <<<'PROMPT' 당신은 SAM(Smart Automation Management) 프로젝트의 기술 문서 어시스턴트입니다. 역할: - 제공된 문서 컨텍스트를 기반으로 사용자의 질문에 정확히 답변합니다. - 문서에 없는 내용은 "해당 정보는 현재 문서에서 찾을 수 없습니다"라고 답변합니다. - 답변은 한글로 작성하며, 코드/경로/기술 용어는 원문 그대로 사용합니다. - 관련 파일 경로가 있으면 함께 안내합니다. 답변 형식: - Markdown 형식으로 작성합니다. - 코드 블록에는 언어를 지정합니다. - 핵심을 먼저 말하고, 상세 설명은 뒤에 배치합니다. PROMPT; $userMessage = "## 질문\n{$query}\n\n## 참조 문서\n{$context}"; $parts = [ ['text' => $systemPrompt], ['text' => $userMessage], ]; $body = [ 'contents' => [ ['parts' => $parts], ], 'generationConfig' => [ 'temperature' => 0.3, 'maxOutputTokens' => 4096, ], ]; try { if ($config->isVertexAi()) { $response = $this->callVertexAi($config, $body); } else { $response = $this->callGoogleAiStudio($config, $body); } if (! $response->successful()) { Log::error('RagSearchService: Gemini API 호출 실패', [ 'status' => $response->status(), 'body' => $response->body(), ]); return [ 'ok' => false, 'error' => 'AI 응답 생성에 실패했습니다. (HTTP '.$response->status().')', ]; } $result = $response->json(); $answer = $result['candidates'][0]['content']['parts'][0]['text'] ?? ''; // 토큰 사용량 계산 $usage = $result['usageMetadata'] ?? []; $promptTokens = $usage['promptTokenCount'] ?? 0; $completionTokens = $usage['candidatesTokenCount'] ?? 0; $totalTokens = $usage['totalTokenCount'] ?? 0; // 비용 계산 $pricing = AiPricingConfig::getActivePricing('gemini'); $inputPrice = $pricing ? (float) $pricing->input_price_per_million / 1_000_000 : 0.10 / 1_000_000; $outputPrice = $pricing ? (float) $pricing->output_price_per_million / 1_000_000 : 0.40 / 1_000_000; $costUsd = ($promptTokens * $inputPrice) + ($completionTokens * $outputPrice); $exchangeRate = AiPricingConfig::getExchangeRate(); $costKrw = $costUsd * $exchangeRate; // 토큰 사용량 DB 저장 AiTokenHelper::saveGeminiUsage($result, $result['modelVersion'] ?? $config->model, 'RAG검색'); // 참조 문서 목록 추출 $references = array_map(fn ($doc) => [ 'path' => $doc['path'], 'score' => round($doc['score'], 1), ], $documents); return [ 'ok' => true, 'answer' => $answer, 'references' => $references, 'searched_count' => count($documents), 'model' => $config->model, 'token_usage' => [ 'prompt_tokens' => $promptTokens, 'completion_tokens' => $completionTokens, 'total_tokens' => $totalTokens, 'cost_usd' => round($costUsd, 6), 'cost_krw' => round($costKrw, 2), ], ]; } catch (\Exception $e) { Log::error('RagSearchService: 예외 발생', ['error' => $e->getMessage()]); return [ 'ok' => false, 'error' => 'AI 호출 중 오류가 발생했습니다: '.$e->getMessage(), ]; } } /** * Google AI Studio API 호출 (API 키 인증) */ private function callGoogleAiStudio(AiConfig $config, array $body): \Illuminate\Http\Client\Response { $model = $config->model; $apiKey = $config->api_key; $baseUrl = $config->base_url ?? 'https://generativelanguage.googleapis.com/v1beta'; $url = "{$baseUrl}/models/{$model}:generateContent?key={$apiKey}"; return Http::timeout(120)->post($url, $body); } /** * Vertex AI API 호출 (서비스 계정 인증) */ private function callVertexAi(AiConfig $config, array $body): \Illuminate\Http\Client\Response { $model = $config->model; $projectId = $config->getProjectId(); $region = $config->getRegion(); $accessToken = $this->getAccessToken($config); if (! $accessToken) { throw new \RuntimeException('Google Cloud 인증 실패'); } $url = "https://{$region}-aiplatform.googleapis.com/v1/projects/{$projectId}/locations/{$region}/publishers/google/models/{$model}:generateContent"; // Vertex AI는 role 필수 $body['contents'][0]['role'] = 'user'; return Http::withToken($accessToken)->timeout(120)->post($url, $body); } /** * Vertex AI 용 OAuth 액세스 토큰 발급 */ private function getAccessToken(AiConfig $config): ?string { $serviceAccountPath = $config->getServiceAccountPath(); if (! $serviceAccountPath || ! file_exists($serviceAccountPath)) { return null; } $serviceAccount = json_decode(file_get_contents($serviceAccountPath), true); if (! $serviceAccount) { return null; } try { $now = time(); $header = base64_encode(json_encode(['alg' => 'RS256', 'typ' => 'JWT'])); $claim = base64_encode(json_encode([ 'iss' => $serviceAccount['client_email'], 'scope' => 'https://www.googleapis.com/auth/cloud-platform', 'aud' => 'https://oauth2.googleapis.com/token', 'exp' => $now + 3600, 'iat' => $now, ])); $privateKey = openssl_pkey_get_private($serviceAccount['private_key']); openssl_sign("{$header}.{$claim}", $signature, $privateKey, OPENSSL_ALGO_SHA256); $jwt = "{$header}.{$claim}.".base64_encode($signature); $response = Http::asForm()->post('https://oauth2.googleapis.com/token', [ 'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer', 'assertion' => $jwt, ]); if ($response->successful()) { return $response->json('access_token'); } Log::error('RagSearchService: OAuth 토큰 발급 실패', [ 'status' => $response->status(), ]); return null; } catch (\Exception $e) { Log::error('RagSearchService: OAuth 토큰 발급 예외', [ 'error' => $e->getMessage(), ]); return null; } } /** * 검색 가능한 문서 통계 조회 */ public function getDocStats(): array { $files = $this->getAllDocFiles(); $totalSize = 0; $byExtension = []; foreach ($files as $file) { $size = filesize($file); $totalSize += $size; $ext = pathinfo($file, PATHINFO_EXTENSION); $byExtension[$ext] = ($byExtension[$ext] ?? 0) + 1; } return [ 'total_files' => count($files), 'total_size_kb' => round($totalSize / 1024), 'by_extension' => $byExtension, ]; } }