메인 콘텐츠로 이동

Cloudflare Vectorize + Workers AI로 시맨틱 검색 만들기

·-
링크 복사 완료!
CloudflareWorkers AIVectorizeSearchNext.js
Cloudflare Workers AI + Vectorize 시맨틱 검색

블로그에 검색 기능을 처음 붙일 때는 Fuse.js를 썼어요. 빌드 타임에 글 데이터를 JSON으로 만들어두고 클라이언트에서 fuzzy 검색을 하는 방식이었어요.
fuzzy 검색은 오타나 철자가 약간 달라도 비슷한 문자열을 찾아주는 검색이에요. "javscript"로 검색해도 "javascript"를 찾아주는 식이죠. 글이 몇 개 안 되니까 충분했고 구현도 간단했어요.

그런데 직접 써보면서 아쉬운 점을 발견했어요.

"admin"은 되는데 "어드민"은 안 되는 문제

R2 어드민 페이지에 대한 글을 쓰고 나서 검색을 테스트해봤어요. "admin"으로 검색하면 글이 잘 나왔어요. 그런데 "어드민"으로 검색하면 아무것도 안 나왔어요. 같은 뜻인데 언어만 다르다고 결과가 완전히 달라지는 거예요.

Fuse.js의 threshold를 0.3에서 0.4로 올려봤더니 한글 검색은 좀 나아졌어요. 하지만 근본적인 문제는 그대로였어요. Fuse.js는 문자열 유사도 기반이라 "admin"과 "어드민"이 같은 의미라는 걸 알 수가 없거든요.

클라이언트 검색의 한계

  • 전체 인덱스를 클라이언트로 내려야 해요. 글이 많아질수록 JSON 파일이 커지고 초기 로드에 부담이 돼요.
  • 빌드 시점에 인덱스를 만들어요. 새 글을 추가하면 검색 인덱스를 다시 빌드해야 해요.
  • 한글 fuzzy 매칭이 불안정해요. 라틴 문자 기준으로 설계된 알고리즘이라 한글 자모 조합에서 예상 못한 결과가 나오기도 해요.

그래서 서버사이드 검색으로 전환하기로 했어요.

어떤 방식으로 갈까

D1 LIKEAlgoliaWorkers AI + Vectorize
한글 매칭정확 매칭만 가능토크나이저 지원의미 기반 매칭
admin ↔ 어드민안 됨동의어 등록 필요자동으로 됨
추가 비용없음 (D1 사용 중)외부 SaaS없음 (무료 티어)
구현 난이도낮음중간중간

D1 LIKE 검색이 제일 간단했지만 "admin" ↔ "어드민" 문제를 해결할 수 없었어요. Algolia는 동의어 사전을 수동으로 등록해야 해요. 동의어가 몇 개일 때는 괜찮지만 계속 관리해야 하는 게 번거로웠어요.

Workers AI + Vectorize는 텍스트를 벡터로 변환해서 의미 기반으로 비교하는 방식이에요. "admin"과 "어드민"이 비슷한 벡터로 변환되니까 별도 사전 없이 자연스럽게 매칭돼요. Cloudflare 스택을 이미 쓰고 있어서 추가 인프라도 필요 없었고요.

Workers AI + Vectorize가 뭔데

Workers AI는 Cloudflare Edge에서 AI 모델을 실행하는 서비스예요. 여기서는 @cf/baai/bge-m3라는 다국어 임베딩 모델을 사용했어요. 텍스트를 넣으면 1024차원의 숫자 배열(벡터)로 변환해줘요. 한국어를 포함해서 100개 이상의 언어를 지원해요.

Vectorize는 Cloudflare의 벡터 데이터베이스예요. 벡터를 저장해두고 유사한 벡터를 빠르게 찾아주는 역할이에요.

흐름은 이래요.

[인덱싱]
글 텍스트 → bge-m3 모델 → 벡터 → Vectorize에 저장

[검색]
검색어 → bge-m3 모델 → 벡터 → Vectorize에서 유사 벡터 검색 → 관련 글 반환

구현

Vectorize 인덱스 만들기

먼저 wrangler CLI로 벡터 인덱스를 만들었어요.

npx wrangler vectorize create blog-search --dimensions=1024 --metric=cosine

wrangler.toml에 바인딩도 추가했어요.

[ai]
binding = "AI"
 
[[vectorize]]
binding = "VECTORIZE"
index_name = "blog-search"

인덱싱 API

글 데이터를 임베딩해서 Vectorize에 저장하는 API를 만들었어요. 배포 후에 이 API를 호출하면 글이 인덱싱돼요.

