Cloudflare Vectorize로 엣지에서 벡터 검색 구현하기

Cloudflare Vectorize로 엣지에서 벡터 검색 구현하기

요즘 챗봇이나 검색 기능을 만들다 보면 “의미 기반 검색”이 거의 필수가 됐어요. 사용자가 “환불 받고 싶어요”라고 입력해도 “결제 취소 절차”가 적힌 문서를 찾아줘야 하잖아요. BM25 같은 키워드 검색은 단어가 정확히 일치해야 하기 때문에 이런 경우에 약한데, 그 빈틈을 메우는 게 바로 의미 기반 검색입니다. 이런 의미 기반 검색을 구현하려면 텍스트를 벡터로 바꿔 저장하고 비슷한 벡터를 빠르게 찾아주는 벡터 데이터베이스가 필요합니다.

Pinecone, Weaviate, Qdrant 같은 전문 서비스도 좋지만 Cloudflare Workers로 엣지에서 돌아가는 서비스를 만들고 있다면 매번 외부로 나가는 게 아깝죠. Cloudflare Vectorize는 Workers 바인딩 하나로 바로 쓸 수 있는 서버리스 벡터 데이터베이스라서 이런 고민을 덜어줍니다.

이번 글에서는 Vectorize의 개념부터 인덱스 생성, 벡터 삽입, 쿼리, 메타데이터 필터링, Workers AI 임베딩 연동까지 RAG를 만드는 데 필요한 흐름을 정리해보겠습니다.

Vectorize란?

Vectorize는 Cloudflare의 엣지 네트워크에서 동작하는 글로벌 분산 벡터 데이터베이스입니다. 임베딩(embedding) 벡터를 저장하고 코사인 유사도 같은 거리 함수로 비슷한 벡터를 빠르게 찾아주는 게 핵심 역할이에요.

벡터 데이터베이스를 따로 운영해본 분이라면 알겠지만, 인덱스를 유지하고 샤딩하고 메모리를 관리하는 일이 만만치 않습니다. Vectorize는 이런 운영 부담을 Cloudflare가 대신 짊어지고, 개발자는 Workers 바인딩으로 “이 벡터를 넣어줘”, “이 벡터랑 비슷한 거 찾아줘” 정도만 호출하면 됩니다.

D1이 SQL을 위한 데이터베이스라면 Vectorize는 임베딩을 위한 데이터베이스라고 생각하면 쉬워요. 실제로 Workers 안에서 D1으로 본문을 저장하고, R2에 원본 파일을 보관하고, Vectorize에 임베딩만 저장하는 식으로 함께 쓰는 경우가 많습니다.

인덱스 만들기

Vectorize에서 가장 먼저 할 일은 인덱스를 만드는 거예요. 인덱스는 벡터를 저장하는 단위인데 차원 수(dimensions)와 거리 함수(metric)를 만들 때 정해야 합니다.

$ npx wrangler vectorize create my-index \
  --dimensions=768 \
  --metric=cosine

--dimensions는 저장할 벡터의 차원 수입니다. 어떤 임베딩 모델을 쓰느냐에 따라 정해지는데, 예를 들어 OpenAI의 text-embedding-3-small은 1536, Workers AI의 bge-base-en-v1.5는 768차원이에요.

--metric은 벡터 간 거리를 계산하는 방법입니다. 텍스트 임베딩에 가장 흔히 쓰는 cosine(코사인 유사도), 이미지나 일반 수치 데이터에 적합한 euclidean(유클리드 거리), 정규화된 벡터에서 빠른 계산이 필요할 때 쓰는 dot-product(내적) 세 가지를 지원합니다.

한 가지 주의할 점은 인덱스를 만든 뒤에는 차원 수나 거리 함수를 바꿀 수 없다는 거예요. 사용할 임베딩 모델을 먼저 정하고, 그 모델의 차원 수에 맞춰 인덱스를 만들어야 합니다.

Workers 바인딩

인덱스를 만들었으니 Wrangler 설정 파일에 바인딩을 추가해서 Worker에서 접근할 수 있게 해줘요.

wrangler.jsonc
{
  "name": "search-worker",
  "main": "src/index.ts",
  "compatibility_date": "2025-01-01",
  "vectorize": [
    {
      "binding": "VECTORIZE",
      "index_name": "my-index",
    },
  ],
}

binding이 Worker 코드에서 인덱스에 접근할 때 쓰는 이름입니다. 이제 env.VECTORIZE로 Vectorize API를 호출할 수 있어요.

src/index.ts
export interface Env {
  VECTORIZE: Vectorize;
}

export default {
  async fetch(request, env): Promise<Response> {
    const info = await env.VECTORIZE.describe();
    return Response.json(info);
  },
} satisfies ExportedHandler<Env>;

