从零搭建高尔夫规则 AI 问答系统:完整记录与踩坑清单
摘要: 记录在 VPS 上搭建「高尔夫规则咨询」RAG 系统的全过程。从 PDF 拆条、向量库构建、LangGraph Agentic RAG、One API 接入、FastAPI 封装、nginx 反代、HTTPS 证书,到 WordPress 前端嵌入。包含所有踩过的坑和最终可用的配置,目标是让后来者照着做就能复现。
一、项目背景与目标
golforfun.net 是一个高尔夫会员网站。目标是给会员提供一个规则问答入口:用户输入问题(如「红桩怎么抛球?罚几杆?」),系统基于 R&A 官方规则原文回答,并明确给出「是否罚杆、罚几杆」。
整体链路:
WordPress 页面(golforfun.net) ↓ HTTPS POST api.golforfun.net(nginx 443,Let's Encrypt 证书) ↓ FastAPI(127.0.0.1:8090,频率限制 + 缓存 + 长度限制) ↓ LangGraph Agentic RAG ├─ 检索:Chroma 向量库(303 条规则子条款) ├─ 评分:DeepSeek 判断条款是否相关 └─ 生成:DeepSeek 输出结构化 JSON(罚杆数 + 依据 + 解释) ↓ One API(127.0.0.1:3000/v1)→ DeepSeek 上游
二、环境准备
VPS:Ubuntu,5.8G 内存,Python 3.10,Docker(One API),nginx,PM2。
mkdir -p /opt/golf-rag && cd /opt/golf-rag python3 -m venv venv source venv/bin/activate pip install langgraph langchain-openai langchain-community pypdf chromadb pip install sentence-transformers langchain-huggingface langchain-chroma pip install fastapi uvicorn
注意 1: sentence-transformers 会拉 torch(CUDA 版,约 2.5GB)。这台 VPS 没 GPU,用不上,但先装完跑通再说,别在功能验证前折腾 CPU 版。
注意 2: HuggingFaceEmbeddings 已从 langchain-community 迁移到 langchain-huggingface,Chroma 迁移到 langchain-chroma。用旧 import 会报 ModuleNotFoundError。
三、知识库构建:PDF → 规则子条款
3.1 获取规则 PDF
使用 2023 版规则中文版 PDF,繁体中文,216 页,含完整规则 1–25 及定义。下载后放到 /opt/golf-rag/golfrule2023.pdf。
3.2 拆条策略(最关键的一步)
不要按页切,也不要按整条规则切。按整条规则切会导致每条 chunk 几千字,embedding 被开头的「规则之目的」主导,检索精度崩。正确做法是按子条款切(1.1、1.2a、17.1d 这种),每条几百字,语义集中。
build_kb.py 核心逻辑:
import re, shutil, os
from pypdf import PdfReader
from langchain_core.documents import Document
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_chroma import Chroma
reader = PdfReader("golfrule2023.pdf")
full = "\n".join((p.extract_text() or "") for p in reader.pages)
# 跳过目录:找第二次出现的「如何使用高爾夫規則」
idx = full.find("如何使用高爾夫規則")
idx2 = full.find("如何使用高爾夫規則", idx + 10)
full = full[idx2:] if idx2 != -1 else full[idx:]
# 找所有「規則 X 标题」的位置(只取第一次,排除页眉)
rule_starts = {}
for mm in re.finditer(r"^規則\s+(\d+)\s+[^\d\s]", full, flags=re.M):
rno = mm.group(1)
if rno not in rule_starts:
rule_starts[rno] = mm.start()
# 在全文上找所有子条款编号「X.Y[a-z]?」在行首
sub_positions = []
for mm in re.finditer(r"^(\d+)\.(\d+[a-z]?)", full, flags=re.M):
rno, sub = mm.group(1), mm.group(2)
if rno in rule_starts:
sub_positions.append((mm.start(), rno, f"{rno}.{sub}"))
# 按子条款位置切
sub_positions.sort()
docs = []
for i, (pos, rno, sub_no) in enumerate(sub_positions):
end = sub_positions[i+1][0] if i+1 < len(sub_positions) else len(full)
seg = full[pos:end].strip()
if len(seg) < 60:
continue
rule_title = ""
rs = rule_starts.get(rno, -1)
if rs != -1:
tm = re.match(r"規則\s+\d+\s+(.+)", full[rs:rs+100])
if tm:
rule_title = tm.group(1).split("\n")[0].strip()[:60]
rest = re.sub(rf"^{re.escape(sub_no)}", "", seg).strip()
sub_title = rest.split("\n")[0].strip()[:50]
content = f"規則 {rno}({rule_title})\n{sub_no} {sub_title}\n\n{seg}"
docs.append(Document(
page_content=content,
metadata={"rule": f"規則 {rno}", "sub": sub_no, "title": rule_title}
))
print(f"拆出 {len(docs)} 个子条款")
if os.path.exists("./chroma_db"):
shutil.rmtree("./chroma_db")
emb = HuggingFaceEmbeddings(model_name="BAAI/bge-small-zh-v1.5")
Chroma.from_documents(docs, emb, persist_directory="./chroma_db")
注意 3: 最初用 re.split(r"(?=^規則\s+\d+\s+[^\d\s])", ...) 按整条规则切,再在块内切子条款,结果只拆出 60 条。原因是页眉「規則 1 21」干扰了块边界,块内子条款丢失。改成直接在全文上找子条款编号位置、按位置切,不依赖块边界,拆出 303 条,覆盖 25 条规则。
注意 4: 子条款编号在文本里是 1.1 高爾夫運動、1.2a 球員被期待的行為,编号前面没有「規則」二字。正则必须写成 ^X.Y,不能写成 ^規則X.Y。
预期结果: 303 条子条款,各规则分布合理(规则 1 只有 5 条,规则 25 有 37 条)。
四、检索优化:同义词扩展 + 关键词硬过滤
4.1 为什么需要优化
bge-small-zh-v1.5 是通用中文模型,对繁体规则 + 简体提问 + 高尔夫术语的语义匹配不够准。直接检索「红桩怎么抛球」命中的是规则 14(球程序)或 19(无法打之球),不是规则 17(处罚区)。
4.2 同义词扩展
SYNONYMS = {
"红桩": "紅色處罰區 側面脫困", "紅樁": "紅色處罰區 側面脫困",
"红色": "紅色處罰區", "紅色": "紅色處罰區",
"黄桩": "黃色處罰區", "黃樁": "黃色處罰區",
"出界": "界外 遺失球", "界外": "界外 遺失球",
"被踩": "被移動 靜止球", "移動": "被移動 靜止球",
"旗杆": "旗桿 果嶺", "旗桿": "旗桿 果嶺",
"抛球": "脫困 拋球", "拋球": "脫困 拋球",
"沙坑": "沙坑", "OB": "界外 遺失球",
"下水": "處罰區 水障礙", "水障碍": "處罰區 水障礙",
}
def expand(q):
extra = [v for k, v in SYNONYMS.items() if k in q]
return (q + " " + " ".join(extra)).strip() if extra else q
4.3 关键词 → 规则号硬过滤(最可靠)
同义词扩展后「红桩」还是可能被规则 19.2c(无法打之球侧面脱困)挤掉。最稳的做法是检测到关键词直接按 metadata 过滤规则号:
KEYWORD_RULE_MAP = [
(["红桩", "紅樁", "红区", "紅色處罰區", "罚杆区", "罰桿區"], "規則 17"),
(["沙坑"], "規則 12"),
(["果岭", "果嶺", "旗杆", "旗桿"], "規則 13"),
(["出界", "界外", "OB", "遗失球", "遺失球"], "規則 18"),
(["暂定球", "暫定球"], "規則 18"),
(["无法打", "無法打"], "規則 19"),
]
forced_rule = None
for kws, rule in KEYWORD_RULE_MAP:
if any(k in query for k in kws):
forced_rule = rule
break
if forced_rule:
docs = vs.similarity_search(eq, k=3, filter={"rule": forced_rule})
else:
docs1 = vs.similarity_search(eq, k=3)
docs2 = vs.similarity_search(query, k=3)
seen = set()
docs = []
for d in docs1 + docs2:
key = (d.metadata["rule"], d.metadata["sub"])
if key not in seen:
seen.add(key)
docs.append(d)
docs = docs[:4]
注意 5: 双路检索 + 语义重排不够可靠,红桩 query 反复命中规则 19.2c。硬过滤(filter={"rule": "規則 17"})是最终方案,确定性强。
五、LangGraph Agentic RAG 流程
5.1 图结构
START → generate_query_or_respond(决定检索)
→ retrieve(检索)
→ grade_documents(评分)
├─ yes → generate_answer → END
└─ no → rewrite_question → 回到 generate_query_or_respond
5.2 关键节点
generate_query_or_respond: 用 bind_tools([retrieve_golf_rules]) 让模型决定是否检索。
grade_documents: 用纯文本 prompt 让模型回答 yes/no,正则抠结果。不要用 with_structured_output,DeepSeek 通过 One API 不支持 json_schema 协议,会报 Structured Output response does not have a 'parsed' field。
rewrite_question: 重写 query,加次数上限防止死循环:
def rewrite_question(state):
q = state["messages"][0].content
rewrites = sum(1 for m in state["messages"] if isinstance(m, HumanMessage)) - 1
if rewrites >= 2:
return {"messages": [HumanMessage(content=q)]}
resp = make_llm().invoke(f"把下面的高爾夫規則問題改寫成更適合檢索規則條款的形式,只輸出改寫後的問題:\n{q}")
return {"messages": [HumanMessage(content=resp.content)]}
generate_answer: 要求模型输出纯 JSON,手动 json.loads 解析,不用 with_structured_output:
GENERATE_PROMPT = """你是高爾夫規則顧問。只根據下面檢索到的條款回答問題。
要求:
1. 必須明確回答「是否罰桿」和「罰幾桿」(0=不罰,1=罰一桿,2=罰二桿,null=條款未明確)。
2. 如果條款裡有「罰一桿」「一般處罰」「不罰」等字樣,按條款給出數字。
3. 如果條款沒有明確寫處罰數量,penalty_strokes 設為 null,並在 explanation 裡說明。
4. 引用規則編號。
5. 不要編造條款裡沒有的內容。
6. 如果問題涉及開球出界(OB)或球遺失,在 explanation 末尾補充:若球場啟用當地規則 E-5,可選擇罰 2 桿在球道附近拋球。
以純 JSON 輸出(不要加 markdown 代碼塊),格式:
{"is_penalty": true/false, "penalty_strokes": 0/1/2/null, "rule_ref": "規則 X.Y", "explanation": "..."}
檢索到的條款:
{context}
用戶問題:{question}"""
注意 6: with_structured_output 在 DeepSeek + One API 上不可用。所有结构化输出都改成「prompt 要求 JSON + 手动解析」。
注意 7: 评分节点判 no → 重写 → 再检索 → 再判 no,没有次数上限会死循环。加 rewrites >= 2 强制生成。
六、One API 接入
make_llm() 指向本机 One API:
ONEAPI_BASE = "http://127.0.0.1:3000/v1"
ONEAPI_KEY = "sk-..." # One API 令牌
MODEL = "deepseek-ai/deepseek-v4.1-flash"
def make_llm(temperature=0):
return ChatOpenAI(
base_url=ONEAPI_BASE, api_key=ONEAPI_KEY, model=MODEL,
temperature=temperature, timeout=300,
)
注意 8: DeepSeek 通过 One API 单次响应 30 秒–2 分钟,timeout=300 必须加。One API 日志里成功请求的 ElapsedTime 常在 100–160 秒。
注意 9: One API 上游渠道会随机 429(bad response status code 429),One API 自动重试其他渠道,最终返回 200。不是代码问题,是上游限流。
七、FastAPI 封装
api.py:
import sqlite3, hashlib, time
from collections import defaultdict
from fastapi import FastAPI, Request, HTTPException
from pydantic import BaseModel, Field
from agent import graph
from langchain_core.messages import HumanMessage
app = FastAPI()
_rate = defaultdict(list)
RATE_LIMIT = 3
RATE_WINDOW = 60
CACHE_DB = "/opt/golf-rag/cache.db"
def _init_cache():
conn = sqlite3.connect(CACHE_DB)
conn.execute("CREATE TABLE IF NOT EXISTS cache (hash TEXT PRIMARY KEY, answer TEXT, ts REAL)")
conn.commit(); conn.close()
_init_cache()
def cache_get(q):
h = hashlib.md5(q.strip().lower().encode()).hexdigest()
conn = sqlite3.connect(CACHE_DB)
row = conn.execute("SELECT answer FROM cache WHERE hash=?", (h,)).fetchone()
conn.close()
return row[0] if row else None
def cache_set(q, a):
h = hashlib.md5(q.strip().lower().encode()).hexdigest()
conn = sqlite3.connect(CACHE_DB)
conn.execute("INSERT OR REPLACE INTO cache (hash, answer, ts) VALUES (?,?,?)", (h, a, time.time()))
conn.commit(); conn.close()
class Question(BaseModel):
text: str = Field(..., min_length=2, max_length=200)
@app.post("/api/golf-rules")
async def ask(q: Question, request: Request):
ip = request.headers.get("X-Real-IP", request.client.host)
now = time.time()
_rate[ip] = [t for t in _rate[ip] if now - t < RATE_WINDOW]
if len(_rate[ip]) >= RATE_LIMIT:
raise HTTPException(status_code=429, detail="请求太频繁,请稍后再试(每分钟最多 3 次)")
_rate[ip].append(now)
cached = cache_get(q.text)
if cached:
return {"answer": cached, "cached": True}
r = graph.invoke({"messages": [HumanMessage(content=q.text)]})
answer = r["messages"][-1].content
cache_set(q.text, answer)
return {"answer": answer, "cached": False}
注意 10: uvicorn 启动时会加载 agent.py(HuggingFace 模型 + Chroma),耗时 10–20 秒。systemd 的 Restart=always 会在模型加载期间误判失败、无限重启。改成 Restart=on-failure,并加 TimeoutStartSec=120。
注意 11: 8000 和 8080 端口已被 nginx 占用(ss -ltnp | grep 8000 显示 nginx)。uvicorn 绑这两个端口会 address already in use。改用 8090(先 ss -ltnp | grep 8090 确认空闲)。
systemd 配置 /etc/systemd/system/golf-rag.service:
[Unit] Description=Golf Rules RAG API After=network.target [Service] Type=simple WorkingDirectory=/opt/golf-rag ExecStart=/opt/golf-rag/venv/bin/uvicorn api:app --host 127.0.0.1 --port 8090 Restart=on-failure RestartSec=5 TimeoutStartSec=120 [Install] WantedBy=multi-user.target
systemctl daemon-reload systemctl enable --now golf-rag
注意 12: 频率限制的 60 秒窗口在测试时容易被 RAG 的 1–3 分钟耗时「冲掉」。测试频率限制时,用已经缓存过的问题(秒回),连发 4–5 次,才能看到 429。
八、nginx 反代与 HTTPS
8.1 子域方案
golforfun.net 的 WordPress 在另一台服务器(85.131.207.20),本 VPS(162.43.92.249)只提供 API。用 api.golforfun.net 子域。
DNS(XServer サーバーパネル):
api.golforfun.net A 162.43.92.249 TTL 3600
注意 13: golforfun.net 的权威 NS 是 ns1.xserver.jp 到 ns5.xserver.jp,不是 Xdomain。DNS 记录必须在 XServer サーバーパネル → ドメイン設定 → DNSレコード設定 里改。Xdomain 面板和 XServer 是两套独立的 DNS 区域,在 Xdomain 加的记录 XServer 看不到。
注意 14: XServer 的 DNS 传播很慢。权威 NS 很快返回新 IP,但公共 DNS(8.8.8.8、1.1.1.1)TTL 3600 内可能一直返回旧 IP。certbot 的 HTTP-01 验证走公共 DNS,会失败(Invalid response from http://api.golforfun.net/… 404,实际打到旧 IP)。
8.2 nginx 配置
/etc/nginx/sites-enabled/api.golforfun.net.conf:
server {
listen 80;
server_name api.golforfun.net;
return 301 https://$host$request_uri;
}
server {
listen 162.43.92.249:443 ssl;
server_name api.golforfun.net;
ssl_certificate /etc/letsencrypt/live/api.golforfun.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.golforfun.net/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location /api/golf-rules {
add_header 'Access-Control-Allow-Origin' 'https://golforfun.net' always;
add_header 'Access-Control-Allow-Methods' 'POST, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Content-Type' always;
if ($request_method = 'OPTIONS') {
return 204;
}
proxy_pass http://127.0.0.1:8090;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 300s;
proxy_connect_timeout 10s;
}
}
注意 15: proxy_read_timeout 300s 必须加,默认 60s 会在 DeepSeek 响应前超时。
注意 16: 本 VPS 的 443 端口被 tailscaled 占用(100.99.4.120:443,Tailscale serve)。nginx 绑 0.0.0.0:443 会失败(Linux 内核里 0.0.0.0 和具体 IP 的绑定冲突)。必须绑公网 IP:listen 162.43.92.249:443 ssl;。
注意 17: UFW 默认没放行 443。ufw allow 443/tcp,否则外部 curl 直接 HTTP 000。
8.3 certbot(DNS-01 验证)
注意 18: snap 版 certbot 的证书路径可能因沙盒权限问题不落到 /etc/letsencrypt/live/。如果 ls 显示目录不存在,但 certbot 打印 Successfully received certificate,说明写文件失败。可以先 mkdir -p /etc/letsencrypt/live 再重签。
注意 19: DNS-01 验证不能用 --nginx 插件(它只支持 HTTP-01)。必须用 certonly --manual:
certbot certonly --manual \ -d api.golforfun.net \ --preferred-challenges dns \ --agree-tos --register-unsafely-without-email
certbot 会打印 TXT 值,去 XServer 面板加 _acme-challenge.api 的 TXT 记录。每次重跑 certbot 的 TXT 值都会变,旧记录必须删掉。
注意 20: --manual 模式不能加 --non-interactive,否则报 An authentication script must be provided with --manual-auth-hook。
8.4 验证
# 证书文件
ls -la /etc/letsencrypt/live/api.golforfun.net/
# 443 监听
ss -ltnp | grep ':443 '
# 公网 HTTPS 测试(--resolve 绕过 DNS 缓存)
curl -s --resolve api.golforfun.net:443:162.43.92.249 \
-X POST https://api.golforfun.net/api/golf-rules \
-H "Content-Type: application/json" \
-d '{"text":"红桩怎么抛球?罚几杆?"}'
九、WordPress 前端嵌入
在 golforfun.net 页面加自定义 HTML 区块:
高尔夫规则咨询
常见问题秒回,新问题约需 1–3 分钟
注意 21: 如果用户从 https://www.golforfun.net 访问,nginx 的 Access-Control-Allow-Origin 只写了 https://golforfun.net,浏览器会拦 CORS。建议在 api.py 加 FastAPI 的 CORSMiddleware 覆盖两个域名:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://golforfun.net", "https://www.golforfun.net"],
allow_methods=["POST", "OPTIONS"],
allow_headers=["Content-Type"],
)
十、知识库管理
kb_manage.sh 支持三种操作:
| 命令 | 作用 | 是否需要重启 |
|---|---|---|
| count | 看主库条数 | 否 |
| test “query” | 走完整检索逻辑(同义词 + 硬过滤),看命中哪条规则 | 否 |
| add <file> <rule> <sub> <title> | 追加一条文档到主库,同 rule+sub 覆盖不重复 | 否 |
| rebuild <pdf> | 全量重建主库,自动备份旧库 + 清缓存 + 重启服务 | 是(脚本自动做) |
| newcol <col> <file> <rule> <title> | 新建独立 collection,不混主库 | 否(但要改 agent.py 才能用) |
注意 22: add 追加的文档如果 rule 字段是 “附錄 A” 这种,KEYWORD_RULE_MAP 里没有对应关键词,硬过滤不会命中它,只能靠双路检索(语义相似度)。如果想让附录也能通过关键词精确命中,需要在 agent.py 的 KEYWORD_RULE_MAP 里加一条:
(["当地规则", "當地規則", "附录", "附錄"], "附錄 A"),
注意 23: rebuild 会清缓存并重启服务。旧缓存基于旧规则,必须清,否则用户会看到旧答案。
十一、验证结果
| 测试问题 | 预期 | 实际 |
|---|---|---|
| 红桩怎么抛球?罚几杆? | 罰 1 桿,规则 17.1d | ✅ |
| 开球出界了怎么算?罚几杆? | 罰 1 桿 + E-5 提示,规则 18.2b | ✅ |
| 推杆时球碰到旗杆算犯规吗? | 不罰桿,规则 13.2a(2) | ✅ |
| 沙坑里球旁边有石子能捡吗? | 可捡不罚,弄动球罚 1 杆,规则 12.2a/15.1b | ✅ |
缓存命中:41ms 秒回。频率限制:第 4 次 429。长度限制:201 字 422。
十二、已知限制与后续改进
延迟: 单次回答 1–3 分钟。已加 SQLite 缓存,重复问题秒回。
繁体/简体: 规则库繁体,用户简体。靠同义词表 + 硬过滤覆盖常见问题,生僻问题(「暂定球怎么打」「球停在 cart path 上」)可能检索不到。持续往 SYNONYMS 和 KEYWORD_RULE_MAP 加词。
证书自动续期: DNS-01 manual 模式不会自动续期。证书 2027-01-17 到期,提前一周手动重跑 certbot。
规则版本: 当前用 2023 版 GAROC 中文版。R&A 更新规则后需重新拆条建库(kb_manage.sh rebuild)。
罚杆判定: 模型从条款里抠「罰一桿」「一般處罰」等字眼。条款没明确写数量时返回 null(「條款未明確」),不瞎猜。这是刻意设计,宁可少答不错答。
十三、踩坑清单(速查)
| # | 坑 | 解法 |
|---|---|---|
| 1 | sentence-transformers 拉 CUDA 版 torch(2.5GB) | 先装完跑通,后续可换 CPU 版 |
| 2 | HuggingFaceEmbeddings 迁移到 langchain-huggingface | 用新 import |
| 3 | 按整条规则切 chunk,检索精度崩 | 按子条款切(X.Y) |
| 4 | 子条款编号前没有「規則」二字 | 正则写 ^X.Y,不是 ^規則X.Y |
| 5 | 双路检索 + 语义重排不够可靠 | 关键词硬过滤 filter={“rule”: “規則 17”} |
| 6 | with_structured_output 在 DeepSeek + One API 上不可用 | 改「prompt 要求 JSON + 手动解析」 |
| 7 | 评分节点判 no → 重写死循环 | 加 rewrites >= 2 强制生成 |
| 8 | DeepSeek 响应 30 秒–2 分钟 | timeout=300 |
| 9 | One API 上游随机 429 | 不是代码问题,One API 自动重试 |
| 10 | uvicorn 启动加载模型 10–20 秒,systemd 误判重启 | Restart=on-failure + TimeoutStartSec=120 |
| 11 | 8000/8080 被 nginx 占 | 改用 8090 |
| 12 | 频率限制 60 秒窗口被 RAG 耗时冲掉 | 测试时用缓存问题连发 |
| 13 | XServer 权威 NS 不是 Xdomain | DNS 记录必须在 XServer 面板改 |
| 14 | XServer DNS 传播慢 | 等 1–24 小时,或用 DNS-01 绕过 |
| 15 | proxy_read_timeout 默认 60s 超时 | 改 300s |
| 16 | tailscaled 占 100.99.4.120:443 | nginx 绑 162.43.92.249:443 |
| 17 | UFW 没放行 443 | ufw allow 443/tcp |
| 18 | snap certbot 证书路径不落盘 | mkdir -p /etc/letsencrypt/live 再重签 |
| 19 | DNS-01 不能用 –nginx 插件 | 用 certonly –manual |
| 20 | –manual 不能加 –non-interactive | 去掉,交互式加 TXT |
| 21 | www 子域 CORS 被拦 | FastAPI CORSMiddleware 覆盖两个域名 |
| 22 | add 的附录文档硬过滤不命中 | 在 KEYWORD_RULE_MAP 加映射 |
| 23 | rebuild 不清缓存会返回旧答案 | 脚本自动 rm cache.db + 重启 |
参考文件路径:
- `/opt/golf-rag/golfrule2023.pdf` — 规则原文
- `/opt/golf-rag/build_kb.py` — 拆条建库
- `/opt/golf-rag/agent.py` — LangGraph RAG
- `/opt/golf-rag/api.py` — FastAPI
- `/opt/golf-rag/kb_manage.sh` — 知识库管理
- `/opt/golf-rag/chroma_db/` — 向量库
- `/etc/systemd/system/golf-rag.service` — 服务托管
- `/etc/nginx/sites-enabled/api.golforfun.net.conf` — 反代
- `/etc/letsencrypt/live/api.golforfun.net/` — 证书
附:常用命令使用例子
下面是这套系统所有常用命令的使用例子,按场景分类,可以直接复制运行。
一、知识库管理(kb_manage.sh)
所有命令都在 /opt/golf-rag 下执行,脚本内部会自动 source venv/bin/activate。
1. 查看当前库条数
cd /opt/golf-rag ./kb_manage.sh count
输出:
主库条数:303
2. 测试检索(走完整逻辑:同义词 + 硬过滤)
./kb_manage.sh test "红桩怎么抛球"
输出:
查询:红桩怎么抛球 扩展后:红桩怎么抛球 紅色處罰區 側面脫困 脫困 拋球 硬过滤:規則 17 [規則 17 17.1d] 處罰區 [規則 17 17.2a] 處罰區 [規則 17 17.1e] 處罰區
再测几个:
./kb_manage.sh test "开球出界了怎么算" # 应命中 規則 18 ./kb_manage.sh test "球打进沙坑怎么办" # 应命中 規則 12 ./kb_manage.sh test "推杆时球碰到旗杆" # 应命中 規則 13 ./kb_manage.sh test "球停在cart path上怎么办" # 可能命中 規則 16(异常状况),如果不准,往 SYNONYMS 加词
3. 追加一条文档
准备文本文件:
cat > appendix_a.txt <<'EOF' 附錄 A 當地規則範例 A.1 優先順序 當地規則的優先順序高於規則本身,但不得違反規則的基本原則。委員會可採用當地規則以應對特殊情況,例如臨時積水、環保敏感區域、球道上的不可移動障礙物等。 A.2 常見當地規則 - 球道上的不可移動障礙物:免罰脫困 - 臨時積水:視為異常狀況,免罰脫困 - 環保敏感區域:禁止進入,必須脫困 EOF
追加到主库:
./kb_manage.sh add appendix_a.txt "附錄 A" "A.1" "當地規則範例"
输出:
✅ 已追加 [附錄 A A.1] 當地規則範例,当前库 304 条
验证追加成功:
./kb_manage.sh count # 应显示 304 ./kb_manage.sh test "当地规则优先顺序" # 可能命中 [附錄 A A.1],也可能不命中(因为 KEYWORD_RULE_MAP 里没有「附錄 A」的映射)
4. 全量重建(换新版 PDF)
准备新版 PDF:
# 把新版 PDF 传到 VPS scp golfrule2024.pdf root@162.43.92.249:/opt/golf-rag/
重建:
cd /opt/golf-rag ./kb_manage.sh rebuild golfrule2024.pdf
输出:
📦 旧库备份到 chroma_db.bak.1792365000 拆出 320 个子条款 ✅ Chroma 已写入 320 条 🧹 清缓存 ✅ 重建完成,服务已重启
验证重建结果:
./kb_manage.sh count
# 应显示 320
./kb_manage.sh test "红桩怎么抛球"
# 应命中 規則 17
curl -s -X POST https://api.golforfun.net/api/golf-rules \
-H "Content-Type: application/json" \
-d '{"text":"红桩怎么抛球?罚几杆?"}' | head -c 300
# 应返回新规则的答案
如果重建出错,回滚:
cd /opt/golf-rag ls -t chroma_db.bak.* | head -1 # 假设是 chroma_db.bak.1792365000 rm -rf chroma_db mv chroma_db.bak.1792365000 chroma_db systemctl restart golf-rag
5. 新建独立 collection(加不同主题)
准备礼仪文本:
cat > etiquette.txt <<'EOF' 高尔夫礼仪要点: 1. 安全第一:挥杆前确认周围无人,球可能打到人时大声喊「看球」。 2. 保持安静:他人击球时不要说话、不要走动。 3. 修复球痕:球落在果岭上留下的球痕要用球叉修复。 4. 耙平沙坑:从沙坑击球后,用沙耙把沙坑耙平。 5. 控制速度:不拖延打球节奏,让后面组跟上。 6. 着装规范:遵守球场的着装要求,通常要求有领上衣和 golf 鞋。 EOF
新建 collection:
./kb_manage.sh newcol etiquette etiquette.txt "禮儀" "高爾夫禮儀"
输出:
✅ 已写入 collection 'etiquette',当前 1 条 注意:agent.py 里要加逻辑查这个 collection 才能用
注意:新建的 collection 不会自动被 agent.py 使用。要在 agent.py 的 retrieve_golf_rules 里加逻辑,根据 query 决定查哪个 collection。如果只是想让礼仪内容也能被检索到,更简单的做法是追加到主库:
./kb_manage.sh add etiquette.txt "禮儀" "E.1" "高爾夫禮儀"
二、API 测试
1. 从 VPS 本地测试(绕过 DNS)
curl -s --resolve api.golforfun.net:443:162.43.92.249 \
-X POST https://api.golforfun.net/api/golf-rules \
-H "Content-Type: application/json" \
-d '{"text":"红桩怎么抛球?罚几杆?"}'
2. 走公网 DNS 测试(正常路径)
curl -s -X POST https://api.golforfun.net/api/golf-rules \
-H "Content-Type: application/json" \
-d '{"text":"开球出界了怎么算?罚几杆?"}'
3. 测试缓存命中(同样问题再问,应秒回)
time curl -s -X POST https://api.golforfun.net/api/golf-rules \
-H "Content-Type: application/json" \
-d '{"text":"红桩怎么抛球?罚几杆?"}'
输出应该带 "cached": true,耗时几十毫秒。
4. 测试频率限制(连发 4 次,第 4 次应 429)
用缓存过的问题,避免 RAG 耗时冲掉 60 秒窗口:
for i in 1 2 3 4; do
curl -s -o /dev/null -w "第 $i 次: HTTP %{http_code}\n" \
-X POST https://api.golforfun.net/api/golf-rules \
-H "Content-Type: application/json" \
-d '{"text":"红桩怎么抛球?罚几杆?"}'
done
输出:
第 1 次: HTTP 200 第 2 次: HTTP 200 第 3 次: HTTP 200 第 4 次: HTTP 429
5. 测试长度限制(201 字应 422)
LONG=$(python3 -c "print('啊'*201)")
curl -s -o /dev/null -w "HTTP %{http_code}\n" \
-X POST https://api.golforfun.net/api/golf-rules \
-H "Content-Type: application/json" \
-d "{\"text\":\"$LONG\"}"
输出:HTTP 422
6. 测试 CORS 预检
curl -s -X OPTIONS https://api.golforfun.net/api/golf-rules \ -H "Origin: https://golforfun.net" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: Content-Type" \ -D - -o /dev/null | grep -iE "HTTP/|access-control"
输出:
HTTP/1.1 204 No Content Access-Control-Allow-Origin: https://golforfun.net Access-Control-Allow-Methods: POST, OPTIONS Access-Control-Allow-Headers: Content-Type
三、服务管理
1. 查看服务状态
systemctl status golf-rag --no-pager | head -10
2. 重启服务
systemctl restart golf-rag
3. 看实时日志
journalctl -u golf-rag -f
另开终端发请求,能看到:
INFO: 127.0.0.1:xxxxx - "POST /api/golf-rules HTTP/1.1" 200 OK
4. 看最近 50 行日志
journalctl -u golf-rag -n 50 --no-pager
5. 看 nginx 错误日志
tail -20 /var/log/nginx/error.log
四、缓存管理
1. 看缓存条数
sqlite3 /opt/golf-rag/cache.db "SELECT COUNT(*) FROM cache;"
2. 看最近 5 条缓存
sqlite3 /opt/golf-rag/cache.db "SELECT ts, answer FROM cache ORDER BY ts DESC LIMIT 5;"
3. 清空缓存(规则更新后必须做)
rm /opt/golf-rag/cache.db systemctl restart golf-rag
4. 删除单条缓存(按问题 hash)
# 先算 hash
python3 -c "import hashlib; print(hashlib.md5('红桩怎么抛球?罚几杆?'.lower().encode()).hexdigest())"
# 假设输出是 abc123...
sqlite3 /opt/golf-rag/cache.db "DELETE FROM cache WHERE hash='abc123...';"
五、证书管理
1. 查看证书有效期
certbot certificates
输出:
Certificate Name: api.golforfun.net Domains: api.golforfun.net Expiry Date: 2027-01-17 (VALID: 89 days) Certificate Path: /etc/letsencrypt/live/api.golforfun.net/fullchain.pem Private Key Path: /etc/letsencrypt/live/api.golforfun.net/privkey.pem
2. 手动续期(到期前一周)
certbot certonly --manual \ -d api.golforfun.net \ --preferred-challenges dns \ --agree-tos --register-unsafely-without-email
去 XServer 面板加新的 TXT 记录,签完后 systemctl reload nginx。
3. 检查 443 监听
ss -ltnp | grep ':443 '
应看到 nginx 在 162.43.92.249:443。
六、调试与排查
1. 测 One API 是否正常
curl -s -o /dev/null -w "HTTP %{http_code}\n" \
-H "Authorization: Bearer $ONEAPI_KEY" \
http://127.0.0.1:3000/v1/models
应返回 HTTP 200。
2. 测 uvicorn 是否在 8090 监听
ss -ltnp | grep 8090
应看到 uvicorn 在 127.0.0.1:8090。
3. 测 nginx 反代是否通(本地)
curl -s -X POST http://127.0.0.1/api/golf-rules \
-H "Host: api.golforfun.net" \
-H "Content-Type: application/json" \
-d '{"text":"红桩怎么抛球"}'
应返回 JSON。
4. 测 DNS 解析
dig +short api.golforfun.net @8.8.8.8
应返回 162.43.92.249。
5. 测公网 443 是否可达
curl -s -o /dev/null -w "HTTP %{http_code}\n" \
--resolve api.golforfun.net:443:162.43.92.249 \
https://api.golforfun.net/api/golf-rules \
-X POST -H "Content-Type: application/json" \
-d '{"text":"test"}'
返回 200 或 422 都说明 443 可达。
6. 看 UFW 443 是否放行
ufw status | grep 443
应看到 443/tcp ALLOW Anywhere。
七、完整端到端测试脚本
把下面存成 /opt/golf-rag/e2e_test.sh,一次跑完所有检查:
cat > /opt/golf-rag/e2e_test.sh <<'EOF'
#!/usr/bin/env bash
API="https://api.golforfun.net/api/golf-rules"
IP="162.43.92.249"
DOMAIN="api.golforfun.net"
echo "=== 1. DNS ==="
dig +short $DOMAIN @8.8.8.8
echo "=== 2. 443 监听 ==="
ss -ltnp | grep ':443 '
echo "=== 3. UFW 443 ==="
ufw status | grep 443 || echo "⚠️ 443 未放行"
echo "=== 4. 服务状态 ==="
systemctl is-active golf-rag
echo "=== 5. 证书 ==="
certbot certificates 2>/dev/null | grep -A2 "api.golforfun.net" | head -3
echo "=== 6. API 测试 ==="
curl -s --resolve $DOMAIN:443:$IP \
-X POST $API \
-H "Content-Type: application/json" \
-d '{"text":"红桩怎么抛球?罚几杆?"}' | head -c 200
echo ""
echo "=== 7. 缓存命中 ==="
curl -s --resolve $DOMAIN:443:$IP \
-X POST $API \
-H "Content-Type: application/json" \
-d '{"text":"红桩怎么抛球?罚几杆?"}' \
-w "\n耗时 %{time_total}s\n" | tail -3
echo "=== 完成 ==="
EOF
chmod +x /opt/golf-rag/e2e_test.sh
/opt/golf-rag/e2e_test.sh
八、日常维护清单
| 频率 | 操作 | 命令 |
|---|---|---|
| 每天 | 看服务是否正常 | systemctl is-active golf-rag |
| 每周 | 看缓存条数增长 | sqlite3 cache.db “SELECT COUNT(*) FROM cache;” |
| 每月 | 看证书剩余天数 | certbot certificates |
| 规则更新时 | 重建库 + 清缓存 | ./kb_manage.sh rebuild new.pdf |
| 加新文档时 | 追加 | ./kb_manage.sh add file.txt “規則 X” “X.Y” “标题” |
| 证书到期前一周 | 手动续期 | certbot certonly –manual … |
先跑 ./kb_manage.sh count 和 ./kb_manage.sh test "红桩怎么抛球" 确认脚本正常,再按需用其他命令。如果某个命令报错,把输出贴回来。
十四、长期维护提醒
- 证书续期:证书 2027-01-17 到期,提前一周(2027-01-10 左右)手动重跑 certbot,加新的 TXT 记录。建议在日历上标记。
- 规则版本更新:用
./kb_manage.sh rebuild 新版.pdf,会自动备份旧库、清缓存、重启服务。 - 检索不准:往 agent.py 的
SYNONYMS和KEYWORD_RULE_MAP加词,然后systemctl restart golf-rag。 - 磁盘监控:torch 占约 2.5GB,加上 Chroma 和模型缓存,
/opt/golf-rag大概占 4–5GB。目前还有约 70G 空闲,暂时不用管,但以后磁盘紧张时可以换 CPU 版 torch 省 2GB。
这次从 PDF 拆条到 HTTPS 上线,踩了 DNS 传播、snap certbot、tailscaled 占 443、UFW 没放行、DeepSeek 不支持结构化输出、评分死循环、频率限制窗口过期等一系列坑,全部在对话里解决并记录。整套流程已经可以复现,后面加规则、换版本、加新主题都有现成脚本。
📄 姊妹篇(精简版): 从零搭建高尔夫规则 RAG 问答系统(LangGraph + Chroma + One API + FastAPI) —— 架构与核心代码速览版。

Pingback: 从零搭建高尔夫规则 RAG 问答系统(LangGraph + Chroma + One API + FastAPI) – helloai