겸손하기 꾸준하기 건강하기

[K-Traveler] GPT로 영문 데이터베이스 구축하기🧐 본문

project

[K-Traveler] GPT로 영문 데이터베이스 구축하기🧐

seminss 2024. 7. 1. 22:22

 

개요

K-Traveler 프로젝트는 외국인을 대상으로 "테마별 국내 여행지"를 추천한다.

문화체육관광부(2022). 2021 외래관광객조사. (주요 참여 활동)

 

위 지표를 참고하여, 테마를 총 8가지로 분류했다.

A 음식/미식 탐방
B 쇼핑
C 역사/문화유적/전통문화 체험
D 자연 풍경 감상
E K-Culture 체험
F 유흥/놀이 시설 체험
G 레저/스포츠
H 현대 문화 체험

 

 

데이터베이스는 한국관광공사_영문 관광정보서비스_GW 와 미디어콘텐츠 영상 내 유명지 데이터를 사용하게 되었다.

 

API를 직접 사용하지 않고 스키마를 구축한 이유는, 공연/페스티벌 등과 달리, 외국인들이 방문하는 관광지는 수십 년이 흘러도 유명한 관광지를 대부분 방문할 것이라고 생각했기 때문이다. 예를 들면 우리가 프랑스를 방문한다고 했을 때, 최근 생긴 핫플을 방문하는 것이 아니라, 필수 관광지인 에펠탑을 방문하는 것과 같다. 

 

자주 업데이트가 되는 정보를 제공하는 것이 아니고, API를 사용하게 되면 조회 시간만 늘어나기 때문에, 굳이 API를 직접 가져다 쓸 이유가 없다고 생각했다.

 

테마별 재분류는 엄청난 정성이 필요했다..

 

 

K-Culture 테마 데이터만, 문화 빅데이터 플랫폼에서 제공하는 미디어콘텐츠 영상 내 유명지 데이터를 사용했다.

나는 미디어콘텐츠 영상 내 유명지 데이터베이스 구축 작업을 맡았다.

 

https://github.com/First-Time-Korea/csv_crawling

 

GitHub - First-Time-Korea/csv_crawling: 미디어콘텐츠 영상 내 유명지 데이터

미디어콘텐츠 영상 내 유명지 데이터. Contribute to First-Time-Korea/csv_crawling development by creating an account on GitHub.

github.com

 

그러나 문제가 있었다.

이 데이터는 영문으로 제공되지 않는다는 것이었다.

 

그러나 우리는 외국인을 대상으로 한 서비스를 개발 중이었기 때문에, 관광지 정보는 영문으로 제공되어야 했다.

 

 


 아이디어 #1

사용자가 K-Culture 관광지를 필요로 하면, 이 때는 GPT API를 사용하자

이를 위해서는 프론트에서 서로 다른 앤드포인트로 접근해야 했다. 

async function getAttraction(wantItem, success, fail) {
  await http.get("attractions", wantItem).then(success).catch(fail);
}

async function getAttractionByAI(wantItem, success, fail) {
  await http.get("attractions/ai", wantItem).then(success).catch(fail);
}

 

그리고 서버는 attractions/ai 요청이 오면 GPT API를 사용해서 번역 후, 데이터를 제공했다.

 

문제가 있었다. GPT의 번역이 매번 달라진다는 점이었다.

 

아이디어 #2

GPT API를 사용하되, 한 번 번역된 데이터는 DB에 저장하자.

	@Override
	@Transactional
	public AttractionDto prompt(MemberContentDto memberContentDto) throws Exception {
		AttractionDto attractionDto = attractionMapper.getAttractionByContentId(memberContentDto);
		if (attractionDto != null) {
			return attractionDto; // 그냥 반환하기
		}
        ... //API를 호출해서 번역 후 파싱
        
        // DB에 저장 후 반환하기
		attractionMapper.insertKCurtureAttractionInfoEnglish(attractionDto);
		attractionMapper.insertKCurtureAttractionDetailEnglish(attractionDto);
		attractionMapper.insertKCurtureAttractionDescriptionEnglish(attractionDto);
		return attractionDto;
	}

 

