이번에 진행하게 된 프로젝트에서 사용자에게 맞춤형 강좌/시설을 추천해 주는 기능을 구현하게 되었습니다.
유저의 앱 사용 기록 등을 토대로 추천을 진행해도 되지만 이러한 방식을 선택하는 경우 앱 사용자가 아직 많은 활동을 하지 않은 경우 추천해 줄 콘텐츠의 정확도나 만족도가 낮을 수 있다는 판단을 하였고, 온보딩 단계에서 사용자의 흥미, 앱 사용 목적 등을 받아 이에 적합한 추천 서비스를 제공하자는 생각을 하여 아래와 같은 온보딩 정보를 회원가입시 받아 이를 추천에 활용하였습니다.
온보딩 및 MongoDB에 MongoUser document 생성
위와 같은 온보딩 과정이 끝나게 되면 MongoDB에 아래와 같이 MongoUser라는 해당 사용자의 온보딩 정보를 담고 있는 document가 생성됩니다.
MongoDB를 활용한 이유는 온보딩 정보는 회원가입 시 한번만 입력하면 되고 이는 추후 변경할 수 없습니다. 이와 같은 경우 embedded document를 통해 유저와 온보딩 정보를 한번에 가져올 수 있고, 조회 성능 또한 관계형 데이터베이스에 비해서 MongoDB가 좋기 때문에 이를 활용하게 되었습니다.
RAG에 사용될 데이터 생성
RAG는 외부 DB에 있는 값을 활용하여 응답을 제공합니다. 이때 RAG에 활용될 데이터들을 DB에 채워주어야 하는데 저는 이 데이터를 제공하기 위해서 사용자들이 상세 조회를 한 로그를 이에 활용하였습니다.
사용자들이 특정한 카테고리의 장소/시설 정보를 상세 조회할 때마다 해당 유저가 해당 게시글을 상세 조회했다는 로그가 MongoDB에 있는 MongoUser에 embedded document로 저장이 되고, 이를 추후 RAG에서 추천 강좌/시설을 요청할 때 비슷한 온보딩을 입력한 유저가 상세 조회한 로그를 활용해 추천 강좌/시설을 제공합니다.
이와 같은 로직을 그림으로 표현하면 아래와 같습니다.
위와 같은 과정을 통해서 저장된 로그의 형태는 아래와 같습니다. 강좌와 시설을 나누어 추천해야 하기 때문에 is_facility를 넣어 강좌와 시설을 분리해 주었습니다.
RAG에 활용될 API 생성
RAG에서는 외부 DB에 있는 값을 활용하여야 하는데 이때 외부 DB의 모든 값이 아니라 필요한 값만을 가져와야 합니다. 따라서 RAG를 실행할 서버에서 필요한 정보들만 제공해야 하기 때문에 해당 정보를 제공하는 API를 생성해야 합니다.
@Operation(summary = "MongoUser 가져오기")
@PostMapping("/mongo-users")
public MongoUserResponseList getAllMongoUsers(
@RequestBody OnboardingAnalysisInfoList onboardingInfos
) {
return MongoUserResponseList.from(aiService.getMongoUsers(onboardingInfos.onboardingAnalysisInfoList()));
}
public record OnboardingAnalysisInfoList(
List<OnboardingAnalysisInfo> onboardingAnalysisInfoList
) {
}
public record OnboardingAnalysisInfo(
String onboardingType,
String content
) {
}
저는 위와 같은 API를 생성하여 필요한 정보들만 RAG를 실행하는 Clova Studio에 제공해 주었습니다. 온보딩에서 아래와 같이 값을 입력받아 이를 MongoUser에 저장하고 있다가 해당 온보딩 정보들을 가지고 있는 MongoUser에서 필요한 정보만 꺼내어 보여줍니다.
onboardingType은 INTEREST, PREFERENCE, PURPOSE 중 하나
INTEREST: 관심사, PREFERENCE: 선호도, PURPOSE: 목적
INTEREST: 태권도, 유도, 복싱, 주짓수, 검도, 합기도, 헬스, 요가, 필라테스, 크로스핏, 에어로빅, 댄스(줌바 등), 축구(풋살), 농구, 배구, 야구, 탁구, 스쿼시,
배드민턴, 테니스, 골프, 볼링, 당구, 클라이밍, 롤러인라인, 빙상(스케이트), 기타종목, 종합체육시설, 무영(발레 등), 줄넘기, 펜싱, 수영, 승마
PREFERENCE: 가까운 곳이 좋아요, 먼 곳도 괜찮아요, 공공시설이 좋아요, 샤워 및 탈의실이 잘 갖춰진 곳이 좋아요, 주차가 편리한 곳이 좋아요, 혼자 하는 활동이 좋아요,
여럿이 함께 하는 활동이 좋아요, 정적인 활동이 좋아요, 역동적인 활동이 좋아요, 실내에서 하는 운동이 좋아요, 야외에서 하는 운동이 좋아요, 다양한 기구를 활용하는 활동이 좋아요, 전문가의 지도가 있는 활동이 좋아요
PURPOSE: 다이어트, 근육 강화, 취미 및 여가 활동, 재활, 스트레스 해소, 대회 준비
Service의 코드는 아래와 같습니다.
public List<MongoUserResponse> getMongoUsers(List<OnboardingAnalysisInfo> onboardingInfoList) {
return mongoUserRepository.findByOnboardingInfo(onboardingInfoList).stream()
.map(MongoUserResponse::from)
.toList();
}
Repository의 코드는 아래와 같은데 onboarding_type과 content를 확인하여 조건에 맞는 다른 사용자들의 정보를 가져옵니다.
@RequiredArgsConstructor
@Repository
public class MongoUserRepositoryImpl implements MongoUserRepositoryCustom {
private final MongoTemplate mongoTemplate;
public List<MongoUser> findByOnboardingInfo(List<OnboardingAnalysisInfo> onboardingAnalysisInfoList) {
List<Criteria> criteriaList = new ArrayList<>();
// 조건을 생성
for (OnboardingAnalysisInfo info : onboardingAnalysisInfoList) {
criteriaList.add(Criteria.where("aiUserOnboardingInfoList")
.elemMatch(Criteria.where("onboarding_type").is(info.onboardingType())
.and("content").is(info.content())));
}
// Aggregation을 사용하여 조건을 만족하는 문서를 필터링하고 랜덤으로 8개 샘플링
Aggregation aggregation = Aggregation.newAggregation(
Aggregation.match(new Criteria().orOperator(criteriaList.toArray(new Criteria[0]))), // 조건에 맞는 문서 필터링
Aggregation.sample(8) // 랜덤으로 8개 샘플링
);
// Aggregation 결과 가져오기
AggregationResults<MongoUser> results = mongoTemplate.aggregate(aggregation, MongoUser.class, MongoUser.class);
return results.getMappedResults();
}
}
위와 같은 로직을 통해 service에 가져온 코드를 아래의 함수로 DTO로 만들어 Clova Studio에 제공합니다.
public record MongoUserResponse(
Long userId,
List<MongoAISearchInfoResponse> aiSearchInfoList
) {
public static MongoUserResponse from(MongoUser domain) {
return new MongoUserResponse(
domain.getUserId(),
domain.getAiSearchInfoList() != null ?
domain.getAiSearchInfoList().stream()
.map(MongoAISearchInfoResponse::from)
.toList() : List.of()
);
}
}
Clova Studio Skill Trainer를 활용한 RAG 구현
저는 RAG를 구현하기 위해 Clova Studio에서 제공하는 Skill Trainer를 활용하였습니다.
새로운 스킬셋을 생성하고 아래와 같이 필요한 정보들을 입력해 줍니다.
스킬셋을 생성한 후에는 스킬을 생성해야 합니다.
스킬에 필요한 정보들을 입력해야 하는데 상세 정보는 아래와 같습니다.
API Spec에서 서버 주소를 제외하고 사용한 코드를 보여드리겠습니다.
{
"info": {
"title": "파일 검색 API",
"version": "1.0.0"
},
"paths": {
"/recommend/mongo-users": {
"post": {
"summary": "파일 내 제목과 내용을 검색하는 API",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"mongoUserResponseList": {
"type": "array",
"items": {
"type": "object",
"properties": {
"userId": {
"type": "integer",
"description": "사용자 ID"
},
"aiSearchInfoList": {
"type": "array",
"items": {
"type": "object",
"properties": {
"placeId": {
"type": "integer",
"description": "장소 ID"
},
"isFacility": {
"type": "boolean",
"description": "시설 여부"
},
"placeCategory": {
"type": "string",
"description": "장소 카테고리"
}
}
}
}
}
}
}
}
}
}
},
"description": "성공"
},
"404": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"errors": {
"type": "array",
"items": {
"type": "object",
"properties": {
"code": {
"type": "string",
"description": "에러 코드"
},
"message": {
"type": "string",
"description": "에러 메시지"
}
}
}
}
}
}
}
},
"description": "카테고리를 찾지 못했습니다"
}
},
"operationId": "searchByCategory",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"onboardingAnalysisInfoList": {
"type": "array",
"items": {
"type": "object",
"required": [
"onboardingType",
"content"
],
"properties": {
"content": {
"type": "string",
"description": "내용"
},
"onboardingType": {
"type": "string",
"description": "온보딩 유형"
}
}
}
}
}
}
}
},
"required": true
}
}
}
},
"openapi": "3.0.0",
"servers": [
{
"url": "서버 주소"
}
]
}
위 코드는 서버에 어떤 형식으로 요청을 보내고, 어떠한 형태의 응답을 받을지에 대한 정의를 하는 부분인데 ChatGPT와 같은 생성형 AI를 활용하면 원하는 포맷의 API 스펙을 만들 수 있으실 겁니다.
이후 Manifest에서 원하는 조건의 기능들을 상세하게 정의하시면 스킬의 생성이 끝납니다.
Clova Studio Skill 데이터 수집 및 학습
스킬을 생성하면 이제 해당 스킬을 학습시켜 원하는 응답이 나오도록 모델을 만들어야 합니다.
데이터 수집 예시는 아래와 같습니다.
원하는 조건의 User Query를 입력하면 이를 분석하여 Clova Studio가 서버에 요청을 발생시킨 후 해당 응답을 이용하여 응답을 생성합니다. 이때 User Query에 저희 서비스에서는 사용자의 온보딩 내용들이 들어가게 됩니다. 생성된 응답의 예시는 아래와 같습니다.
위와 같은 응답에 대해서 최종적으로 수정을 하고 작업 완료를 누르면 데이터 수집이 완료가 됩니다.
이후 아래와 같이 학습 시작을 눌러 학습을 진행하면 됩니다.
위 학습 과정을 그림으로 나타내면 아래와 같습니다.
RAG 활용 강좌/시설 추천
위와 같은 과정이 끝나면 RAG를 활용할 준비가 끝났습니다.
이제 RAG를 본 서비스에서 어떻게 활용했는지 보여드리도록 하겠습니다.
시설 추천에 대해서만 예시로 보여드리도록 하겠습니다. 강좌 추천의 경우도 이와 동일한 로직입니다.
@Operation(summary = "장소 추천", description =
"현재 위치만 입력 & 다음 페이징은 /places/search/facilities 사용<br>" +
"예시 데이터 경도: 127.0965824, 위도: 37.47153792 - 서울특별시 강남구 자곡로 116")
@GetMapping("/search/facilities")
public ResponseEntity<ResponseTemplate<?>> recommendFacilities(
@AuthenticationPrincipal Long userId,
@RequestParam Double longitude,
@RequestParam Double latitude) {
SearchPlaceResponseList nearestPlaces =
aiServiceFacade.searchRecommendPlaces(userId, true, longitude, latitude);
return ResponseEntity
.status(HttpStatus.OK)
.body(ResponseTemplate.from(nearestPlaces));
}
public SearchPlaceResponseList searchRecommendPlaces(
Long userId, boolean isFacility, double longitude, double latitude
) {
ClovaAnalysisResponse recommendation = aiService.getRecommendation(userId, isFacility);
if (isFacility) {
return searchFacilities(longitude, latitude, recommendation);
} else {
return searchLectures(longitude, latitude, recommendation);
}
}
private SearchPlaceResponseList searchFacilities(double longitude, double latitude,
ClovaAnalysisResponse recommendation) {
List<FacilityCategory> categories = recommendation.categories().stream()
.filter(category -> {
try {
FacilityCategory.valueOf(category);
return true;
} catch (IllegalArgumentException e) {
return false; // 유효하지 않은 값은 무시
}
})
.map(FacilityCategory::valueOf)
.toList();
if (categories.isEmpty()) {
categories = new ArrayList<>(List.of(FacilityCategory.ALL));
}
return placeService.searchFacilityPlaces(
longitude, latitude, 100000, categories, SortType.DISTANCE_ASC, "", 0, 10);
}
위와 같은 로직으로 추천이 이루어지는데 이때 AIService에서 Clova Studio의 API를 활용하여 추천 카테고리를 받아옵니다. 이때 추천 카테고리의 응답 양식은 위에서 데이터 수집을 진행했을 때 마지막 응답과 동일합니다.
public ClovaAnalysisResponse getRecommendation(Long userId, Boolean isFacility) {
MongoUser mongoUser = mongoUserRepository.findByUserId(userId)
.orElseThrow(() -> new MongoUserNotFoundException(RecommendErrorCode.MONGO_USER_NOT_FOUND));
String query = makeRecommendQuery(mongoUser, isFacility);
log.info("query: {}", query);
return clovaStudioClient.getFinalAnswer(
clovaStudioProperties.studioApiKey(),
clovaStudioProperties.apigwApiKey(),
clovaStudioProperties.requestId(),
AIRecommendRequest.of(query, false)
); // 필요한 경우 최종 응답을 반환
}
private String makeRecommendQuery(MongoUser mongoUser, Boolean isFacility) {
String query = mongoUser.getAiUserOnboardingInfoList().stream()
.map(aiUserOnboardingInfo -> {
String onboardingType = aiUserOnboardingInfo.getOnboardingType();
String content = aiUserOnboardingInfo.getContent();
return onboardingType + "은 " + content;
})
.collect(Collectors.joining(","));
if (isFacility) {
return query + "\n시설을 추천해줘.";
} else {
return query + "\n강좌를 추천해줘.";
}
}
@FeignClient(name = "clovaStudioClient", url = "${clova.api-url}")
public interface ClovaStudioClient {
@PostMapping(value = "${clova.api-path}", consumes = MediaType.APPLICATION_JSON_VALUE)
ClovaAnalysisResponse getFinalAnswer(
@RequestHeader("X-NCP-CLOVASTUDIO-API-KEY") String clovaApiKey,
@RequestHeader("X-NCP-APIGW-API-KEY") String apiGwApiKey,
@RequestHeader("X-NCP-CLOVASTUDIO-REQUEST-ID") String requestId,
@RequestBody AIRecommendRequest requestBody
);
}
이때 Clova Studio에서 위에서 사용한 키들은 데이터 학습이 끝난 후 버전 관리에서 테스트 앱을 눌러 생성이 가능합니다.
이때 Clova Studio의 응답은 아래와 같은 형태로 받을 수 있습니다.
package com.sportus.be.recommend.dto.response;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.util.List;
@JsonIgnoreProperties(ignoreUnknown = true)
public record ClovaAnalysisResponse(
Status status,
Result result
) {
public record Status(
String code,
String message
) {
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Result(
String finalAnswer // JSON 문자열로 수신
) {
// JSON 문자열을 FinalAnswer 객체로 변환하는 메서드
public FinalAnswer parseFinalAnswer() throws IOException {
ObjectMapper objectMapper = new ObjectMapper();
return objectMapper.readValue(finalAnswer, FinalAnswer.class);
}
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record FinalAnswer(
Boolean isFacility,
List<String> categories
) {}
public List<String> categories() {
try {
return this.result.parseFinalAnswer().categories();
} catch (IOException e) {
return List.of(); // 빈 리스트 반환
}
}
}
위의 과정들을 그림으로 정리하면 아래와 같습니다.
Clova Studio가 현재 베타 버전으로 배포가 진행되고 있어 참고할 자료가 많지 않았고, 공식 문서에서 예시코드가 node.js 또는 python으로만 제공되고 있어 java와 spring으로 이를 구현하는 데에 어려움이 있었습니다.
따라서 java 언어와 SpringBoot를 활용해 Clova Studio를 구현하는 데에 어려움이 있었는데 다른 분들은 이 글에 있는 정보들을 활용하여 조금 더 편하게 이와 같은 시스템을 구축하시면 좋을 거 같습니다.
'CS > 스프링' 카테고리의 다른 글
Offset을 사용하지 않는 무한 페이징 기능 구현 (1) | 2024.09.30 |
---|---|
AOP를 이용한 분산락(Named Lock) 처리 (0) | 2024.07.28 |
synchnonized, 비관적 락, 낙관적 락, 분산락(named lock)을 사용한 데드락 처리 (0) | 2024.07.26 |
QueryDSL을 활용한 동적 쿼리 처리 (0) | 2024.07.17 |
join 제거, dto projection, covering index를 활용한 성능 최적화 (1) | 2024.07.04 |
댓글