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:
- Retrieve: tìm tài liệu/chunk liên quan từ kho kiến thức.
- Augment: đưa chunk đó vào prompt làm context.
- 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ì
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 chunk | Số chunk | Top-5 recall (chunk liên quan trong top 5) |
|---|---|---|
| 200 token | 1200 | 62% |
| 500 token | 580 | 78% |
| 1000 token | 320 | 71% |
| Cả bài | 58 | 45% |
Đ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
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.