给静态博客接入 RAG 问答:Cloudflare 一把梭实战
本站右下角的聊天助手(以及 /chat 页面)就是这套方案的成品:提问后它检索全站文章、流式生成回答、给出可点击的来源引用,支持多轮追问。整套系统没有一台自建服务器,全部跑在 Cloudflare 上,月成本约等于零。
这篇教程记录完整的技术栈、实现流程和踩过的坑。前身是一套 Spring Boot + ChromaDB 的自建 RAG(FileRAG),需要常驻服务器 和定时向量化任务,服务器一挂全灭——迁移到托管方案后,这些运维负担全部消失。
一、架构总览
分工:
| 组件 | 职责 |
|---|---|
| Cloudflare AI Search(原 AutoRAG,免费 beta) | 托管 RAG 的全部脏活:切片、embedding、向量索引、混合检索、查询重写、调用生成模型 |
| Cloudflare Worker(与静态站同一个) | 静态资产 + /api/chat 聊天接口 + /api/rag/* 知识库管理接口 |
同步脚本(scripts/sync-knowledge.mjs) | 把仓库里的 markdown 增量推给 AI Search,处理新增/修改/删除 |
| 前端组件(React) | 浮窗 + 独立聊天页,SSE 流式渲染、行内引用角标 |
关键设计决策:
- 聊天 API 和静态站同源(一个 Worker 全包):没有 CORS、没有额外域名、没有 mixed content。
- 选托管(AI Search)而非自建(Vectorize + D1 手工缝合):自建要自己处理切片、双库一致性、增量对账、幂等——托管方案把这些全部内化,代价是黑盒出 bug 只能绕(后文有实例)。
- 数据源选"内置存储 + Items API"而非 R2 bucket:上传即索引(R2 源是 6 小时一次的 sync job)、不需要在 dashboard 手动创建 service token、少管理一个 bucket。内容的唯一事实源本来就是 git 仓库。
二、创建 AI Search 实例
wrangler 绑定(wrangler.jsonc):
{
"main": "worker/index.js",
"assets": { "directory": "build", "binding": "ASSETS", "not_found_handling": "404-page" },
"ai_search_namespaces": [
{ "binding": "AI_SEARCH", "namespace": "default", "remote": true }
]
}
实例创建做成了 Worker 里的幂等接口(POST /api/rag/setup),每次同步前都会执行,配置以代码为唯一事实源:
const config = {
embedding_model: '@cf/qwen/qwen3-embedding-0.6b', // 多语言,8k 输入;建索引后不可更换
ai_search_model: '@cf/qwen/qwen3-30b-a3b-fp8', // 生成模型,可随时换
chunk_size: 512,
chunk_overlap: 15, // 注意:百分比(0-30),不是 token 数
index_method: { vector: true, keyword: true }, // 混合检索
indexing_options: { keyword_tokenizer: 'trigram' },// trigram 对中日文关键词友好
custom_metadata: [
{ field_name: 'md5', data_type: 'text' }, // 增量同步用
{ field_name: 'url', data_type: 'text' }, // 文章真实 permalink
],
};
await env.AI_SEARCH.create({ id: 'kibou-rag', ...config });
选型要点:
- embedding 用多语言模型(qwen3-embedding,8k token 输入)。中文内容配英文模型或短输入模型(如 512 token 的模型配 1800 字中文分块)会被静默截断,检索质量打对 折。
- 混合检索必开:纯向量检索对专有名词(人名、产品名、"春琴抄")很弱,BM25 关键词精确命中能救回来;
trigram分词器对中文是刚需。
三、知识库怎么和仓库保持同步
同步脚本每次跑"三方对账":本地文件树(期望状态)、远端 items 列表(实际状态)、内容 md5(变更检测)。
- 改了文章 → md5 变化 → 同 key 重新上传,AI Search 整篇替换重索引;
- 删了文章 → 远端多出来 → 按 item id 删除,向量一并清理;
- 新增/重命名 → 上传新 key(重命名等于删旧增新);
- 无状态:md5 存在 item 的自定义 metadata 里,不依赖本地清单文件,本地、CI、任何机器跑结果一致,漏跑一次也能自愈。
两个容易被忽略、但决定问答质量的细节:
**1. 注入元数据头。**标题和日期往往只存在于文件名里(比如 blog/2025-03-17-两个人的话,去1912散步也是可以的.mdx,正文通篇没有"1912"两个字)。只索引正文的话,"作者哪天去了1912?"这类问题既检索不到也答不出。同步时在正文前注入一行:
[文档信息] 标题: 两个人的话,去1912散步也是可以的 | 日期: 2025-03-17 | 位置: blog
标题和日期就进了分块,再配合系统提示词教模型使用这一行,日期类问题就能答了。
**2. 来源链接用构建元数据,别用文件路径拼。**Docusaurus 会剥离目录的数字前缀(03-建站与OnPage → 建站与OnPage)、应用 frontmatter slug,拿文件路径猜 URL 必然 404。正确做法:从 .docusaurus/ 构建产物里读每篇文章的真实 permalink,作为 url metadata 存进索引,前端直接用。
四、聊天接口:一次问答的完整链路
Worker 端核心就一个调用:
const upstream = await env.AI_SEARCH.get('kibou-rag').chatCompletions({
messages: [{ role: 'system', content: SYSTEM_PROMPT }, ...history],
stream: true,
ai_search_options: {
retrieval: { max_num_results: 8, filters }, // filters 可按 folder 前缀限定 docs/blog
query_rewrite: { enabled: history.length > 1, rewrite_prompt },
},
});
AI Search 内部依次做:查询重写(多轮时把"继续""它是什么"结合历史改写成独立检索句)→ 混合检索 → 把命中分块作为上下文喂给生成模型 → 流式返回。上游 SSE 里分块先到、文字后到,Worker 把它转译成前端友好的精简协议:
event: sources
data: {"sources":[{filePath,fileName,url,snippet,score}...],"refMap":{"1":1,"3":2}}
data: {"delta":"MySQL"}
data: {"delta":" 的隔离级别"}
data: [DONE]
refMap 是行内引用的关键:系统提示词要求模型引用材料时标注 [n](n 是分块序号),但展示时来源按文件去重过,refMap 负责把"分块编号"换算成"来源编号",前端再把 [n] 渲染成可点击的角标,点击直达原文。
多轮对话是无状态设计:前端每次把完整消息历史(截最近 16 条)发给后端,服务端不存会话——没有 KV、没有 session 超时清理,换来的是零状态运维。
五、前端
- 浮窗(全站右下角)和独立聊天页(/chat)共用一个
useChathook(状态 + SSE 解析)和ChatMessages组件(渲染 + 滚动); - 来源纸片放在回答上方:流式输出时文字在气泡底部增长,自动滚动跟随时看到的才是正在生成的内容(放下方的话视口会一直卡在引用区);
- 智能滚动:只有用户本来就停在底部附近才跟随,上翻阅读时不打断;
react-markdown默认不渲染表格——GFM 扩展语法需要挂remark-gfm插件,否则模型输出的规范表格会被折叠成一行竖线。