Neoself의 기술 블로그

ChatGPT와 네이버 책검색 api로 도서매칭 시스템 구현하기(고도화 과정 3/4) 본문

개발지식 정리/Swift

ChatGPT와 네이버 책검색 api로 도서매칭 시스템 구현하기(고도화 과정 3/4)

Neoself 2025. 1. 26. 19:24

1. 기존 시스템의 한계점 및 기존 시스템 플로우 개선

초기 도서 매칭 시스템은 GPT가 제공하는 도서 정보의 정확성을 전제로 설계되었습니다. 시스템은 단순히 제목, 저자, 출판사 간의 텍스트 유사도 비교에만 중점을 두었습니다. 하지만 테스트 과정에서 GPT가 실제로 존재하지 않는 도서를 추천하는 케이스가 상당수 발견되었습니다. 따라서, 기존 로직 흐름에 재시도 로직을 추가해, 존재하지 않는 책에 대한 매칭이 이뤄지는 것을 최소화하고자 했습니다.

개선된 시스템 플로우 계획

1. 책 추천에 대한 질문에 대해 GPT가 1~3개의 책 데이터 반환

2. 각 책 데이터에 대해 재시도 횟수 3회로 두고 존재하는 책이 나올때까지 아래 내용반복수행 <- GPT가 존재하는 책을 제시할때까지 반복수행하는 로직 추가

        1. 제목과 저자를 네이버 책 검색 api query에 전달 후, 상위 20개의 검색결과들 요청

        2. 20개의 검색결과 데이터에 대해
       
         1
.
 GPT가 반환한 제목 vs 검색결과 제목 유사도 측정

                2. GPT가 반환한 저자 vs 검색결과 저자 유사도 측정

                3GPT가 반환한 출판사 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 matchToRealBook(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로 합니다.
        if let result = results.first, result.value[0]>=0.42, result.value[1]>0.80 {
            return (
                isMatching: true,
                book: finalBook,
                similarities:similarities)
        } else {
        /// 임계값 기준을 상회할 경우, 정상 매칭되어 반환된 책으로 판단하고, isMatching을 false로 합니다.
            print("Failed value for \(searchedResults[results[0].index].title):\(results.first?.value)")
            return (isMatching:false,
                    book:finalBook,
                    similarities:similarities)
        }
    }

하지만, 반환값에 정상매칭여부를 의미하는 isMatching 불린값을 포함시켜, 임계값과의 대소관계를 분석해 정상 매칭여부와 유사도가 가장 높은 책 같이 반환하도록 했습니다.

 

matchToRealBook() 전체 코드

더보기
더보기
private func matchToRealBook(from sourceBook: RawBook) async throws -> (isMatching: Bool, book: BookItem?, similarities: [Double]) {
    /// 각 책을 유사도와 매핑시켜 저장하기 위한 튜플 배열을 생성합니다.
    var results = [(index:Int,value:[Double])]()

    /// 여기서 0개 searchedResult 나오면 isMatching을 false로 반환합니다.
    guard let searchedResults = try await getSearchResults(from: sourceBook) else { 
        return (isMatching:false, book:nil, similarities:[0.0,0.0,0.0]) 
    }

    /// 각 책마다 누적 유사도 값을 계산하고 배열에 id값과 함께 저장합니다.
    for (index, searchedBook) in searchedResults.enumerated() {
        let similarities = calculateOverAllSimilarity(for: searchedBook, from: sourceBook)
        results.append((index:index, value: similarities))
    }

    /// 가장 유사도가 높은 책 검출 위해 sort를 실행합니다.
    results.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]
        }
    })

    let finalBook = searchedResults[results[0].index]
    let similarities = results[0].value

    /// 임계값 기준을 상회할 경우, 정상 매칭되어 반환된 책으로 판단하고, isMatching을 true로 합니다.
    if let result = results.first, result.value[0]>=0.42, result.value[1]>0.80 {
        return (
            isMatching: true,
            book: finalBook,
            similarities:similarities)
    } else {
    /// 임계값 기준을 상회할 경우, 정상 매칭되어 반환된 책으로 판단하고, isMatching을 false로 합니다.
        print("Failed value for \(searchedResults[results[0].index].title):\(results.first?.value)")
        return (isMatching:false,
                book:finalBook,
                similarities:similarities)
    }
}

3.2. GPT에게 새로운 책 데이터 요청하는 메서드 구현

이러한 재시도 로직에 핵심적인 것은 바로 ChatGPT로부터 새로운 책에 대한 정보를 요청하는 함수 구현이였습니다. 같이 전달된 기존 추천되었던 도서 배열을 제외하고, 주어진 질문에 적합한 새로운 도서의 제목-저자-출판사를 반환하는 메서드를 구현해 존재하지 않는 책이 최종반환될 확률을 최소화하고자 했습니다.

 