관광지를 조회할 때와, 관광지를 재조회 할 때, 모두 같은 설명을 확인할 수 있게 되었다.

 

아이디어 #3

그냥 전부 DB에 담자..

기획부터 설계, 개발까지 10일 만에 완료되어야 했던 프로젝트여서, 개발 중에는 시간이 도무지 부족했지만,

개발이 끝나고 나니, 데이터베이스 내재화를 해도 좋을 것 같다는 생각을 했다.

 

그러나, 매번 API 호출을 하는 것이 아니라 데이터베이스에 영구 저장을 한 게 된다면, 번역을 조금 더 신경 써서 해야 한다고 생각했다. 영구 저장되는 정보에 오번역이 있으면 안 되기 때문이다.

 

❓ GPT 3.5 vs GPT 4

첫 번째 고민은 GPT의 버전을 어떤 것을 쓸 것이냐였다.

GPT 4o
GPT 3.5

 

4 vs 4o vs 3.5를 비교했을 때, 번역 수준에 큰 차이가 있지는 않았다. 3.5도 충분히 좋은 성능을 냈다.

 

GPT 4
GPT 3.5

 

그러나 설명이 짧아지면, 성능이 확실히 떨어졌다. 

일정한 포맷으로 반환받아야 파싱 하여 사용하기 용이한데, 설명이 짧아지면 원하는 포맷으로 반환하지 못했다.

 

3.5와 4 API가 사용하는 토큰 수가 30배가량이 차이가 나기 때문에, 3.5를 사용하고 요구사항을 자세하게 작성하는 방식이 효율적이라 판단했다.

 

다음과 같은 DTO를 만들어 요청을 하였다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CompletionRequestDto {

    private String model; //모델 : gpt-3.5-turbo
    private List<Message> messages; //프롬프트

    public CompletionRequestDto(String model, List<Message> messages) {
        this.model = model;
        this.messages = messages;
    }

    @Getter
    @NoArgsConstructor(access = AccessLevel.PROTECTED)
    public static class Message {
        private String role; //역할 : user
        private String content; //실질적인 메세지
        private Float temperature; //창의성 : 0

        public Message(String role, String content, Float temperature) {
            this.role = role;
            this.content = content;
            this.temperature= temperature;
        }
    }
}

 

Message클래스의 content에 넣을, 프롬프트 메시지는 다음과 같이 작성했다.

public enum PromptMessage {
    MESSAGE("%s___%s Translate into English."
            + "The one in '<>' is the title, '___'The first word is the name of the store."
            + " so make sure to put it in."
            + "For example: 'converted english store name___<converted english title> converted english description'"
            + "Don't mix your opinions, just convert Hangul into English.");

    private final String message;

    PromptMessage(String message) {
        this.message = message;
    }

    public String formatMessage(Object... args) {
        return String.format(message, args);
    }

}

 

<미디어 콘텐츠명> 관광지이름___관광지 설명 영어로 번역해 줘.
<>에 있는 것은 제목이고,   '___'  앞의 첫 단어는 가게 이름이야. 그러니까 반드시 넣어줘.
예를 들면: '변환된 영문 스토어 이름___<변환된 영문 제목> 변환된 영문 설명'
너의 의견을 섞지 말고, 한글을 영어로 변환해 줘.

 

생성형 AI의 할루시네이션이 번역 작업에는 포함되지 않아야 하기 때문에, temperature를 0으로 설정하는 것과 별개로, Don't mix your opinions, just convert Hangul into English.이라는 설명을 추가해서 한 번 더 명시했다.

 

 

 미디어 명을 어떻게 번역해야 할까?

