[Co Labor] 생성형 AI를 활용한 AI 검색 보완

2024. 10. 27. 08:07프로젝트: Co Laobr

기능 설명 📘

  • 현재 AI 검색은 단일 키워드에 대해 학습된 모델에서 유사 키워드를 DB에서 찾아서 검색하는 방식이다. 이 방식은 문장에 대해 성립하지 않아서 생성형 AI를 이용해서 문장을 여러 개의 keywords로 바꾸고 keywords를 검색해서 결과를 반환하는 방식으로 보완이 필요하다.

구현 방법 🛠

  • 문장을 키워드로 변환: 사용자가 입력한 문장을 Open AI API를 사용하여 키워드 리스트로 변환한다.
  • 키워드를 기반으로 검색: 변환된 키워드를 사용하여 데이터베이스에서 유사한 데이터를 검색한다(기존 알고리즘 사용).

오픈 AI API를 사용하여 sentence를 연관된 keywords로 바꾸는 코드를 새로 작성했다. 스프링의 RestTemplate로 오픈 AI API에 POST 요청을 보내는 방식이다.

https://platform.openai.com/docs/api-reference/chat/create

공식문서 기반으로 작성하였다.

OpenAiService

package pelican.co_labor.service;

import org.json.JSONArray;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

@Service
public class OpenAiService {

    private static final Logger logger = LoggerFactory.getLogger(OpenAiService.class);

    @Value("${openai.api.key}")
    private String apiKey;

    private final RestTemplate restTemplate = new RestTemplate();

    public List<String> extractKeywords(String sentence) {
        URI uri = UriComponentsBuilder.fromHttpUrl("https://api.openai.com/v1/chat/completions")
                .build().toUri();

        try {
            boolean isKorean = Pattern.matches(".*[ㄱ-ㅎㅏ-ㅣ가-힣]+.*", sentence);
            String systemPrompt = isKorean ?
                    "당신은 데이터 추출 도우미입니다. 주어진 문장에서 주요 키워드를 추출하세요. 간결하고 관련성 있는 키워드를 제공하세요. 또한 키워드만 제공하고 추가적인 키워드도 제공해주세요. DB 검색 용도이니 키워드로 제공해야 하고 sentence에 존재하지 않는 연관된 키워드도 유추해서 추가로 제공해주세요." :
                    "You are a data extraction assistant. Extract key phrases and concepts from the provided sentences. Just provide only keywords. Provide additional keywords including input sentence's primary keywords.";
            String userPrompt = isKorean ?
                    "이 문장에서 키워드를 추출하세요: " + sentence :
                    "Extract keywords from this sentence: " + sentence;

            JSONArray messages = new JSONArray();
            messages.put(new JSONObject().put("role", "system").put("content", systemPrompt));
            messages.put(new JSONObject().put("role", "user").put("content", userPrompt));

            JSONObject request = new JSONObject();
            request.put("model", "gpt-3.5-turbo");
            request.put("messages", messages);
            request.put("temperature", 0.5);

            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            headers.setBearerAuth(apiKey);

            HttpEntity<String> entity = new HttpEntity<>(request.toString(), headers);

            ResponseEntity<String> response = restTemplate.exchange(uri, HttpMethod.POST, entity, String.class);
            JSONObject jsonResponse = new JSONObject(response.getBody());
            JSONArray choices = jsonResponse.getJSONArray("choices");

            List<String> keywords = new ArrayList<>();
            for (int i = 0; i < choices.length(); i++) {
                String text = choices.getJSONObject(i).getJSONObject("message").getString("content").trim();

                // Remove "추가 키워드:" or "Additional keywords:" if present
                text = text.replaceAll("(?i)(추가 키워드:|Additional keywords:)", "").trim();

                String[] extractedKeywords = text.split(", ");
                for (String keyword : extractedKeywords) {
                    keyword = keyword.replaceAll("[^\\p{IsAlphabetic}\\p{IsHangul}]", "").trim(); // Keep only alphabetic characters and Hangul
                    if (!keyword.isEmpty()) {
                        keywords.add(keyword);
                    }
                }
            }

            logger.info("Extracted keywords: {}", keywords);
            return keywords;
        } catch (Exception e) {
            logger.error("An error occurred while calling OpenAI API", e);
            return new ArrayList<>();
        }
    }
}

Open AI API 공식 문서에서 Chat 기능을 사용하면 된다. 공식 문서를 따르면 요청 형식은 다음과 같다.

curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4o",
    "messages": [
      {
        "role": "system",
        "content": "You are a helpful assistant."
      },
      {
        "role": "user",
        "content": "Hello!"
      }
    ]
  }'

URI uri = UriComponentsBuilder.fromHttpUrl("https://api.openai.com/v1/chat/completions") .build().toUri(); : 해당 url로 요청을 보낸다.

boolean isKorean = Pattern.matches(".*[ㄱ-ㅎㅏ-ㅣ가-힣]+.*", sentence);
String systemPrompt = isKorean ?
        "당신은 데이터 추출 도우미입니다. 주어진 문장에서 주요 키워드를 추출하세요. 간결하고 관련성 있는 키워드를 제공하세요. 또한 키워드만 제공하고 추가적인 키워드도 제공해주세요. DB 검색 용도이니 키워드로 제공해야 하고 sentence에 존재하지 않는 연관된 키워드도 유추해서 추가로 제공해주세요." :
        "You are a data extraction assistant. Extract key phrases and concepts from the provided sentences. Just provide only keywords. Provide additional keywords including input sentence's primary keywords.";