getAdditionalBookFromGPT 메서드 코드

더보기
더보기
func getAdditionalBookFromGPT(for question: String, from previousResults: [RawBook]) async throws -> RawBook {
    let maxRetries = 3
    var retryCount = 0

    while retryCount < maxRetries {
        do {
            // 1. 환경변수 검증
            guard
                let system = loadEnv()?["ADDITIONAL_PROMPT"],
                let openAIApiKey = loadEnv()?["OPENAI_API_KEY"],
                let url = URL(string: "https://api.openai.com/v1/chat/completions") else {
                print("GPT Error in getAdditionalBookFromGPT")
                throw GPTError.noKeys
            }
            
            // 2. API 요청 구성
            let requestBody: [String: Any] = [
                "model": "gpt-4",
                "messages": [
                    ["role": "system", "content": system],
                    ["role": "user",
                     "content": "질문: \(question)\n기존 도서 제목 배열: \(previousResults.map{$0.title})"]
                ],
                "temperature": 0.01,
                "max_tokens": 500
            ]
            
            // 3. API 호출 및 응답 처리
            var request = URLRequest(url: url)
            request.httpMethod = "POST"
            request.addValue("Bearer \(openAIApiKey)", forHTTPHeaderField: "Authorization")
            request.addValue("application/json", forHTTPHeaderField: "Content-Type")
            request.httpBody = try JSONSerialization.data(withJSONObject: requestBody)
            
            // 3. API 호출 및 응답 처리
            let (data, _) = try await URLSession.shared.data(for: request)
            let response = try JSONDecoder().decode(ChatGPTResponse.self, from: data)

            // 4. 응답 데이터 검증
            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])
        } catch {
            retryCount += 1
            print("Retry attempt \(retryCount): \(error.localizedDescription)")
            continue
        }
    }
    
    // 최대 재시도 횟수 도달 시 최종 에러 전파
    throw GPTError.invalidResponse
}

해당 메서드 또한 GPT로부터 반환되는 값을 활용하는 메서드인만큼, GPT 반환 데이터에 대한 무결성 보장을 위해 GPT 오류에 대한 엣지케이스 처리가 필요합니다. 이를 위해 재시도 횟수를 3회 설정해, GPT의 잘못된 포맷 반환으로 인한 index out of range 문제를 방지하고자 했습니다.

전체 메서드에 throws 키워드를 선언한 후, while문 안에, do catch 패턴을 사용함으로써, 프롬프트 및 키값 부재, GPT 반환값 타입 불일치 등 여러 상황의 에러에 대해 모두 일관된 처리 흐름을 명시하였으며, 재시도를 최대횟수로 수행하여도 포맷 불일치가 계속 발생할 경우, 최상단 메서드에 에러를 전달해 최상단 메서드의 catch문으로 호출자 위치를 이동시키고자 했습니다.

 

재시도 처리 구현 완료 후, 테스트케이스 설계 및 실행을 통해 정상동작 여부를 확인했습니다.

더보기
더보기
func testGetAdditionalBookFromGPT() async {
    do {
        sut = 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: []
        )
        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를 활용하면서 발생하는 변수들에 대한 예외처리였습니다. 이는 아래와 같이 정리할 수 있습니다.

  1. 최상단 메서드에서 재시도를 최대횟수만큼 시도했음에도 임계값을 상회하는 책이 끝내 반환되지 않았을 경우, 사용자 입장에서는 질문에 대해 아무런 책도 확인할 수 없는 상황이 발생할 수 있습니다.
    이를 방지하고자 isMatching이 false였을때에도 유사도가 가장 높은 책을 같이 반환하도록 반환타입을 수정하였으며, 같이 반환되었던 유사한 책 3권 중 가장 유사도가 높은 책을 최종 선택하는 로직을 구현하였습니다.
  2. 동일한 질문에 대해 2개 이상의 GPT 반환 책에서 재시도 로직이 호출될 경우, 동일한 책이 반환되어 같은 질문에 대해 중복된 책 데이터가 저장될 수 있습니다.
    이를 방지하기 위해, 질문에 대한 전체 컨텍스트에서 GPT로부터 반환받은 책 제목들에 대한 배열을 생성해, 재시도 로직에 같이 전달하였습니다.
  3. 2번째 예외처리를 하였음에도, LLM모델 특성상 동일한 책 데이터모델을 제시할 가능성이 존재합니다.
    따라서 최종 반환 시, 중복 데이터를 제거하는 추가 처리를 진행해 사용자에게 혼란을 주지 않고자 했습니다.
  4. 초기 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회를 유지하기로 했습니다.

 

다음 게시글은 이를 패키지화하는 내용을 다루도록 하겠습니다.