跳到正文
SL Blog 技术探索 · 工程实践 · AI 时代思考
返回

RAG 系列 (2)-把 RAG 接入博客:鉴权 Web UI + Nginx 挂载

文章目录
  1. 1. 为什么静态博客不能直接跑 RAG
  2. 2. 鉴权设计(只有指定的人能访问)
  3. 2.1 用户白名单
  4. 2.2 会话:HMAC 签名的 HttpOnly Cookie
  5. 2.3 API 路由
  6. 3. 前端:登录页 + 聊天页(无框架)
  7. 子路径部署最大的坑:相对 URL
  8. 4. systemd 常驻
  9. 5. Nginx 反向代理
  10. 5.1 HTTPS 主站(slblog.online:443)挂 /rag/
  11. 5.2 HTTP 8081 站:/rag/ 一律重定向到 HTTPS
  12. 5.3 Secure Cookie 自动开启
  13. 6. 博客导航加入口
  14. 7. 实际测试(公网链路)
  15. 8. 运维
  16. 9. 小结

系列:「(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)

不引入 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;
}

后端根据 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 评测,用数据决定要不要换模型。


RAG 智能问答

针对本文继续提问:《RAG 系列 (2)-把 RAG 接入博客:鉴权 Web UI + Nginx 挂载》