String userPrompt = isKorean ?
        "이 문장에서 키워드를 추출하세요: " + sentence :
        "Extract keywords from this sentence: " + sentence;

JSONArray messages = new JSONArray();
messages.put(new JSONObject().put("role", "system").put("content", systemPrompt));
messages.put(new JSONObject().put("role", "user").put("content", userPrompt));

JSONObject request = new JSONObject();
request.put("model", "gpt-3.5-turbo");
request.put("messages", messages);
request.put("temperature", 0.5);

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(apiKey);

한글인지 판단하고 프롬프트를 설정한다. systemPrompt는 모델에게 미리 학습시키는 것이고 userPrompt는 실제 질문을 날리는 것이다. 그 외에는 Request message를 설정하는 코드이다.

HttpEntity<String> entity = new HttpEntity<>(request.toString(), headers);

ResponseEntity<String> response = restTemplate.exchange(uri, HttpMethod.POST, entity, String.class);
JSONObject jsonResponse = new JSONObject(response.getBody());
JSONArray choices = jsonResponse.getJSONArray("choices");

List<String> keywords = new ArrayList<>();
for (int i = 0; i < choices.length(); i++) {
    String text = choices.getJSONObject(i).getJSONObject("message").getString("content").trim();

    // Remove "추가 키워드:" or "Additional keywords:" if present
    text = text.replaceAll("(?i)(추가 키워드:|Additional keywords:)", "").trim();

    String[] extractedKeywords = text.split(", ");
    for (String keyword : extractedKeywords) {
        keyword = keyword.replaceAll("[^\\p{IsAlphabetic}\\p{IsHangul}]", "").trim(); // Keep only alphabetic characters and Hangul
        if (!keyword.isEmpty()) {
            keywords.add(keyword);
        }
    }
}

logger.info("Extracted keywords: {}", keywords);
return keywords;

ResponseEntity<String> response = restTemplate.exchange(uri, HttpMethod.POST, entity, String.class); 에서 실제 요청을 보내고 응답을 받는다.

응답은 List로 저장해두고 전처리 후 리턴하게 된다. 전처리의 과정에는 Open Ai의 응답에 추가 키워드 혹은 Additional keywords가 포함되는 경우가 있어서 해당 텍스트를 제거하고 문자만 파싱해서 리턴하게 된다.

문자만 파싱하는 과정은 정규식을 사용하였다. "[^\\\\p{IsAlphabetic}\\\\p{IsHangul}]"

 

AISearchController

@GetMapping("/ai-search")
public Map<String, Object> search(@RequestParam String sentence) throws JSONException {
    Map<String, Object> response = new HashMap<>();

    // 문장을 키워드로 변환
    List<String> keywords = openAiService.extractKeywords(sentence);
    logger.info("Extracted keywords: {}", keywords);

    if (keywords.isEmpty()) {
        response.put("message", "No keywords extracted from the given sentence.");
        return response;
    }

    // 유사 키워드 검색
    List<String> similarKeywords = keywords.stream()
            .flatMap(keyword -> keywordSearchService.searchSimilarWords(keyword).stream())
            .distinct()
            .collect(Collectors.toList());
    logger.info("Similar keywords: {}", similarKeywords);

    if (similarKeywords.isEmpty()) {
        response.put("message", "No similar words found for the given keywords in the database.");
        return response;
    }

    // 키워드를 통해 DB 검색
    List<Job> jobs = searchService.searchJobs(similarKeywords);
    List<Review> reviews = searchService.searchReviews(similarKeywords);
    List<Enterprise> enterprises = searchService.searchEnterprises(similarKeywords);

    response.put("jobs", jobs);
    response.put("reviews", reviews);
    response.put("enterprises", enterprises);

    return response;
}

하나씩 뜯어보자.

// 문장을 키워드로 변환
List<String> keywords = openAiService.extractKeywords(sentence);
logger.info("Extracted keywords: {}", keywords);

if (keywords.isEmpty()) {
    response.put("message", "No keywords extracted from the given sentence.");
    return response;
}

OepnAiService 에서 문장을 바탕으로 키워드를 추출해낸다.

// 유사 키워드 검색
List<String> similarKeywords = keywords.stream()
        .flatMap(keyword -> keywordSearchService.searchSimilarWords(keyword).stream())
        .distinct()
        .collect(Collectors.toList());
logger.info("Similar keywords: {}", similarKeywords);

if (similarKeywords.isEmpty()) {
    response.put("message", "No similar words found for the given keywords in the database.");
    return response;
}

// 키워드를 통해 DB 검색
List<Job> jobs = searchService.searchJobs(similarKeywords);
List<Review> reviews = searchService.searchReviews(similarKeywords);
List<Enterprise> enterprises = searchService.searchEnterprises(similarKeywords);

response.put("jobs", jobs);
response.put("reviews", reviews);
response.put("enterprises", enterprises);

return response;

추출된 키워드를 리스트로 만들고 Job, Review, Enterprise에서 검색한다. 여기서의 검색은 Word2Vec를 사용하는 기존의 로직과 동일하다.