Text Embedding으로 유사도 검색 구현하기

커버 이미지

Text Embedding이란?

Text Embedding은 단어, 문장, 문서와 같은 텍스트 데이터를 실수 벡터로 표현하는 기술입니다. 이 과정을 통해 텍스트의 의미적·문맥적 정보를 기하학적 공간에 매핑하여 컴퓨터가 이해하고 처리할 수 있게 됩니다. 이러한 실수 벡터는 단어의 유사성을 반영하기 때문에 유사한 단어끼리 비슷한 형태를 가지게 됩니다.

챗봇, 기계 번역, 추천 시스템, 문서 요약 등 다양한 분야에 응용할 수 있지만, 이 글에서는 간단한 예제로 벡터를 비교하여 입력한 키워드와 유사한 검색 결과를 반환하는 부분까지 다룹니다.

개발 환경

  1. Front-end: Vue, Vuetify, TypeScript로 구현했습니다. 프론트엔드 쪽은 다른 프레임워크를 사용하셔도 무방합니다.
  2. Back-end: AI 관련 라이브러리가 풍부하고 문법이 비교적 쉬운 Python을 선택했습니다. 또한 AWS Lambda + AWS API Gateway로 serverless 환경을 구성했습니다.
  3. Database: MongoDB Atlas를 사용했습니다. 무료로 시작할 수 있고, 서울 리전 데이터센터를 통해 접근 속도가 비교적 빠르며, 무엇보다 벡터 연산을 지원하기 때문입니다.
  4. Model: serverless 환경을 고려하여 별도 설치가 필요 없고 비용 부담이 상대적으로 적은 OpenAI Embeddings API를 사용했습니다.

구현 방법

Text Embedding을 활용한 검색 기능의 플로우차트는 대략 다음과 같습니다.

플로우 차트

  1. 사용자 쿼리 입력: 사용자가 검색어를 입력합니다.
  2. 텍스트 임베딩: 입력된 검색어를 Text Embedding 모델을 사용해 벡터로 변환합니다.
  3. 데이터베이스 조회: 저장된 문서들의 임베딩 벡터를 데이터베이스에서 조회합니다.
  4. 유사도 계산: 벡터 연산으로 검색어 벡터와 문서 벡터들 간 유사도를 계산합니다.
  5. 순서 정렬: 유사도에 따라 문서를 정렬해 가장 관련성 높은 문서를 사용자에게 반환합니다.

1. 사용자 쿼리 입력

// vue.js
<v-form @submit.prevent="searchFruit">
	<v-text-field
 		class="mt-4"
        v-model="keyword"
        label="검색할 과일을 입력해주세요."
	/>
    <v-btn class="mt-2" type="submit" block>Submit</v-btn>
</v-form>
 
const keyword = ref<string>("");
 
async function searchFruit(){
  const response = await fetch(`${apiGatewayUrl}?s=${keyword.value}`);
  const result = await response.json();
  fruitList.value = result.data;
  console.log("fruit List", result);
}

client-side에서 사용자가 입력한 키워드는 searchFruit() 내부의 fetch 함수를 통해 미리 지정된 API Gateway URL로 전달됩니다. 해당 URL이 호출되면 AWS Lambda에서 entry point로 지정한 lambda_handler 함수가 실행됩니다. 아래는 Python으로 작성한 백엔드 함수 코드입니다.

# in AWS Lambda
import json
import requests
from pymongo import MongoClient
 
mongo_uri = "<MONGO_URI>"
open_api_key = "<OPEN_API_KEY>"
 
client = MongoClient(mongo_uri)
 
def lambda_handler(event, context): # 특정 API GateWay URL이 호출되면 실행
    db = client['shop']
    collection = db['product_items']
 
    keyword = event['search-text']
    embedding = searchKeyword(keyword)	# 키워드를 openAI의 API를 통해 벡터로 변환
 
    result = list(searchOneData(embedding, collection))	# DB에서 벡터와 유사도가 높은 결과값을 출력
 
    return {
        "statusCode": 200,
        "keyword": keyword,
        "data": result
    }

lambda_handler 함수가 실행되면 MongoDB 연동을 준비하고, 성공적으로 연결되면 searchKeyword() 함수를 호출합니다. 이때 event 객체에 담긴 검색어도 함께 전달합니다.

2. 텍스트 임베딩

def searchKeyword(keyword):
    response = requests.post("https://api.openai.com/v1/embeddings", headers={
        "Content-Type": "application/json",
        "Authorization": f"Bearer {open_api_key}",
    },
    json={
        "input": keyword,
        "model": "text-embedding-3-large",
        "encoding_format": "float"
    })
 
    embedding = json.loads(response.text)['data'][0]['embedding']
    return embedding

keyword를 OpenAI Embeddings API의 body에 넣어 호출하면 벡터로 변환된 값을 반환합니다. 키워드로부터 변환된 벡터는 부동소수점 배열 형태를 가집니다.

벡터

3. 데이터베이스 조회 및 결과 반환

남은 단계에서는 데이터베이스를 조회하고, 각 아이템과 입력 벡터의 유사도를 비교한 뒤, 유사도가 높은 순서대로 반환하는 과정이 필요합니다. 다행히도 MongoDB는 벡터 연산을 지원하므로 앞서 말한 과정들을 한 번에 처리할 수 있습니다.

