Neoself의 기술 블로그

텍스트 유사도 기반 도서 검색 매커니즘 구현하기 (3/3) 본문

개발지식 정리/Swift

텍스트 유사도 기반 도서 검색 매커니즘 구현하기 (3/3)

Neoself 2025. 1. 26. 19:24

GPT 추천 신뢰성 분석을 통한 최적 임계값 도출

0. 기존 도서 검색 시스템의 한계점

초기 시스템은 GPT가 제공하는 도서 정보의 정확성을 전제로 설계되었습니다. 검색 알고리즘은 단순히 제목, 저자, 출판사 간의 텍스트 유사도 비교에만 중점을 두었으나, 테스트 과정에서 GPT가 실제로 존재하지 않는 도서를 추천하는 케이스가 상당수 발견되었습니다.

1. 데이터 분석을 통한 개선 방향 도출

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의 표기 여부에 따라 실제로는 동일한 저자임에도 불구하고, 오히려 존재하지 않는 도서보다도 더 낮은 유사도 점수를 기록하는 경우도 확인할 수 있었습니다.

 

출판사 정보의 경우, 가장 낮은 변별력을 보였습니다. 정상적으로 매칭된 도서와 잘못 매칭된 도서 간에 유사도 점수 분포가 거의 동일한 패턴을 보여, 유의미한 판단 기준으로 활용하기에는 적절하지 않다고 판단되었습니다.

 

분석 결과를 바탕으로, 다음과 같이 최적화된 임계값을 도출하였습니다:

제목은 0.40을 하한선으로 정의

저자는 0.8로 정의

출판사는 고려대상에서 제외

 

 

2. 설정한 임계값을 중심으로  검색 매커니즘 고도화

앞서 설정한 임계값들을 토대로 예외처리와 재시도 로직을 고도화하였습니다.

기존에는 존재하는 검색결과들중 단순히 유사도점수가 가장 높은 책 결과만을 반환하는 메서드였습니다.

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)
    }

하지만, matchToRealBook에서 책 api로부터 비교대상이 되는 검색결과들을 반환받는 로직을 getSearchResults라는 별도 메서드로 캡슐화하여 분리하였습니다. 또한 기존 메서드에서는 반환값에 정상매칭여부를 의미하는 isMatching 불린값을 포함시켜, 임계값과의 대소관계를 분석해 정상 매칭여부와 유사도가 가장 높은 책을 반환하도록 했습니다.

 

getSearchResults(), matchToRealBook() 코드 구현부분

더보기
private func getSearchResults(from sourceBook: RawBook) async throws -> [BookItem]? {
        var searchedResults: [BookItem] = []
        /// 제목으로 네이버 책 api에 검색하여 나온 상위 책 10개를 반환받습니다.
        async let searchByTitle = fetchSearchResults(sourceBook.title)
        /// 저자로 네이버 책 api에 검색하여 나온 상위 책 10개를 반환받습니다.
        async let searchByAuthor = fetchSearchResults(sourceBook.author)
        
        let (searchByTitleResult, searchByAuthorResult) = try await (searchByTitle,searchByAuthor)
        
        searchedResults.append(contentsOf: searchByTitleResult)
        searchedResults.append(contentsOf: searchByAuthorResult)
        
        let subTitleDivider = [":","|","-"]
        
        if searchedResults.isEmpty {
            /// 예외처리 1: 검색결과 비어있는데 제목 내부에 특수문자 존재할 경우, 
            /// 다음단어는 부제라 판단하고 비교대상 최대한 가져오기 위해 주제목만 query로 검색
            if !subTitleDivider.filter({ sourceBook.title.contains($0) }).isEmpty {
                if let divider = subTitleDivider.first(where: { sourceBook.title.contains($0) }),
                   let title = sourceBook.title.split(separator: divider).first {
                    searchedResults = try await fetchSearchResults(String(title))
                }
            } else {
                /// 예외처리 2: 검색결과 비어있는데 특수문자도 없는 경우,
                /// matchToRealBook에서 guard 필터링에 걸리도록 nil 반환
                return nil
            }
        }
        return searchedResults
    }
    
    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)
        }
    }