describe()는 인덱스의 메타 정보(벡터 개수, 차원 수, 거리 함수 등)를 반환합니다. 처음 설정한 뒤 바인딩이 잘 연결됐는지 확인할 때 유용해요.

벡터 삽입과 업서트

벡터를 인덱스에 넣을 때는 insert()upsert()를 씁니다.

src/index.ts
const vectors = [
  {
    id: "doc-1",
    values: [0.12, 0.45, 0.67 /* ... 768개 */],
    metadata: { title: "환불 안내", category: "support" },
  },
  {
    id: "doc-2",
    values: [0.34, 0.21, 0.89 /* ... 768개 */],
    metadata: { title: "결제 취소 절차", category: "support" },
  },
];

const result = await env.VECTORIZE.insert(vectors);
console.log(result);

각 벡터에는 id, values(차원 수와 일치하는 숫자 배열), 그리고 선택적으로 metadata가 들어갑니다. values는 자바스크립트 일반 숫자 배열뿐 아니라 Float32Array, Float64Array도 받아주기 때문에 임베딩 모델 출력 형식에 맞춰 그대로 넘기면 돼요.

insert()upsert()의 차이는 같은 id를 다시 넣었을 때 동작이 다르다는 점입니다. insert()는 먼저 들어간 값을 유지하고 뒤에 들어온 건 무시합니다. upsert()는 반대로 뒤에 들어온 값으로 덮어써요. 문서가 수정될 때마다 임베딩을 다시 만들어 같은 id로 갱신하는 패턴이라면 upsert()가 자연스럽습니다.

한 번에 여러 벡터를 넣을 때는 배치당 최대 1,000개 또는 200,000차원이라는 제한이 있어요. 대량 데이터를 마이그레이션할 때는 1,000개 단위로 잘라서 여러 번 호출해야 합니다.

CLI로도 NDJSON 파일을 한꺼번에 올릴 수 있는데, 초기 데이터 적재에 편해요.

$ npx wrangler vectorize insert my-index --file=embeddings.ndjson

벡터 쿼리

저장한 벡터에서 비슷한 걸 찾으려면 query()에 쿼리 벡터를 넘기면 됩니다.

src/index.ts
const queryVector = [0.13, 0.25, 0.44 /* ... 768개 */];

const matches = await env.VECTORIZE.query(queryVector, {
  topK: 5,
  returnValues: false,
  returnMetadata: "all",
});

return Response.json(matches);

topK는 가장 비슷한 결과를 몇 개 받을지 정합니다. 기본값은 5예요.

returnValuestrue로 하면 매칭된 벡터의 원본 값까지 응답에 포함됩니다. 정확도가 더 높은 재계산이 필요할 때 쓰지만, 응답 크기가 커지고 약간 느려지니까 보통은 false로 두는 게 좋아요.

returnMetadata"none", "indexed", "all" 중에 고를 수 있습니다. "all"은 저장한 모든 메타데이터를 돌려주고, "indexed"는 메타데이터 인덱스가 걸린 필드만 돌려줘서 응답이 가벼워집니다.

응답은 비슷한 순서대로 정렬된 매칭 배열이에요. 각 매칭에는 id, 유사도 score, 그리고 옵션에 따라 metadatavalues가 들어갑니다.

결과
{
  "matches": [
    {
      "id": "doc-2",
      "score": 0.9234,
      "metadata": { "title": "결제 취소 절차", "category": "support" }
    },
    {
      "id": "doc-1",
      "score": 0.8721,
      "metadata": { "title": "환불 안내", "category": "support" }
    }
  ],
  "count": 2
}

코사인 유사도일 때 score는 -1에서 1 사이 값이고 1에 가까울수록 비슷합니다.

네임스페이스

여러 사용자나 테넌트의 데이터를 한 인덱스에 같이 저장하고 싶을 때 네임스페이스가 유용합니다. 인덱스를 따로 만들 필요 없이 벡터마다 namespace를 지정해두면 쿼리할 때 해당 네임스페이스 안에서만 검색됩니다.

src/index.ts
await env.VECTORIZE.insert([
  {
    id: "doc-1",
    values: [0.12, 0.45 /* ... */],
    namespace: "tenant-a",
    metadata: { title: "공지사항" },
  },
  {
    id: "doc-2",
    values: [0.34, 0.21 /* ... */],
    namespace: "tenant-b",
    metadata: { title: "공지사항" },
  },
]);

const matches = await env.VECTORIZE.query(queryVector, {
  namespace: "tenant-a",
  topK: 3,
});

네임스페이스 이름은 최대 64자이고 인덱스당 1,000개까지 만들 수 있어요. 중요한 건 네임스페이스 필터링이 벡터 검색 전에 적용된다는 점입니다. 다른 테넌트의 데이터가 결과에 섞일 일도 없고, 검색 대상이 미리 줄어들어서 결과 정확도도 올라가요.

