我第一次在2024年的黑客马拉松上看到RAG。令我难忘的一刻是意识到LLM可以“搜索”我刚上传的文档并回答关于它们的问题——不是来自其训练数据,而是来自我的内容。我真的很惊讶;这感觉就像是与仅仅与模型聊天不同的一类能力。
如今,这已经不是新鲜事了——对于任何需要回答其训练数据未涉及内容的聊天机器人来说,这几乎是基本期望。聊天机器人只知道它训练时所学的内容——如果你询问它关于你自己的文档、产品或笔记,它要么会编造答案,要么告诉你它不知道。检索增强生成(RAG)是标准的解决方案:在模型回答之前,你先找到实际相关的文本并将其作为上下文提供。我想构建该模式的最小版本,使其仍然诚实——检索到正确的内容,在无法检索时承认,并且在摄取文档过程中不会丢失数据。
这是对 带有RAG的聊天机器人 的演练,一个完全在Cloudflare上运行的最小RAG聊天演示:
- Workers AI 用于嵌入和生成。
- Vectorize 用于相似性搜索。
- D1 用于存储实际文本。
- Workflows 用于持久化摄取。
无需LangChain agent框架,无需自托管向量数据库,无需独立后端——一个Worker,四个绑定。
架构
两个流程,运行在同一个Worker中。
两个存储通过共享的id关联:D1中块的自增id正是该块在Vectorize中的向量id。相似性搜索会返回一个id;在D1中根据该id查询可得到实际文本。无需元数据重复,无需保持第二个索引同步——只有一个id,两个存储,各自存储它们擅长的内容的真实来源(Vectorize for "相似的内容," D1 for "实际内容是什么")。
为什么摄取通过Workflow运行,而不是普通循环
显而易见的摄入方式是:拆分文档,遍历块,为每个块生成嵌入并插入,完成。问题在于当第 12 块中的第 7 块失败时会发生什么——无论是受限制的嵌入调用,还是短暂的 D1 故障,都无关紧要。直接循环要么导致整个请求崩溃(丢失全部 12 块),要么需要你实现重试逻辑。
Cloudflare Workflows 通过将每个 step.do() 调用设为持久化检查点来解决此问题。如果某个步骤失败,只有 该步骤 会重试——在此之前的所有步骤已经保存且不会重新执行:
export class RAGWorkflow extends WorkflowEntrypoint<Env, RagWorkflowParams> {
async run(event: WorkflowEvent<RagWorkflowParams>, step: WorkflowStep) {
const { data } = event.payload;
const texts = await step.do('split text', async () => {
// split `data` into chunks with RecursiveCharacterTextSplitter
});
for (const [i, text] of texts.entries()) {
const record = await step.do(`store in D1 db: ${i}/${texts.length}`, async () => {
// insert the chunk's text into D1, return its new row id
});
const vector = await step.do(`Generate Embeddings: ${i}/${texts.length}`, async () => {
// embed the chunk's text via Workers AI (bge-base-en-v1.5)
});
await step.do(`Insert Vector: ${i}/${texts.length}`, async () => {
// upsert { id: record, values: vector } into Vectorize
});
}
}
}
值得明确说明的是,因为这是个容易陷入的陷阱:循环是放在 step.do() 的 外部,而不是内部。将若干次插入的循环放在单个 step.do() 调用内部,Workflows 只能将整个步骤作为一个单位进行检查点 - 第 7 项失败意味着 全部 步骤会重试,重新插入第 1 到第 6 项。为每个块分配一个唯一命名的步骤(如 store in D1 db: 3/12,而非在各次迭代中重复使用的泛化 store in D1 db —— 步骤名称是 Workflows 用来追踪已完成内容的缓存键),这实际上才能让你获得按块重试而不是按批次重试的能力。
此外要注意的是,record —— 这是 step.do() 从 D1 插入返回的 ID:
const record = await step.do(`store in D1 db: ${i}/${texts.length}`,
async () => { /* ... */ });
在 Vectorize upsert 中两步后重新出现:
env.VECTORIZE.upsert([{ id: record.toString(), values: vector }]);
这并非偶然:step.do() 回调返回的任何内容,正是 Workflows 持久化保存为该步骤结果的内容,因此后续步骤可以免费复用,而无需重新计算或重新获取。这与架构图中虚线块 ID 箭头所指向的关系相同——一个步骤产生的值被后续步骤消费,中间没有任何环节需要知道它的来源。
设置绑定
在 wrangler.jsonc 中有四个绑定 - Workers AI、Vectorize、D1 和 Workflow 本身:
{
"ai": { "binding": "AI" },
"vectorize": [{ "binding": "VECTORIZE", "index_name": "vector-index" }],
"d1_databases": [{ "binding": "database", "database_name": "database", "database_id": "..." }],
"workflows": [{ "name": "rag", "binding": "RAG_WORKFLOW", "class_name": "RAGWorkflow" }],
"assets": { "directory": "./public/", "binding": "ASSETS" }
}
在向其中写入数据之前,必须先确保 Vectorize 已经存在:
npx wrangler vectorize create vector-index --dimensions=768 --metric=cosine
768 维度是因为 @cf/baai/bge-base-en-v1.5 —— 即嵌入模型 —— 输出的维度。如果以后更换嵌入模型,维度必须匹配,否则插入操作会直接失败。
查询侧:带下限的检索,而不仅仅是顶部匹配
这部分实际上决定了聊天机器人是可靠的还是只是自信地错误。这里有两个关键决策:检索多少个块,以及当它们实际上都不相关时该怎么做。
const RELEVANCE_THRESHOLD: number = 0.58;
const TOP_K: number = 3;
export function filterRelevant(matches: VectorizeMatch[], threshold = RELEVANCE_THRESHOLD) {
return matches.filter((match) => match.score > threshold);
}
async function QueryVector(question: string, c: Context<AppEnv>) {
const modelResp = await c.env.AI.run('@cf/baai/bge-base-en-v1.5', { text: question });
const vector = modelResp.data[0]; // (async/queued-response guard omitted for brevity)
let { matches } = await c.env.VECTORIZE.query(vector, { topK: TOP_K });
matches = filterRelevant(matches);
const notes: string[] = [];
const citeIds: number[] = [];
for (const match of matches) {
// look up match.id's text in D1, push it (and its id) onto notes / citeIds
}
const contextMessage = notes.length ? `Context:\n${notes.map((note) => `- ${note}`).join('\n')}` : '';
return { contextMessage, citeIds };
}
topK: 3 最多可检索三个候选项 - 但 filterRelevant 仍可能在没有任何项超过阈值时全部丢弃,这一点正是关键:Vectorize 总会返回 某个 与你的查询向量相近的结果,即便“最近”仍然意味着“不实际相关”。不顾分数强制使用最近匹配正是导致聊天机器人对每个问题都给出毫无根据的自信回答的方式。
这种空上下文情况会在生成端显式处理,而不是静默处理:
async function LlmWithRag(c: Context<AppEnv>, question: string, contextMessage: string) {
const systemPrompt = contextMessage.length > 0
? `You are a helpful assistant. Answer using the context provided.\n\n${contextMessage}`
: 'You are a helpful assistant. No relevant notes were found for this question - say so plainly rather than guessing.';
const modelResp = await c.env.AI.run('@cf/qwen/qwen3.8-27b', {
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: question },
],
});
return modelResp.choices[0]?.message?.content; // (empty-response handling omitted for brevity)
}
根据检索是否真的找到内容,使用两套不同的系统提示词——而不是一个含糊的‘如有相关则使用上下文’的单一提示,并寄希望于模型能自行正确处理‘无上下文’的情况。
The /api/query 路由将这一切串联起来,并返回 哪些 笔记是答案的依据,而不仅仅是答案文本:
app.get('/api/query', async (c) => {
const question = c.req.query('text');
if (!question) return c.text('specify text in ?text query', 400);
const { contextMessage, citeIds } = await QueryVector(question, c);
const answer = await LlmWithRag(c, question, contextMessage);
return c.json({ llmAnswer: answer, citedNoteIds: citeIds }, 200);
});
这只是一个小细节,但正是它让管理面板的删除按钮真正有用——如果一个答案可以追溯到一个错误或过时的笔记,你就知道到底要删除哪一行。
直接在本地环境测试端点
首先需要满足以下几个条件:D1 迁移已应用,以及一个真实的 ADMIN_TOKEN设置(如果没有它,下面的管理端点将无法进行身份验证)。
npx wrangler d1 migrations apply database --local
cp .dev.vars.example .dev.vars # then set a real ADMIN_TOKEN
npm run dev
运行之后:
# Ask a question
curl -G "http://127.0.0.1:8787/api/query" --data-urlencode "text=How long is a Personal Access Token valid for?"
# → {"llmAnswer":"...", "citedNoteIds":[3]}
# Upload a document (raw markdown as the body - no JSON wrapping needed for a single field)
curl -X POST http://127.0.0.1:8787/admin/ingest \
-H "Content-Type: text/markdown" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
--data-binary @my-notes.md
# → {"message":"Ingestion started"}
# List everything currently ingested
curl -H "Authorization: Bearer $ADMIN_TOKEN" http://127.0.0.1:8787/admin/notes
# Delete a chunk from both D1 and Vectorize
curl -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" http://127.0.0.1:8787/admin/notes/3
值得一提的是,这次上传的 body 是原始的 Markdown 文本,而不是 JSON——因为浏览器的 File 对象可以直接作为 fetch() 的 body 传入,而 /admin/ingest 只是通过 c.req.text() 读取它。无需对任意文件内容进行 JSON 转义,也不会出现需要调试的编码惊喜——多行文本需要经历的字符串化和解析步骤越少,在传输过程中被损坏的可能性就越低。
聊天界面
上面的一切与您刚才用 curl 发出的相同 /api/query 调用一样——实际的聊天小部件只是一个普通的 HTML 表单,用来包裹该调用,无需任何框架:
formEl.addEventListener('submit', async (e) => {
e.preventDefault();
const question = inputEl.value.trim();
if (!question) return;
inputEl.value = '';
addMessage('user', question);
submitBtn.disabled = true;
try {
const response = await fetch('/api/query?' + new URLSearchParams({ text: question }));
if (!response.ok) {
addMessage('assistant error', `Request failed (${response.status})`);
return;
}
const { llmAnswer, citedNoteIds } = await response.json();
addMessage('assistant', `${llmAnswer}\n\n (from note #${citedNoteIds.join(', ')})`);
} finally {
submitBtn.disabled = false;
}
});
citedNoteIds — 与 QueryVector 之前构建的同一数组 — — 会变成答案下方的 (from note #62, 74, 63) 行。这是一个小细节,但正是它区分了仅仅断言的聊天机器人和展示其工作过程的聊天机器人。
管理面板
一个可折叠的面板,受 ADMIN_TOKEN 保护,存储在 sessionStorage 中(标签页关闭时清除——出于设计考虑,因为它是管理员凭据,任何知道 URL 的人都可以在页面中输入)。解锁后:提供一个用于上传 .md 文件的文件选择器,以及一个实时表格,列出每个已摄取的数据块,每行都带有删除按钮。
值得精确地说明这个锁实际上保护什么,因为很容易夸大其词:客户端的门只是一个方便的包装器,并不是真正的安全边界。实际的执行是在服务器上的 bearerAuth —— 拥有令牌的任何人都可以直接使用 curl 调用 /admin/ingest 或 DELETE /admin/notes/:id,与 UI 显示的内容无关。这个锁的存在是为了让你不必记住 curl 语法来管理自己的内容,而不是为了阻止已经拥有令牌的人。
总结
-
检索需要一个下限。
topK单独使用并不是质量控制 - 相似度搜索总会返回 某些内容;相关性阈值才能让系统说“这里实际上没有相关内容”,而不是被迫从最近的匹配中给出答案。 -
步骤粒度是重试的单位。 工作流仅在你通过
step.do()划定的边界处提供韧性 - 在其外部循环,而不是内部;否则,单次失败会导致整批次重新执行。 - 共享的 ID 胜过重复的元数据。 使用相同的值对 Vectorize 和 D1 进行键定,使得“哪个存储有真相”这一问题变得毫无意义 - Vectorize 找到它,D1 说明它是什么。
完整代码:github.com/palermo-777/chatbot-with-rag。如果你对阈值或分块策略有不同的调优思路,欢迎在评论区留言交流。



