文章 · 2026-01-28

为个人网站构建 RAG 驱动的 AI 助手

我为个人网站添加了一个浮动聊天组件,使用检索增强生成(RAG)回答关于博客文章、项目和个人背景的问题。这个组件运行在 GitHub Pages 上,通过 Cloudflare Workers 代理 API 调用,前端完全静态化,月成本控制在 $5 以下。

需求

聊天组件需要满足:

架构

系统将四个组件串联起来:

┌─────────────────────────────────────────────────────────────────┐
│                        浏览器(前端)                            │
├─────────────────────────────────────────────────────────────────┤
│  聊天组件 ──────▶ 搜索客户端                                     │
│       │          ├─ 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;
    }
}

关键决策:

混合搜索实现 RAG

网站已有搜索系统结合两种策略:

聊天组件复用这套基础设施:

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();
}

经验总结

  1. 环境变量胜于硬编码:在 Cloudflare 上存储 system_prompt 允许迭代 prompt 而不需代码部署。

  2. 复用现有基础设施:在混合搜索系统上构建 RAG 相比实现新索引节省了大量工作。

  3. 白名单优于黑名单:显式话题白名单相比宽泛限制更易维护,也不易误杀。

  4. 简单正则已足够:基本 Markdown 解析可处理最常见的格式需求,无需重型库。

  5. 从最简单的持久层开始:localStorage 可用于单设备短期历史。只在需求改变时升级。

成本分析

使用 gpt-4o-minitext-embedding-3-small

这些是基于模型定价的投影成本,非实际使用测量。

后续改进

可能的方向:流式响应以改进用户体验、使用统计、博客截图的图片理解。实现已开源在网站仓库。

© 2026 Yuxu Ge ·