// src/app/api/search/index/route.ts
export async function POST(request: Request) {
  const { env } = getRequestContext();
 
  const res = await fetch(new URL("/search-index.json", request.url));
  const posts = (await res.json()) as SearchItem[];
 
  const texts = posts.map(
    (p) => `${p.title} ${p.description} ${p.tags.join(" ")}`,
  );
 
  const { data: embeddings } = (await env.AI.run("@cf/baai/bge-m3", {
    text: texts,
  })) as { data: number[][] };
 
  const vectors = posts.map((post, i) => ({
    id: post.slug,
    values: embeddings[i],
    metadata: { title: post.title, description: post.description },
  }));
 
  await env.VECTORIZE.upsert(vectors);
  return Response.json({ indexed: posts.length });
}

검색 API

검색어를 임베딩해서 Vectorize에서 유사한 글을 찾는 API예요.

// src/app/api/search/route.ts
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const q = searchParams.get("q")?.trim();
 
  if (!q) return Response.json({ results: [] });
 
  const { env } = getRequestContext();
 
  const { data: embeddings } = (await env.AI.run("@cf/baai/bge-m3", {
    text: [q],
  })) as { data: number[][] };
 
  const matches = await env.VECTORIZE.query(embeddings[0], {
    topK: 5,
    returnMetadata: "all",
  });
 
  const topScore = matches.matches[0]?.score ?? 0;
  const cutoff = Math.max(0.35, topScore * 0.8);
 
  const results = matches.matches
    .filter((m) => m.score >= cutoff)
    .map((m) => ({ slug: m.id, score: m.score }));
 
  return Response.json({ results });
}

SearchBar 로직 변경

기존 SearchBar에서 Fuse.js 로직을 전부 걷어내고 API 호출로 바꿨어요.

useEffect(() => {
  if (!debouncedQuery) {
    onSearch(null);
    return;
  }
 
  abortRef.current?.abort();
  const controller = new AbortController();
  abortRef.current = controller;
 
  setLoading(true);
  fetch(`/api/search?q=${encodeURIComponent(debouncedQuery)}`, {
    signal: controller.signal,
  })
    .then((res) => res.json() as Promise<{ results: { slug: string }[] }>)
    .then((data) => {
      onSearch(data.results.map((r) => r.slug));
      setLoading(false);
    })
    .catch((err) => {
      if (err.name !== "AbortError") setLoading(false);
    });
}, [debouncedQuery]);

Fuse.js를 로드하거나 인덱스 JSON을 받아올 필요가 없어졌어요. fuse.js 패키지도 삭제했어요.

시행착오

1. 임베딩에 본문을 넣었더니 검색이 이상해졌어요

처음에는 임베딩 텍스트에 글 본문까지 다 포함했어요.

const texts = posts.map(
  (p) => `${p.title} ${p.description} ${p.tags.join(" ")} ${p.content}`,
);

이렇게 하니까 검색 결과가 이상했어요. "admin"으로 검색하면 cloudflare-r2-admin이 1위가 아니라 hello-world가 1위로 나왔어요. 본문이 길다 보니 "admin"이라는 키워드가 전체 텍스트에서 희석되어 버린 거예요.

임베딩 텍스트를 제목 + 설명 + 태그로 줄였더니 결과가 확 좋아졌어요. 적어도 임베딩 단계에서는 주제 정보만 한 벡터에 담는 게 의도에 맞았어요.

const texts = posts.map(
  (p) => `${p.title} ${p.description} ${p.tags.join(" ")}`,
);
쿼리개선 전 1위개선 후 1위
adminhello-world (0.44)cloudflare-r2-admin (0.51)
어드민cloudflare-d1-blog (0.28)cloudflare-r2-admin (0.40)

2. 재인덱싱했는데 검색 결과가 그대로였어요

임베딩 텍스트를 바꾸고 재인덱싱했는데 검색 결과가 똑같았어요. score가 소수점 아래까지 완전히 같았어요. 처음에는 코드 문제인 줄 알았는데 Vectorize의 eventual consistency 때문이었어요.

eventual consistency는 데이터를 업데이트해도 모든 노드에 즉시 반영되지 않고 시간이 지나야 최신 상태가 되는 특성이에요. 일시적인 불일치는 허용하되 시간이 지나면 반드시 일치됨을 보장해요.

그래서 재인덱싱 후 바로 테스트했을 때는 기존과 같았지만 20초 정도 기다리니까 새로운 score가 반영되어 검색 결과로 나왔어요.

3. score threshold 튜닝

첫 번째 시도에서는 score가 0.5 이상인 결과만 보여줬어요. 그랬더니 검색 결과가 아무것도 안 나왔어요. 시맨틱 검색의 cosine similarity score는 키워드 매칭처럼 높게 나오지 않거든요. 짧은 검색어와 긴 텍스트를 비교하면 0.3~0.5 정도가 보통이에요.

