From 713f331656992d01e338d87d036c81fd55675b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=8A=B9=ED=98=84?= Date: Wed, 24 Dec 2025 17:30:10 +0900 Subject: [PATCH 01/12] =?UTF-8?q?[feat]=20-=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=ED=95=9C=EA=B5=AD=EC=96=B4=20=EB=B6=84=EC=84=9D=EC=86=8C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20LLM=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD,=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/article/service/SearchService.java | 483 +++++++----------- 1 file changed, 194 insertions(+), 289 deletions(-) diff --git a/api-server/src/main/java/com/ssafy/newstagram/api/article/service/SearchService.java b/api-server/src/main/java/com/ssafy/newstagram/api/article/service/SearchService.java index 2bb88c2..a072b61 100644 --- a/api-server/src/main/java/com/ssafy/newstagram/api/article/service/SearchService.java +++ b/api-server/src/main/java/com/ssafy/newstagram/api/article/service/SearchService.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.ssafy.newstagram.api.article.dto.ArticleDto; import com.ssafy.newstagram.api.article.dto.EmbeddingResponse; -import com.ssafy.newstagram.api.article.dto.IntentAnalysisResponse; import com.ssafy.newstagram.api.article.dto.SearchHistoryDto; import com.ssafy.newstagram.api.article.repository.NewsCategoryRepository; import com.ssafy.newstagram.api.article.repository.ArticleRepository; @@ -12,21 +11,13 @@ import com.ssafy.newstagram.domain.news.entity.Article; import com.ssafy.newstagram.domain.user.entity.User; import com.ssafy.newstagram.domain.user.entity.UserSearchHistory; -import kr.co.shineware.nlp.komoran.constant.DEFAULT_MODEL; -import kr.co.shineware.nlp.komoran.core.Komoran; -import kr.co.shineware.nlp.komoran.model.KomoranResult; -import kr.co.shineware.nlp.komoran.model.Token; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.annotation.Lazy; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.annotation.Cacheable; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; +import org.springframework.context.annotation.Lazy; +import org.springframework.http.*; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -34,8 +25,10 @@ import org.springframework.web.client.RestTemplate; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.function.Predicate; import java.util.stream.Collectors; @Slf4j @@ -51,29 +44,23 @@ public class SearchService { @Autowired @Lazy private SearchService self; - + @Value("${gms.api.base-url}") private String gmsApiBaseUrl; @Value("${gms.api.llm-base-url}") private String gmsLlmBaseUrl; @Value("${gms.api.key}") private String gmsApiKey; - + private static final String MODEL_NAME = "text-embedding-3-small"; private static final String LLM_MODEL_NAME = "gpt-4o-mini"; private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - private static final Komoran komoran = new Komoran(DEFAULT_MODEL.FULL); @Transactional(readOnly = true) public List searchArticles(Long userId, String query, int limit, int page) { - // 1. Save Search History (Only for the first page) if (page == 0) { self.saveSearchHistory(userId, query); } - - // 2. Perform Search (Cached) - // Authenticated search uses strict threshold (0.8) to limit results to relevant ones - // Use 'self' to invoke via proxy for caching return self.getCachedSearchResults(query, limit, page, 0.80); } @@ -82,298 +69,178 @@ public List getCachedSearchResults(String query, int limit, int page long totalStartTime = System.currentTimeMillis(); log.info("[Search] Original Query: {}, Page: {}", query, page); - // 1. Try Local Analysis (Rule-based) - IntentAnalysisResponse intent = analyzeIntentLocal(query); + // 1. LLM Analysis + SearchAnalysisResult analysis = analyzeQueryWithLLM(query); + log.info("[Search] LLM Analysis Result: {}", analysis); + + // 2. Prepare for DB Query + final LocalDateTime startDate = (analysis.getDateRange() > 0) + ? LocalDateTime.now().minusDays(analysis.getDateRange()) + : LocalDateTime.now().minusDays(7); - if (intent == null) { - intent = new IntentAnalysisResponse(query, null, 7, new ArrayList<>()); // Default 7 days if analysis fails - log.info("[Search] Local Analysis Failed or Skipped. Using raw query with default 7 days."); - } else { - // Default to 7 days if no date specified - if (intent.getDateRange() == 0) { - intent.setDateRange(7); + List categoryIds = new ArrayList<>(); + if (analysis.getCategories() != null) { + for (String code : analysis.getCategories()) { + newsCategoryRepository.findByName(code).ifPresent(category -> categoryIds.add(category.getId())); } - log.info("[Search] Local Analysis Result: Query={}, Category={}, DateRange={}", - intent.getQuery(), intent.getCategory(), intent.getDateRange()); } - // Case 2: Vector Search (Keywords exist OR Category not found) - String searchKeywords = (intent.getQuery() != null && !intent.getQuery().isBlank()) - ? intent.getQuery() - : query; - - long embeddingStartTime = System.currentTimeMillis(); - List embedding = self.getCachedEmbedding(searchKeywords); - long embeddingEndTime = System.currentTimeMillis(); - log.info("[Search] Embedding API took {} ms", (embeddingEndTime - embeddingStartTime)); - - String embeddingString = toPgVectorLiteral(embedding); - - // 3. LLM Category Analysis - long llmStartTime = System.currentTimeMillis(); - List categoryIds = analyzeCategoryWithLLM(query); - long llmEndTime = System.currentTimeMillis(); - log.info("[Search] LLM Category Analysis took {} ms", (llmEndTime - llmStartTime)); - - LocalDateTime startDate = null; - if (intent.getDateRange() > 0) { - startDate = LocalDateTime.now().minusDays(intent.getDateRange()); - } + List keywordsForSearch = (analysis.getSearchKeywords() != null && !analysis.getSearchKeywords().isEmpty()) + ? analysis.getSearchKeywords() + : List.of(query); + + // 여러 키워드 임베딩을 가져와 평균을 계산 + List> embeddings = keywordsForSearch.stream() + .map(self::getCachedEmbedding) + .collect(Collectors.toList()); - // Optimization: Single DB Query with MAX threshold and large limit - int candidateLimit = 800; + List averageEmbedding = calculateAverageEmbedding(embeddings); + String embeddingString = toPgVectorLiteral(averageEmbedding); + // --- [로직 수정 끝] --- + + int candidateLimit = 800; - long dbStartTime = System.currentTimeMillis(); - List
articles = articleRepository.findCandidatesByEmbedding( + // DB 쿼리는 단 한번만 호출 + List
candidateArticles = articleRepository.findCandidatesByEmbedding( embeddingString, candidateLimit, categoryIds, startDate, threshold); - long dbEndTime = System.currentTimeMillis(); - log.info("[Search] DB Query took {} ms", (dbEndTime - dbStartTime)); + log.info("[Search] Found {} candidate articles from vector search.", candidateArticles.size()); - List filterKeywords = (intent.getKeywords() != null && !intent.getKeywords().isEmpty()) - ? intent.getKeywords() - : List.of(query.split("\\s+")); + // 4. 키워드 필터링은 'primary_keywords'를 사용 + List keywordsForFilter = (analysis.getPrimaryKeywords() != null && !analysis.getPrimaryKeywords().isEmpty()) + ? analysis.getPrimaryKeywords() + : keywordsForSearch; // primary_keywords가 없으면 search_keywords를 대신 사용 (Fallback) - List result = articles.stream() + List
filteredArticles = candidateArticles.stream() .filter(article -> { - String title = article.getTitle() != null ? article.getTitle() : ""; - String description = article.getDescription() != null ? article.getDescription() : ""; - // Check if ANY of the keywords are present - return filterKeywords.stream().anyMatch(keyword -> - title.contains(keyword) || description.contains(keyword) - ); + String title = article.getTitle() != null ? article.getTitle().toLowerCase() : ""; + String description = article.getDescription() != null ? article.getDescription().toLowerCase() : ""; + // 'keywordsForFilter' 중 하나라도 포함되면 통과 + return keywordsForFilter.stream() + .anyMatch(keyword -> + title.contains(keyword.toLowerCase()) || description.contains(keyword.toLowerCase()) + ); }) - .sorted((a1, a2) -> a2.getPublishedAt().compareTo(a1.getPublishedAt())) // Sort by Date DESC - .skip((long) page * limit) // Pagination in memory - .limit(limit) + .collect(Collectors.toList()); + log.info("[Search] {} articles remaining after PRIMARY keyword filtering.", filteredArticles.size()); + + // 5. 최종 정렬 및 메모리 기반 페이징 + List
sortedArticles = filteredArticles.stream() + .sorted(Comparator.comparing(Article::getPublishedAt).reversed()) // 최신순으로 정렬 + .collect(Collectors.toList()); + + int startIdx = page * limit; + if (startIdx >= sortedArticles.size()) { + return new ArrayList<>(); + } + int endIdx = Math.min(startIdx + limit, sortedArticles.size()); + + List result = sortedArticles.subList(startIdx, endIdx).stream() .map(this::convertToDto) .collect(Collectors.toList()); - + long totalEndTime = System.currentTimeMillis(); log.info("[Search] Total Service Execution took {} ms, query : {}", (totalEndTime - totalStartTime), query); return result; } - private IntentAnalysisResponse analyzeIntentLocal(String query) { - String category = null; - int dateRange = 0; - List cleanKeywords = new ArrayList<>(); - boolean komoranSuccess = false; - - // 1. Komoran Analysis - try { - KomoranResult analyzeResultList = komoran.analyze(query); - List tokenList = analyzeResultList.getTokenList(); - komoranSuccess = true; - - log.info("[Search] Komoran Tokens: {}", tokenList.stream() - .map(t -> t.getMorph() + "(" + t.getPos() + ")") - .collect(Collectors.joining(", "))); - - StringBuilder currentChunk = new StringBuilder(); - - for (Token token : tokenList) { - String morph = token.getMorph(); - String pos = token.getPos(); - int matchedDate = matchDateRange(morph); - - if (matchedDate != 0) { - flushChunk(cleanKeywords, currentChunk); // 이전 청크 저장 - if (dateRange == 0) dateRange = matchedDate; - continue; - } - - // 2. 불용어 처리 (임베딩에서 제외) - if (isStopWord(morph)) { - flushChunk(cleanKeywords, currentChunk); // 이전 청크 저장 - continue; - } - - // 3. 검색어 병합 (Chunking) - if (isSearchablePos(pos)) { - currentChunk.append(morph); // 공백 없이 병합 (예: 기아+타이거즈 -> 기아타이거즈) - } else { - flushChunk(cleanKeywords, currentChunk); // 조사/어미 등 만나면 청크 종료 - } - } - flushChunk(cleanKeywords, currentChunk); // 남은 청크 저장 - - } catch (Exception e) { - log.error("[Search] Komoran Analysis Failed", e); + // 평균 임베딩을 계산하는 유틸리티 메서드 (클래스 내부에 추가) + private List calculateAverageEmbedding(List> embeddings) { + if (embeddings == null || embeddings.isEmpty()) { + return new ArrayList<>(); } + // 모든 벡터가 동일한 차원을 가지고 있다고 가정 (e.g., 1536) + int dimensions = embeddings.get(0).size(); + List averageVector = new ArrayList<>(Collections.nCopies(dimensions, 0.0)); - // 2. Fallback: Simple Space Splitting (only if Komoran failed OR resulted in empty keywords) - // Komoran이 성공했더라도 모든 토큰이 필터링되어 키워드가 없는 경우(예: 신조어만 있거나 불용어만 있는 경우) - // 단순 띄어쓰기 기준으로 다시 시도하여 불용어만 제거하고 나머지는 살린다. - if (!komoranSuccess || cleanKeywords.isEmpty()) { - cleanKeywords.clear(); // 혹시 모를 잔여 데이터 제거 - - String[] words = query.split("\\s+"); - for (String word : words) { - int matchedDate = matchDateRange(word); - - if (matchedDate != 0) { - if (dateRange == 0) dateRange = matchedDate; - } else if (!isStopWord(word)) { - cleanKeywords.add(word); - } + for (List vector : embeddings) { + for (int i = 0; i < dimensions; i++) { + averageVector.set(i, averageVector.get(i) + vector.get(i)); } } - - String finalQuery = cleanKeywords.isEmpty() ? query : String.join(" ", cleanKeywords); - if (finalQuery.isBlank()) finalQuery = query; - - return new IntentAnalysisResponse(finalQuery, category, dateRange, cleanKeywords); - } - private void flushChunk(List keywords, StringBuilder chunk) { - if (chunk.length() > 0) { - String keyword = chunk.toString(); - keywords.add(keyword); - log.debug("[Search] Chunk added: {}", keyword); - chunk.setLength(0); // 버퍼 초기화 + for (int i = 0; i < dimensions; i++) { + averageVector.set(i, averageVector.get(i) / embeddings.size()); } + return averageVector; } - private boolean isSearchablePos(String pos) { - // NNG(일반명사) 포함: NNP(고유명사), SL(외국어), SH(한자), SN(숫자) - // 연속된 명사를 병합하기 위해 NNG도 포함시킴 (단, 불용어 필터링 필수) - // NA(분석불능), NF(추정명사), NV(추정동사), XR(어근) 추가하여 신조어/미등록어 대응 - return "NNG".equals(pos) || "NNP".equals(pos) || "SL".equals(pos) || "SH".equals(pos) || "SN".equals(pos) || - "NA".equals(pos) || "NF".equals(pos) || "NV".equals(pos) || "XR".equals(pos); + // Article ID 기준으로 중복을 제거하기 위한 유틸리티 메서드 + public static Predicate distinctByKey(Function keyExtractor) { + Set seen = ConcurrentHashMap.newKeySet(); + return t -> seen.add(keyExtractor.apply(t)); } - private boolean isStopWord(String word) { - return word.equals("뉴스") || word.equals("기사") || word.equals("관련") || word.equals("소식") || - word.equals("내용") || word.equals("대해") || word.equals("관하") || word.equals("궁금") || - word.equals("알려") || word.equals("주") || word.equals("좀") || word.equals("어떻") || - word.equals("무엇") || word.equals("어") || word.equals("하") || word.equals("되") || - word.equals("이") || word.equals("가") || word.equals("을") || word.equals("를") || - word.equals("은") || word.equals("는") || word.equals("의") || word.equals("에") || - word.equals("에서") || word.equals("로") || word.equals("으로") || word.equals("와") || - word.equals("과") || word.equals("도") || word.equals("만") || word.equals("나") || - word.equals("이나") || word.equals("부터") || word.equals("까지") || word.equals("필요") || - word.equals("및") || word.equals("또는") || word.equals("혹은") || word.equals("그리고") || - word.equals("그러나") || word.equals("하지만") || word.equals("그래서") || word.equals("따라서") || - word.equals("때문에") || word.equals("인하여") || word.equals("위하") || word.equals("따르") || - word.equals("보이") || word.equals("보") || word.equals("드리") || word.equals("시키") || - word.equals("만들") || word.equals("가지") || word.equals("갖") || word.equals("그렇") || - word.equals("저렇") || word.equals("이렇") || word.equals("무슨") || word.equals("어느") || - word.equals("어떤") || word.equals("누구") || word.equals("언제") || word.equals("어디") || - word.equals("왜") || word.equals("어떻게") || word.equals("보도") || word.equals("속보") || - word.equals("결과") || word.equals("발표") || word.equals("예정") || word.equals("계획") || - word.equals("진행") || word.equals("상황") || word.equals("상태") || word.equals("문제") || - word.equals("해결") || word.equals("방안") || word.equals("대책") || word.equals("이유") || - word.equals("원인") || word.equals("배경") || word.equals("전망") || word.equals("분석") || - word.equals("평가") || word.equals("의견") || word.equals("주장") || word.equals("생각") || - word.equals("입장") || word.equals("반응") || word.equals("논란") || word.equals("의혹") || - word.equals("사실") || word.equals("확인") || word.equals("공개") || word.equals("등등") || - word.equals("것") || word.equals("수") || word.equals("등"); - } + @lombok.Data + @lombok.NoArgsConstructor + @lombok.AllArgsConstructor + private static class SearchAnalysisResult { + // @JsonProperty는 LLM이 보내주는 JSON의 key와 필드명을 매핑합니다. + @com.fasterxml.jackson.annotation.JsonProperty("primary_keywords") + private List primaryKeywords; - private int matchDateRange(String word) { - if (word.equals("오늘") || word.equals("금일") || word.equals("하루")) return 1; - if (word.equals("어제") || word.equals("작일")) return 2; - if (word.equals("이번주") || word.equals("주간") || word.equals("요즘") || word.equals("최근") || - word.equals("최신") || word.equals("일주일")) return 7; - if (word.equals("이번달") || word.equals("월간") || word.equals("한달")) return 30; - if (word.equals("분기") || word.equals("3개월")) return 90; - if (word.equals("상반기") || word.equals("하반기") || word.equals("반기")) return 180; - if (word.equals("올해") || word.equals("연간") || word.equals("일년")) return 365; - if (word.equals("작년") || word.equals("지난해")) return 730; - return 0; - } + @com.fasterxml.jackson.annotation.JsonProperty("search_keywords") + private List searchKeywords; - private String toPgVectorLiteral(List embedding){ - String inner = embedding.stream() - .map(d -> String.format(java.util.Locale.US, "%.6f", d)) - .collect(Collectors.joining(",")); - return "[" + inner + "]"; + private int dateRange; + private List categories; } - @Cacheable(value = "keyword_embedding", key = "#inputText") - public List getCachedEmbedding(String inputText) { - if (inputText == null || inputText.isBlank()) { - throw new IllegalArgumentException("Embedding input text must not be empty"); - } - - String url = gmsApiBaseUrl.endsWith("/") - ? gmsApiBaseUrl + "embeddings" - : gmsApiBaseUrl + "/embeddings"; - - String escapedInput; - try { - escapedInput = OBJECT_MAPPER.writeValueAsString(inputText); - } catch (Exception e) { - throw new RuntimeException("입력 텍스트 JSON 직렬화 실패", e); + private SearchAnalysisResult analyzeQueryWithLLM(String query) { + if (query == null || query.isBlank()) { + return new SearchAnalysisResult(new ArrayList<>(), new ArrayList<>(), 7, new ArrayList<>()); } - String rawJson = String.format( - "{\"model\":\"%s\",\"input\":%s}", - MODEL_NAME, - escapedInput - ); + String prompt = """ + You are an expert search query pre-processor for a news article system. + Your task is to analyze a user's natural language query and convert it into a structured JSON object. - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.setAccept(List.of(MediaType.APPLICATION_JSON)); - headers.setBearerAuth(gmsApiKey); + Analyze the query and extract the following information in JSON format: - HttpEntity entity = new HttpEntity<>(rawJson, headers); + 1. 'primary_keywords': 1 or 2 word of essential, core nouns/entities from the original query. These are "must-include" keywords for final filtering. + 2. 'search_keywords': A list of at least 3 semantically rich keywords for vector embedding search. This list MUST include all 'primary_keywords' and also expand with synonyms and related concepts. + 3. 'dateRange': An integer representing the lookback period in days. (Rules are the same as before). + 4. 'categories': A list of up to 3 relevant category codes. (List is the same as before). - RestTemplate restTemplate = new RestTemplate(); - ResponseEntity response; + Categories List: + TOP, POLITICS, ECONOMY, BUSINESS, SOCIETY, LOCAL, WORLD, NORTH_KOREA, CULTURE_LIFE, ENTERTAINMENT, SPORTS, WEATHER, SCIENCE_ENV, HEALTH, OPINION, PEOPLE - try { - response = restTemplate.exchange(url, HttpMethod.POST, entity, EmbeddingResponse.class); - } catch (HttpClientErrorException e) { - log.error("[Embedding] GMS 4xx 에러. status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString()); - throw new RuntimeException("GMS/OpenAI 4xx 에러: " + e.getStatusCode(), e); - } catch (Exception e) { - throw new RuntimeException("GMS 통신 실패", e); - } + --- + Here are some high-quality examples: - if (!response.getStatusCode().is2xxSuccessful()) { - log.error("[Embedding] API 응답 실패, response={}", response.getStatusCode()); - throw new RuntimeException("Embedding API 실패, status=" + response.getStatusCode()); + Query: "페이커 뉴스" + JSON Output: + { + "primary_keywords": ["페이커"], + "search_keywords": ["페이커", "e스포츠", "리그 오브 레전드", "선수 소식"], + "dateRange": 7, + "categories": ["SPORTS", "PEOPLE"] } - EmbeddingResponse body = response.getBody(); - if (body == null || body.getData() == null || body.getData().isEmpty()) { - log.error("[Embedding] API 응답 body 없음"); - throw new RuntimeException("Embedding API 응답 비어있음"); + Query: "삼성전자 최근 주가 흐름 알려줘" + JSON Output: + { + "primary_keywords": ["삼성전자"], + "search_keywords": ["삼성전자", "주가", "주식", "시세"], + "dateRange": 7, + "categories": ["ECONOMY", "BUSINESS"] } - - return body.getData().get(0).getEmbedding(); - } - private List analyzeCategoryWithLLM(String query) { - log.info("[Prompt Search] query={}", query); - if (query == null || query.isBlank()) { - return new ArrayList<>(); + Query: "오늘 가장 핫한 뉴스가 뭐야?" + JSON Output: + { + "primary_keywords": ["헤드라인", "속보"], + "search_keywords": ["주요 뉴스", "헤드라인", "속보", "오늘의 이슈"], + "dateRange": 1, + "categories": ["TOP", "SOCIETY"] } + --- - String prompt = "Analyze the following search query and identify at least the top 3 most relevant categories from the list below. " + - "Return ONLY the category codes separated by commas (e.g., POLITICS, ECONOMY, WORLD). " + - "Categories:\n" + - "TOP (속보, 최신 기사, 헤드라인, 전체 기사 스트림)\n" + - "POLITICS (정치 관련 기사)\n" + - "ECONOMY (경제 관련 기사)\n" + - "BUSINESS (기업, 산업, 증권, 부동산, 마켓 관련 기사)\n" + - "SOCIETY (사회 일반 기사)\n" + - "LOCAL (지역, 전국 이슈)\n" + - "WORLD (국제, 세계 뉴스)\n" + - "NORTH_KOREA (북한 관련 기사)\n" + - "CULTURE_LIFE (문화, 생활, 라이프 기사)\n" + - "ENTERTAINMENT (연예, 예능, 게임 등)\n" + - "SPORTS (스포츠 기사)\n" + - "WEATHER (날씨 기사)\n" + - "SCIENCE_ENV (과학, 기술, 환경 기사)\n" + - "HEALTH (건강, 의료 기사)\n" + - "OPINION (사설, 칼럼, 오피니언)\n" + - "PEOPLE (사람들, 인물 기사)\n" + - "Query: " + query; + Now, analyze the following query and provide the JSON output. + Query: + """ + query; + String url = gmsApiBaseUrl.endsWith("/") ? gmsApiBaseUrl + "chat/completions" : gmsApiBaseUrl + "/chat/completions"; @@ -382,9 +249,10 @@ private List analyzeCategoryWithLLM(String query) { String requestBody = OBJECT_MAPPER.writeValueAsString(java.util.Map.of( "model", LLM_MODEL_NAME, "messages", List.of( - java.util.Map.of("role", "developer", "content", "You are a helpful assistant that categorizes news queries."), + java.util.Map.of("role", "developer", "content", "You are a helpful assistant that analyzes news search queries and returns JSON."), java.util.Map.of("role", "user", "content", prompt) - ) + ), + "response_format", java.util.Map.of("type", "json_object") )); HttpHeaders headers = new HttpHeaders(); @@ -393,55 +261,92 @@ private List analyzeCategoryWithLLM(String query) { HttpEntity entity = new HttpEntity<>(requestBody, headers); RestTemplate restTemplate = new RestTemplate(); - + ResponseEntity response = restTemplate.exchange( url, HttpMethod.POST, entity, com.fasterxml.jackson.databind.JsonNode.class); if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { String content = response.getBody().path("choices").get(0).path("message").path("content").asText(); log.info("[Search] LLM Response: {}", content); - - List categoryIds = new ArrayList<>(); - String[] codes = content.split(","); - for (String code : codes) { - String cleanCode = code.trim().toUpperCase(); - if (cleanCode.contains(" ")) { - cleanCode = cleanCode.split("\\s+")[0]; - } - - String finalCode = cleanCode; - newsCategoryRepository.findByName(finalCode).ifPresent(category -> categoryIds.add(category.getId())); - } - return categoryIds; + return OBJECT_MAPPER.readValue(content, SearchAnalysisResult.class); } } catch (Exception e) { - log.error("[Search] LLM Category Analysis Failed", e); + log.error("[Search] LLM Analysis Failed", e); + } + + List fallbackKeywords = Arrays.asList(query.split("\\s+")); + return new SearchAnalysisResult(fallbackKeywords, fallbackKeywords, 7, new ArrayList<>()); + } + + private String toPgVectorLiteral(List embedding){ + if (embedding == null) return "[]"; + String inner = embedding.stream() + .map(d -> String.format(java.util.Locale.US, "%.6f", d)) + .collect(Collectors.joining(",")); + return "[" + inner + "]"; + } + + @Cacheable(value = "keyword_embedding", key = "#inputText") + public List getCachedEmbedding(String inputText) { + if (inputText == null || inputText.isBlank()) { + throw new IllegalArgumentException("Embedding input text must not be empty"); } + + String url = gmsApiBaseUrl.endsWith("/") + ? gmsApiBaseUrl + "embeddings" + : gmsApiBaseUrl + "/embeddings"; - return new ArrayList<>(); + try { + String requestBody = OBJECT_MAPPER.writeValueAsString(Map.of( + "model", MODEL_NAME, + "input", inputText + )); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAccept(List.of(MediaType.APPLICATION_JSON)); + headers.setBearerAuth(gmsApiKey); + + HttpEntity entity = new HttpEntity<>(requestBody, headers); + RestTemplate restTemplate = new RestTemplate(); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.POST, entity, EmbeddingResponse.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + EmbeddingResponse body = response.getBody(); + if (body.getData() != null && !body.getData().isEmpty()) { + return body.getData().get(0).getEmbedding(); + } + } + log.error("[Embedding] API 응답 실패 또는 데이터 없음, status={}", response.getStatusCode()); + throw new RuntimeException("Embedding API 응답 데이터 비어있음"); + + } catch (HttpClientErrorException e) { + log.error("[Embedding] GMS 4xx 에러. status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new RuntimeException("GMS/OpenAI 4xx 에러: " + e.getStatusCode(), e); + } catch (Exception e) { + log.error("[Embedding] GMS 통신 또는 직렬화 실패", e); + throw new RuntimeException("Embedding API 호출 실패", e); + } } @Transactional(propagation = Propagation.REQUIRES_NEW) public void saveSearchHistory(Long userId, String query) { try { - // 1. Try to update existing history timestamp to move it to top int updated = userSearchHistoryRepository.updateCreatedAtByUserIdAndQuery(userId, query); - - // 2. If not exists, save new history if (updated == 0) { User user = userRepository.getReferenceById(userId); UserSearchHistory history = UserSearchHistory.builder() .user(user) .query(query) .build(); - userSearchHistoryRepository.save(history); } } catch (Exception e) { - log.error("[Serach History] Failed to save search history for user={}", userId, e); + log.error("[Search History] Failed to save search history for user={}", userId, e); } } + private ArticleDto convertToDto(Article article) { return ArticleDto.builder() .id(article.getId()) From 844ae77896b63b998b3a8b2402647791eb449a91 Mon Sep 17 00:00:00 2001 From: hyunjo01 Date: Fri, 26 Dec 2025 10:18:44 +0900 Subject: [PATCH 02/12] =?UTF-8?q?[fix]=20-=20api/ArticleRepository:=20find?= =?UTF-8?q?ByEmbeddingSimilarity=20=EC=BF=BC=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../newstagram/api/article/repository/ArticleRepository.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/api-server/src/main/java/com/ssafy/newstagram/api/article/repository/ArticleRepository.java b/api-server/src/main/java/com/ssafy/newstagram/api/article/repository/ArticleRepository.java index b37f1df..d824eaa 100644 --- a/api-server/src/main/java/com/ssafy/newstagram/api/article/repository/ArticleRepository.java +++ b/api-server/src/main/java/com/ssafy/newstagram/api/article/repository/ArticleRepository.java @@ -34,9 +34,6 @@ public interface ArticleRepository extends JpaRepository { List
findByCategory_IdOrderByPublishedAtDesc(Long categoryId, Pageable pageable); - @Query(value = "SELECT id, title, content, description, url, thumbnail_url, author, published_at, created_at, updated_at, feed_id, category_id, sources_id, NULL as embedding FROM articles ORDER BY embedding <=> cast(:embedding as vector) LIMIT :limit", nativeQuery = true) - List
findByEmbeddingSimilarity(@Param("embedding") String embedding, @Param("limit") int limit); - @Query(value = "SELECT id, title, content, description, url, thumbnail_url, author, published_at, created_at, updated_at, feed_id, category_id, sources_id, NULL as embedding " + "FROM articles " + "WHERE (:categoryId IS NULL OR category_id = :categoryId) " + @@ -55,7 +52,7 @@ List
findByEmbeddingSimilarityWithFilters( @Query(value = "SELECT id, title, content, description, url, thumbnail_url, author, published_at, created_at, updated_at, feed_id, category_id, sources_id, NULL as embedding " + "FROM articles " + - "WHERE (cast(:startDate as timestamp) IS NULL OR published_at >= cast(:startDate as timestamp)) " + + "WHERE (cast(:startDate as timestamp) IS NOT NULL AND published_at >= cast(:startDate as timestamp)) " + "ORDER BY embedding <=> cast(:embedding as vector) " + "LIMIT :limit", nativeQuery = true) List
findByEmbeddingSimilarity( From 1b82c35518388d32c9c414a31997ccefe12479cf Mon Sep 17 00:00:00 2001 From: hyunjo01 Date: Fri, 26 Dec 2025 10:21:36 +0900 Subject: [PATCH 03/12] =?UTF-8?q?[feat]=20-=20HomeIssueService:=20?= =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=EB=82=A0=EC=A7=9C=20=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ssafy/newstagram/api/article/service/HomeIssueService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/api-server/src/main/java/com/ssafy/newstagram/api/article/service/HomeIssueService.java b/api-server/src/main/java/com/ssafy/newstagram/api/article/service/HomeIssueService.java index 744135c..04806ff 100644 --- a/api-server/src/main/java/com/ssafy/newstagram/api/article/service/HomeIssueService.java +++ b/api-server/src/main/java/com/ssafy/newstagram/api/article/service/HomeIssueService.java @@ -20,6 +20,7 @@ public class HomeIssueService { public List findByEmbeddingSimilarity(String embedding, int limit, LocalDateTime startDate) { List
articles = articleRepository.findByEmbeddingSimilarity(embedding, limit, startDate); + log.info("[HomeIssueService] startDate:{}", startDate); // Article 리스트를 ArticleDto 리스트로 변환 return articles.stream() .map(this::toArticleDto) // toArticleDto 메소드 사용 From 36388bc3be4206f8c3aea6bc96fcbcb449472dca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=8A=B9=ED=98=84?= Date: Fri, 26 Dec 2025 10:26:44 +0900 Subject: [PATCH 04/12] =?UTF-8?q?[chore]=20-=20DB=20=EC=97=B0=EA=B2=B0=20S?= =?UTF-8?q?SL=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api-server/src/main/resources/application-prod.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api-server/src/main/resources/application-prod.properties b/api-server/src/main/resources/application-prod.properties index 5dccd51..12b06fc 100644 --- a/api-server/src/main/resources/application-prod.properties +++ b/api-server/src/main/resources/application-prod.properties @@ -1,6 +1,6 @@ # DataSource spring.datasource.driver-class-name=org.postgresql.Driver -spring.datasource.url=jdbc:postgresql://${RDS_ENDPOINT}:5432/newstagram +spring.datasource.url=jdbc:postgresql://${RDS_ENDPOINT}:5432/newstagram?ssl=true&sslmode=verify-full spring.datasource.username=${RDS_USERNAME} spring.datasource.password=${RDS_PASSWORD} From a3f79b678b1d7ce3e0ee84bfd6472fafba6e9040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=8A=B9=ED=98=84?= Date: Fri, 26 Dec 2025 10:38:40 +0900 Subject: [PATCH 05/12] =?UTF-8?q?[chore]=20-=20DB=20SSL=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api-server/src/main/resources/application-prod.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api-server/src/main/resources/application-prod.properties b/api-server/src/main/resources/application-prod.properties index 12b06fc..d9395c9 100644 --- a/api-server/src/main/resources/application-prod.properties +++ b/api-server/src/main/resources/application-prod.properties @@ -1,6 +1,6 @@ # DataSource spring.datasource.driver-class-name=org.postgresql.Driver -spring.datasource.url=jdbc:postgresql://${RDS_ENDPOINT}:5432/newstagram?ssl=true&sslmode=verify-full +spring.datasource.url=jdbc:postgresql://${RDS_ENDPOINT}:5432/newstagram?ssl=true&sslmode=require spring.datasource.username=${RDS_USERNAME} spring.datasource.password=${RDS_PASSWORD} From 76e98bf5a2d55ff9300299575747a89f601869e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=8A=B9=ED=98=84?= Date: Fri, 26 Dec 2025 10:51:20 +0900 Subject: [PATCH 06/12] =?UTF-8?q?[fix]=20-=20=EC=97=B0=EA=B2=B0=20?= =?UTF-8?q?=EA=B0=AF=EC=88=98=20=EB=A1=A4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api-server/src/main/resources/application.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api-server/src/main/resources/application.properties b/api-server/src/main/resources/application.properties index de2db77..55a7e9e 100644 --- a/api-server/src/main/resources/application.properties +++ b/api-server/src/main/resources/application.properties @@ -11,8 +11,8 @@ spring.jpa.properties.hibernate.default_schema=public spring.jpa.properties.hibernate.default_batch_fetch_size=100 # HikariCP Common -spring.datasource.hikari.maximum-pool-size=50 -spring.datasource.hikari.minimum-idle=10 +spring.datasource.hikari.maximum-pool-size=10 +spring.datasource.hikari.minimum-idle=5 spring.datasource.hikari.connection-timeout=30000 spring.datasource.hikari.idle-timeout=600000 spring.datasource.hikari.max-lifetime=1800000 From 48c2682e2df9c73c03f7c7e5bb002bfa542b98d6 Mon Sep 17 00:00:00 2001 From: hyunjo01 Date: Fri, 26 Dec 2025 10:53:50 +0900 Subject: [PATCH 07/12] =?UTF-8?q?[feat]=20-=20ArticleRepository:=20?= =?UTF-8?q?=ED=83=80=EC=9E=84=EC=A1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../newstagram/api/article/repository/ArticleRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api-server/src/main/java/com/ssafy/newstagram/api/article/repository/ArticleRepository.java b/api-server/src/main/java/com/ssafy/newstagram/api/article/repository/ArticleRepository.java index d824eaa..086f8f5 100644 --- a/api-server/src/main/java/com/ssafy/newstagram/api/article/repository/ArticleRepository.java +++ b/api-server/src/main/java/com/ssafy/newstagram/api/article/repository/ArticleRepository.java @@ -52,7 +52,7 @@ List
findByEmbeddingSimilarityWithFilters( @Query(value = "SELECT id, title, content, description, url, thumbnail_url, author, published_at, created_at, updated_at, feed_id, category_id, sources_id, NULL as embedding " + "FROM articles " + - "WHERE (cast(:startDate as timestamp) IS NOT NULL AND published_at >= cast(:startDate as timestamp)) " + + "WHERE (cast(:startDate as timestamp) IS NOT NULL AND published_at >= (:startDate AT TIME ZONE 'Asia/Seoul')) " + "ORDER BY embedding <=> cast(:embedding as vector) " + "LIMIT :limit", nativeQuery = true) List
findByEmbeddingSimilarity( From fc824e15d5d42b5a3440e17e295e588d5024d64e Mon Sep 17 00:00:00 2001 From: hyunjo01 Date: Fri, 26 Dec 2025 10:54:49 +0900 Subject: [PATCH 08/12] =?UTF-8?q?[feat]=20-=20HomeIssueService:=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ssafy/newstagram/api/article/service/HomeIssueService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/api-server/src/main/java/com/ssafy/newstagram/api/article/service/HomeIssueService.java b/api-server/src/main/java/com/ssafy/newstagram/api/article/service/HomeIssueService.java index 04806ff..935b3fc 100644 --- a/api-server/src/main/java/com/ssafy/newstagram/api/article/service/HomeIssueService.java +++ b/api-server/src/main/java/com/ssafy/newstagram/api/article/service/HomeIssueService.java @@ -21,6 +21,7 @@ public class HomeIssueService { public List findByEmbeddingSimilarity(String embedding, int limit, LocalDateTime startDate) { List
articles = articleRepository.findByEmbeddingSimilarity(embedding, limit, startDate); log.info("[HomeIssueService] startDate:{}", startDate); + log.info("[HomeIssueService] articles.size:{}", articles.size()); // Article 리스트를 ArticleDto 리스트로 변환 return articles.stream() .map(this::toArticleDto) // toArticleDto 메소드 사용 From fea6ea49ec76a62aef37e759f029f9b3d5506a32 Mon Sep 17 00:00:00 2001 From: hyunjo01 Date: Fri, 26 Dec 2025 11:04:32 +0900 Subject: [PATCH 09/12] =?UTF-8?q?[fix]=20-=20ArticleRepository:=20findByEm?= =?UTF-8?q?beddingSimilarity=20=EC=BF=BC=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../newstagram/api/article/repository/ArticleRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api-server/src/main/java/com/ssafy/newstagram/api/article/repository/ArticleRepository.java b/api-server/src/main/java/com/ssafy/newstagram/api/article/repository/ArticleRepository.java index 086f8f5..de27fce 100644 --- a/api-server/src/main/java/com/ssafy/newstagram/api/article/repository/ArticleRepository.java +++ b/api-server/src/main/java/com/ssafy/newstagram/api/article/repository/ArticleRepository.java @@ -52,7 +52,7 @@ List
findByEmbeddingSimilarityWithFilters( @Query(value = "SELECT id, title, content, description, url, thumbnail_url, author, published_at, created_at, updated_at, feed_id, category_id, sources_id, NULL as embedding " + "FROM articles " + - "WHERE (cast(:startDate as timestamp) IS NOT NULL AND published_at >= (:startDate AT TIME ZONE 'Asia/Seoul')) " + + "WHERE published_at >= :startDate " + "ORDER BY embedding <=> cast(:embedding as vector) " + "LIMIT :limit", nativeQuery = true) List
findByEmbeddingSimilarity( @@ -60,7 +60,7 @@ List
findByEmbeddingSimilarity( @Param("limit") int limit, @Param("startDate") LocalDateTime startDate ); - + @Query(value = "SELECT id, title, content, description, url, thumbnail_url, author, published_at, created_at, updated_at, feed_id, category_id, sources_id, NULL as embedding " + "FROM articles " + "WHERE (:categoryIds IS NULL OR category_id IN (:categoryIds)) " + From 628a01669525cc1ce7743eca6163efbcac602c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=8A=B9=ED=98=84?= Date: Fri, 26 Dec 2025 11:10:22 +0900 Subject: [PATCH 10/12] =?UTF-8?q?[fix]=20-=20=EC=BF=BC=EB=A6=AC=EB=AC=B8?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=95=A0=EB=A7=A4=ED=95=9C=20startdate=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B5=AC=EB=AC=B8=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/article/repository/ArticleRepository.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api-server/src/main/java/com/ssafy/newstagram/api/article/repository/ArticleRepository.java b/api-server/src/main/java/com/ssafy/newstagram/api/article/repository/ArticleRepository.java index b37f1df..77f0388 100644 --- a/api-server/src/main/java/com/ssafy/newstagram/api/article/repository/ArticleRepository.java +++ b/api-server/src/main/java/com/ssafy/newstagram/api/article/repository/ArticleRepository.java @@ -40,7 +40,7 @@ public interface ArticleRepository extends JpaRepository { @Query(value = "SELECT id, title, content, description, url, thumbnail_url, author, published_at, created_at, updated_at, feed_id, category_id, sources_id, NULL as embedding " + "FROM articles " + "WHERE (:categoryId IS NULL OR category_id = :categoryId) " + - "AND (cast(:startDate as timestamp) IS NULL OR published_at >= cast(:startDate as timestamp)) " + + "AND published_at >= cast(:startDate as timestamp) " + "AND (embedding <=> cast(:embedding as vector)) < :threshold " + "ORDER BY embedding <=> cast(:embedding as vector) " + "LIMIT :limit OFFSET :offset", nativeQuery = true) @@ -55,7 +55,7 @@ List
findByEmbeddingSimilarityWithFilters( @Query(value = "SELECT id, title, content, description, url, thumbnail_url, author, published_at, created_at, updated_at, feed_id, category_id, sources_id, NULL as embedding " + "FROM articles " + - "WHERE (cast(:startDate as timestamp) IS NULL OR published_at >= cast(:startDate as timestamp)) " + + "WHERE published_at >= cast(:startDate as timestamp) " + "ORDER BY embedding <=> cast(:embedding as vector) " + "LIMIT :limit", nativeQuery = true) List
findByEmbeddingSimilarity( @@ -67,7 +67,7 @@ List
findByEmbeddingSimilarity( @Query(value = "SELECT id, title, content, description, url, thumbnail_url, author, published_at, created_at, updated_at, feed_id, category_id, sources_id, NULL as embedding " + "FROM articles " + "WHERE (:categoryIds IS NULL OR category_id IN (:categoryIds)) " + - "AND (cast(:startDate as timestamp) IS NULL OR published_at >= cast(:startDate as timestamp)) " + + "AND published_at >= cast(:startDate as timestamp) " + "AND (embedding <=> cast(:embedding as vector)) < :threshold " + "ORDER BY embedding <=> cast(:embedding as vector) " + "LIMIT :limit", nativeQuery = true) From b1216b8053c742afc970df26e6839e221add3a90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=8A=B9=ED=98=84?= Date: Fri, 26 Dec 2025 11:39:11 +0900 Subject: [PATCH 11/12] =?UTF-8?q?[fix]=20-=20limit=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../newstagram/api/article/repository/ArticleRepository.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/api-server/src/main/java/com/ssafy/newstagram/api/article/repository/ArticleRepository.java b/api-server/src/main/java/com/ssafy/newstagram/api/article/repository/ArticleRepository.java index 11462c3..1dfb885 100644 --- a/api-server/src/main/java/com/ssafy/newstagram/api/article/repository/ArticleRepository.java +++ b/api-server/src/main/java/com/ssafy/newstagram/api/article/repository/ArticleRepository.java @@ -66,8 +66,7 @@ List
findByEmbeddingSimilarity( "WHERE (:categoryIds IS NULL OR category_id IN (:categoryIds)) " + "AND published_at >= cast(:startDate as timestamp) " + "AND (embedding <=> cast(:embedding as vector)) < :threshold " + - "ORDER BY embedding <=> cast(:embedding as vector) " + - "LIMIT :limit", nativeQuery = true) + "ORDER BY embedding <=> cast(:embedding as vector) ", nativeQuery = true) List
findCandidatesByEmbedding( @Param("embedding") String embedding, @Param("limit") int limit, From b35bf05e4ab3e2f5447a3f55035fbbfe0bd1c671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=8A=B9=ED=98=84?= Date: Fri, 26 Dec 2025 11:52:23 +0900 Subject: [PATCH 12/12] =?UTF-8?q?[fix]=20-=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ssafy/newstagram/api/article/service/SearchService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api-server/src/main/java/com/ssafy/newstagram/api/article/service/SearchService.java b/api-server/src/main/java/com/ssafy/newstagram/api/article/service/SearchService.java index a072b61..a885472 100644 --- a/api-server/src/main/java/com/ssafy/newstagram/api/article/service/SearchService.java +++ b/api-server/src/main/java/com/ssafy/newstagram/api/article/service/SearchService.java @@ -52,7 +52,7 @@ public class SearchService { @Value("${gms.api.key}") private String gmsApiKey; - private static final String MODEL_NAME = "text-embedding-3-small"; + private static final String MODEL_NAME = "text-embedding-3-large"; private static final String LLM_MODEL_NAME = "gpt-4o-mini"; private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();