Skip to content
// 0x
0x12 // 工具设计

RAG + 本地/在线 LLM:一个可离线的 AI 客服架构

动机

营销站点通常只有 “Contact Sales” 表单,访客咨询靠邮件流转。当产品功能逐渐丰富(定价方案、40+ 集成、25 项 AI 技能),FAQ 篇幅暴涨,人工客服的瓶颈越来越明显。

目标很明确:一个 7x24 在线、能回答产品问题的 AI 客服,而且不能太贵。

架构选型

                        ┌─────────────────────────────┐
                        │    Nginx Reverse Proxy      │
                        │   resolver 127.0.0.11 +     │
                        │      set_backend            │
                        └──────────────┬──────────────┘

                                   /api/chat/*


                         ┌───────────────────────────┐
                         │     FastAPI Backend       │
                         │   (FAISS + RAG Pipeline)  │
                         └─────────────┬─────────────┘

                ┌──────────────────────┼──────────────────────┐
                │                      │                      │
                ▼                      │                      ▼
    ┌───────────────────────┐          │          ┌───────────────────────────┐
    │         Ollama        │          │          │        Online LLM         │
    │  ┌─────────────────┐  │          │          │  ┌─────────────────────┐  │
    │  │      nomic      │  │          │          │  │      DeepSeek       │  │
    │  │      qwen2      │  │          │          │  │      OpenAI etc.    │  │
    │  └─────────────────┘  │          │          │  └─────────────────────┘  │
    └───────────────────────┘          │          └───────────────────────────┘


                              (Local)  │  (Optional)

                        ┌──────────────┴──────────────┐
                        │        Online/Offline       │
                        │        *  Local Mode        │
                        │        -  Hybrid Mode       │
                        └─────────────────────────────┘

三个关键决策:

为什么是 RAG 而不是 fine-tune? 产品内容经常变更(定价调整、新功能发布),RAG 只需更新知识库文档,不需要重新训练模型。

为什么选 DeepSeek? 成本极低(约 $0.5/月),API 兼容 OpenAI 格式,中英文能力均衡,不需要海外信用卡。同时支持切换到任意 OpenAI 兼容 API。

为什么保留本地 Ollama? 嵌入模型 nomic-embed-text(274MB)负责将文本转为向量,本地处理零延迟零成本。Ollama 还可以运行本地对话模型(如 qwen2:1.5b),实现完全离线的客服系统。

组件一览

系统由两个 Docker 容器组成:

组件部署方式用途
FastAPI 后端(含 FAISS 索引)Docker 容器REST API + RAG 检索管道
OllamaDocker 容器嵌入模型 + 可选本地对话模型

FAISS 向量索引集成在 FastAPI 进程内,不独立部署。对话模型可选本地(通过 Ollama)或在线(DeepSeek 等),由 .env 一个配置项切换。

RAG 管道:从提问到回答

用户提问 "Essential 方案多少钱?"

    ├── 1. 向量化
    │     Ollama POST /api/embed → 768 维向量

    ├── 2. 语义检索
    │     FAISS 搜索 top 30 → 取最相似片段

    ├── 3. 关键词检索
    │     分词 → 中文/英文混合匹配 → 补充语义盲区

    ├── 4. 合并去重 → 构建增强提示

    └── 5. LLM 生成回答
           ├── 线上模式:DeepSeek API
           └── 本地模式:Ollama qwen2:1.5b

混合搜索的必要性

纯语义搜索有一个问题:中文查询词在英文文档上的向量匹配度往往不够。比如 “价格” 这个词,在纯英文的定价文档中语义向量距离较远。

解决方式是关键词检索作为语义检索的补丁——对查询词分词后,在文档库中做逐词匹配,命中的片段直接加入结果集,不依赖向量相似度。

查询: "Essential 价格"
  → 语义搜索找到 "Subscription Model"(距离 0.50)
  → 关键词搜索找到 "Price: $249 / month"(命中 "Essential" + "价格" 中文标注)
  → 合并去重 → DeepSeek 回答 "每月 249 美元"

一个坑:去重逻辑

语义搜索返回 top 40 后,关键词搜索开始时遇到一个 bug:语义搜索把全部文档 ID 都放进了 seen 集合,关键词搜索无法添加新结果,导致中文查询永远找不到答案。

修复很简单——移除 seen 集合的互斥逻辑,改为在最终步骤统一按分数去重。

中文知识库的双语策略

为了让中文查询词能命中英文文档,知识库文件采用了中英文混写:

### Essential Plan(基础版方案)
- Price(价格): $249 / month(每月249美元)
- 1 included warehouse(包含1个仓库)
- API access(API接口): included

无论用户用中文还是英文提问,都能被关键词检索命中。生成脚本也内建了双语模板,不需要手动编写。

模型配置:一键切换本地/在线

早期版本对话模型固定走 DeepSeek。后来加入了本地模型支持,通过 .env 一个参数切换:

# .env
CHAT_MODEL=deepseek-chat          # 线上模型
CHAT_MODEL=ollama/qwen2:1.5b      # 本地模型

改完重启 backend 容器,前端自动生效。

实现原理

前端不直接关心模型名。页面加载时先请求后端的配置接口:

// chat.js
fetch('/api/chat/config')
  .then(function(r) { return r.json(); })
  .then(function(d) { MODEL = d.model; });

后端根据配置决定走哪条路:

# llm/__init__.py
def get_llm(model: str):
    if model.startswith("ollama/") or model.startswith("local/"):
        return OllamaLLM(), model.split("/", 1)[1]  # 本地 Ollama
    return OpenAILLM(), model                         # 在线 API

本地模型走 Ollama 容器(http://ollama:11434),不依赖任何 API key;线上模型走 OpenAI 兼容 API(DeepSeek / GPT 等),需要 LLM_BASE_URL + LLM_API_KEY

当前可用的本地模型

qwen2:1.5b   934MB   通义千问 1.5B,中英文均衡

拉取新模型也很简单:

docker exec burtonsupport-ollama ollama pull <模型>

Prompt 设计

系统提示词对回答质量影响很大,迭代后的最终版本:

你是平台客服助手。
根据以下文档内容回答用户问题。
规则:以客服身份直接回答,不说"根据文档内容",不用 markdown。

关键在于三点:

知识库管理

data/kb/
├── auto/           ← 自动生成(从站点模板提取)
│   ├── 01-pricing.md
│   ├── 02-platform-features.md
│   ├── 03-faq.md
│   ├── 04-integrations.md
│   ├── 05-burt-skills.md
│   ├── 06-benefits.md
│   └── 07-legal.md
└── manual/         ← 人工编写(补充自动内容)

自动生成脚本读取产品站点的翻译文件和模板,输出结构化 markdown。产品内容变更后跑一遍即可同步。

限流与安全

客服 API 暴露在公网,必须有防护。在 FastAPI 中加了一层内存滑动窗口限流:

RATE_LIMIT_WINDOW = 60       # 时间窗口(秒)
RATE_LIMIT_MAX_REQUESTS = 10  # 窗口内最大请求数

限流参数通过 .env 配置。超出限制返回 HTTP 429 + Retry-After 头。

部署:路径转发而非子域名

采用路径转发避免跨域和 SSL 问题。用户请求经主站 nginx 直接转发到后端:

用户请求 → /api/chat/v1/chat/completions

        nginx proxy_pass → burtonsupport-backend:8000

一个坑:nginx 启动依赖

升级后遇到过一个隐蔽的问题——nginx 在启动时强制解析所有 upstream 主机名,如果 backend 容器未运行,nginx 直接 crash loop,连主站都访问不了。

修复方案:使用 Docker DNS resolver + 变量 proxy_pass,让 nginx 在运行时才解析 upstream,启动时不检查可达性。

location /api/chat/ {
    resolver 127.0.0.11 valid=10s;
    set $backend "http://burtonsupport-backend:8000";
    rewrite ^/api/chat(/.*)$ $1 break;
    proxy_pass $backend;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}

注意 set 必须在 rewrite 之前,否则 rewrite 的 break 会跳过赋值。

前端也从 HTTPS 完整 URL 改为相对路径 /api/chat/...,同源策略自然通过。

前端小部件

聊天 UI 是纯前端组件,无框架依赖。浮动按钮 + 弹出面板,对话记录通过 localStorage 持久化。中英文自动跟随页面语言。

容错:后端不可用时自动隐藏

后端可能因配置错误、外部 API 故障等原因不可用。如果按钮照常显示,用户点击后得到 502 页面,体验很差。

解决方案是页面加载时做一次健康检测

fetch('/api/chat/health', { method: 'GET', cache: 'no-cache' })
  .then(function(r) { return r.json(); })
  .then(function(d) {
    if (d.status === 'ok') btn.style.display = 'flex';  // 显示按钮
  })
  .catch(function() {
    console.info('[Chat] 客服不在线');                    // 隐藏按钮,仅日志提示
  });

后端不可用时按钮直接隐藏,用户不会感知到客服系统的存在。后端恢复后需要刷新页面才会重新显示。

成本

系统支持两种运行模式,成本差异大:

模式每月成本
本地模式(Ollama 嵌入 + Ollama 对话)$0
混合模式(Ollama 嵌入 + DeepSeek 对话)~$0.5-2

本地模式完全离线运行,适合开发环境或无网络的私有部署。混合模式用本地嵌入、在线对话,性价比最高。

小结

这套系统的核心思路是用最少的成本解决 80% 的客服问题。不是最先进的技术栈,但足够实用:

如果你也有一个内容相对稳定的 SaaS 产品站点,这套方案值得参考。整个工程代码不到 1000 行,一个周末就能跑通。


Share this post on:

Previous Post
无框架 PHP 项目的渐进式工程化改造
Next Post
「未实现」还是「不实现」:PPS 脚手架的设计选择