그래서 threshold를 0.3으로 낮췄는데 이번에는 관련 없는 글이 너무 많이 나왔어요. "d1"으로 검색하면 삿포로 여행기가 나오고 "미디어"로 검색하면 hello-world가 나왔어요.

최종적으로 절대 threshold + 상대 threshold를 조합했어요.

const topScore = matches.matches[0]?.score ?? 0;
const cutoff = Math.max(0.35, topScore * 0.8);

1위 score의 80% 미만이면 잘라내되 최소 0.35는 유지하는 방식이에요. 이렇게 하니까 "d1" 검색에서 D1 글만 나오고 "미디어" 검색에서 R2 글만 나왔어요.

4. 짧은 영문 키워드 쿼리에서 결과가 이상했어요

배포 후에 "react"로 검색해봤더니 결과가 이상했어요. 정작 React를 주제로 다룬 글은 빠지고 React를 태그로만 가진 Next.js 시리즈가 결과를 차지했거든요.

원인은 두 가지가 겹쳐 있었어요. 짧은 영문 단어는 임베딩 공간에서 약하게 표현돼서 한국어 위주 글과의 유사도가 0.4 ~ 0.5 정도로 낮게 나와요. React를 주제로 다룬 글이라도 본문이 한국어라면 점수가 약하게 잡히고요. 그 상태에서 컷오프 Math.max(0.35, topScore * 0.8)이 topScore의 80%까지 걸러내니까 들어와야 할 글이 빠지고 비슷한 점수의 다른 글이 결과를 차지하는 식으로 어긋났어요.

시맨틱만으로는 짧은 영문 쿼리를 못 잡는다는 걸 받아들이고 키워드 매칭을 같이 돌리는 하이브리드로 바꿨어요.

function keywordSearch(q: string, posts: SearchItem[]) {
  const lower = q.toLowerCase();
  const scored: { slug: string; score: number }[] = [];
  for (const p of posts) {
    let score = 0;
    if (p.title.toLowerCase().includes(lower)) score += 1.0;
    if (p.tags.some((t) => t.toLowerCase().includes(lower))) score += 0.7;
    if (p.description.toLowerCase().includes(lower)) score += 0.4;
    if (p.content.toLowerCase().includes(lower)) score += 0.2;
    if (score > 0) scored.push({ slug: p.slug, score });
  }
  return scored.sort((a, b) => b.score - a.score);
}

가중치는 제목 1.0, 태그 0.7, 설명 0.4, 본문 0.2로 줬어요. 1번에서 본문을 임베딩에 통째로 넣어 노이즈가 됐던 거랑은 결이 달라요. 임베딩은 본문 전체가 한 벡터로 압축돼서 한 키워드의 영향력이 텍스트 길이에 희석돼요. 키워드 매칭은 단어가 본문에 등장하는지만 확인하니까 길이 영향을 안 받고요. 가중치만 낮게 주면 본문에만 등장하는 글이 결과를 흐리지 않으면서 점수만 살짝 더해줘요.

시맨틱 결과는 컷오프를 0.4로 단순화하고 키워드 매칭 결과와 합쳤어요. 키워드 매칭을 앞에 두고 시맨틱은 키워드에 안 잡힌 글만 보강하는 식이에요. "react"처럼 짧은 키워드도 잘 잡히고 "어드민" 같은 의미 기반 쿼리도 시맨틱 쪽에서 그대로 매칭돼요.

챗봇 RAG에도 적용

블로그 검색이 잘 되니까 챗봇의 RAG에도 같은 방식을 적용했어요. 기존에는 사용자 질문에서 키워드를 추출하고 청크에서 해당 키워드가 몇 번 나오는지 세는 방식이었어요.

// 기존: 키워드 빈도 기반
const score = queryWords.reduce((acc, word) => {
  const matches = text.match(new RegExp(word, "g"));
  return acc + (matches?.length ?? 0);
}, 0);

이걸 Vectorize 기반으로 바꿨어요. 검색용 인덱스와 별도로 rag-chunks라는 인덱스를 만들었어요. 검색은 글 한 편을 통째로 한 단위로 다루고(9개) RAG는 글을 잘게 쪼갠 청크 단위로 다뤄요(21개). 다루는 단위 크기가 달라서 인덱스도 따로 둔 거예요.

"블로그 배포는 어떻게 해?"라는 질문으로 테스트해봤어요. 키워드 매칭이었으면 "배포"라는 단어가 있는 청크만 찾았을 텐데 시맨틱 검색은 Cloudflare Pages 관련 청크까지 잘 찾아줬어요.

