2026-02-22 23:05:55 +09:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Services;
|
|
|
|
|
|
|
|
|
|
use App\Helpers\AiTokenHelper;
|
|
|
|
|
use App\Models\System\AiConfig;
|
2026-02-22 23:33:15 +09:00
|
|
|
use App\Models\System\AiPricingConfig;
|
2026-02-22 23:05:55 +09:00
|
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
|
|
|
|
|
|
class RagSearchService
|
|
|
|
|
{
|
|
|
|
|
/**
|
|
|
|
|
* 문서 검색 기본 경로
|
|
|
|
|
*/
|
|
|
|
|
private string $docsBasePath;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 검색 대상 확장자
|
|
|
|
|
*/
|
|
|
|
|
private array $extensions = ['md', 'php', 'js', 'json'];
|
|
|
|
|
|
|
|
|
|
public function __construct()
|
|
|
|
|
{
|
2026-02-22 23:20:21 +09:00
|
|
|
$this->docsBasePath = '/var/www/docs';
|
2026-02-22 23:05:55 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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'] ?? '';
|
|
|
|
|
|
2026-02-22 23:33:15 +09:00
|
|
|
// 토큰 사용량 계산
|
|
|
|
|
$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 저장
|
2026-02-22 23:05:55 +09:00
|
|
|
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,
|
2026-02-22 23:33:15 +09:00
|
|
|
'token_usage' => [
|
|
|
|
|
'prompt_tokens' => $promptTokens,
|
|
|
|
|
'completion_tokens' => $completionTokens,
|
|
|
|
|
'total_tokens' => $totalTokens,
|
|
|
|
|
'cost_usd' => round($costUsd, 6),
|
|
|
|
|
'cost_krw' => round($costKrw, 2),
|
|
|
|
|
],
|
2026-02-22 23:05:55 +09:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
} 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,
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|