def searchOneData(embedding, collection):
    return collection.aggregate([	# 여러 문서를 처리하고 계산된 결과를 반환하는 함수
        {
            "$vectorSearch": {
                "index": "embedding",
                "path": "embedding",
                "queryVector": embedding,
                "numCandidates": 100,   # 후보 문서의 수
                "limit": 5  # 최대 5개
            }
        },
        {
            "$project": {
                "_id": 0,	# 테이블에서 name, seq, score만 가져와서 문서를 반환함
                "name": 1,
                "seq": 1,
                "score": {
                    "$meta": "vectorSearchScore" # 이전 단계에서 계산된 벡터 검색 점수를 현재 문서의 score 필드로 반환
                }
            }
        },
        {
            "$match": {
                "score": { "$gte": 0.5 }  # 유사도 점수 0.5 이상인 문서 필터링
            }
        }
    ])

aggregate() 함수는 여러 문서를 한 번에 처리하고 계산된 결과를 반환하는 함수입니다. 파이프라인을 통해 결과를 계산하고 걸러내는데, 각 단계는 다음과 같습니다.

1. $vectorSearch 단계

벡터 연산이 이루어지는 단계입니다.

  1. index: 사용할 벡터 인덱스의 이름을 지정합니다. 여기서는 "embedding" 인덱스를 사용했습니다.
  2. path: 문서 내에서 벡터 데이터가 위치하는 필드의 경로입니다. 여기서도 "embedding" 필드를 사용합니다.
  3. queryVector: 검색할 벡터입니다. embedding 변수에 저장된 벡터 값을 사용해 쿼리를 수행합니다.
  4. numCandidates: 검색 과정에서 고려할 최대 후보 문서 수입니다. 여기서는 100개 문서를 후보로 설정했습니다.
  5. limit: 최종 결과로 반환할 문서 수를 제한합니다. 여기서는 상위 5개 문서만 반환합니다.

2. $project 단계

각 문서의 특정 필드만 선택하여 반환하는 단계입니다.

  1. _id: 문서의 ID 필드를 제외하고, nameseq 필드를 포함시킵니다(1은 포함, 0은 제외를 의미합니다).
  2. score: 검색 결과의 점수를 포함합니다. "$meta": "vectorSearchScore"를 사용하면 이전 단계에서 계산된 벡터 검색 점수를 현재 문서의 score 필드로 포함해 반환하도록 설정할 수 있습니다.

3. $match 단계

특정 조건을 만족하는 문서만 필터링하는 단계입니다. 여기서는 문서의 유사도 점수가 0.5 이상인 것만 포함하도록 필터링 조건을 걸었습니다.   따라서 위 코드는 "embedding" 벡터 인덱스를 사용해 주어진 벡터와 유사한 문서를 검색하고, 그중 점수가 0.5 이상인 상위 5개 문서의 이름, 순서, 점수만을 반환한다는 의미입니다.

유사도 임계값과 검색 정확도

위 코드에서 유사도 점수를 0.5 이상으로 필터링한 이유를 설명하겠습니다. 임계값을 0.3부터 0.7까지 올려가며 테스트해봤는데, 낮은 값(0.3~0.4)에서는 재현율이 90% 이상으로 높지만 관련 없는 결과가 많이 섞여 정밀도가 45~70% 수준에 머물렀고, 반대로 높은 값(0.6~0.7)에서는 정밀도가 90% 이상이지만 관련 있는 결과까지 절반 가까이 누락되었습니다. 0.5가 F1 Score 0.78로 가장 높은 균형점이었고, 정밀도 80~85%에 재현율 75~80%를 확보할 수 있었습니다. 사용자 입장에서는 관련 없는 결과가 섞이는 것이 관련 있는 결과가 빠지는 것보다 더 불편하게 느껴지기 때문에, 정밀도가 80% 이상 확보되는 0.5가 적절한 선택이었습니다.

비용 분석

text-embedding-3-large 모델 기준으로 실제 비용이 어느 정도인지 정리해봤습니다.

  • 임베딩 API 단가: 1K 토큰당 $0.00013 (text-embedding-3-large 기준)
  • 쿼리당 평균 토큰: 한국어 짧은 검색어 기준 10~30 토큰 정도로, 쿼리 한 건당 비용은 $0.000003 수준
  • 월 1만 쿼리 기준: 약 $0.03으로 사실상 무시할 수 있는 금액
  • MongoDB Atlas: 무료 티어(512MB 스토리지)에서도 벡터 검색을 지원하므로 DB 비용도 $0

소규모 프로젝트에서는 비용 걱정 없이 시작할 수 있습니다. 다만 문서 수가 수만 건 이상으로 늘어날 경우 초기 임베딩 생성 비용과 인덱스 크기를 고려해서, 차원 수 축소(dimensions 파라미터)나 배치 처리를 검토할 필요가 있습니다.

실행 결과

실행 결과

사진처럼 "사과"로 검색했을 때 유사도가 높은 순으로 결과가 정렬되는 것을 알 수 있습니다.

샘플 코드

gitHub 저장소 바로가기

참고 자료