K-Culture 데이터베이스는 장소에 대한 설명에, 해당 장소가 어떤 미디어에 등장한 장소인지가 포함되어 있다.

 

만약 '선재 업고 튀어' 드라마에 나온 장소를 소개하려고 한다.

'선재 업고 튀어'를 어떻게 번역해야 할까?

Carry Sunjae and jump..? Seon-jae eop-go twi-eo..?

 

정답은  Lovely Runner 다.🤣🤣

 

미디어명은 우리가 생각하는 것처럼 한글 제목- 영문 제목이 1:1 매칭되지 않았다.

별도의 영문 제목이 존재했다..🤨

 

이걸 GPT가 알고 있지는 않기에, 미디어명을 번역하는 게 맞을지에 대한 고민을 했다.

미디어명만 한글로 제공해도 되지 않을까..?라는 고민을 말이다.

 

그렇지만, 미디어명만 한국어라고 했을 때, 외국인이 해당 미디어가 무엇을 의미하는지 예측조차 못할 것 같다는 생각이 들었다. 따라서 GPT 자체 번역을 진행하고, 원본 미디어명을 뒤에 붙이는 방식을 사용하기로 했다.

영문 미디어명 (한글 미디어명), 뒤에는 해당 장소에 대한 설명

 

 

DB에 어떻게 올바른 정보를 넣을 수 있을까?

 

⚡영문 번역을 하지 않는 문제

사실 처음 프롬프트 메시지는 간단했다.

Translate into English. However, keep the same structure as 'place___address___description' : (번역하고 싶은 문장)

이렇게 간단하니, 번역을 제대로 안 하고 그냥 한글을 반환하는 경우가 있었다.

___ is a separator, so separate each item by ___ 
English translated attraction___English translated address___English translated media___English translated description'

때문에 프롬프트 메시지에 구분자를 명시하고, 예제에 영어여야 한다는 것을 포함하도록 수정했다.

 

 

⚡잘못된 포맷으로 반환하는 문제

 

요구사항을 자세하게 작성하고, 파라미터를 설정하는 것만으로는 100% 원하는 포맷으로 반환하지 않았다.

설명을 자세하게 적어도 원하는 포맷으로 반환하지 못하는 경우가 존재했다.

 

따라서, Response를 받고 유효성 검증을 거친 다음에, 원하는 포맷이 아니라면 API를 재요청하는 방식으로 해결했다.

 

  • fetchAndTraslateAttraction
    private AttractionDto fetchAndTranslateAttraction(MemberContentDto memberContentDto) throws SQLException, KTravelerException {
        AttractionDto attractionDto = attractionMapper.getKCurtureAttractionByContentId(memberContentDto);
        if (attractionDto == null) {
            throw new SQLException(); //해당 content Id가 존재하지 않음
        }

        //K Culture에 있는 값 파싱
        String[] mediaAndDescription = attractionDto.getOverView().split(">");
        String media = parseMedia(mediaAndDescription);
        String description = mediaAndDescription[1];
        String addr = attractionDto.getAddr1();
        String title = attractionDto.getTitle();

        //GPT 호출할 프롬프트 세팅
        CompletionRequestDto completionRequestDto = setGptPrompt(media, description, addr, title);

        //GPT 가 잘 번역할 때까지 3번 기회 줌
        int tryCount = 0;
        while (tryCount < RETRY_CNT) {
            //실제 API 호출
            ResponseEntity<String> response = callGptApi(completionRequestDto);
            AttractionDto translated = handleApiResponse(attractionDto, media, response);
            if (translated != null) { //null은 잘못 번역된 것, 또는 포맷에 맞지 않는 것
                return translated;
            }
            tryCount++;
        }
        throw new KTravelerException(RetConsts.ERR604);
    }

 

  • handleApiResponse
    private AttractionDto handleApiResponse(AttractionDto attractionDto, String media, ResponseEntity<String> response) throws SQLException {
        //Reponse에서 필요한 값들 파싱
        //구분자 ___로 split 해서 4개의 아이템을 만든다.
        
        if (!GptApiValidator.isValidLength(englishItem)) { //사용해야 할 요소 4개를 전부 번역한 게 아니라면
            return null; // 재시도를 유발
        }

        return parseApiResponse(attractionDto, media, englishItem);
    }

 

  • parseApiResponse
    private AttractionDto parseApiResponse(AttractionDto attractionDto, String media, String[] englishItem) throws SQLException {
...
        if (!GptApiValidator.isValidTitle(englishTitle)
                || !GptApiValidator.isValidAddr(englishAddr)
                || !GptApiValidator.isValidOverView(englishOverView)) { //영어로 잘 번역되었는지, 올바른 포맷으로 번역 되었는지 확인
            return null; // 재시도를 유발
        }

...
        return attractionDto;
    }

 

  • GptApiValidator
