일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
- 라이브러리 없이
- Android
- 360도 이미지 뷰어
- launch screen
- @sendable
- 360도 뷰어
- React-Native
- completion handler
- data driven construct
- 네이티브
- 명시적 정체성
- 구조적 정체성
- react-native-fast-image
- 스켈레톤 통합
- 파노라마 뷰
- 앱 성능 개선
- 360도 이미지
- launchscreen
- SwiftUI
- native
- 뷰 생명주기
- ios
- 뷰 정체성
- 리액트 네이티브
- panorama view
- react
- 리액트
- requirenativecomponent
- React Native
- ssot
- Today
- Total
Neoself의 기술 블로그
ChatGPT와 네이버 책검색 api로 도서추천 시스템 구현하기(고도화 과정 3/3) 본문
1. 기존 시스템의 한계점 및 기존 시스템 플로우 개선
초기 도서 매칭 시스템은 GPT가 제공하는 도서 정보의 정확성을 전제로 설계되었습니다. 시스템은 단순히 제목, 저자, 출판사 간의 텍스트 유사도 비교에만 중점을 두었습니다. 하지만 테스트 과정에서 GPT가 실제로 존재하지 않는 도서를 추천하는 케이스가 상당수 발견되었습니다. 따라서, 기존 로직 흐름에 재시도 로직을 추가해, 존재하지 않는 책에 대한 매칭이 이뤄지는 것을 최소화하고자 했습니다.
개선된 시스템 플로우 계획
1. 책 추천에 대한 질문에 대해 GPT가 1~3개의 책 데이터 반환
2. 각 책 데이터에 대해 재시도 횟수 3회로 두고 존재하는 책이 나올때까지 아래 내용반복수행 <- GPT가 존재하는 책을 제시할때까지 반복수행하는 로직 추가
1. 제목과 저자를 네이버 책 검색 api query에 전달 후, 상위 20개의 검색결과들 요청
2. 20개의 검색결과 데이터에 대해
1. GPT가 반환한 제목 vs 검색결과 제목 유사도 측정
2. GPT가 반환한 저자 vs 검색결과 저자 유사도 측정
3. GPT가 반환한 출판사 vs 검색결과 출판사 유사도 측정
4. 2개 유사도 점수들에 대해 각각 중요도에 따른 가중치 부여
5. 가중치 부여된 2가지 유사도 점수들을 누적합산하여 총 유사도 점수 저장
3. 가장 높은 유사도를 지닌 검색결과가 임계치 넘길 경우, 검색결과 최종 반환 배열에 추가<- 임계치 상회 여부, 재시도 횟수에 따라 분기처리 추가
가장 높은 유사도를 지닌 검색결과가 임계치를 넘기지 못할 경우,
1. 재시도 횟수가 3회 미만일 경우, GPT로부터 새로운 책 데이터 요청 후 2-1번부터 실행
2. 재시도 횟수가 3회일 경우, 기존 임계치를 넘기지 못한 3권 중 가장 높은 검색결과 최종 반환 배열에 추가
3. 최종 반환 배열 반환
2. 데이터 기반 개선 전략 수립
위 시스템 플로우에서 3번 절차를 구현하기에 앞서
2.1. GPT 오류 케이스 패턴 분석
GPT의 잘못된 도서 추천 케이스들을 분석한 결과, 각 속성별 유사도 점수에서 특징적인 패턴이 발견되었습니다. 다음은 존재하지 않는 책이 반환되어 잘못 매칭된 대표적인 케이스들입니다:
// 존재하지 않는 책 반환으로 인해 잘못 매칭된 책 케이스들
[0.33, 1.00, 0.00], // 심리학의 모든 것
[1.00, 0.00, 0.00], // 심리학 입문
[0.13, 1.00, 0.00], // 길의 길
[0.09, 1.00, 0.40], // 길장 리더십
[0.12, 1.00, 1.00], // 파이썬 for Beginner
[0.30, 1.00, 0.00], // 철학 입문
[0.27, 1.00, 0.00], // 세상에서 가장 쉬운 철학
[0.14, 1.00, 0.00], // 취업 면접의 기술
[0.14, 1.00, 0.50] // 합격을 부르는 면접의 기술
잘못 반환된 책들에 대한 평균 제목 유사도 점수 = 0.28
잘못 반환된 책들에 대한 평균 저자 유사도 점수 = 0.89
잘못 반환된 책들에 대한 평균 출판사 유사도 점수 = 0.21
먼저, 제목 유사도는 높은 변별력을 보여주었습니다. 존재하지 않는 도서들의 경우 평균 0.28이라는 낮은 유사도 점수를 기록해, 존재하는 도서와 명확히 구분됩니다. 이러한 특성을 고려할 때, 부제목이나 공백 처리 방식의 차이로 인해 정상적으로 매칭된 도서들의 유사도가 다소 낮게 측정되더라도, 임계값을 적절히 조정함으로써 이를 수용할 수 있다고 판단했습니다.
반면 저자 유사도는 예상과 달리 변별력이 떨어지는 것으로 나타났습니다. 존재하지 않는 도서와 실제 도서 모두에서 높은 유사도 점수가 관찰되었으며, 외국 저자의 경우, Middle Name의 표기 여부에 따라 실제로는 동일한 저자임에도 불구하고, 오히려 존재하지 않는 도서보다도 더 낮은 유사도 점수를 기록하는 경우도 확인할 수 있었습니다.
출판사 정보의 경우, 가장 낮은 변별력을 보였습니다. 정상적으로 매칭된 도서와 잘못 매칭된 도서 간에 유사도 점수 분포가 거의 동일한 패턴을 보여, 유의미한 판단 기준으로 활용하기에는 적절하지 않다고 판단되었습니다.
2.2. 최적 임계값 도출
분석 결과를 바탕으로, 다음과 같이 최적화된 임계값을 도출하였습니다:
- 제목: 0.40 (하한선)
- 저자: 0.80
- 출판사: 제외
3. 재시도 로직 구현
앞서 설정한 임계값들을 토대로 재시도의 트리거가 되는 정상매칭여부 판단 로직과 재시도 로직을 구현하였습니다.
1. 책 추천에 대한 질문에 대해 GPT가 1~3개의 책 데이터 반환
2. 각 책 데이터에 대해 재시도 횟수 3회로 두고 존재하는 책이 나올때까지 아래 내용반복수행
1. 제목과 저자를 네이버 책 검색 api query에 전달 후, 상위 20개의 검색결과들 요청
2. 20개의 검색결과 데이터에 대해
1. GPT가 반환한 제목 vs 검색결과 제목 유사도 측정
2. GPT가 반환한 저자 vs 검색결과 저자 유사도 측정 <- 임계값 도출 간 인사이트를 통해 출판사 유사도 연산 로직 제거
3. 2개 유사도 점수들에 대해 각각 중요도에 따른 가중치 부여
4. 가중치 부여된 2가지 유사도 점수들을 누적합산하여 총 유사도 점수 저장
3. 가장 높은 유사도를 지닌 검색결과가 임계치 넘길 경우, 검색결과 최종 반환 배열에 추가
가장 높은 유사도를 지닌 검색결과가 임계치를 넘기지 못할 경우,
1. 재시도 횟수가 3회 미만일 경우, GPT로부터 새로운 책 데이터 요청 후 2-1번부터 실행
2. 재시도 횟수가 3회일 경우, 기존 임계치를 넘기지 못한 3권 중 가장 높은 검색결과 최종 반환 배열에 추가
3. 최종 반환 배열 반환
3.1. 임계값 기반 정상 매칭여부 판단 로직 추가
기존에는 존재하는 검색결과들중 단순히 유사도점수가 가장 높은 책 결과만을 반환하는 메서드였습니다.
func convertToRealBook(from sourceBook: RawBook) async throws -> (book: BookItem, similarities: [Double])? {
/// 제목으로 네이버 책 api에 검색하여 나온 상위 책 10개를 반환받습니다.
/// 각 책마다 누적 유사도 값을 계산하고 배열에 id값과 함께 저장합니다.
/// 가장 유사도가 높은 책 검출 위해 sort 실행
/// 기존: 가장 높은 유사도 보유한 책 데이터, 책 데이터에 대한 유사도 분포 점수 확정적으로 반환!
// return (book: searchedResults[results[0].index], similarities:results[0].value)
// 변경 후: 책 데이터 뿐만 아니라, 기준 충족 여부를 의미하는 isMatching도 같이 반환
/// 임계값 기준을 상회할 경우, 정상 매칭되어 반환된 책으로 판단하고, isMatching을 true로 합니다.
let isMatching = result.value[0] >= 0.42 && result.value[1] >= 0.80
return (
isMatching: isMatching,
book: finalBook,
similarities:similarities)
}
하지만, 반환값에 정상매칭여부를 의미하는 isMatching 불린값을 포함시켜, 임계값과의 대소관계를 분석해 정상 매칭여부와 유사도가 가장 높은 책 같이 반환하도록 했습니다.
convertToRealBook() 전체 코드
private func convertToRealBook(_ input: RawBook)
-> Single<(isMatching: Bool, book: BookItem?, similarity: Double)> {
/// 검색 결과를 나중에 처리하기 위해 Single로 지연 시킵니다.
let searchStream: Single<[BookItem]> = Single.deferred { [weak self] in
guard let self else {
return .just([])
}
return searchOverallBooks(from: input)
}
/// 각 검색 결과에 대한 유사도를 계산하는 클로저를 정의합니다.
let processSearchResult = { [weak self] (searchResult: BookItem)
-> Observable<(BookItem,[Double])> in
guard let self else {
return .never()
}
let titleCalculation = titleStrategy
.calculateSimilarity(searchResult.title, input.title).asObservable()
let authorCalculation = authorStrategy.calculateSimilarity(
searchResult.author,
input.author
).asObservable()
/// zip으로 title과 author 유사도를 병렬로 계산하고 결과를 배열로 묶어 반환합니다.
return Observable.zip(titleCalculation, authorCalculation)
.map { titleSimilarity, authorSimilarity in
(searchResult, [titleSimilarity, authorSimilarity])
}
}
// Results에 대한 병렬 처리가 필요하므로, Observable 스트림 생성 후, 최종 Single 반환 필요합니다.
return searchStream
/// 여기서 0개 searchedResult 나오면 error를 반환합니다.
.flatMap { searchResults -> Single<[BookItem]> in
guard !searchResults.isEmpty else {
return .error(BookMatchError.noMatchFound)
}
return .just(searchResults)
}
/// 각 책마다 유사도 값을 계산하고 배열에 저장합니다.
.flatMap { searchResults in
Observable.from(searchResults)
.flatMap { book -> Observable<(BookItem, [Double])> in
processSearchResult(book)
}
.toArray()
}
/// 가장 유사도가 높은 책을 검출하고 임계값을 체크합니다.
.map { [weak self] results -> (isMatching: Bool, book: BookItem?, similarity: Double) in
guard let self else {
return (isMatching: false, book: nil, similarity: 0.0)
}
/// 가중치가 적용된 총점으로 정렬합니다.
let sortedResults = results
.sorted { weightedTotalScore($0.1) > weightedTotalScore($1.1) }
guard let bestMatch = sortedResults.first else {
return (isMatching: false, book: nil, similarity: 0.0)
}
let totalSimilarity = weightedTotalScore(bestMatch.1)
/// 임계값 기준을 상회할 경우, 정상 매칭되어 반환된 책으로 판단하고, isMatching을 true로 합니다.
let isMatching = bestMatch.1[0] >= config.titleSimilarityThreshold && bestMatch
.1[1] >= config.authorSimilarityThreshold
return (
isMatching: isMatching,
book: bestMatch.0,
similarity: totalSimilarity
)
}
/// 에러 발생 시 기본값을 반환합니다.
.catch { _ in
.just((isMatching: false, book: nil, similarity: 0.0))
}
}
3.2. GPT에게 새로운 책 데이터 요청하는 메서드 구현
이러한 재시도 로직에 핵심적인 것은 바로 ChatGPT로부터 새로운 책에 대한 정보를 요청하는 함수 구현이였습니다. 같이 전달된 기존 추천되었던 도서 배열을 제외하고, 주어진 질문에 적합한 새로운 도서의 제목-저자-출판사를 반환하는 메서드를 구현해 존재하지 않는 책이 최종반환될 확률을 최소화하고자 했습니다.
getAdditionalBookFromGPT 메서드 코드
func getAdditionalBook(
question: String,
previousBooks: [RawBook]
) -> Single<RawBook> {
// 1. 환경변수 검증
guard let system = loadEnv()?["ADDITIONAL_PROMPT"],
let openAIApiKey = loadEnv()?["OPENAI_API_KEY"] else {
print("GPT Error in getAdditionalBookFromGPT")
return .error(GPTError.noKeys)
}
// 2. API 요청 구성
let messages = [
ChatMessage(role: "system", content: system),
ChatMessage(
role: "user",
content: "질문: \(question)\n기존 도서 제목 배열: \(previousBooks.map(\.title))"
)
]
// 3. API 호출 및 응답 처리
return sendChatRequest(
messages: messages,
temperature: 0.01,
maxTokens: 100
)
// 4. 응답 데이터 검증
.map { response in
guard let result = response.choices.first?.message.content,
result.map({ String($0) }).filter({ $0 == "-" }).count >= 2 else {
throw GPTError.invalidResponse
}
// 5. 응답 데이터 파싱
let arr = result
.split(separator: "-")
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
return RawBook(title: arr[0], author: arr[1], publisher: arr[2])
}
.retry(3) // 최대 3회 재시도
.catch { error in
print("All retry attempts failed: \(error.localizedDescription)")
throw GPTError.invalidResponse
}
}
private func sendChatRequest(
messages: [ChatMessage],
temperature: Double,
maxTokens: Int
) -> Single<ChatGPTResponse> {
// 1. URL 검증
guard let url = URL(string: "https://api.openai.com/v1/chat/completions") else {
return .error(GPTError.noKeys)
}
// 2. 요청 본문 구성
let requestBody: [String: Any] = [
"model": "gpt-4",
"messages": messages.map { ["role": $0.role, "content": $0.content] },
"temperature": temperature,
"max_tokens": maxTokens
]
// 3. URLRequest 구성
return Single<ChatGPTResponse>.create { single in
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.addValue("Bearer \(openAIApiKey)", forHTTPHeaderField: "Authorization")
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
do {
request.httpBody = try JSONSerialization.data(withJSONObject: requestBody)
} catch {
single(.failure(GPTError.invalidResponse))
return Disposables.create()
}
// 4. 네트워크 요청 실행
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
single(.failure(error))
return
}
guard let data = data else {
single(.failure(GPTError.invalidResponse))
return
}
do {
let response = try JSONDecoder().decode(ChatGPTResponse.self, from: data)
single(.success(response))
} catch {
single(.failure(GPTError.invalidResponse))
}
}
task.resume()
return Disposables.create {
task.cancel()
}
}
}
해당 메서드 또한 GPT로부터 반환되는 값을 활용하는 메서드인만큼, GPT 반환 데이터에 대한 무결성 보장을 위해 GPT 오류에 대한 엣지케이스 처리가 필요합니다. 이를 위해 재시도 횟수를 3회 설정해, GPT의 잘못된 포맷 반환으로 인한 index out of range 문제를 방지하고자 했습니다.
전체 메서드에 throws 키워드를 선언한 후, while문 안에, do catch 패턴을 사용함으로써, 프롬프트 및 키값 부재, GPT 반환값 타입 불일치 등 여러 상황의 에러에 대해 모두 일관된 처리 흐름을 명시하였으며, 재시도를 최대횟수로 수행하여도 포맷 불일치가 계속 발생할 경우, 최상단 메서드에 에러를 전달해 최상단 메서드의 catch문으로 호출자 위치를 이동시키고자 했습니다.
재시도 처리 구현 완료 후, 테스트케이스 설계 및 실행을 통해 정상동작 여부를 확인했습니다.
func testGetAdditionalBookFromGPT() async {
do {
module = EnhancedBookSearchManager(
titleStrategy: LevenshteinStrategyWithNoParenthesis(),
authorStrategy: LevenshteinStrategy(),
weights: [0.7, 0.3],
initialSearchCount: 10,
threshold: [0.42,0.80],
maxRetries: 3
)
let book = try! await sut.getAdditionalBookFromGPT(
for: "요리하는 데에 참고할 수 있는 책 추천해주세요",
from: []
).value
print(book)
} catch {
print(error)
}
}
3.3. 최상단 메서드 구현
마지막으로 GPT에 질문을 입력하는 과정부터, 책 검색 api를 통해 책을 매칭하는 로직을 구현한 recommendBookFor라는 메서드에는 재시도를 중심으로 GPT가 존재하지 않는 책을 반환하는 케이스에 대한 대응 로직을 구현했습니다.
func recommendBookFor(question: String, ownedBook: [String]) async throws -> [BookItem]{
/// ChatGPT로 책에 대한 질문을 입력하고, 제목, 저자, 출판사가 담긴 데이터 모델을 반환받습니다.
/// 이 데이터 모델은 책 검색 api에서 최종 데이터 모델을 추출하기 위해 사용되는 인풋모델이 됩니다.
let (recommendedOwnedBookIds,recommendations) = try await getBookRecommendation(question: question, ownedBooks: ownedBook)
var validUnownedBooks = [BookItem]()
let maxRetries = 3
/// 엣지 케이스 1: ChatGPT로부터 추가추천을 하는 상황에 기존에 추가추천했던 책을 반복제시해 2개 이상 동일한 책이 배열에 저장되는 경우
/// 질문에 대한 전체 컨텍스트에서 관리되고 공유되는 추천받은 책 제목 배열 생성하고 비교에 사용
var previousBooks = recommendations // [RawBook]
/// 3번의 재시도를 통해 ChatGPT로부터 새로운 책 정보를 받아, 존재하지 않는 책이 추출되는 에러를 해결합니다.
for book in recommendations {
/// 재시도 횟수를 트래킹하기 위한 정수 변수
var retryCount = 0
/// 재시도 마다 변경되는 GPT 반환 데이터 모델을 관리하기 위한 변수입니다.
var currentBook = book
// previousBooks를 통해 전혀 다른 제목의 책을 추천받아 나온 연관 검색결과들이 저장되는 배열이기 때문에, 매 책마다 리셋되어 관리됩니다.
var candidates = [(BookItem,[Double])]()
do {
while retryCount <= maxRetries { /// 재시도 횟수가 3회이하일 경우, matchToRealBook에서 유효한 결과를 받지 못한 상황에서
if retryCount == maxRetries {
candidates.sort(by: {
$0.1.enumerated().reduce(0.0){ result, item in
result + item.element*weights[item.offset]
} > $1.1.enumerated().reduce(0.0){ result, item in
result + item.element*weights[item.offset]
}
})
/// 엣지 케이스 2: 3회 재시도를 반복하여도, 임계값을 상회하지 않는 책만 제시되었을 경우,
/// 기존 isMatching이 false였을때 같이 반환되었던 유사한 책들 중 가장 유사도가 높은 책을 최종 배열에 추가합니다.
print("max retries reached, returning best candidate: \(candidates.first!.0.title)")
validUnownedBooks.append(candidates.first!.0)
}
let (isMatching,matchedBook,similarities) = try await matchToRealBook(from: currentBook)
/// 매칭 성공된 책이 반환되면, 최종반환 배열에 추가후 while문을 나옵니다.
if isMatching, let matchedBook {
print("match Success!: \(currentBook)")
validUnownedBooks.append((matchedBook))
break
/// 매칭 실패된 책이 반환되면, 추후 3회를 다 채울 케이스를 대비해 최종반환된 책을 임시로 보관한 후,
/// 다음 매칭을 위한 새로운 책 데이터를 GPT에 요청합니다.
} else if !isMatching, let matchedBook {
print("match Failure! retryCount: \(retryCount) / \(currentBook)")
candidates.append((matchedBook,similarities))
currentBook = try await getAdditionalBookFromGPT(for: question, from: previousBooks) // RawBook
previousBooks.append(currentBook)
retryCount+=1
}
}
} catch {
print("Error during book matching: \(error)")
continue
}
}
/// 엣지 케이스 3: previousBooks 배열을 통해 중복되는 추가추천을 방지하는 장치를 두어도
/// LLM모델 특성상, 동일한 책 데이터모델을 제시할 가능성이 존재합니다.
/// 따라서 최종 반환 시, 중복 데이터는 제거해 최소 1개의 데이터가 반환되도록 보장합니다.
return Array(Set(validUnownedBooks))
}
4. 재시도 로직 구현 간 엣지케이스 처리
재시도 로직을 구현하면서 가장 신경썼던 요소는 바로 ChatGPT를 활용하면서 발생하는 변수들에 대한 예외처리였습니다. 이는 아래와 같이 정리할 수 있습니다.
- 최상단 메서드에서 재시도를 최대횟수만큼 시도했음에도 임계값을 상회하는 책이 끝내 반환되지 않았을 경우, 사용자 입장에서는 질문에 대해 아무런 책도 확인할 수 없는 상황이 발생할 수 있습니다.
이를 방지하고자 isMatching이 false였을때에도 유사도가 가장 높은 책을 같이 반환하도록 반환타입을 수정하였으며, 같이 반환되었던 유사한 책 3권 중 가장 유사도가 높은 책을 최종 선택하는 로직을 구현하였습니다. - 동일한 질문에 대해 2개 이상의 GPT 반환 책에서 재시도 로직이 호출될 경우, 동일한 책이 반환되어 같은 질문에 대해 중복된 책 데이터가 저장될 수 있습니다.
이를 방지하기 위해, 질문에 대한 전체 컨텍스트에서 GPT로부터 반환받은 책 제목들에 대한 배열을 생성해, 재시도 로직에 같이 전달하였습니다. - 2번째 예외처리를 하였음에도, LLM모델 특성상 동일한 책 데이터모델을 제시할 가능성이 존재합니다.
따라서 최종 반환 시, 중복 데이터를 제거하는 추가 처리를 진행해 사용자에게 혼란을 주지 않고자 했습니다. - 초기 GPT를 통해 책 추천을 요청하거나, 재시도 간 추가추천을 요청할 때 잘못된 형식으로 응답하거나 실패할 가능성이 존재합니다.
이를 방지하고자 환경 변수 유효성과 응답 데이터 구조 검증 단계를 추가해, 이를 통과하지 못할때 에러반환을 통해 재시도를 수행하도록 로직을 고도화해 일시적인 GPT 응답오류에 대한 복원력을 확보했습니다.
5. 테스트를 통한 변수 조정
class BookSearchManagerTests: XCTestCase {
var sut: EnhancedBookSearchManager!
let weightOptions: [[Double]] = [[0.8, 0.2], [0.5, 0.5]]
let thresholdOptions: [[Double]] = [[0.40, 0.80], [0.70, 0.80]]
let searchCountOptions: [Int] = [10, 20]
let maxRetriesOptions: [Int] = [5, 3]
let questions = [
"요즘 스트레스가 많은데, 마음의 안정을 찾을 수 있는 책 추천해주세요.",
"SF와 판타지를 좋아하는데, 현실과 가상세계를 넘나드는 소설 없을까요?",
"창업 준비 중인데 스타트업 성공사례를 다룬 책을 찾고 있어요.",
"철학책을 처음 읽어보려고 하는데, 입문자가 읽기 좋은 책이 있을까요?",
"퇴사 후 새로운 삶을 준비하는 중인데, 인생의 방향을 찾는데 도움이 될 만한 책 있나요?",
"육아로 지친 마음을 위로받을 수 있는 책을 찾고 있어요.",
"무라카미 하루키 스타일의 미스터리 소설 없을까요?",
"'사피엔스'를 재미있게 읽었는데, 비슷한 책 추천해주세요.",
"우울할 때 읽으면 좋은 따뜻한 책 추천해주세요.",
"의욕이 없을 때 동기부여가 될 만한 책 없을까요?"
]
/// 1번째 게시글 참고: 최종 매칭된 도서가 초기 질문에 적합한 도서인지 판단하고 0과1 중 하나를 반환합니다.
func accurancyTester(question: String, title:String) async -> Int {
// ...
}
func testOptimizeParameters() async throws {
var results: [TestResult] = []
/// 주입하는 변수들을 변경해가며 모든 경우의 수에 대해 도서 매칭 수행
for weights in weightOptions {
for thresholds in thresholdOptions {
for searchCount in searchCountOptions {
for maxRetries in maxRetriesOptions {
print("\nStarting test combination \(weights)-\(thresholds)-\(searchCount)-\(maxRetries)")
do {
let (accuracy, totalMatches, retries) = try await runTest(
weights: weights,
thresholds: thresholds,
searchCount: searchCount,
maxRetries: maxRetries
)
results.append(TestResult(
weights: weights,
thresholds: thresholds,
searchCount: searchCount,
maxRetries: maxRetries,
accuracy: accuracy,
totalMatches: totalMatches,
totalRetries: retries
))
print("""
Test completed:
- weights: \(weights)
- thresholds: \(thresholds)
- searchCount: \(searchCount)
- maxRetries: \(maxRetries)
- accuracy: \(accuracy)
- totalMatches: \(totalMatches)
- retryCounts: \(retries)
----------------------
""")
} catch {
print("Error in combination \(weights)-\(thresholds)-\(searchCount)-\(maxRetries): \(error)")
continue
}
/// 네트워크 연결 과부하로 인한 타임스탬프 최대개수 도달을 방지하고자 api 호출 간격 증가
try await Task.sleep(nanoseconds: 1_000_000_000) //
}
}
}
}
// 결과 분석 및 최적 파라미터 도출
let sortedResults = results.sorted { $0.accuracy > $1.accuracy }
let bestResult = sortedResults[0]
print("""
최적 파라미터 조합:
- weights: \(bestResult.weights)
- thresholds: \(bestResult.thresholds)
- searchCount: \(bestResult.searchCount)
- maxRetries: \(bestResult.maxRetries)
- 최종 정확도: \(bestResult.accuracy)
- 총 매칭 수: \(bestResult.totalMatches)
""")
}
/// 기존사용한 테스트 메서드
private func runTest(
weights: [Double],
thresholds: [Double],
searchCount: Int,
maxRetries: Int
) async throws -> (accuracy: Double, totalMatches: Int, retries: [Int]) {
sut = EnhancedBookSearchManager(
titleStrategy: LevenshteinStrategyWithNoParenthesis(),
authorStrategy: LevenshteinStrategy(),
weights: weights,
initialSearchCount: searchCount,
threshold: thresholds,
maxRetries: maxRetries
)
var cnt = 0
var total = 0
var retries = [Int]()
for question in questions {
do {
/// 네트워크 연결 과부하로 인한 타임스탬프 최대개수 도달을 방지하고자 api 호출 간격 증가
try await Task.sleep(nanoseconds: 2_000_000_000)
let (validBooks, retryCount) = try await sut.recommendBookFor(question: question, ownedBook: [])
total += validBooks.count
retries.append(retryCount)
for book in validBooks {
let myBool = await accurancyTester(question: question, title: book.title)
if myBool == 1 { cnt += 1 }
}
print("question: \(question) proceeded")
} catch {
print("Error during test: \(error)")
}
}
return (Double(cnt)/Double(total), total, retries)
}
이후 도서매칭 시스템 클래스에 외부로부터 주입되어야하는 변수들에 배리에이션을 두어, 총 16가지 경우의 수에 대해 정확도 측정을 진행하였습니다.
보시면 모든 테스트를 종료하기까지 약 56분의 시간이 소요된 것을 확인하실 수 있는데요. 이는 도서 매칭 과정 간에 발생하는 api호출 횟수, 그리고 속도제한 초과 에러를 방지하기 위해 api 호출 간 삽입한 Task.sleep에 의한 소요시간입니다.
16가지 경우의 수에 대한 정확도 분포를 분석한 결과 흥미로운 패턴을 확인할 수 있었는데요.
먼저 테스트에 대한 로그 내용에 대해 설명드리겠습니다.
Weights: 최종 반환 책 데이터 선정을 위한 유사도 연산 간에 제목 vs 저자가 차지하는 비중입니다.
Thresholds: GPT가 초기 반환한 책이 존재하는 책인지 여부를 판단하기 위한 기준 임계깂을 의미합니다.
Search Count: 네이버 책검색 api로부터 단일 검색에 대해 반환받는 상위 검색결과 수를 의미합니다.
Max Retries: GPT 호출 간, 존재하지 않는 책이거나 형식 미준수로 인한 에러 발생 시, 재시도할 수 있는 최대 횟수를 의미합니다.
Accurancy: 챗지피티로부터 매칭된 도서가 질문에 적합하다고 판정된 비율을 의미합니다.
TotalMatches: GPT가 반환한 결과값들 중 아무런 에러 없이 정상적으로 도서가 최종 매칭된 횟수를 의미합니다.
Retry Counts: 각 질문 별 GPT 초기추천과 추가 추천에 대한 누적 재시도 횟수를 의미합니다.
Weights: [0.8,0.2] 조합의 경우 평균 95% 정확도를 보였으며, [0.5,0.5] 조합의 경우 평균 89% 정확도를 보였습니다.
Thresholds: [0.4,0.8] 조합의 경우 평균 93.3% 정확도를 보였으며, [0.7,0.8] 조합의 경우 평균 88.7% 정확도를 보였습니다.
*제목에 대한 임계값을 0.7로 설정할 경우, 성능 변동이 0.4일때보다 더 컸는데, 이는 부제 및 상세내용으로 인해 유사도가 낮게 측정되는 결과를 포용하지 못하였다고도 볼 수 있습니다.
Search Count: 상위 검색결과를 더 많이 비교대상에 포함시킬 경우, GPT가 추천한 책이 네이버 책 DB의 하위권에 있는 경우를 커버할 수 있을 것이라는 가설을 세워, SearchCount를 기존 10에서 20으로 늘려서도 테스트해보았는데요. 실제 결과의 경우, 10과 20 모두 90% 이상의 정확도를 보이면서 유의미한 차이를 이끌어내지 못하는 것을 확인했습니다. 따라서 네트워크 호출 비용 절감을 위해 10으로 fix하기로 했습니다.
Max Retries: 재시도 횟수가 늘어나면 GPT로부터 존재하는 책을 반환할 확률이 더 늘어날 것이다라고 가설을 세워 3회에서 2회 더 늘린 5회를 테스트 케이스에 추가하게 되었지만, 이 역시도 유의미한 결과를 이끌어내지 못한 것을 확인했습니다. 오히려 오차범위 내이지만, 3회 재시도 시 정확도가 더 높았습니다... 따라서, 재시도 회수 역시 3회를 유지하기로 했습니다.
6. 도서 매칭 시스템 정확도 검증 및 개선 결과
앞서 진행한 테스트를 통해 존재하는 책에 대한 임계값을 미세 조정한 이후, 시스템의 안정성과 일관성을 검증하고자, 테스트환경을 아래와 같이 구현했습니다.
let questions = [
"요즘 스트레스가 많은데, 마음의 안정을 찾을 수 있는 책 추천해주세요.",
"SF와 판타지를 좋아하는데, 현실과 가상세계를 넘나드는 소설 없을까요?",
"창업 준비 중인데 스타트업 성공사례를 다룬 책을 찾고 있어요.",
"철학책을 처음 읽어보려고 하는데, 입문자가 읽기 좋은 책이 있을까요?",
"퇴사 후 새로운 삶을 준비하는 중인데, 인생의 방향을 찾는데 도움이 될 만한 책 있나요?",
"육아로 지친 마음을 위로받을 수 있는 책을 찾고 있어요.",
"무라카미 하루키 스타일의 미스터리 소설 없을까요?",
"'사피엔스'를 재미있게 읽었는데, 비슷한 책 추천해주세요.",
"우울할 때 읽으면 좋은 따뜻한 책 추천해주세요.",
"의욕이 없을 때 동기부여가 될 만한 책 없을까요?"
]
func test_Accurancy() async throws {
let client = DefaultAPIClient(configuration: config)
let module = BookMatchModule(apiClient:client)
var cnt = 0
var total = 0
var results = [Double]()
for id in 0 ..< 5 {
for question in questions {
do {
let input = BookMatchModuleInput(
question: question,
ownedBooks: []
)
let result = try await module.processBookRecommendation(input)
total += result.newBooks.count
XCTAssertTrue(!result.description.isEmpty)
XCTAssertTrue(!result.newBooks.isEmpty)
for book in result.newBooks {
let myBool = await accurancyTester(question: question, title: book.title)
if myBool == 1 { cnt += 1 }
}
} catch {
print("Error during test: \(error)")
}
}
let acc = Double(cnt)/Double(total)
print("accurancy in \(id+1)th try: \(acc)")
results.append(acc)
}
print("total accurancy: \(results.reduce(0.0,+) / 5.0)")
}
개선 결과
패키지화와 구조 개선 후 최종 정확도 테스트를 한 결과, 아래 수치들을 검출할 수 있었습니다.
초기 정확도: 58.3%
개선 후 정확도: 92.8%
향상률: 34.5%
특히 GPT가 제시한 도서가 실제 검색 가능한 도서가 아닐 수 있다는 제약 조건을 고려할 때, 92.8%의 정확도는 매우 유의미한 결과라고 판단됩니다.
도서 매칭 시스템의 정확도 개선 요인들은 아래와 같이 정리해볼 수 있습니다.
1. 유사도 계산 알고리즘 개선
- 대상 간 텍스트들이 포함관계인지를 통해 참,거짓만을 반환하는 ContainsStrategy 대신 0에서 1 사이 소수로 정규화해 유사도를 반환할 수 있는 레벤슈타인 알고리즘으로 검색결과 간 변별력 극대화하였습니다.
- 상세내용이 포함된 괄호 내부 내용을 유사도 비교대상에서 제외하였습니다.
- 정상/비정상 매칭 사례들의 유사도 패턴을 통해 출판사의 변별력 부제를 확인하였으며, 이를 토대로 출판사의 비중을 과감히 배제했습니다.
2. 검색 범위 확장
- 기존 제목 기반 검색에 저자 기반 검색을 추가해 유사도 비교대상을 늘렸습니다.
- 특수문자를 기준으로 부제목을 구분하고 네이버 책검색결과가 나오지 않을 경우, 부제목을 제외한 주제목으로 다시 검색해, 책 검출확률을 높히고자 했습니다.
3. 재시도 매커니즘
- GPT api 와 네이버 책 검색 api 호출 간 발생할 수 있는(특히 GPT...) 변수들에 대한 복원력을 확보하기 위해 3회 재시도 로직을 추가하였으며, 최종 3회 실패 시에도 가장 높은 유사도를 보유했던 도서를 반환함으로써 단계적 대응 전략을 수립하였습니다.
4. GPT 프롬프트 최적화
- 정확한 형식을 명시하여, GPT api 호출 간 변수를 최소화했습니다.
이러한 요소들이 복합적으로 작용하여 34.5%의 정확도 향상을 달성할 수 있었습니다.
이상으로 GPT와 네이버 책 검색 api를 결합한 도서 매칭 시스템 구현하기 글을 마치겠습니다.
감사합니다.
'개발지식 정리 > Swift' 카테고리의 다른 글
BookMatchKit 리펙토링 과정-1/3 (Strategy 패턴, Adapter 패턴, Facade 패턴) (0) | 2025.02.17 |
---|---|
Vision 프레임워크를 활용한 도서 표지 이미지 매칭 구현하기 (2) | 2025.02.16 |
ChatGPT와 네이버 책검색 api로 도서추천 시스템 구현하기(구현 과정 2/3) (1) | 2025.01.25 |
ChatGPT와 네이버 책검색 api로 도서추천 시스템 구현하기(설계 과정 1/3) (0) | 2025.01.23 |
클린 아키텍처 도입기 (0) | 2025.01.15 |