SaaS 서비스에서 고객별로 데이터를 격리해야 한다면 네임스페이스를 우선 고려해볼 만합니다.

메타데이터 필터링

검색 결과를 특정 조건으로 좁히고 싶을 때는 메타데이터 필터링을 사용합니다. 다만 필터링하려는 필드에는 미리 메타데이터 인덱스를 만들어둬야 해요.

$ npx wrangler vectorize create-metadata-index my-index \
  --property-name=category \
  --type=string

--typestring, number, boolean 중에 고를 수 있고 인덱스당 최대 10개까지 만들 수 있습니다.

메타데이터 인덱스가 준비되면 query()filter 옵션에 조건을 넘길 수 있어요.

src/index.ts
const matches = await env.VECTORIZE.query(queryVector, {
  topK: 5,
  filter: { category: "support" },
  returnMetadata: "all",
});

객체에 키-값을 직접 적으면 같음($eq) 조건으로 동작합니다. 좀 더 복잡한 조건이 필요하면 연산자를 사용하면 됩니다.

// 카테고리가 'archive'가 아닌 문서만
filter: { category: { $ne: "archive" } }

// 여러 카테고리 중 하나
filter: { category: { $in: ["support", "billing"] } }

// 특정 기간 이후
filter: { timestamp: { $gte: 1700000000 } }

// 범위 검색
filter: { score: { $gte: 0.5, $lt: 0.9 } }

지원하는 연산자는 $eq, $ne, $in, $nin, $gt, $gte, $lt, $lte입니다.

한 가지 흥미로운 패턴은 문자열에 범위 연산자를 써서 접두사 검색을 흉내내는 방법이에요. 예를 들어 “net”으로 시작하는 모든 값을 찾고 싶다면 { $gte: "net", $lt: "neu" }처럼 사전식 정렬을 활용할 수 있습니다.

여러 조건을 같이 적으면 모두 만족(AND)해야 매칭됩니다.

filter: {
  category: "support",
  timestamp: { $gte: 1700000000 }
}

필터는 벡터 검색 전에 적용되기 때문에 topK도 필터링된 집합 안에서 계산됩니다. 조건을 잘 활용하면 검색 정확도가 확 올라가요.

Workers AI로 임베딩 만들기

지금까지 예제에서는 벡터 값을 그냥 적었지만, 실제로는 텍스트를 임베딩 모델에 통과시켜 만들어야 하잖아요. Workers AI의 임베딩 모델을 함께 쓰면 외부 API 없이 Worker 안에서 임베딩까지 한 번에 처리할 수 있습니다.

먼저 wrangler.jsonc에 AI와 Vectorize 바인딩을 둘 다 추가해줍니다.

wrangler.jsonc
{
  "name": "rag-worker",
  "main": "src/index.ts",
  "compatibility_date": "2025-01-01",
  "ai": {
    "binding": "AI",
  },
  "vectorize": [
    {
      "binding": "VECTORIZE",
      "index_name": "my-index",
    },
  ],
}

문서를 임베딩으로 변환해서 Vectorize에 저장하는 코드는 이런 모양이에요.

src/index.ts
export interface Env {
  AI: Ai;
  VECTORIZE: Vectorize;
}

async function indexDocument(env: Env, id: string, text: string) {
  const { data } = await env.AI.run("@cf/baai/bge-base-en-v1.5", {
    text: [text],
  });

  await env.VECTORIZE.upsert([
    {
      id,
      values: data[0],
      metadata: { text },
    },
  ]);
}

bge-base-en-v1.5는 768차원의 벡터를 만들어주니까 인덱스의 dimensions도 768로 맞춰 만들어야 합니다. 다국어 텍스트를 다룬다면 @cf/baai/bge-m3 모델을 고려해볼 만해요.

검색 쪽은 거의 같은 패턴인데, 쿼리 텍스트를 임베딩으로 바꿔서 query()에 넘기는 것만 다릅니다.

src/index.ts
async function searchDocuments(env: Env, query: string) {
  const { data } = await env.AI.run("@cf/baai/bge-base-en-v1.5", {
    text: [query],
  });

  const matches = await env.VECTORIZE.query(data[0], {
    topK: 3,
    returnMetadata: "all",
  });

  return matches.matches;
}

이렇게 하면 Workers 한 곳에서 임베딩 생성부터 벡터 검색까지 다 처리되니까 외부 API 키나 별도 인프라가 필요 없어요.

간단한 RAG 예제

조각조각 본 걸 모아서 RAG(Retrieval-Augmented Generation) 패턴을 만들어보겠습니다. 사용자 질문이 들어오면 관련 문서를 Vectorize에서 찾고, 그 내용을 LLM 프롬프트에 끼워서 답변을 생성하는 흐름이에요.