public class GptApiValidator {

    private GptApiValidator() {
    }

    public static boolean isValidTitle(String englishTitle) {
        String lowerCaseTitle = englishTitle.toLowerCase(); // 소문자 변환
        return lowerCaseTitle.matches(".*[a-z].*") // 대소문자 구분 없이 영어 알파벳 체크
                && !lowerCaseTitle.contains("place")
                && !lowerCaseTitle.contains("attr")
                && !lowerCaseTitle.contains("translated");
    }

    public static boolean isValidAddr(String englishAddr) {
        String lowerCaseAddr = englishAddr.toLowerCase(); // 소문자 변환
        return lowerCaseAddr.matches(".*[a-z].*") // 대소문자 구분 없이 영어 알파벳 체크
                && !lowerCaseAddr.contains("address")
                && !lowerCaseAddr.contains("translated");
    }

    public static boolean isValidOverView(String englishOverView) {
        String lowerCaseOverview = englishOverView.toLowerCase(); // 소문자 변환
        return lowerCaseOverview.matches(".*[a-z].*") // 대소문자 구분 없이 영어 알파벳 체크
                && !lowerCaseOverview.contains("media")
                && !lowerCaseOverview.contains("translated");
    }

    public static boolean isValidLength(String[] englishItem) {
        return englishItem.length == 4;
    }
}

 

번역을 하다 보니, Gpt가 가 오번역을 하는 케이스가 정형화되어있어서, 그 부분을 검증해 줬다.

 

 

⚡같은 미디어에 대해 번역이 달라지는 문제

2TV 생생정보 에 대한 번역이 각기각색이다.

 

Retry handling을 하다 보니, 같은 미디어에 대한 번역이 달라지는 문제가 발생했다.

완벽한 번역은 불가능하다고 하더라도, 적어도 2TV 생생정보에 등장한 장소를 방문하려고 하는 외래 관광객이 편하게 검색할 수 있도록, 같은 번역으로 일치되어야 한다고 생각했다.

 

번역하려고 하는 미디어가 한 번이라도 번역이 된 적이 있다면, 기존 값을 가져와서 사용하도록 했다.

private AttractionDto parseApiResponse(AttractionDto attractionDto, String media, String[] englishItem) throws SQLException {
    String englishTitle = englishItem[0];
    String englishAddr = englishItem[1];

    String existingMedia = attractionMapper.existMedia(media);
    String englishOverview = constructEnglishOverview(existingMedia, media, englishItem[2], englishItem[3]);

	//유효성 검증 로직 (생략)

    attractionDto.setEnglishTitle(englishTitle);
    attractionDto.setEnglishAddr(englishAddr);
    attractionDto.setEnglishOverview(englishOverview);
  
    return attractionDto;
}

private String constructEnglishOverview(String existingMedia, String media, String defaultOverview, String additionalInfo) {
    if (existingMedia != null) {
        return "<" + parseMedia(existingMedia.split(">")) + ">" + additionalInfo;
    } else {
        return "<" + defaultOverview + "(" + media + ")>" + additionalInfo;
    }
}

