Vectorize + RAG: embeddings, top-K, hybrid search edge

Vectorize là vector DB native của Cloudflare, kết hợp Workers AI bge-m3 cho RAG trọn edge. Pipeline ingest + query, chunking, lọc metadata, hybrid search D1, reranking.

· 9 phút đọc · Read in English
Pipeline RAG trọn edge với Vectorize: ingest markdown → chunking → embedding bge-m3 qua Workers AI → upsert binding VECTORIZE → query top-K với metadata filter, hybrid search D1 và reranking

TL;DR

Vectorize là vector DB native của Cloudflare. Binding VECTORIZE, không có egress với Worker, cặp đôi tự nhiên với Workers AI bge-m3 để dựng RAG trọn edge.

Luận điểm chính:

RAG không phải chỉ “embed + top-K + nhét vào prompt”. RAG production cần chunk đúng, lọc metadata, hybrid search (vector + keyword), reranking, và observability. Stack Cloudflare cho tất cả trong một mạng. Nhưng Vectorize không phải Pinecone — biết giới hạn trước khi chọn.

Bài này đi qua: pipeline ingest (chunk → embed → upsert), pipeline query (embed → top-K → bổ sung → LLM), chiến lược chunking, hybrid search với D1 FTS5, reranking, so sánh với Pinecone/Qdrant/pgvector, và pattern RAG production từ blog 58 bài.


Dành cho ai

  • Dev muốn dựng semantic search / bot Q&A trên dữ liệu riêng.
  • Ai đang dùng Pinecone/Qdrant muốn biết đánh đổi khi chuyển sang Vectorize.
  • Team với nội dung markdown/MDX cần RAG không phức tạp về hạ tầng.

Nên đọc trước: Part 13 (Workers AI + AI Gateway), Part 6 (D1).

Sau bài này bạn sẽ:

  • Thiết lập Vectorize index + upsert embedding trong < 30 phút.
  • Triển khai pipeline query với top-K + lọc metadata.
  • Kết hợp vector + keyword search (hybrid) với D1.
  • Biết khi nào Vectorize đủ, khi nào cần Pinecone/Qdrant.

Bài này không nói về gì

  • Huấn luyện / fine-tune LLM: RAG là phương án thay thế cho fine-tune, không phải thay hẳn. Bài không cover pipeline huấn luyện.
  • Framework agent (LangChain, LlamaIndex): có thể dùng với Vectorize, nhưng bài dùng binding thô cho rõ ràng.
  • RAG ảnh / đa phương thức: tập trung chỉ văn bản. Embedding CLIP + retrieval ảnh có nhưng không đi sâu.

RAG là gì (30 giây)

Retrieval Augmented Generation. Thay vì gửi query người dùng thẳng cho LLM và hy vọng model biết, bạn:

  1. Retrieve: tìm tài liệu/chunk liên quan từ kho kiến thức.
  2. Augment: đưa chunk đó vào prompt làm context.
  3. Generate: LLM trả lời dựa trên context.

RAG giải quyết:

  • Knowledge cutoff: LLM không biết sự kiện sau ngày huấn luyện.
  • Dữ liệu đặc thù miền: LLM không được huấn luyện trên docs nội bộ công ty.
  • Hallucination: neo vào nguồn thật giảm bịa đặt.
  • Trích dẫn: biết câu trả lời đến từ tài liệu nào.

Phương án thay thế là fine-tune — huấn luyện model trên dữ liệu của bạn. RAG rẻ hơn, cập nhật dễ hơn, linh hoạt hơn. Fine-tune cho hành vi đặc thù tác vụ (tone, định dạng), RAG cho tri thức.


Vectorize là gì

Pipeline RAG: Ingest (chunk markdown → embed bge-m3 → upsert Vectorize → lưu index); Query (query người dùng → embed → search top-K VECTORIZE.query → bổ sung prompt → LLM qua AI Gateway); Tùy chọn hybrid: lọc metadata, phương án dự phòng keyword D1 FTS5, reranking top 20 → top 5, MMR đa dạng hóa.

Vectorize là vector database có quản lý. Mỗi index lưu vector nhiều chiều (thường 384-1536 dim) + metadata tùy chọn. Query theo độ tương đồng cosine hoặc khoảng cách Euclidean.