src/index.ts
export default {
  async fetch(request, env): Promise<Response> {
    const { question } = await request.json<{ question: string }>();

    // 1. 질문을 임베딩으로 변환
    const { data: queryEmbeddings } = await env.AI.run(
      "@cf/baai/bge-base-en-v1.5",
      { text: [question] },
    );

    // 2. Vectorize에서 관련 문서 검색
    const { matches } = await env.VECTORIZE.query(queryEmbeddings[0], {
      topK: 3,
      returnMetadata: "all",
    });

    // 3. 검색 결과를 컨텍스트로 정리
    const context = matches
      .map((m) => m.metadata?.text)
      .filter(Boolean)
      .join("\n\n");

    // 4. LLM에 컨텍스트와 함께 질문 전달
    const answer = await env.AI.run("@cf/meta/llama-3.1-8b-instruct", {
      messages: [
        {
          role: "system",
          content: `당신은 친절한 고객 지원 도우미입니다. 다음 문서를 참고해서 답변하세요:\n\n${context}`,
        },
        { role: "user", content: question },
      ],
    });

    return Response.json({ answer, sources: matches.map((m) => m.id) });
  },
} satisfies ExportedHandler<Env>;

이 패턴이 RAG의 본질이에요. LLM 자체는 학습 데이터 이후의 정보나 회사 내부 문서를 알지 못하지만, 검색해서 가져온 컨텍스트를 프롬프트에 넣어주면 마치 그 내용을 알고 있는 것처럼 답변할 수 있죠.

문서가 자주 업데이트되는 환경에서는 LLM을 새로 학습시킬 필요 없이 Vectorize의 데이터만 갱신하면 되니까 운영이 훨씬 가벼워집니다.

로컬 개발

wrangler dev로 로컬 개발 서버를 띄우면 Vectorize도 함께 시뮬레이션됩니다.

$ npx wrangler dev

다만 D1과 달리 Vectorize는 로컬 시뮬레이션 시에도 원격 인덱스를 사용하는 경우가 있어요. --remote 플래그를 명시적으로 붙이면 항상 원격 인덱스에 연결됩니다.

$ npx wrangler dev --remote

개발 중에 테스트 데이터를 다 지우고 싶다면 인덱스를 지웠다가 다시 만드는 방법이 가장 깔끔합니다.

$ npx wrangler vectorize delete my-index
$ npx wrangler vectorize create my-index --dimensions=768 --metric=cosine

벡터를 개별로 지울 때는 deleteByIds()를 사용합니다.

await env.VECTORIZE.deleteByIds(["doc-1", "doc-2"]);

요금

Vectorize는 저장된 벡터 차원 수와 쿼리된 벡터 차원 수를 기준으로 과금합니다. “벡터 1개당 얼마”가 아니라 “차원 1개당 얼마”라는 점에 주의해야 해요. 같은 1만 개 벡터라도 768차원 모델과 1536차원 모델은 비용이 두 배 차이가 납니다.

무료 플랜에서도 월 3천만 쿼리 차원과 5백만 저장 차원이 포함돼서 프로토타입을 만들기에 충분해요. 유료 플랜(월 $5)은 5천만 쿼리 차원과 1천만 저장 차원이 포함되고, 초과분은 쿼리 차원 100만당 $0.01, 저장 차원 1억당 $0.05입니다.

50만 벡터(1536차원), 월 100만 쿼리 정도의 중간 규모 서비스라면 한 달에 약 $23 수준이라 다른 매니지드 벡터 데이터베이스에 비해 꽤 합리적이에요. 인덱스 개수나 활성 시간에는 별도 요금이 없으니까 여러 작은 인덱스를 만들어 분리해도 부담이 없습니다.

마치며

Cloudflare Vectorize는 Workers AI와 결합되면서 진가가 드러나요. 임베딩 생성과 벡터 검색, LLM 추론까지 같은 엣지 네트워크에서 한 번에 끝낼 수 있다는 게 핵심 장점입니다. 외부 API 호출이 없으니까 지연도 줄고, 데이터를 굳이 다른 서비스로 보낼 필요도 없죠.

물론 Pinecone이나 Weaviate 같은 전문 벡터 데이터베이스와 비교하면 고급 기능에서 차이가 있을 수 있어요. 하지만 Workers 기반 서비스를 만들고 있고 RAG나 의미 검색을 도입하고 싶다면 Vectorize가 가장 자연스러운 선택입니다. D1에 본문을 저장하고 R2에 첨부 파일을 보관하고 Vectorize에 임베딩을 두는 식으로 Cloudflare 생태계 안에서 깔끔하게 풀스택을 구성할 수 있어요 💪

더 자세한 내용은 Vectorize 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord