系列:「(1) 从零实现最小 RAG」 → 本文 (2) → 「(3) Embedding 模型 A/B 评测」。
上一篇实现了最小 RAG(CLI 可用)。这篇把它变成网页:登录、问答、显示来源,并挂到博客域名 https://slblog.online/rag/。核心诉求是鉴权——只有指定的人能访问。
1. 为什么静态博客不能直接跑 RAG
本博客是 AstroPaper 静态站(pnpm build 产物由 Nginx 托管)。静态站无法执行 Python、无法调用 Embedding/LLM API,所以 RAG 必须是一个独立的动态服务,由 Nginx 反向代理挂到子路径上:
浏览器 ── https://slblog.online/rag/ ──▶ Nginx ──▶ FastAPI(127.0.0.1:8787)
│
├─ 鉴权(登录/会话)
├─ 检索 pgvector
└─ 调用 Embedding / LLM API
2. 鉴权设计(只有指定的人能访问)
2.1 用户白名单
账号存在 webapp/users.json(已 gitignore),密码用 PBKDF2-HMAC-SHA256 加盐存储,绝不明文:
# webapp/auth.py
PBKDF2_ITERATIONS = 200_000
def hash_password(password: str, salt: bytes | None = None):
salt = salt or secrets.token_bytes(16)
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, PBKDF2_ITERATIONS)
return salt.hex(), dk.hex()
def verify_password(password: str, salt_hex: str, hash_hex: str) -> bool:
dk = hashlib.pbkdf2_hmac("sha256", password.encode(), bytes.fromhex(salt_hex), PBKDF2_ITERATIONS)
return hmac.compare_digest(dk.hex(), hash_hex)
2.2 会话:HMAC 签名的 HttpOnly Cookie
不引入 Redis/服务端 session,登录成功后签发一个自包含的签名 Cookie:
def make_session_token(username: str) -> str:
expiry = int(time.time()) + config.SESSION_MAX_AGE_DAYS * 86400 # 7 天
payload = f"{username}.{expiry}"
sig = hmac.new(SESSION_SECRET, payload.encode(), hashlib.sha256).hexdigest()
return f"{payload}.{sig}"
def verify_session_token(token):
# 拆 payload+签名 → 常数时间比较 → 检查过期 → 返回 username / None
Cookie 属性:HttpOnly(JS 读不到)、SameSite=Lax、Secure(通过 HTTPS 时自动开启,见 §6)。
2.3 API 路由
| 路由 | 鉴权 | 说明 |
|---|---|---|
GET /login | 无 | 登录页 |
POST /api/login | 无 | 校验账号,签发 Cookie(失败延迟 0.3s 防爆破) |
POST /api/logout | 无 | 清除 Cookie |
GET /api/me | 有 | 返回当前用户名 |
POST /api/ask | 有 | 完整 RAG 问答 |
POST /api/search | 有 | 只看检索结果 |
POST /api/reindex | 有 | 增量重建索引 |
受保护路由用 FastAPI 依赖统一拦截:
def get_current_user(request: Request) -> str:
username = auth.verify_session_token(request.cookies.get(COOKIE_NAME))
if not username:
raise HTTPException(status_code=401, detail="not authenticated")
return username
@app.post("/api/ask")
def api_ask(body: QuestionRequest, _user: str = Depends(get_current_user)):
rows = search(body.question, top_k=body.top_k)
answer = LLMClient().generate(body.question, rows)
return {"answer": answer, "sources": [...], "retrieved": [...], "total_ms": ...}
3. 前端:登录页 + 聊天页(无框架)
两个 HTML 文件,vanilla JS + fetch。聊天页支持:问答(显示答案 + 来源列表 + 可展开的检索片段)、只看检索模式、重新索引按钮。
// 每条消息展示
const srcs = (data.sources || []).map(s => `<li><a href="${slugLink(s)}" target="_blank">${esc(s)}</a></li>`).join('');
addMsg(`<div class="msg a">${esc(data.answer)}</div>`);
addMsg(`<div class="sources">来源:<ul>${srcs}</ul></div>`);
子路径部署最大的坑:相对 URL
如果 JS 里写死 fetch('/api/ask'),部署在 /rag/ 下会请求到 https://slblog.online/api/ask(博客根路径)——404。正确做法是从当前 URL 推导应用前缀,所有请求都用相对路径:
// 计算应用根目录:本机是 "/",博客上是 "/rag/"
const BASE = (() => {
const p = window.location.pathname;
return p.endsWith('/') ? p : p.slice(0, p.lastIndexOf('/') + 1);
})();
fetch(BASE + 'api/ask', {...}); // 而不是 '/api/ask'
window.location.href = BASE; // 而不是 '/'
这样同一份前端在 127.0.0.1:8787/ 和 https://slblog.online/rag/ 下都能跑。
4. systemd 常驻
后台进程会随会话退出,不能当生产服务。写一个 systemd 单元:
# /etc/systemd/system/rag-web.service
[Unit]
Description=Blog RAG web app (FastAPI, authenticated)
After=network.target docker.service
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/rag/minimal-rag
ExecStart=/home/rag/minimal-rag/.venv/bin/uvicorn webapp.app:app --host 127.0.0.1 --port 8787 --log-level warning
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now rag-web.service
systemctl status rag-web # active
只监听 127.0.0.1,公网只能通过 Nginx 访问。
5. Nginx 反向代理
5.1 HTTPS 主站(slblog.online:443)挂 /rag/
在博客的 443 server 块里加两个 location(放在静态 catch-all location / 之前,更长的前缀优先匹配):
# ===== ==== ==== RAG 问答(FastAPI,带鉴权)==== ==== ====
location = /rag {
return 301 /rag/; # 补尾斜杠
}
location /rag/ {
proxy_pass http://127.0.0.1:8787/; # 注意结尾斜杠:剥掉 /rag/ 前缀
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; # 用于判断 HTTPS
proxy_read_timeout 300s; # LLM 生成可能 10s+,重索引更长
proxy_send_timeout 300s;
proxy_connect_timeout 10s;
}
proxy_pass http://127.0.0.1:8787/; 末尾的 / 会把请求路径的 /rag/ 前缀剥离:/rag/api/ask → 后端 /api/ask,前端相对 URL 再次拼上 /rag/,闭环。
5.2 HTTP 8081 站:/rag/ 一律重定向到 HTTPS
博客还能从 http://43.139.41.82:8081/ 访问,但鉴权绝不允许走明文 HTTP(密码、Cookie 会被嗅探)。所以 8081 站点对 /rag/ 直接 301 到 HTTPS:
location = /rag {
return 301 https://slblog.online/rag/;
}
location /rag/ {
return 301 https://slblog.online$request_uri;
}
5.3 Secure Cookie 自动开启
后端根据 Nginx 传来的 X-Forwarded-Proto 决定是否给 Cookie 加 Secure:
secure = config.COOKIE_SECURE or request.headers.get("x-forwarded-proto") == "https"
response.set_cookie(COOKIE_NAME, token, httponly=True, samesite="lax",
secure=secure, max_age=config.SESSION_MAX_AGE_DAYS * 86400)
实测经 HTTPS 登录后的响应头:
set-cookie: rag_session=admin.1788093191.41754782…; HttpOnly; Max-Age=604800; Path=/; SameSite=lax; Secure
6. 博客导航加入口
静态博客导航在 src/components/Header.astro 的 navItems 数组里。给「AI 问答」特殊处理 href(不走 i18n 的 locale 前缀,直接指向 /rag/):
const navItems = [
{ path: "/", label: t.nav.home },
/* ... archives / tags / about ... */
{ path: "/rag", label: "AI 问答" },
];
<a
href={path === "/rag" ? "/rag/" : getRelativeLocaleUrl(locale, path)}
class:list={{ "active-nav": isActive(path) }}
>
然后重新构建并部署(仅改组件,不重跑 Obsidian 转换):
cd /opt/astropaper && pnpm build
sudo rsync -a --delete --chown=www-data:www-data dist/ /var/www/astropaper/
7. 实际测试(公网链路)
GET https://slblog.online/rag/ → 200(登录页)
POST /rag/api/ask(无 Cookie) → 401 not authenticated
POST /rag/api/login(admin / 正确密码) → 200 + Secure Cookie
POST /rag/api/ask "Flink 是干什么的?"(带 Cookie) → 答案来自 flink-01-use-cases.md
POST /rag/api/login(错误密码) → 401 用户名或密码错误
GET http://43.139.41.82:8081/rag/ → 301 → https://slblog.online/rag/
8. 运维
# 服务
sudo systemctl status rag-web | restart rag-web
sudo journalctl -u rag-web -f
# 管理"指定的人"
cd /home/rag/minimal-rag
./.venv/bin/python -m webapp.add_user alice # 新增 / 重置密码(交互式)
./.venv/bin/python -m webapp.add_user --list # 查看用户
./.venv/bin/python -m webapp.add_user --remove alice # 删除用户
# 发布新博客文章后刷新知识库
./.venv/bin/python -m src.cli ingest # 或网页右上角「重新索引」
9. 小结
- 静态博客本身不跑 RAG,动态服务 + Nginx 反代是常规做法;
- 鉴权 = 白名单(PBKDF2)+ HMAC 签名会话 Cookie(HttpOnly + Secure);
- 子路径部署的两大坑:前端相对 URL、HTTP 站自动重定向到 HTTPS;
- systemd 保证服务常驻、开机自启。
下一步(系列 (3)):光「感觉不错」不够,给 Embedding 模型做一个可回归的 A/B 评测,用数据决定要不要换模型。