단 160줄의 코드로 구현하는 강력한 의미 기반 검색 엔진
PartyKit과 Cloudflare Vectorize를 활용하여 단 160줄의 코드로 강력한 의미 기반(Semantic) 검색 엔진을 구축하는 방법을 소개합니다.
김테크
8년차 개발자
안녕하세요, 8년차 개발자 김테크입니다.
백엔드 개발을 하다 보면 가장 까다로운 요구사항 중 하나가 바로 '검색' 기능입니다. 단순히 텍스트를 매칭하는 것을 넘어, 사용자의 의도를 파악하고 연관된 결과를 보여주는 것은 전통적인 데이터베이스나 단순한 엘라스틱서치 설정만으로는 꽤 많은 튜닝이 필요한 작업이었습니다.
하지만 최근 AI 기술의 발전으로 이 검색 영역의 진입 장벽이 대폭 낮아졌습니다. 오늘은 PartyKit과 Cloudflare의 Vectorize를 활용해, 단 160줄 정도의 코드로 '터무니없이 성능이 좋은' 검색 엔진을 구축하는 방법을 소개해 드리려고 합니다.
이 기술이 얼마나 강력한지 보여주는 예시가 있습니다. BBC Radio 4의 프로그램 아카이브를 다루는 사이드 프로젝트 'Braggoscope'의 사례입니다. 여기서 사용자가 "Jupiter(목성)"를 검색하면 당연히 목성 관련 에피소드가 나옵니다. 놀라운 점은 "the biggest planet(가장 큰 행성)"이라고 검색해도 목성 에피소드가 최상단에 뜬다는 것입니다. 키워드가 일치하지 않아도 의미를 파악해 찾아주는 것, 이것이 바로 시맨틱 검색(Semantic Search)입니다.
이 과정을 구현하기 위해 우리는 두 가지 핵심 개념을 이해해야 합니다. 바로 임베딩(Embedding)과 벡터 데이터베이스(Vector Database)입니다.
임베딩 모델은 텍스트(단어, 문장, 문서)를 벡터, 즉 숫자의 배열로 변환해 줍니다. 이를 의미 공간(Semantic Space)상의 좌표라고 생각하시면 편합니다. 2차원 그래프에서 x, y 좌표가 가까우면 두 점이 가까운 것처럼, 약 1,000차원의 공간에서 두 벡터가 가깝다면 그 의미가 유사하다는 뜻입니다.
따라서 검색 엔진을 만드는 과정은 다음과 같이 단순화됩니다.
모든 문서를 임베딩 모델을 통해 벡터로 변환하여 벡터 데이터베이스에 저장합니다(인덱싱).
사용자가 검색어를 입력하면, 그 검색어 역시 벡터로 변환합니다.
데이터베이스에서 검색어 벡터와 가장 거리가 가까운 문서 벡터를 찾습니다.
전통적인 DB는 이런 거리 계산에 취약하기 때문에 전용 벡터 데이터베이스를 사용합니다.
그럼 실제로 구현해 보겠습니다. 여기서는 PartyKit을 사용합니다. PartyKit은 최근 벡터 데이터베이스(Cloudflare Vectorize)와 AI 모델(Cloudflare Workers AI)을 아주 쉽게 통합할 수 있는 기능을 추가했습니다.
먼저, 프로젝트를 생성하고 필요한 라이브러리를 설치합니다. Remix 스타터 템플릿을 사용하면 웹 UI까지 한 번에 구성할 수 있어 편리합니다.
다음으로 벡터 데이터베이스를 생성해야 합니다. 터미널에서 간단한 명령어로 생성할 수 있습니다. 여기서 `--preset` 플래그를 통해 사용할 임베딩 모델에 맞는 차원 수를 자동으로 설정해 줍니다.
데이터베이스가 준비되었다면 `partykit.json` 설정 파일에 이를 연결해 줍니다.
{
"parties" : {
"search" : "party/search.ts"
},
"vectorize" : {
"searchIndex" : "braggoscope-index"
},
"ai" : true
}위 설정에서 `ai: true`는 임베딩 모델에 접근하겠다는 의미이며, `vectorize` 항목은 우리가 생성한 인덱스를 `searchIndex`라는 이름으로 코드 내에서 사용하겠다는 선언입니다.
이제 가장 중요한 인덱싱 로직을 살펴보겠습니다. 원본 데이터(예: 에피소드 목록 JSON)를 가져와 벡터화하고 저장하는 코드입니다. `party/search.ts` 파일에 작성되는 핵심 로직은 다음과 같습니다.
async index(episodes: Episode[]) {
// 1. 에피소드 설명 텍스트를 임베딩(벡터)으로 변환합니다.
const { data: embeddings } = await this.ai.run(
"@cf/baai/bge-base-en-v1.5",
{
text: episodes.map((episode) => episode.description),
}
);
// 2. 벡터 데이터와 원본 메타데이터를 결합합니다.
const vectors = episodes.map((episode, i) => ({
id: episode.id,
values: embeddings[i],
metadata: {
title: episode.title,
published: episode.published,
permalink: episode.permalink,
},
}));
// 3. 벡터 데이터베이스에 저장(Upsert)합니다.
await this.room.context.vectorize.searchIndex.upsert(vectors);
}코드를 보시면 `this.ai.run(...)` 메서드가 핵심입니다. Cloudflare의 AI 모델을 호출하여 텍스트 배열을 벡터 배열로 변환해 줍니다. 그리고 변환된 벡터(`values`)와 나중에 화면에 보여줄 메타데이터(`metadata`)를 묶어 `upsert` 메서드로 저장하면 인덱싱 과정이 끝납니다.
이 방식이 매력적인 이유는 인프라 관리의 복잡함이 거의 없다는 점입니다. 별도의 무거운 검색 서버를 띄우거나, 임베딩을 위해 파이썬 서버를 따로 관리할 필요가 없습니다. 단지 몇 줄의 설정과 코드로 강력한 시맨틱 검색 엔진이 완성되는 것이죠.
검색 쿼리 로직은 위 인덱싱 과정의 역순입니다. 사용자의 검색어를 `this.ai.run`으로 벡터화한 뒤, `vectorize.searchIndex.query()` 메서드를 사용해 가장 가까운 벡터들을 찾아오면 됩니다.
과거에는 수개월이 걸렸을지도 모를 수준의 검색 품질을 이제는 주말 사이드 프로젝트로 뚝딱 만들어낼 수 있는 세상이 되었습니다. 여러분의 서비스에도 이런 '의미 기반 검색'을 도입해 사용자 경험을 한 단계 업그레이드해 보시는 건 어떨까요?
오늘도 즐거운 개발 되세요!