하이브리드 검색

본 블로그 글에서는 전통적인 검색 방식의 한계를 극복하고, 키워드 검색과 시맨틱 검색을 결합한 하이브리드 검색에 대해 자세히 알아봅니다.

Contents


전통적인 방식의 검색

  전통적인 검색은 주로 키워드 검색을 의미합니다. 정확한 단어가 매칭되었을 때 해당 결과를 반환하는 방식입니다. 가장 간단한 구현 방법은 SQL의 LIKE 구문을 사용하는 것입니다. 하지만 시스템이 고도화되면서 SQL 검색 기능의 다양한 한계가 드러나게 됩니다. 이러한 한계는 데이터베이스 성능상의 한계와 검색 기능상의 한계로 나눌 수 있습니다.


데이터베이스 성능상의 한계

SELECT *
FROM courses
WHERE title LIKE 'java%'


  먼저 서버 성능상의 한계는, 일반적인 관계형 데이터베이스의 인덱스 구조의 한계로 발생합니다. 위의 SQL 예시에서 title 컬럼에 인덱스를 걸 수 있지만, 인덱스 구조의 특성상 키워드로 시작하는 검색어에 대해서만 효율적으로 검색할 수 있습니다. 강의 제목값을 가나다/알파벳 순서대로 정렬하기 때문에, 키워드로 시작하는 단어 의 검색에만 유리하기 때문이죠.

SELECT *
FROM courses
WHERE title LIKE '%java%'

검색 기능에서 가장 일반적으로 사용되는 부분일치 키워드 검색 SQL 은 인덱스의 도움을 받을 수 없습니다.


검색 기능상의 한계

  SQL 로 구현한 검색 기능의 경우, 기능적으로도 아쉬운 점들이 있습니다. SQL 의 LIKE 절의 경우, 단순히 키워드가 포함되었는지, 포함되지 않았는지만을 판단해 줍니다. 하지만 통합 검색 페이지 등을 구현할 때에는, 이외에도 다양한 기능들이 요구됩니다. 다음은 고도화된 검색 기능에서 요구되는 대표적인 예시들입니다.


고도화된 키워드 검색

  위의 한계점들을 해결하기 위해, "검색엔진"이라고 불리는 기술들이 사용되기 시작했습니다. 단순한 텍스트 검색을 벗어나 동의어, 가중치 부여 등의 기능이 활용되기 시작했습니다. ElasticSearch, Mongo Atlas Search, AWS OpenSearch 등을 활용하면 검색엔진을 쉽게 연동하여 사용할 수 있습니다.

  하지만 이러한 검색엔진의 경우에도 데이터에 포함된 검색어를 찾는다 라는 측면에서는 전통적인 방식의 검색과 유사합니다. 그렇다면 어떻게 더 똑똑한 검색엔진을 만들 수 있을까요 ?

  LLM 서비스가 발전하면서, 유저들은 점점 더 REPL(Read-Eval-Print Loop) 를 익숙하게 받아들이고 있습니다. 기존의 구글 검색에서는 키워드 검색이 익숙한 패턴이었지만, LLM 의 시대에는 그렇지 않습니다. 새로운 시대의 검색엔진에서는 사용자의 의도를 파악한 후, 키워드를 추출한 후 검색을 해야 합니다. 어떻게 보면, RAG(Retriever, Augment, Generator)의 뒤집힌 방향이라고 할 수 있습니다. 자유롭게 입력된 사용자의 검색 쿼리를 분석하여 키워드를 분석하고, 이를 통해 검색엔진에서 키워드 검색을 진행할 수 있습니다.


reverse-rag-search.png

하이브리드 검색

  위의 도식에 나온 검색의 형태는 LLM과 결합된, 고도화된 검색의 형태입니다. LLM 서비스와 안정적으로 통합된다면, 위와 같은 검색의 형태를 선택할 수 있습니다. 하지만 완전한 자연어 검색을 지원하지 않고 키워드 검색을 하더라도, 사용자의 의도를 파악하여 검색하려면 어떤 방법을 선택할 수 있을까요?

  하이브리드 검색은 키워드 검색과 시멘틱 검색을 혼합하여 유저가 의도한 검색의 정확도를 더 넓히는 방식입니다. 자연어 검색과 키워드 검색의 중간 단계에 존재하는 검색 형태라고 할 수 있습니다.

  기존의 키워드 기반 검색에 추가로, 데이터를 벡터로 변환하여 저장하고 검색 키워드도 벡터로 변환한 후 유사도 검색을 함께 수행하면 됩니다. 여기서 벡터는 단어나 문장의 의미를 숫자로 표현한 것이라고 생각해 주시면 됩니다. 예를 들어, 다음의 단어들에 대해 살펴봅시다.

강아지, 개, 고양이, 자동차

