从零搭建高尔夫规则 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) —— 架构与核心代码速览版。

One thought on “从零搭建高尔夫规则 AI 问答系统:完整记录与踩坑清单”

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注