diff --git a/app/Http/Controllers/Additional/RagSearchController.php b/app/Http/Controllers/Additional/RagSearchController.php new file mode 100644 index 00000000..5a183a71 --- /dev/null +++ b/app/Http/Controllers/Additional/RagSearchController.php @@ -0,0 +1,60 @@ + RAG 검색 컨트롤러 + * SAM 프로젝트 문서를 Gemini AI로 검색/답변 + */ +class RagSearchController extends Controller +{ + public function __construct( + private readonly RagSearchService $ragService, + ) {} + + /** + * RAG 검색 메인 페이지 + */ + public function index(Request $request): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('additional.rag.index')); + } + + $stats = $this->ragService->getDocStats(); + + return view('additional.rag.index', compact('stats')); + } + + /** + * RAG 검색 실행 (HTMX) + */ + public function search(Request $request) + { + $query = trim($request->input('query', '')); + + if (empty($query)) { + return response()->json([ + 'ok' => false, + 'error' => '검색어를 입력해주세요.', + ]); + } + + if (mb_strlen($query) < 2) { + return response()->json([ + 'ok' => false, + 'error' => '검색어는 2자 이상 입력해주세요.', + ]); + } + + $result = $this->ragService->search($query); + + return response()->json($result); + } +} diff --git a/app/Services/RagSearchService.php b/app/Services/RagSearchService.php new file mode 100644 index 00000000..4a815375 --- /dev/null +++ b/app/Services/RagSearchService.php @@ -0,0 +1,424 @@ +docsBasePath = base_path('../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'] ?? ''; + + // 토큰 사용량 저장 + 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, + ]; + + } 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, + ]; + } +} diff --git a/resources/views/additional/rag/index.blade.php b/resources/views/additional/rag/index.blade.php new file mode 100644 index 00000000..c4e90065 --- /dev/null +++ b/resources/views/additional/rag/index.blade.php @@ -0,0 +1,277 @@ +@extends('layouts.app') + +@section('title', 'RAG 검색') + +@push('styles') + +@endpush + +@section('content') +
+
+

RAG 검색

+

SAM 프로젝트 문서를 AI가 분석하여 답변합니다. Gemini API + 문서 컨텍스트 기반 검색.

+
+ + {{-- 통계 --}} +
+
+
+ +
+
+
검색 가능 문서
+
{{ $stats['total_files'] }}개
+
+
+
+
+ +
+
+
총 문서 크기
+
{{ number_format($stats['total_size_kb']) }}KB
+
+
+
+
+ +
+
+
AI 엔진
+
Gemini
+
+
+
+ + {{-- 검색 --}} + + + {{-- 로딩 --}} +
+
+

문서를 검색하고 AI가 답변을 생성하고 있습니다...

+
+ + {{-- 결과 영역 --}} +
+ + {{-- 예시 질문 --}} +
+
예시 질문
+
+ + + + +
+
+
+@endsection + +@push('scripts') + + +@endpush diff --git a/routes/web.php b/routes/web.php index d96a4024..bc1cc790 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Additional\KioskController; use App\Http\Controllers\Additional\NotionSearchController; +use App\Http\Controllers\Additional\RagSearchController; use App\Http\Controllers\Api\BusinessCardOcrController; use App\Http\Controllers\ApiLogController; use App\Http\Controllers\AppVersionController; @@ -714,6 +715,11 @@ Route::get('/', [NotionSearchController::class, 'index'])->name('index'); Route::post('/search', [NotionSearchController::class, 'search'])->name('search'); }); + + Route::prefix('rag')->name('rag.')->group(function () { + Route::get('/', [RagSearchController::class, 'index'])->name('index'); + Route::post('/search', [RagSearchController::class, 'search'])->name('search'); + }); }); /*