[강아지, 개]는 서로 유사한 의미의 단어입니다. 고양이는 그 다음으로 [강아지, 개]와 의미적으로 가깝고, 자동차는 가장 먼 단어입니다. 벡터는 이러한 단어들 간의 의미적인 관계를 수치로 표현한 값입니다.

"강아지" → [0.2, -0.1, 0.8, 0.3, -0.5, ...]
"개"     → [0.3, -0.2, 0.7, 0.4, -0.4, ...]
"고양이" → [-0.1, 0.6, 0.2, -0.3, 0.8, ...]
"자동차" → [0.9, 0.1, -0.6, 0.2, 0.1, ...]


그렇다면 어떻게 단어들을 벡터로 변환할 수 있을까요 ? 가장 익숙하고 쉽게 이용하는 방식은 OpenAI 의 Vector Embedding API 를 사용하는 것입니다. 다음은 embedding 을 생성하는 예시 코틀린 코드입니다.

import com.openai.client.OpenAIClient
import com.openai.core.RequestOptions
import com.openai.models.embeddings.EmbeddingCreateParams
import com.openai.models.embeddings.EmbeddingModel
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.toJavaDuration

class OpenAIEmbeddingService(
    private val openAiClient: OpenAIClient,
) {

    /**
     * 특정한 텍스트를 바탕으로 Vector 숫자 리스트를 생성합니다.
     */
    fun generateEmbedding(text: String, timeout: Duration = 10.seconds): List<Float> {
        val params = EmbeddingCreateParams.builder()
            .input(text)
            .model(EmbeddingModel.TEXT_EMBEDDING_3_SMALL)
            .build()

        val requestOptions = RequestOptions.builder()
            .timeout(timeout.toJavaDuration())
            .build()

        val response = openAiClient.embeddings().create(params, requestOptions)

        return response.data().firstOrNull()?.embedding() ?: emptyList()
    }
}


위의 코드를 사용하면, 검색할 대상들을 수치화된 벡터 정보로 변환할 수 있습니다. 그렇다면 적재한 벡터 정보들을 어떻게 검색에서 활용할 수 있을까요 ? 구체적인 사용법은 검색엔진의 종류에 따라 다르지만, 이 글에서는 Mongo Atlas Search 를 기준으로 작성하겠습니다.


Vector Index 생성

  Vector 검색에서 사용할 Document 의 인덱스를 생성해야 합니다. 다음은 도큐먼트의 embedding 이라는 path 에 내적(Dot Product)을 활용한 벡터 인덱스를 설정하는 예시입니다.

{
  "type": "vector",
  "path": "embedding",
  "numDimensions": 1536,
  // 코사인, 유클리드, 내적(dot product)이 주로 사용됨. OpenAI 임베딩에서 권장하는 방식은 dot product 입니다.
  "similarity": "dotProduct"
}

이제 벡터 검색을 수행할 준비가 완료되었습니다. 그렇다면 어떻게 검색 쿼리를 작성할 수 있을까요? Mongo Atlas Search에서는 $vectorSearch aggregation을 사용해서 벡터 검색을 수행할 수 있습니다.

const results = await db.collection('products').aggregate([
    {
      "$vectorSearch": {
        "index": "vector_index",
        "path": "embedding",
        "queryVector": queryVector,
        "numCandidates": 100,
        "limit": 10
      }
    },
    {
      "$project": {
        "name": 1,
        "description": 1,
        "price": 1,
        "score": { "$meta": "vectorSearchScore" }
      }
    }
  ]).toArray();

이러한 방식으로 벡터 검색을 수행할 수 있습니다. 이제 벡터 검색을 수행했으므로, 기존의 키워드 검색 결과와 적절히 합쳐준다면, 하이브리드 검색이 완성됩니다. 이 때, 키워드 검색과 벡터 검색의 가중치(score) 를 분배하여, 검색의 정확도를 보정할 수 있습니다. 예를 들어, 벡터 검색과 키워드 검색에 3:7 가중치를 준다면, 다음과 같이 최종 score 를 도출하면 됩니다.

// 벡터, 키워드 검색에 score를 부여한다. score가 가장 높은 건이 가장 정확한 검색 결과가 된다.
score = vector_score * 0.3 + keyword_score * 0.7


앞으로

  지금까지 간단한 텍스트 검색, 키워드 검색, 그리고 키워드와 벡터 검색을 섞은 하이브리드 검색에 대해 간단히 알아보았습니다. 지금까지 알아본 하이브리드 검색에 OpenAI, Gemini 등을 엮어 LLM 까지 포함시킨다면, 완전한 자연어로 원하는 데이터를 쿼리할 수 있는 검색엔진을 만들 수 있을 것입니다.


참고자료


이것도 읽어보세요