| name | cf-rag-integration |
| description | Use when building a Cloudflare-native RAG (retrieval-augmented generation) layer on a Hono Worker, or grafting cf-rag's ingest + vector-search + MCP pattern onto an existing Hono + Cloudflare Workers app. Covers the idempotent-ingest invariant, the embed-model drift state machine, the Vectorize-as-source-of-truth-for-content split, and the MCP server wiring. |
cf-rag-integration
This repo (cf-rag) is a working reference implementation of a Cloudflare-native RAG service. The whole point is that the patterns inside it should be liftable — drop a few files into an existing Hono + Workers project and you have semantic search, idempotent ingest, an MCP server, and an auto-generated OpenAPI spec.
This skill captures the design invariants that make it work, the order to assemble pieces in (whether building fresh or integrating), and the traps to avoid.
When to use this skill
- The user wants to add vector search / RAG to a Cloudflare Worker.
- The user wants to expose a knowledge base to LLM clients (Claude Desktop, Cursor, etc.) via MCP.
- The user is reading
cf-rag source and asks "how would I do this in my own project."
- The user says "build me a RAG service" without naming the stack and Cloudflare is already in the mix (Workers, D1, or Vectorize binding in their
wrangler.jsonc).
Do not use this for: non-Cloudflare stacks (pgvector / Pinecone / Weaviate), pure embedding-without-search (just env.AI.run), or LLM answer synthesis (cf-rag does not do that — the client LLM does).
Architecture in one diagram
HTTP client ──┐
├─► Hono app ──► requireAuth(scope) ──► route ──► core.ts ──► Workers AI (embed)
LLM via MCP ──┘ ──► Vectorize (vectors + content metadata)
──► D1 via Drizzle (hash mirror + api_keys + meta)
Queue producer ──► CF Queue ──► same Worker's `queue()` handler ──► core.ts (same ingest path)
Service binding ──► RagEntrypoint (WorkerEntrypoint RPC, no auth)
One Worker, three entrypoints, one ingest path. The single ingest function (ingestDoc) is called from HTTP, queue, and RPC. That's the invariant; don't fork it.
The three load-bearing invariants
If you only remember three things from cf-rag, remember these. Breaking any of them silently corrupts the index.
1. Idempotency = (contentHash, embedModel) match
const sameContent = existing?.contentHash === contentHash;
const sameModel = !existing?.embedModel || existing.embedModel === cfg.EMBED_MODEL;
if (existing && sameContent && sameModel && !doc.forceReindex) return { skipped: true };
contentHash is SHA-256 over stableStringify({ title, body, sortedTags, metadata }). Stable stringify: sort object keys, sort tag arrays before hashing. Without sorting, semantically-identical docs hash differently and you re-embed on no-op writes.
- Tracking the model alongside the hash is what lets you change embed models without an explicit migration step — see invariant 3.
2. Vectorize metadata is the source of truth for content; D1 is the source of truth for state
- D1 stores only:
id, contentHash, indexedAt, active, embedModel. No body, no title.
- Vectorize stores the full body (as
rawBody), the formatted markdown used for embedding, tags, custom metadata, and active.
- This split is deliberate: Vectorize has no list/scan API, so you walk D1 to find ids, then
getByIds from Vectorize to read content. D1 stays small. Vectorize is your blob store + index.
- Always store the raw body in metadata under a dedicated field (
rawBody in cf-rag). Do not try to reconstruct the body from the formatted markdown — the reverse-engineering is lossy and silently corrupts content on reindex.
3. Embed model drift is a four-state machine, not a flag
ensureModelState(env) compares env-configured EMBED_MODEL / EMBED_DIMS against what was last written to D1 meta:
| Status | Trigger | Action |
|---|
fresh | no stored model (first boot) | Write current as stored. Carry on. |
ok | stored == env | No-op. |
reindexing | same dims, different model name | Enqueue all docs with forceReindex=true + a _reindexFromQueue marker. Bump meta. |
dims_mismatch | different dims | Throw 503 on every request. Vectorize dims are immutable — operator must intervene. |
The reindex consumer pulls each doc's rawBody from Vectorize metadata and re-embeds with the new model. There is no "where did the body go" question because invariant 2 already answered it.
Cache the state per-isolate (60s TTL) so you don't hit D1 on every request. Throw on dims_mismatch from both ingestDoc and searchDocs — fail loud, no silent corruption.
Build-from-scratch order
If you're starting a fresh project, do it in this order. Skipping a step works until it doesn't.
wrangler.jsonc bindings first. AI, Vectorize, D1, Queues. Set remote: true on AI and Vectorize (local wrangler dev doesn't run Vectorize/AI). Pick a compatibility_date you control.
- Vectorize index + metadata indexes.
wrangler vectorize create <name> --dimensions=768 --metric=cosine, then create-metadata-index for tags (string) and active (boolean). Without metadata indexes, filter clauses in .query() silently fail.
- Drizzle schema before any queries.
documents, api_keys, meta tables. Emit migrations via drizzle-kit generate; apply with wrangler d1 migrations apply --local. Never write raw SQL in app code — only scripts/mint-key.ts (a node CLI that can't load the D1 binding) is allowed that exception.
computeContentHash and stableStringify in core.ts. Write the unit tests for these first — tag-order and metadata-key-order invariance. If these don't hold, everything else is broken.
ingestDoc happy path. D1 lookup → hash check → AI embed → Vectorize upsert → D1 upsert. Idempotency check is the first thing after computing the hash; AI is only called if the check misses.
searchDocs. Embed the query, env.VECTORIZE.query() with the same dims, map metadata → hit. topK capped at a sensible max (50 in cf-rag).
- Auth as middleware, not inline checks.
requireAuth(scope) wraps each route; the internal-secret super-admin is a strict-subset bypass (whoever has the secret has all scopes). Hash both sides of the secret comparison before timingSafeEqual — never timingSafeEqual(rawToken, rawSecret), that leaks the secret's length.
- OpenAPI via
hono-openapi. Reuse the same zod schemas for validator("json", S) and resolver(S) in describeRoute. Spec drift becomes structurally impossible.
- MCP last.
@hono/mcp + @modelcontextprotocol/sdk. Build a fresh McpServer per request inside an app.all("/mcp") handler, connect a StreamableHTTPTransport, return transport.handleRequest(c). Tool descriptions matter — write them for the LLM, not for humans.
- Queue consumer + RPC entrypoint. Both just call
ingestDoc(env, msg.body.doc). WorkerEntrypoint subclass for service-binding consumers (no auth — CF service bindings are structurally trusted).
- Model-state check. Add
ensureModelStateCached and call it from the top of ingestDoc and searchDocs. This is the last thing because everything else has to work before drift detection is meaningful.
Integration into an existing Hono + Workers project
You don't need to copy the whole repo. The lift is roughly five files plus a schema migration.
Files to copy
| From cf-rag | What it gives you | Modify how |
|---|
src/core.ts | ingestDoc, searchDocs, getDoc, deleteDoc, getStats | Rename binding names if yours differ. |
src/model-state.ts | drift state machine + per-isolate cache | None. |
src/db/schema.ts | documents + meta tables | Drop api_keys if you already have auth. |
src/config.ts | getConfig(env) for env-driven model/dims | None. |
src/mcp.ts | MCP tools | Adjust tool descriptions for your corpus. |
src/auth/api-key.ts (optional) | pi_* scoped keys + internal secret | Skip if you have existing auth. |
Wiring into your existing Hono app
import { Hono } from "hono";
import { ingestDoc, searchDocs } from "./rag/core";
import { ensureModelStateCached } from "./rag/model-state";
import { buildMcpServer } from "./rag/mcp";
import { StreamableHTTPTransport } from "@hono/mcp";
const app = new Hono<{ Bindings: YourBindings }>();
app.post("/rag/ingest", yourExistingAuthMiddleware("write"), async (c) => {
return c.json(await ingestDoc(c.env, await c.req.json()));
});
app.post("/rag/search", yourExistingAuthMiddleware("read"), async (c) => {
return c.json(await searchDocs(c.env, await c.req.json()));
});
app.all("/mcp", yourExistingAuthMiddleware("read"), async (c) => {
const server = buildMcpServer(c.env);
const transport = new StreamableHTTPTransport();
await server.connect(transport);
return (await transport.handleRequest(c)) ?? c.body(null, 204);
});
export default {
fetch: app.fetch,
async queue(batch, env) {
for (const msg of batch.messages) {
try { await ingestDoc(env, msg.body.doc); msg.ack(); }
catch (e) { console.error(e); msg.retry(); }
}
},
};
Bindings additions to wrangler.jsonc
{
"ai": { "binding": "AI", "remote": true },
"vectorize": [{ "binding": "VECTORIZE", "index_name": "<your-index>", "remote": true }],
"d1_databases": [{
"binding": "DB",
"database_name": "<your-d1>",
"database_id": "<id>",
"migrations_dir": "drizzle/migrations"
}],
"queues": {
"producers": [{ "binding": "RAG_INGEST_QUEUE", "queue": "rag-ingest" }],
"consumers": [{
"queue": "rag-ingest",
"max_batch_size": 10,
"max_retries": 5,
"dead_letter_queue": "rag-ingest-dlq"
}]
},
"vars": {
"EMBED_MODEL": "@cf/baai/bge-base-en-v1.5",
"EMBED_DIMS": "768",
"AUTO_REINDEX_ON_MODEL_CHANGE": "true"
}
}
If your project already has a D1 binding under a different name (e.g., MY_DB), either rename it to DB or rename documents/meta table refs everywhere. The first is much less work.
Drizzle schema merge
If your project doesn't use Drizzle, the lowest-friction path is to introduce it just for the RAG tables — Drizzle co-exists with hand-rolled D1 queries since it's just a thin query builder over the same binding. Add drizzle/migrations/ alongside any existing migrations/ dir; both get applied by wrangler d1 migrations apply if you point migrations_dir correctly.
Auth choice
Three options, in increasing order of effort:
- Already have auth? Wrap the new routes in your existing middleware. Done.
- No auth, just a private internal service? Set
INTERNAL_API_SECRET as a wrangler secret, require Authorization: Bearer ${INTERNAL_API_SECRET} on the new routes. Hash-compare both sides (see src/auth/api-key.ts:compareInternalSecret).
- Want scoped API keys for external users? Copy
src/auth/ wholesale and the api_keys table.
Traps and gotchas
These are all things that took time to debug in cf-rag and will take time again if forgotten.
wrangler dev under pm2 → EBADF death loop. The wrangler parent process keeps writing to its pm2 IPC channel and crashes when the fd is gone. workerd survives but remote bindings stop working. Use nohup bunx wrangler dev ... & or run in foreground.
- Vectorize metadata indexes are not in
wrangler.jsonc. They're created via wrangler vectorize create-metadata-index <index> --property-name=<x> --type=<string|boolean>. If you forget, filter queries return empty results silently — no error.
- Vectorize newly-ingested docs are not immediately searchable. ~25s propagation. Bake this into integration tests and user-facing copy ("indexing…").
- Local wrangler dev does not support Vectorize. That's why
remote: true. Same for AI on the free tier in many setups.
bun test vs bun run test. Bun's own test runner is bun test. To run vitest you need bun run test (or bunx vitest run). Easy to miss.
- Drizzle-kit
meta/ journal vs wrangler .sql files. Both coexist in drizzle/migrations/. Wrangler ignores meta/; drizzle-kit needs it for diffing. Never hand-edit either after generation.
@cloudflare/vitest-pool-workers wipes local D1 state via miniflare's in-memory binding. Tests must apply migrations in beforeAll. Cf-rag does this with readD1Migrations(path.resolve("drizzle/migrations")) injected as a TEST_MIGRATIONS binding.
- Errors that should map to a specific HTTP status: throw a
new Error(msg) with (err as { status?: number }).status = 503 and let app.onError translate it. Don't c.json(..., 503) from inside core functions — they're called from the queue consumer and RPC entrypoint too, where there's no c.
- Internal-secret comparison must hash both sides first.
timingSafeEqual returns false on length mismatch and that leaks the secret's length. Hash via SHA-256 then compare the equal-length hex strings.
- The MCP server is built per-request, not per-isolate. Building once and reusing breaks streaming transport state across concurrent requests.
Testing strategy
The high-leverage tests in cf-rag are:
computeContentHash stability under tag/metadata-key reorder. Unit-testable, no bindings needed.
- Auth path — missing bearer / wrong bearer / wrong scope / internal-secret accepted. Integration-style via
SELF.fetch.
- OpenAPI surface —
paths["/v1/search"] exists. Catches accidental route deletion.
ingestDoc short-circuit with AI mocked to throw. The most important test: if the dedup invariant breaks, this fails. Pattern:
const realAi = env.AI;
(env as { AI: unknown }).AI = { run: async () => { throw new Error("should not call"); } };
try {
const result = await ingestDoc(env, doc);
expect(result.skipped).toBe(true);
} finally {
(env as { AI: unknown }).AI = realAi;
}
End-to-end search/ingest tests need remote Vectorize + AI, which makes them slow and flaky. Skip them in CI; run them in a smoke-test script (scripts/smoke.ts) before deploy. Sleep 25s between ingest and search for index propagation.
Reference: file responsibilities in cf-rag
When the user is reading cf-rag source, point them at the right file:
| File | What lives here |
|---|
src/index.ts | The three entrypoints (fetch, queue, RagEntrypoint). |
src/app.ts | Hono composition, /openapi.json, /docs, /mcp, app.onError. |
src/core.ts | ingestDoc, searchDocs, getDoc, deleteDoc, getStats, hashing. |
src/model-state.ts | Drift state machine + per-isolate cache. |
src/config.ts | getConfig(env) reading env at request time. |
src/schemas.ts | All zod schemas (reused in validator() and resolver()). |
src/mcp.ts | MCP server + tool definitions. |
src/auth/api-key.ts | requireAuth(scope), requireInternalSecret(). |
src/auth/hash.ts | sha256Hex, timingSafeEqual, randomTokenHex. |
src/db/schema.ts | Drizzle table definitions. |
src/routes/public.ts | /v1/* HTTP routes. |
src/routes/internal.ts | /v1/internal/* admin routes. |
src/ui.tsx | Dark dashboard (hono/html template literals despite .tsx). |
drizzle/migrations/*.sql | drizzle-kit emitted; applied by wrangler. |
scripts/mint-key.ts | Node CLI (raw SQL exception — can't load D1 binding from node). |
scripts/setup.ts | One-time CF resource creation. |
scripts/smoke.ts | E2E smoke test (sleeps for Vectorize propagation). |
Don't
- Don't add LLM answer synthesis to this layer. The MCP client (Claude / Cursor) does that. Stay a pure retrieval service.
- Don't add chunking. The caller supplies already-sized docs. Chunking heuristics belong in the producer.
- Don't store doc bodies in D1. Vectorize metadata is the source of truth for content.
- Don't write raw SQL in app code. Drizzle for everything except the node CLI.
- Don't skip the model-state check to "make tests faster." It's <1ms after the first call and the corruption it prevents is unrecoverable.
- Don't
c.json(..., 503) from inside core. Throw with status: 503 attached and let app.onError translate.