Thiết lập

Tạo index:

npx wrangler vectorize create my-blog-index \
  --dimensions=1024 \
  --metric=cosine

Tùy chọn:

  • dimensions: phải khớp embedding model. bge-m3 = 1024, bge-small-en = 384, OpenAI text-embedding-3-small = 1536.
  • metric: cosine (mặc định cho text), euclidean, dot-product.

wrangler.jsonc:

{
  "vectorize": [
    {
      "binding": "VECTORIZE",
      "index_name": "my-blog-index"
    }
  ]
}

Upsert

await env.VECTORIZE.upsert([
  {
    id: "post-1-chunk-0",
    values: [0.01, -0.23, ...],  // 1024 float
    metadata: {
      postSlug: "hello-world",
      chunkIdx: 0,
      tags: ["cloudflare", "beginner"],
      title: "Hello World",
      lang: "vi",
    },
  },
  // ...
]);

Upsert là idempotent — cùng id sẽ ghi đè.

Query

const results = await env.VECTORIZE.query(queryEmbedding, {
  topK: 5,
  returnMetadata: "all",
  filter: {
    lang: "vi",
    tags: { $in: ["cloudflare"] },
  },
});

results.matches.forEach((m) => {
  console.log(m.id, m.score, m.metadata);
});

topK: số match top. Mặc định 5, tối đa 100.

filter: lọc metadata trước khi query. Giảm không gian recall.

returnMetadata: "none", "indexed" (chỉ field đã index), "all".


Pipeline ingest

Blog này có 58 bài markdown. Pipeline ingest:

1. Nạp markdown

import { glob } from "glob";
import { readFileSync } from "fs";
import matter from "gray-matter";

const files = await glob("src/content/blog/*.md");
const posts = files.map((f) => {
  const raw = readFileSync(f, "utf-8");
  const { data, content } = matter(raw);
  return { slug: f.split("/").pop()!.replace(".md", ""), frontmatter: data, body: content };
});

2. Chunk

Context LLM có giới hạn (Claude 4.7: 200k token, Llama 70B: 128k). Nhưng:

  • Retrieve cả bài 5000 token = tốn token + làm loãng tín hiệu.
  • Chunk nhỏ hơn (300-500 token) giúp match chính xác.
  • Nhưng chunk quá nhỏ mất context.

Chiến lược chunking:

A. Chunk theo ngữ nghĩa (khuyến nghị cho blog):

Cắt theo tiêu đề h2. Mỗi phần ~500 token. Chồng lấn 50 token giữa các chunk để không mất context ở biên.