하나의 미디어는 동일한 번역을 갖는다.

 

 


결과

K-Culture 외 7개의 테마로 구분되어 있던 테이블을 병합 함으로써, 얻은 장점을 하나씩 소개해보겠다.

 

🟢로직 단순해짐 + 유지보수 용이

K-Culture 관련 테이블(3개), K-Culture 외 7개 테마를 담은 테이블(3개)이 있었지만, 하나의 테이블로 병합됨으로써 데이터 관리가 편해졌다.

attraction_info, attraction_description, attraction_detail 만 사용하게 되었다.

	<select id="getAttractionBySearch" resultType="AttractionDto"
			parameterType="searchDto">
		(
			SELECT info.content_id, info.latitude, info.longitude, detail.theme_code
			FROM attraction_detail detail
			LEFT JOIN attraction_info AS info
			ON detail.content_id = info.content_id
			LEFT JOIN bookmark AS book
			ON book.member_id = #{memberId}
			AND detail.content_id = book.content_id
			WHERE
			//검색 조건 동적 쿼리
		)
		UNION ALL
		(
			SELECT k_info.content_id, k_info.latitude, k_info.longitude, k_detail.theme_code
            FROM k_attraction_detail k_detail
            LEFT JOIN k_attraction_info AS k_info
            ON k_detail.content_id = k_info.content_id
            LEFT JOIN bookmark AS book
            ON book.member_id = #{memberId}
            AND k_detail.content_id = book.content_id
            WHERE
			//검색 조건 동적 쿼리
		)

	</select>

 

덕분에 관광지 검색을 위해 위와 같은 union 연산 후 검색... 을 하지 않아도 되게 되었다. 

위 쿼리를 제외하고도, 대부분의 쿼리가 전반적으로 매우 간단해져서! 유지보수가 용이해졌다.

 

 

🟢API 호출 시간의 감소

1. 전체 조회

전: 252ms
후: 29ms

 

 

2. 상세 조회

전: 1313ms
후: 15ms

상세 조회 시 1~2초가 걸려서 '느리다'는 체감이 들었는데, 이 부분이 해결되었다.

 

 

🟢 프론트에서 호출의 편의성 UP

function openModal(contentId, themeCode) {
    const wantItem = {
        memberId: userInfo.value.id,
        contentId: contentId,
    };
    if (themeCode === "E") {
        getAttractionByAI(
            wantItem,
            (response) => {
               ...
            },
            (error) => {
                console.log(error.data);
            }
        );
    } else {
        getAttraction(
            wantItem,
            (response) => {
               ...
            },
            (error) => {
                console.log(error.data);
            }
        );
    }
    isModalVisible.value = true;
}

기존 코드는 테마별로 구분해서, K-Culture 관련 요청이라면 다른 API를 요청해야 했다.

function openModal(contentId, themeCode) {
    const wantItem = {
        memberId: userInfo.value.id,
        contentId: contentId,
    };

    getAttraction(
        wantItem,
        (response) => {
           ...
        },
        (error) => {
           console.log(error.data);
        }
   	  );
    }
    isModalVisible.value = true;
}

그러나, 구분하지 않아도 괜찮아졌다.

 

 


회고

잘 번역을 하기 위해서 할 수 있는 건 최대한 한 것 같다..^^

그래도 더 잘 검증할 수 있는 방법이 있지 않았을까 라는 아쉬움이 남는다.

그리고 이렇게 많은 데이터에서 조회할 때는 Redis를 사용해 봐도 좋았을 것 같다.

 

https://github.com/First-Time-Korea/K-Traveler-BE/tree/develop-v2

 

GitHub - First-Time-Korea/K-Traveler-BE

Contribute to First-Time-Korea/K-Traveler-BE development by creating an account on GitHub.

github.com