为个人网站构建 RAG 驱动的 AI 助手
我为个人网站添加了一个浮动聊天组件,使用检索增强生成(RAG)回答关于博客文章、项目和个人背景的问题。这个组件运行在 GitHub Pages 上,通过 Cloudflare Workers 代理 API 调用,前端完全静态化,月成本控制在 $5 以下。
需求
聊天组件需要满足:
- 使用 RAG 从博客内容中回答问题
- 优雅地处理敏感或离题问题
- 在无后端数据库的静态托管上运行
- 实现足够简单以便迭代
架构
系统将四个组件串联起来:
┌─────────────────────────────────────────────────────────────────┐
│ 浏览器(前端) │
├─────────────────────────────────────────────────────────────────┤
│ 聊天组件 ──────▶ 搜索客户端 │
│ │ ├─ BM25 关键词搜索(本地) │
│ │ └─ Voy WASM 语义搜索 │
│ │ │ │
│ │◀───────────────────┘ (Top 3 片段作为上下文) │
└───────┼─────────────────────────────────────────────────────────┘
│ POST /api/chat { messages, context }
▼
┌─────────────────────────────────────────────────────────────────┐
│ Cloudflare Worker (yuxu.ge/api/*) │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ /api/embedding │ │ /api/chat │ │
│ │ (查询向量化) │ │ system_prompt │ │
│ └────────┬────────┘ │ + RAG 上下文 │ │
│ │ └────────┬────────┘ │
└───────────┼──────────────────────────┼──────────────────────────┘
│ │
▼ ▼
┌─────────────────────────────────────────────────────────────────┐
│ OpenAI API │
│ text-embedding-3-small (512d) gpt-4o-mini │
└─────────────────────────────────────────────────────────────────┘
前端组件在浏览器中运行,将用户查询发送到 Cloudflare Workers,后者调用 OpenAI 的 embedding 和补全 API。搜索结果被送入系统提示以支撑聊天回复。
实现细节
聊天组件(前端)
组件是一个独立的 JavaScript 模块,注入浮动气泡 UI:
export class ChatWidget {
constructor() {
this.messages = [];
this.isOpen = false;
this.searchClient = null;
}
async sendMessage(text) {
// 从搜索客户端获取 RAG 上下文
let context = '';
if (this.searchClient?.isReady()) {
const results = await this.searchClient.search(text, 3);
context = results.map(r => `[${r.title}]\n${r.text}`).join('\n\n');
}
// 调用聊天 API,附带上下文
const response = await fetch('https://yuxu.ge/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: this.messages,
context,
}),
});
const data = await response.json();
return data.reply;
}
}
关键决策:
- Markdown 渲染:用简单正则处理
**粗体**和[链接](url),无外部依赖 - CSS-in-JS 注入:所有样式动态注入,避免外部样式表
- localStorage 持久化:对话历史在 24 小时内保留页面重载;支持自动清除和手动清除(垃圾桶按钮)
混合搜索实现 RAG
网站已有搜索系统结合两种策略:
- BM25 关键词搜索:倒排索引用于精确词项匹配
- Voy WASM 语义搜索:预计算 embeddings 和向量相似度
- RRF 融合:用 Reciprocal Rank Fusion 合并两路排序结果
聊天组件复用这套基础设施:
const [keywordResults, semanticResults] = await Promise.all([
this.keywordSearch(query, limit * 2),
this.semanticSearch(query, limit * 2),
]);
// 使用 RRF 合并
for (const result of keywordResults) {
rrfScores[result.url] = keywordWeight / (k + result.rank);
}
for (const result of semanticResults) {
rrfScores[result.url] += semanticWeight / (k + result.rank);
}
这样避免了重建搜索索引,降低了延迟,因为 embeddings 已经提前计算好。
Cloudflare Worker(API 代理)
两个端点处理聊天流程:
/api/embedding — 将查询文本转换为向量用于语义搜索:
const response = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Authorization': `Bearer ${env.OPENAI_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'text-embedding-3-small',
input: text,
dimensions: 512,
}),
});
/api/chat — 返回带 RAG 上下文的聊天补全:
const systemMessage = context
? `${env.system_prompt}\n\n## 相关博客内容:\n${context}`
: env.system_prompt;
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Authorization': `Bearer ${env.OPENAI_API_KEY}` },
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemMessage },
...messages,
],
}),
});
敏感话题处理
个人网站的助手应该自由讨论网站主人,但拒绝政治或离题问题。这个逻辑存储在系统提示中,作为 Cloudflare 环境变量:
## 关于网站主人
Yuxu Ge(葛于旭)是网站的主人,你可以自由讨论:
- 职业背景、技术经历、项目作品
- 博客内容、技术观点
- 公开的个人信息(教育、工作经历等)
## 对话边界
对以下话题礼貌拒绝并引导:
- 政治人物、政府政策、地缘争议
- 宗教、意识形态争论
拒绝时回复:"这超出了我作为技术助手的讨论范围,
我们聊聊技术相关的话题吧?"
## 公开联系方式(可以分享)
- Email: [email protected]
- GitHub: https://github.com/geyuxu
- LinkedIn: https://linkedin.com/in/yuxuge
早期尝试过于激进,甚至屏蔽了关于我自己的问题。解决方案:明确白名单允许的话题,而不是黑名单禁止的话题。
使用 localStorage 实现对话历史
将历史存储在浏览器而非 Cloudflare KV 更务实:
const CHAT_CONFIG = {
storageKey: 'chat_history',
historyTTL: 24 * 60 * 60 * 1000, // 24 小时
};
// 每次成功响应后保存
saveHistory() {
const data = {
messages: this.messages.slice(-20),
timestamp: Date.now(),
};
localStorage.setItem(CHAT_CONFIG.storageKey, JSON.stringify(data));
}
// 组件初始化时加载
loadHistory() {
const raw = localStorage.getItem(CHAT_CONFIG.storageKey);
if (!raw) return;
const data = JSON.parse(raw);
// 检查 TTL 过期
if (Date.now() - data.timestamp > CHAT_CONFIG.historyTTL) {
localStorage.removeItem(CHAT_CONFIG.storageKey);
return;
}
this.messages = data.messages;
this.renderHistory();
}
- 一次性访客:大多数用户只有单次对话,服务端存储徒增复杂性无益
- 无需用户身份:localStorage 已足够
- 零边际成本:KV 存储即使便宜也是不必要的开销
- 权衡:浏览器清除缓存时历史被删除;无法跨设备同步
经验总结
环境变量胜于硬编码:在 Cloudflare 上存储
system_prompt允许迭代 prompt 而不需代码部署。复用现有基础设施:在混合搜索系统上构建 RAG 相比实现新索引节省了大量工作。
白名单优于黑名单:显式话题白名单相比宽泛限制更易维护,也不易误杀。
简单正则已足够:基本 Markdown 解析可处理最常见的格式需求,无需重型库。
从最简单的持久层开始:localStorage 可用于单设备短期历史。只在需求改变时升级。
成本分析
使用 gpt-4o-mini 和 text-embedding-3-small:
- Embedding:每次查询约 $0.00002
- Chat:每次响应约 $0.0001–0.0005(取决于上下文长度)
- 预估:中等流量月费 < $5
这些是基于模型定价的投影成本,非实际使用测量。
后续改进
可能的方向:流式响应以改进用户体验、使用统计、博客截图的图片理解。实现已开源在网站仓库。