로컬 개발에서는

Vectorize 바인딩은 로컬 next dev에서 쓸 수 없어요. Cloudflare의 원격 바인딩이 필요하거든요. 그래서 로컬에서는 search-index.json 기반 텍스트 매칭으로 fallback하도록 했어요.

try {
  // Vectorize 시맨틱 검색 (프로덕션)
  const matches = await env.VECTORIZE.query(embeddings[0], { topK: 5 });
  // ...
} catch {
  // 텍스트 매칭 fallback (로컬 개발)
  const res = await fetch(new URL("/search-index.json", request.url));
  const posts = (await res.json()) as SearchItem[];
  return Response.json({ results: localSearch(q, posts) });
}

wrangler pages dev --remote로 원격 바인딩을 쓸 수도 있지만 빌드가 필요해서 HMR이 안 돼요. 일반 개발은 next dev로 하고 검색 테스트만 프로덕션에서 확인하는 걸로 했어요.

배포할 때 자동으로 재인덱싱하기

인덱싱 API를 수동으로 호출하는 건 잊기 쉬워요. 글을 수정하고 배포한 뒤에 재인덱싱을 안 하면 검색 결과가 옛날 데이터 그대로예요.

GitHub Actions로 content/posts/** 경로에 변경이 push되면 자동으로 재인덱싱되도록 만들었어요.

# .github/workflows/reindex.yml
name: Reindex after post changes
 
on:
  push:
    branches: [main]
    paths:
      - "content/posts/**"
 
jobs:
  reindex:
    runs-on: ubuntu-latest
    steps:
      - name: Wait for Cloudflare Pages deployment
        run: |
          for i in $(seq 1 30); do
            STATUS=$(curl -sf \
              -H "Authorization: Bearer $CF_API_TOKEN" \
              "https://api.cloudflare.com/client/v4/accounts/$CF_ACCOUNT_ID/pages/projects/sw-blog/deployments?sort_by=created_on&sort_order=desc&per_page=1" \
              | jq -r '.result[0].latest_stage.status')
            if [ "$STATUS" = "success" ]; then break; fi
            if [ "$STATUS" = "failure" ]; then exit 1; fi
            sleep 20
          done
 
      - name: Reindex search
        run: curl -sf -X POST $SITE_URL/api/search/index ...
 
      - name: Reindex RAG
        run: curl -sf -X POST $SITE_URL/api/chat/index ...

흐름은 이래요.

  1. content/posts/**에 변경이 push되면 워크플로우가 트리거돼요
  2. Cloudflare API를 폴링해서 Pages 배포가 완료될 때까지 기다려요 (최대 10분)
  3. 배포가 끝나면 검색 인덱싱 API와 RAG 인덱싱 API를 순서대로 호출해요

처음에는 GitHub의 deployment_status 이벤트를 쓰려고 했는데 Cloudflare Pages가 이 이벤트를 발생시키지 않아서 Cloudflare API 폴링 방식으로 바꿨어요. 배포 상태가 success가 되면 바로 인덱싱을 시작하고 failure면 스킵해요.

비용

항목무료 티어
Vectorize 벡터 수5,000,000개
Vectorize 쿼리30,000,000건/월
Workers AI 임베딩10,000건/일

무료 티어 규모를 넘길 일은 당분간은 없을거라고 봐요. 글이 수천 편이고 검색이 하루 수만 건이어야 걱정할 수준이에요.

정리

좋았던 점

  • "admin" ↔ "어드민", "sapporo" ↔ "삿포로" 같은 교차 언어 검색이 돼요
  • Fuse.js 클라이언트 번들이 사라져서 초기 로드가 가벼워졌어요
  • Cloudflare 스택 안에서 해결돼서 추가 인프라가 없어요
  • 챗봇 RAG까지 같은 방식으로 확장할 수 있었어요

아쉬웠던 점

  • Vectorize의 eventual consistency 때문에 디버깅을 좀 헤맸어요
  • 로컬 개발에서 시맨틱 검색을 직접 테스트할 수 없어요
  • score threshold 튜닝이 경험적이에요. 글이 더 많아지면 다시 조정해야 할 수도 있어요

Fuse.js로 시작한 건 나쁜 선택이 아니었어요. 초기에 빠르게 검색을 붙일 수 있었으니까요. 다만 한글 블로그에서 영어/한글 혼용 검색은 fuzzy 매칭의 한계가 분명했어요. 시맨틱 검색으로 전환하면서 검색 품질이 확실히 올라갔고 앞으로 글이 늘어나도 대응이 되어서 좋아요.