function chunkBySection(markdown: string, maxTokens = 500, overlap = 50) {
  const sections = markdown.split(/(?=^## )/m);
  const chunks: string[] = [];

  for (const section of sections) {
    const tokens = estimateTokens(section);
    if (tokens <= maxTokens) {
      chunks.push(section);
    } else {
      // Split further by paragraph
      const paras = section.split(/\n\n/);
      let buffer = "";
      for (const p of paras) {
        if (estimateTokens(buffer + p) > maxTokens) {
          chunks.push(buffer);
          buffer = chunks.length > 0
            ? getLastTokens(buffer, overlap) + "\n\n" + p
            : p;
        } else {
          buffer += "\n\n" + p;
        }
      }
      if (buffer.trim()) chunks.push(buffer);
    }
  }

  return chunks;
}

function estimateTokens(text: string): number {
  // Ước chừng: 1 token ~ 4 ký tự tiếng Anh, ~ 3 ký tự tiếng Việt
  return Math.ceil(text.length / 3.5);
}

B. Chunk kích thước cố định (đơn giản):

function chunkFixed(text: string, size = 500, overlap = 50): string[] {
  const chunks: string[] = [];
  const tokens = text.split(/\s+/);
  for (let i = 0; i < tokens.length; i += size - overlap) {
    chunks.push(tokens.slice(i, i + size).join(" "));
  }
  return chunks;
}

C. Nhận biết câu (cho tài liệu dài):

Dùng thư viện như @llamaindex/langchain-text-splitters với RecursiveCharacterTextSplitter.

3. Embed

async function embed(texts: string[], env: Env): Promise<number[][]> {
  const { data } = await env.AI.run("@cf/baai/bge-m3", { text: texts });
  return data;
}

Workers AI batch 100 text cùng lúc. Chia batch nếu nhiều hơn:

async function embedBatch(texts: string[], env: Env): Promise<number[][]> {
  const results: number[][] = [];
  for (let i = 0; i < texts.length; i += 100) {
    const batch = texts.slice(i, i + 100);
    const emb = await embed(batch, env);
    results.push(...emb);
  }
  return results;
}

4. Upsert vào Vectorize

async function ingestPost(post: Post, env: Env) {
  const chunks = chunkBySection(post.body);
  const embeddings = await embedBatch(chunks, env);

  const vectors = chunks.map((chunk, i) => ({
    id: `${post.slug}:${i}`,
    values: embeddings[i],
    metadata: {
      postSlug: post.slug,
      chunkIdx: i,
      title: post.frontmatter.title,
      tags: post.frontmatter.tags,
      lang: post.frontmatter.lang,
      chunkText: chunk.slice(0, 500),  // xem trước cho debug, không dùng cho prompt LLM
    },
  }));

  // Upsert theo batch (tối đa 1000 vector mỗi call)
  for (let i = 0; i < vectors.length; i += 1000) {
    await env.VECTORIZE.upsert(vectors.slice(i, i + 1000));
  }
}

5. Tự động hóa

Phương án A: Script chạy lúc build, chạy trong CI sau npm run build.

# .github/workflows/deploy.yml
- run: npm run build
- run: npm run ingest  # script ingest vào Vectorize
- run: npx wrangler deploy

Phương án B: Scheduled Worker. Cron chạy mỗi giờ kiểm tra bài mới, ingest.

Phương án C: Consumer Queue. Webhook từ CMS → đẩy vào Queue → consumer ingest.

Blog này dùng Phương án A — ingest lúc build. Script chạy 1-2 phút cho 58 bài.


Pipeline query

Query RAG từ user “Cloudflare D1 là gì” → LLM trả lời:

async function ragQuery(query: string, env: Env): Promise<string> {
  // 1. Embed query
  const { data } = await env.AI.run("@cf/baai/bge-m3", { text: [query] });
  const queryEmbedding = data[0];

  // 2. Search top-K
  const results = await env.VECTORIZE.query(queryEmbedding, {
    topK: 5,
    returnMetadata: "all",
  });

  // 3. Dựng context từ các match
  const context = results.matches
    .map((m) => `[${m.metadata.title}]\n${m.metadata.chunkText}`)
    .join("\n\n---\n\n");

  // 4. Bổ sung prompt
  const messages = [
    {
      role: "system",
      content: `Bạn là trợ lý trả lời câu hỏi từ blog của KhaVan.
Trả lời dựa CHỈ trên context. Nếu context không có thông tin, nói "Tôi không biết".
Trích dẫn nguồn với format [Title của post].`,
    },
    {
      role: "user",
      content: `Context:\n\n${context}\n\nCâu hỏi: ${query}`,
    },
  ];

  // 5. Gọi LLM qua AI Gateway
  const response = await env.AI.run(
    "@cf/meta/llama-3.3-70b-instruct",
    { messages },
    {
      gateway: { id: "my-gateway", cacheTtl: 3600 },
    }
  );

  return response.response;
}

Phân tách độ trễ (edge, warm):

  • Embed query: ~30ms
  • Query Vectorize: ~50ms
  • LLM sinh: 500-2000ms (thời gian chính)
  • Tổng: ~600-2100ms

Với AI Gateway cache hit, tất cả ~200ms.


Chunking: chi tiết quan trọng

Kích thước chunk ảnh hưởng recall

Thử với blog 58 bài:

Kích thước chunkSố chunkTop-5 recall (chunk liên quan trong top 5)
200 token120062%
500 token58078%
1000 token32071%
Cả bài5845%

Điểm ngọt: 500 token cho blog công nghệ. Quá nhỏ mất context, quá lớn làm loãng tín hiệu.

Điều chỉnh theo loại nội dung:

  • Docs / tham chiếu: 300-500 token (thông tin đậm).
  • Blog / dài: 500-800 token (dẫn chuyện).
  • Code: cắt theo function (hiểu AST).
  • Hội thoại: 1 lượt = 1 chunk.

Chồng lấn tránh cắt đứng ở biên

Chồng lấn 10-20% ngăn trường hợp câu trả lời nằm đúng vị trí biên:

Chunk 0: [... Worker được triển khai tới]
Chunk 1: [300+ PoP trên toàn cầu ...]
Query: "Worker triển khai ở đâu"

Nếu không chồng lấn, không chunk nào match. Chồng lấn 50 token:

Chunk 0: [... Worker được triển khai tới 300+ PoP]
Chunk 1: [triển khai tới 300+ PoP trên toàn cầu ...]

Cả 2 chunk match.

Thêm context heading

Chunk 0 của bài “D1 deep-dive” có đầy đủ context. Chunk 10 (phần “Gotcha”) không biết mình thuộc bài nào.

Fix: prepend title + đường dẫn heading vào mỗi chunk:

const chunkWithContext = `# ${post.title}\n## ${section.heading}\n\n${section.text}`;

Chi phí nhỏ (+50 token/chunk) nhưng tăng recall và LLM hiểu context.


Lọc metadata

Lọc trước query giảm không gian tìm kiếm, tăng độ chính xác.

await env.VECTORIZE.query(embedding, {
  topK: 5,
  filter: {
    lang: "vi",
    tags: { $in: ["d1", "database"] },
    publishDate: { $gte: 1704067200 },  // > 2024-01-01
  },
});

Nhưng metadata phải được index khi tạo. Tạo index với index metadata:

npx wrangler vectorize create-metadata-index my-blog-index \
  --property-name=lang --type=string

npx wrangler vectorize create-metadata-index my-blog-index \
  --property-name=tags --type=string

npx wrangler vectorize create-metadata-index my-blog-index \
  --property-name=publishDate --type=number

Tối đa 10 field được index mỗi index. Lên kế hoạch trước field nào lọc thường xuyên.

Tình huống

  • Multi-tenant: lọc tenantId để user A không thấy dữ liệu user B.
  • Ngôn ngữ: lọc lang để blog VI không trả bài tiếng Anh.
  • Độ tươi: lọc publishDate để ưu tiên bài mới.
  • Quyền: lọc visibility: "public" để tránh rò rỉ nội dung riêng tư.

Hybrid search: vector + keyword

Vector search dở với:

  • Match chính xác (tên, mã code).
  • Phủ định (“không”, “except”).
  • Từ hiếm (acronym, tên sản phẩm).

Keyword search dở với:

  • Tương đồng ngữ nghĩa (query “triển khai” match bài về “publish”).
  • Đa ngôn ngữ (query VI, nội dung EN).

Hybrid = cả hai, gộp điểm.

Triển khai với D1 FTS5

Thiết lập full-text search D1:

CREATE VIRTUAL TABLE posts_fts USING fts5(
  slug UNINDEXED,
  title,
  body,
  tags
);

INSERT INTO posts_fts (slug, title, body, tags)
SELECT slug, title, body, tags FROM posts;

Query song song:

async function hybridSearch(query: string, env: Env) {
  // Song song: vector + keyword
  const [vectorResults, keywordResults] = await Promise.all([
    vectorSearch(query, env),
    keywordSearch(query, env),
  ]);

  // Reciprocal Rank Fusion (RRF)
  const scores = new Map<string, number>();
  const k = 60;  // hằng số RRF

  vectorResults.forEach((r, i) => {
    scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (k + i));
  });

  keywordResults.forEach((r, i) => {
    scores.set(r.id, (scores.get(r.id) ?? 0) + 1 / (k + i));
  });

  // Sắp xếp theo điểm gộp
  const ranked = Array.from(scores.entries())
    .sort((a, b) => b[1] - a[1])
    .slice(0, 5);

  return ranked;
}

async function keywordSearch(query: string, env: Env) {
  const results = await env.DB
    .prepare("SELECT slug, rank FROM posts_fts WHERE posts_fts MATCH ? ORDER BY rank LIMIT 10")
    .bind(query)
    .all();
  return results.results.map((r, i) => ({ id: r.slug, rank: i }));
}

RRF là công thức đơn giản và hoạt động tốt trong thực tế.


Reranking

Top-K từ vector không luôn chính xác. Model cross-encoder rerank lại top 20 xuống top 5 với độ chính xác cao hơn.

Cross-encoder vs bi-encoder

  • Bi-encoder (bge-m3): embed query và doc độc lập, so sánh vector. Nhanh, mở rộng tốt.
  • Cross-encoder (bge-reranker): lấy cả query + doc làm đầu vào, sinh điểm. Chính xác hơn, chậm hơn.

Workers AI chưa có cross-encoder native (tới 05/2026). Cách đi vòng: dùng LLM làm trọng tài.

async function rerank(query: string, candidates: Match[], env: Env) {
  const prompt = `Rank these 10 passages by relevance to query: "${query}"

Passages:
${candidates.map((c, i) => `[${i}] ${c.metadata.chunkText}`).join("\n\n")}

Return JSON array of indices in order, most relevant first. Example: [3, 7, 0, ...]`;

  const response = await env.AI.run(
    "@cf/meta/llama-3.1-8b-instruct",
    { messages: [{ role: "user", content: prompt }] }
  );

  const ranking = JSON.parse(response.response);
  return ranking.slice(0, 5).map((i: number) => candidates[i]);
}

Chi phí: thêm 1 call LLM (~$0.001). Đánh đổi với độ chính xác.


Blog này làm gì

/api/search endpoint cho blog:

// worker/search.ts
export async function handleSearch(request: Request, env: Env) {
  const { query, lang } = await request.json();

  // Embed query
  const { data } = await env.AI.run("@cf/baai/bge-m3", { text: [query] });

  // Top-K với lọc lang
  const results = await env.VECTORIZE.query(data[0], {
    topK: 8,
    returnMetadata: "all",
    filter: { lang },
  });

  // Khử trùng lặp theo postSlug (nhiều chunk cùng bài)
  const seen = new Set<string>();
  const deduped = results.matches.filter((m) => {
    if (seen.has(m.metadata.postSlug)) return false;
    seen.add(m.metadata.postSlug);
    return true;
  }).slice(0, 5);

  return Response.json({
    results: deduped.map((m) => ({
      slug: m.metadata.postSlug,
      title: m.metadata.title,
      score: m.score,
      preview: m.metadata.chunkText,
    })),
  });
}

Khử trùng lặp quan trọng: nếu không, 5 chunk của 1 bài nổi tiếng chiếm hết top-5, bỏ sót bài khác liên quan.


So sánh với phương án khác

So sánh Vectorize với Pinecone, Qdrant, pgvector theo: giá, chi phí egress, hybrid có sẵn, lọc metadata, giới hạn mở rộng, và đánh đổi vận hành.

Khi Vectorize tốt nhất

  • Stack Worker + AI, muốn không egress.
  • < 5M vector (giới hạn hiện tại).
  • Pattern query đơn giản: top-K + lọc metadata cơ bản.
  • Không cần hybrid có sẵn (OK với ghép D1).
  • Nhạy cảm chi phí (rẻ nhất trong các lựa chọn có quản lý).

Khi cần Pinecone

  • Mở rộng > 10M vector.
  • Hybrid search có sẵn (sparse + dense).
  • Lọc metadata nâng cao phức tạp.
  • Đã có team / hợp đồng Pinecone.

Khi cần Qdrant

  • Tự host để kiểm soát.
  • Query nâng cao (lọc lồng, group by, tìm kiếm địa lý).
  • MMR + reranking có sẵn.
  • Ưu tiên mã nguồn mở.

Khi cần pgvector

  • Đã có Postgres, muốn 1 database.
  • Query cần JOIN với dữ liệu quan hệ.
  • Mở rộng < 100M vector.
  • Với Workers: dùng qua Hyperdrive.

Gotcha

① Lệch dimension

Index tạo với dimensions=1024 (bge-m3), nhưng upsert vector 768-dim (bge-base). Lỗi khi chạy. Luôn kiểm tra:

if (embedding.length !== 1024) throw new Error("Dimension mismatch");

② Giới hạn kích thước metadata

Metadata Vectorize tối đa 10KB/vector. Không lưu full body bài, chỉ xem trước + tham chiếu. Nội dung đầy đủ ở D1/R2.

③ Cập nhật không atomic với embedding

Bài cập nhật title + body → embed lại + upsert. Nếu upsert một phần (10 chunk update OK, chunk 11 fail), index không nhất quán.

Fix: xóa tất cả chunk của bài trước, rồi upsert lại:

await env.VECTORIZE.deleteByIds([`${slug}:0`, `${slug}:1`, ...]);
await env.VECTORIZE.upsert(newChunks);

Hoặc dùng queue để retry phần fail.

④ bge-m3 tokenize VI chưa hoàn hảo

VI có từ ghép 2-3 âm tiết. bge-m3 tokenize theo BPE, không hiểu cấu trúc VI. Kết quả vẫn tốt, nhưng đôi khi miss query chính xác (“cloud security” vs “an ninh đám mây”).

Fix: index song song cả bản EN và VI của bài. Query theo lang của user.

⑤ Top-K không có ngưỡng điểm

VECTORIZE trả đủ top-5 ngay cả khi điểm thấp (0.3 = không liên quan). Lọc ngưỡng ở phía ứng dụng:

const filtered = results.matches.filter((m) => m.score > 0.5);
if (filtered.length === 0) return { answer: "Tôi không tìm thấy thông tin" };

⑥ Chi phí mở rộng theo số chunk

1000 bài, 10 chunk/bài = 10k vector. Query 1k/ngày = 10M dimension/ngày = ~$0.30/ngày. Với blog cá nhân có thể bỏ qua. Với enterprise 10M vector, kiểm tra giá kỹ.

⑦ Dev local không có Vectorize

wrangler dev không giả lập Vectorize (tới 05/2026). Phải dùng --remote:

wrangler dev --remote

Chậm hơn local, nhưng query Vectorize thật.


Observability

Metrics cần theo dõi:

  • Recall@5: % query có chunk liên quan trong top 5. Đánh giá với bộ test có ground-truth.
  • Độ trễ: embed + query vector + LLM. Phân tách từng bước.
  • Chi phí/query: chi phí embedding + chi phí query vector + chi phí LLM.
  • Tỉ lệ không có câu trả lời: % query trả “không biết” (ngưỡng điểm quá cao).
  • Phản hồi user: thumb-up/down để lặp cải thiện.

Log qua AI Gateway + Analytics Engine. Chi tiết Part 17.


Production checklist

  • Chiến lược chunking phù hợp loại nội dung (ngữ nghĩa cho blog, cố định cho docs).
  • Chồng lấn 10-20% giữa chunk.
  • Context heading được prepend vào mỗi chunk.
  • Model embedding khớp dimension với index.
  • Field lọc metadata đã được index (tối đa 10 field).
  • Khử trùng lặp top-K theo tài liệu nguồn.
  • Ngưỡng điểm để không trả kết quả không liên quan.
  • Hybrid search (vector + keyword) cho trường hợp match chính xác.
  • Reranking (LLM làm trọng tài hoặc cross-encoder) cho độ chính xác cao.
  • Ingest idempotent (chạy lại không nhân đôi).
  • Chiến lược cập nhật: xóa-rồi-upsert hoặc versioning.
  • Giám sát + cảnh báo chi phí.
  • Pipeline đánh giá với bộ test có ground-truth.

Kết

Vectorize + Workers AI + D1 là stack RAG trọn edge, không egress. Đủ cho blog, docs, bot Q&A < 5M tài liệu. Mở rộng lớn hơn hoặc cần hybrid có sẵn → Pinecone/Qdrant/pgvector có ưu thế.

Nhưng RAG không phải phép màu. Chunking, metadata, hybrid, reranking, đánh giá đều là việc kỹ thuật. Vectorize cho hạ tầng; pattern là của team.

Part 15: Durable Objects cho realtime — chat, trình soạn thảo cộng tác, game state, điều phối WebSocket, và khi nào Durable Object là công cụ đúng.


Tham khảo