임계값 비교를 통해 존재하지 않는 책임을 확인하였을 경우, 크게 두가지 방안이 있었습니다. 별도 조치를 취하지 않은채, 다른 GPT 반환 책 데이터모델의 매칭으로 넘어가는 방안과, 존재하는 책이 추출될 수 있을때까지, ChatGPT로부터 새로운 책 데이터를 반환받아 책 매칭을 반복하는 것이 있습니다.

여기서 저는 1번째 방안을 할 경우, GPT가 반환한 모든 책들에 대해 모두 매칭이 실패되어, 최종적으로 사용자에게 아무런 책이 보이지 않는 상황으로 이어질 수 있을 것이라 생각했습니다. 따라서 재시도 로직을 추가로 구현해, 반환되는 최종결과의 수를 늘리고자 했으며, 최대 재시도 횟수를 채웠음에도 존재하는 책이 끝까지 추출되지 않을 경우, 기존에 반환되었던 임시 책 데이터들 중 가장 유사도가 높은 데이터를 제시해 이러한 엣지 케이스에 대응하고자 했습니다.

 

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

더보기
private func getAdditionalBookFromGPT(for question:String, from previousResults: [RawBook]) async throws -> RawBook {
    guard let system = loadEnv()?["ADDITIONAL_PROMPT"], let openAIApiKey = loadEnv()?["OPENAI_API_KEY"] else {
        print("Prompt is missing")
        throw GPTError.noKeys
    }

    guard let url = URL(string: "https://api.openai.com/v1/chat/completions") else {
        throw GPTError.noKeys
    }

    let previewBookTitles = previousResults.map{$0.title}
    let prompt = "질문: \(question)\n기존 도서 제목 배열: \(previewBookTitles)"

    let requestBody: [String: Any] = [
        "model": "gpt-4o",
        "messages": [
            ["role": "system", "content": system],
            ["role": "user", "content": prompt]
        ],
        "temperature": 0.01,
        "max_tokens": 500
    ]

    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)

        let (data, _) = try await URLSession.shared.data(for: request)

        let response = try JSONDecoder().decode(ChatGPTResponse.self, from: data)

        if let result = response.choices.first?.message.content {
            let arr = result.split(separator: "-").map { String($0) }
            return RawBook(title: arr[0], author: arr[1], publisher: arr[2])
        } else {
            throw GPTError.invalidResponse
        }
    } catch {
        print("GPT Error :\(error)")
        throw GPTError.invalidResponse
    }
}

이후 앞서 캡슐화하였던 메서드들을 조합해 GPT에 질문을 입력하는 과정부터, 책 검색 api를 통해 책을 매칭하는 로직을 구현한 recommendBookFor라는 메서드를 새로 구현하였습니다.

더보기
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))
    }

위 메서드를 구현하면서 가장 신경썼던 요소는 바로 ChatGPT를 활용하면서 발생하는 변수들에 대한 예외처리였습니다. 이는 아래와 같이 정리할 수 있습니다.

1. ChatGPT로부터 추가추천을 하는 상황에 기존에 추가추천했던 책을 반복제시해 2개 이상 동일한 책이 배열에 저장되는 경우,질문에 대한 전체 컨텍스트에서 관리되고 공유되는 추천받은 책 제목 배열 생성하고 비교에 사용하고자 했습니다.

2. 3회 재시도를 반복하여도, 임계값을 상회하지 않는 책만 제시되었을 경우, 기존 isMatching이 false였을때 같이 반환되었던 유사한 책들 중 가장 유사도가 높은 책을 최종 배열에 추가하고자 했습니다.

3. previousBooks 배열을 통해 중복되는 추가추천을 방지하는 장치를 두어도 LLM모델 특성상, 동일한 책 데이터모델을 제시할 가능성이 존재합니다. 따라서 최종 반환 시, 중복 데이터를 제거하는 추가 처리를 진행해 사용자에게 혼란을 주지 않고자 했습니다.

 

3. 테스트 방식 변경

이전

최종검출 된 책의 제목과 GPT가 제시한 책 제목이 포함관계인가?

 

이후

최종 검출된 책의 상세정보가 초기 사용자 질문에 대해 부합한가?

 

때문에, XCTest 케이스 기준만을 위한 GPT 프롬프트 설계 및 메서드를 구현해 Test 케이스에서 호출하게 되었습니다.

이는 직접 테스트에 사용될 메서드가 아니기 때문에, test 접두사를 붙이지 않았습니다.

func accurancyTester(question: String, title:String, detail: String) async -> Int {
    let prompt = """
        질문: \(question)
        도서 제목: \(title)
        """

     let system = """
          당신은 전문 북큐레이터입니다. 도서의 제목을 보고, 질문에 적합한 도서인지 여부를 0이나 1로 표현해주세요:

          1. 입/출력 형식
          입력: 
          - 질문 (문자열)
          - 도서 제목: (문자열)

          출력: 0 또는 1
          - 0 : 책 제목이 질문에 대한 서적이 아님.
          - 1 : 책 제목이 질문에 적합함.

          2. 필수 규칙
          - 최근 한달 간 제일 많이 팔린 책과 같이 확인이 어려운 질문은 1로 반환
        """

    let requestBody: [String: Any] = [
        "model": "gpt-4o-mini",
        "messages": [
            ["role": "system", "content": system],
            ["role": "user", "content": prompt]
        ],
        "temperature": 0.01,
        "max_tokens": 100
    ]
    ...
}

보다 단순한 Task를 담당하고 있기 때문에, 책 추천 시와는 달리 GPT-4.0-mini api를 사용하기로 했으며, 도서의 제목과 상세정보를 통해 초기 사용자가 입력한 질문에 부합한지 여부를 0과 1중 하나를 반환하는 것을 목표로 프롬프트를 설계하여 메서드를 구현했습니다.

 

전체 질문과 비교하며 매커니즘의 정확도를 평가하게 되었기 때문에, 초기에 입력하게될 질문들 또한 재고려가 필요했습니다. "최근 한달간 가장 많이 팔린 책을 추천해줘"와 같은 질문과 책을 받게 될 경우, 진위여부를 정확히 구분하기 어렵다고 판단했기 때문입니다.

 

이제 어느정도 정확도도 높아졌기 때문에, 이제 GPT에 질문하는 것부터 시작해, 전체 플로우에 대한 성공 확률을 측정하기로 했습니다.

 

전체 플로우에 대한 테스트 진행하자!

이때 평균 정확률 높히기

더보기
func testAccurancy() async throws {
        var testData = [(String,String,String,String)]()
        // MARK: - test 케이스 수집
        for id in 0..<questions.count {
            viewModel.question = questions[id]
            await viewModel.getBookRecommendation()
            viewModel.recommendationFromUnowned.forEach{
                testData.append((questions[id],$0.title,$0.author,$0.publisher))
            }
        }
        
        sut = EnhancedBookSearchManager(
            titleStrategies: [(LevenshteinStrategyWithNoParenthesis(), 1.0)],
            authorStrategies: [(LevenshteinStrategy(), 1.0)],
            publisherStrategies: [(LevenshteinStrategy(), 1.0)],
            weights: [0.7, 0.3, 0.0],
            initialSearchCount: 10
        )
        
        var cnt = 0
        
        for (question,title,author,pulisher) in testData {
            try await Task.sleep(nanoseconds: 1_000_000_000 / 5) /// 책 검색 api 속도 제한 초과 방지
            let book = RawBook(title: title, author: author, publisher: pulisher)
            
            do {
                let _finalBook = try await sut.process(book)
                
                if let finalBook = _finalBook {
                    let isAccurate = await accurancyTester(
                        question: question,
                        title: finalBook.book.title,
                        detail: finalBook.book.description
                    )
                    
                    let debugDescription: String = """
                        질문에 적합한 책이 최종반환되지 않았습니다.
                        question: \(question)

                        책 유사도:
                        \(finalBook.similarities.map{String(format:"%.2f",$0)})

                        GPT가 제시한 제목-저자 -> 최종 검출된 책 제목-저자:
                        \(book.title)-\(book.author) -> \(finalBook.book.title)-\(finalBook.book.author)
                    """
                    
                    XCTAssertEqual(isAccurate, 1, debugDescription)
                    
                    if isAccurate == 1 {cnt+=1}
                } else {
                    print("failed for: \(book.title)-\(book.author)")
                }
                
            } catch {
                print("\(error) in \(book.title) \(book.author) \(book.publisher)")
            }
        }
        
        print("최종 정확도: \(Double(cnt)/Double(testData.count))")
    }