✅ 기존 방식의 문제점
while (!categories.isEmpty()) {
// 1. AI가 질문 생성
Map<String, String> createdQuestions = questionAIService.createQuestions(categories);
for (String categoryName : createdQuestions.keySet()) {
String question = createdQuestions.get(categoryName);
// 2. 질문 중복 검증
if (!questionAIService.isSimilarTextExist(question)) {
questionAIService.saveQuestion(categoryName, question); // 3. 데이터베이스에 질문 저장
log.info("질문 검증 완료: {}: {}", categoryName, question);
} else {
log.info("질문 중복: {}: {}", categoryName, question);
}
}
}
- AI가 질문 생성
- 생성한 질문 중복 검증
- 통과한 질문 저장
- 통과 못한 질문의 카테고리는 다시 질문 생성
- 모든 카테고리 통과할 때 까지 반복
이 방식의 문제점은 질문이 중복되더라도 같은 프롬프트로 계속해서 질문 요청을 한다는 점이다.
[질문 생성 내부 로직 - createQuestions()]
/**
* 생성해야하는 카테고리 목록들을 받아 프롬프트를 작성해 질문 생성
*/
public Map<String, String> createQuestions(List<String> categories) {
log.info("질문 생성 시도");
Prompt prompt = createPrompt(categories);
ChatResponse response = chatModel.call(prompt);
String content = response.getResult().getOutput().getContent(); // json 형태의 질문들
log.info("질문 생성 완료: {}", content);
ObjectMapper objectMapper = new ObjectMapper();
try {
JsonNode questionsNode = objectMapper.readTree(content); // 문자열 형태의 Json을 JsonNode로 파싱
Map<String, String> questionMap = new HashMap<>();
categories.forEach(category -> questionMap.put(category, questionsNode.get(category).asText())); // 뽑아낸 질문들을 Map에 저장
return questionMap;
} catch (JsonProcessingException e) {
return Map.of(); // Json Parsing 오류시 빈 Map 반환
}
}
그 결과 다음과 같이 무한정 질문이 생성되는 현상이 발생한다. 생성형 AI API는 요청에 따라 과금이 되는 구조이기 때문에 이는 심각한 비용문제로 이어질 수도있다.
✅ 개선1 - 생성된 질문들을 동적으로 프롬프트에 추가
questionBlackList라는 Map 자료구조를 중복된 질문을 담는 블랙리스트로 사용한다.
블랙리스트에는 AI가 생성한 질문들을 저장한다.
private Map<String, Set<String>> questionBlackListMap; // 카테고리 별로 중복된 질문을 담는 블랙 리스트
// 모든 카테고리의 질문들이 생성될 때 까지 반복
while(!categories.isEmpty()) {
Map<String, String> createdQuestions = questionAIService.createQuestions(categories, questionBlackList);
// 질문 검증 및 저장
for (String categoryName : createdQuestions.keySet()) {
String question = createdQuestions.get(categoryName);
if (!questionAIService.isSimilarTextExist(question)) { // 중복되지 않는 질문일 때,
questionAIService.saveQuestion(categoryName, question); // 데이터베이스에 질문 저장
} else { // 중복된 질문이면
// 질문 블랙 리스트에 추가
log.info("질문 중복: {}: {}", categoryName, question);
if(questionBlackList.containsKey(categoryName)) {
questionBlackList.get(categoryName).add(question);
} else {
questionBlackList.put(categoryName, new ArrayList<>(List.of(question)));
}
}
}
}
해당 블랙리스트를 동적으로 프롬프트에 추가한다.
/**
* 생성해야하는 카테고리 목록들을 받아 프롬프트를 작성해 질문 생성
*/
public Map<String, String> createQuestions(List<String> categories, Map<String, Set<String>> questionBlackListMap) {
log.info("질문 생성 시도");
log.info("질문 블랙리스트: {}", questionBlackListMap);
Prompt prompt = createPrompt(categories, questionBlackListMap);
ChatResponse response = chatModel.call(prompt);
calculateTokens(response); // 사용된 토큰 계산하기
String content = response.getResult().getOutput().getContent(); // json 형태의 질문들
log.info("질문 생성 완료: {}", content);
ObjectMapper objectMapper = new ObjectMapper();
try {
JsonNode questionsNode = objectMapper.readTree(content); // 문자열 형태의 Json을 JsonNode로 파싱
Map<String, String> questionMap = new HashMap<>();
categories.forEach(category -> questionMap.put(category, questionsNode.get(category).asText())); // 뽑아낸 질문들을 Map에 저장
return questionMap;
} catch (JsonProcessingException e) {
return Map.of(); // Json Parsing 오류시 빈 Map 반환
}
}
이때 동적으로 추가되는 프롬프트는 다음과 같다.
Generate a "completely" different question from the following content.
{questionBlackList}
Ensure that the new question has no semantic, structural, or thematic similarity to the above questions. Avoid rephrasing or minor changes.
💡 결과
이전보다는 나아졌지만 여전히 중복질문이 많이 발생하는 모습이다.
✅ 개선 2 - 중복되는 질문까지 블랙리스트에 추가
이전에는 AI가 갓 생성한 따끈따끈한 질문만 블랙리스트에 추가했다면, 이번에는 Redis Vector Database에 저장되어 있던 중복되는 질문까지 블랙리스트에 추가해보겠다.
이러면 한번의 AI 요쳉에 대한 토큰 수 자체는 많아지겠지만 그만큼 AI 요청 수가 줄어들기 때문에 오히려 사용되는 토큰 수는 줄어들 것이라고 생각했다.
기존에는 다음과 같이 비슷한 텍스트가 하나라도 있는지 검색해서 중복여부만을 판단했다.
/**
* 비슷한 텍스트가 있는지
*/
public boolean isSimilarTextExist(String text) {
// 유사도가 0.8이상인 텍스트를 하나만 검색
List<Document> documents = vectorStore.similaritySearch(
SearchRequest.query(text)
.withSimilarityThreshold(0.8)
.withTopK(1)
);
return !documents.isEmpty();
}
이번에는 중복되는 질문들을 모두 검색한다.
/**
* 비슷한 텍스트를 찾아 반환
*/
public List<String> findSimilarText(String text) {
// 유사도가 0.8이상인 텍스트를 하나만 검색
List<Document> documents = vectorStore.similaritySearch(
SearchRequest.query(text)
.withSimilarityThreshold(0.8)
.withTopK(10000) // 최댓값: 10000
);
return documents.stream()
.map(Document::getContent)
.collect(Collectors.toList());
}
그렇게 해서 받아온 질문들을 블랙리스트에 저장한다.
// 모든 카테고리의 질문들이 생성될 때 까지 반복
while(!categories.isEmpty()) {
Map<String, String> createdQuestions = questionAIService.createQuestions(categories, questionBlackList);
// 질문 검증 및 저장
for (String categoryName : createdQuestions.keySet()) {
String question = createdQuestions.get(categoryName);
List<String> similarQuestions = questionAIService.findSimilarText(question);
if (similarQuestions.isEmpty()) { // 중복되지 않는 질문일 때,
questionAIService.saveQuestion(categoryName, question); // 데이터베이스에 질문 저장
} else { // 중복된 질문이면
// 질문 블랙 리스트에 추가
log.info("질문 중복: {}: {}", categoryName, question);
if(questionBlackList.containsKey(categoryName)) {
questionBlackList.get(categoryName).add(question); // 생성된 질문 블랙리스트에 저장
} else {
questionBlackList.put(categoryName, new ArrayList<>(List.of(question)));
}
questionBlackList.get(categoryName).addAll(similarQuestions); // 중복된 질문들을 블랙 리스트에 저장
}
}
}
💡 결과
중복이 아예 발생하지 않는 것은 아니지만 확실하게 개선된 모습이다.
생성된 질문 확인
꽤 다른 질문을 뽑아주는 모습이다.
비용
사용된 토큰을 통해 비용을 계산해보면 $0.0006852(약 1원)이다.
물론 서비스가 거듭할수록 누적되는 질문이 늘어 토큰 사용량은 늘겠지만 하루에 한번 생성되는 것을 감안했을 때 감당 가능한 수준이다.
✅ 추가 개선 - 질문 일괄저장
질문은 두 개의 데이터베이스(MySQL, Redis Vector Database)에 저장된다.
MySQL은 트랜잭션을 통해 관리되지만 Redis Vector Database는 아니다.
따라서 질문 생성 도중 에러가 발생하면 이전까지 생성된 질문은 Redis Vector Database에 저장되지만 MySQL에선 롤백이 되어버린다.
따라서 모든 카테고리에 대한 검증이 끝난 시점에 데이터베이스에 모든 질문이 일괄 저장되도록 로직을 변경하였다
private Map<String, String> questionTempStore; // 카테고리 별로 검증이 끝난 질문 임시 저장소
while (!categories.isEmpty()) {
Map<String, String> createdQuestions = questionAIService.createQuestions(categories, questionBlackListMap);
for (String categoryName : createdQuestions.keySet()) {
String question = createdQuestions.get(categoryName);
List<String> similarQuestions = questionAIService.findSimilarText(question);
if (similarQuestions.isEmpty()) {
log.info("질문 검증 완료: {}: {}", categoryName, question);
questionTempStore.put(categoryName, question); // 검증된 질문이면 임시 저장소에 저장
removeCategoryFromList(categoryName);
} else {
log.info("질문 중복: {}: {}", categoryName, question);
addQuestionsToBlackListMap(categoryName, question, similarQuestions);
}
}
}
log.info("모든 질문 생성완료.");
questionAIService.saveQuestions(questionTempStore); // 임시 저장소에 있는 질문들을 일괄적으로 데이터베이스에 저장
log.info("질문 생성 스케줄링 성공");
검증이 완료된 질문들은 임시적으로 별도의 자료구조에 저장해두었다가 모든 카테고리에 대해 질문 생성이 완료되면 질문이 저장된다.
💡 결과
✅ 로직 최종 정리
기존의 질문 생성 로직을 개선하여 질문이 무한 생성돼 비용이 무한하게 발생하는 현상을 해결하고
오류발생시 MySQL과 Redis Vector Database의 데이터가 불일치하는 문제를 해결하였다.
이제 완성된 로직을 최종적으로 정리하도록 하겠다.
1️⃣ 질문 생성
- 매일 1개씩 각 6개의 카테고리별로 질문 생성(하루에 총 6개 질문 생성) - 23시 30분 스케줄링
- 텍스트 임베딩 모델을 이용해 각 질문을 벡터값으로로 변환
- 각 카테고리별로 Redis Vector Database에서 유사한 질문을 검색
- 임계값을 넘는 질문이 존재하면 생성된 질문과 검색된 질문을 블랙리스트에 추가
- 질문 블랙리스트를 프롬프트에 동적으로 추가하고 해당 카테고리에 한해 질문 재생성
- 모든 카테고리의 질문이 통과될 때까지 반복
- 질문 생성이 완료되면 질문들을 RDBMS에 저장하고 질문의 벡터값을 Redis Vector Database에 저장
✔️ 시퀀스 다이어그램
2️⃣ 질문 상태 업데이트
- 00시에 질문 업데이트 스케줄링이 시작.
- 기존의 ACTIVATED 상태의 질문들을 INACTIVATED 상태로 수정.
- 23시 30분에 생성된 질문들은 CREATED로 설정 되어 있는데 해당 질문의 상태를 ACTIVATED로 수정.
- 이후 질문을 조회 시 ACTIVATED 상태의 질문만 조회되도록 설정
✔️ 시퀀스 다이어그램
'데브코스 > 실습 & 프로젝트' 카테고리의 다른 글
[최종 프로젝트] 좋아요 API 동시성 이슈 해결 - 1. Like 테이블 데이터 정합성 문제 (0) | 2024.12.31 |
---|---|
[최종 프로젝트] 좋아요 API 동시성 이슈 해결 - 0. 배경 설명 (1) | 2024.12.31 |
[최종 프로젝트] 질문 생성 기능 - 4. 질문 생성 로직 구현하기 (0) | 2024.12.30 |
[최종 프로젝트] 질문 생성 기능 - 3. 질문의 벡터를 저장하기 위한 Redis Vector Database & Vector Search (Spring AI) (0) | 2024.12.30 |
[최종 프로젝트] 질문 생성 기능 - 2. 중복 질문 방지를 위한 텍스트 임베딩 모델 조사 (2) | 2024.12.29 |