从零搭建高尔夫规则 RAG 问答系统(LangGraph + Chroma + One API + FastAPI)
摘要: 记录在 VPS 上搭建「高尔夫规则咨询」RAG 系统的完整过程。从 PDF 拆条、向量库构建、LangGraph Agentic RAG 流程、One API 接入、FastAPI 封装到 nginx 反代和 HTTPS,包含所有踩过的坑和最终可用的配置。目标是让后来者照着做就能复现。
一、目标与整体架构
目标: 在 golforfun.net 网站上提供一个高尔夫规则问答入口,会员输入问题(如「红桩怎么抛球?罚几杆?」),系统基于官方规则原文回答,并明确给出「是否罚杆、罚几杆」。
整体链路:
WordPress 页面(golforfun.net) ↓ HTTPS POST api.golforfun.net(nginx 反代,443) ↓ 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:
# 双路检索:扩展 query + 原始 query,取并集
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:
from fastapi import FastAPI
from pydantic import BaseModel
from agent import graph
from langchain_core.messages import HumanMessage
app = FastAPI()
class Question(BaseModel):
text: str
@app.post("/api/golf-rules")
async def ask(q: Question):
r = graph.invoke({"messages": [HumanMessage(content=q.text)]})
return {"answer": r["messages"][-1].content}
坑 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
八、nginx 反代与 HTTPS
8.1 子域方案
golforfun.net 的 WordPress 在另一台服务器(85.131.207.20),本 VPS(162.43.92.249)只提供 API。用 api.golforfun.net 子域。
DNS(Xdomain 面板):
api.golforfun.net A 162.43.92.249 TTL 3600
坑 12: Xdomain 的 DNS 传播很慢。权威 NS ns1.xdomain.ne.jp 很快返回新 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)。解决:等 1–2 小时缓存过期,或改用 --preferred-challenges dns 走 TXT 验证。
8.2 nginx 配置
/etc/nginx/sites-enabled/api.golforfun.net.conf:
server {
listen 80;
server_name api.golforfun.net;
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;
}
}
坑 13: proxy_read_timeout 300s 必须加,默认 60s 会在 DeepSeek 响应前超时。
坑 14: 本 VPS 的 443 端口被 tailscaled 占用(100.99.4.120:443,Tailscale serve)。nginx 绑 0.0.0.0:443 不冲突(不同 IP 可同端口),certbot 默认写 listen 443 ssl; 即可。
8.3 certbot
snap install --classic certbot ln -sf /snap/bin/certbot /usr/bin/certbot certbot --nginx -d api.golforfun.net --non-interactive --agree-tos --register-unsafely-without-email
坑 15: certbot 的 --nginx 插件如果不随主包装(snap 版常见),需 snap install certbot-nginx。
8.4 测试
# 本地测(Host 头模拟公网)
curl -s -X POST http://127.0.0.1/api/golf-rules \
-H "Host: api.golforfun.net" \
-H "Content-Type: application/json" \
-d '{"text":"红桩怎么抛球?罚几杆?"}'
# 公网 HTTPS 测
curl -s -X POST https://api.golforfun.net/api/golf-rules \
-H "Content-Type: application/json" \
-d '{"text":"沙坑里球旁边有石子能捡吗"}'
九、WordPress 前端嵌入
在 golforfun.net 页面加自定义 HTML 区块:
高尔夫规则咨询
回答约需 1–2 分钟,请耐心等待
十、验证结果
| 测试问题 | 预期 | 实际 |
|---|---|---|
| 红桩怎么抛球?罚几杆? | 罰 1 桿,规则 17.1d | ✅ |
| 开球出界了怎么算?罚几杆? | 罰 1 桿 + E-5 提示,规则 18.2b | ✅ |
| 推杆时球碰到旗杆算犯规吗? | 不罰桿,规则 13.2a(2) | ✅ |
| 沙坑里球旁边有石子能捡吗? | 可捡不罚,弄动球罚 1 杆,规则 12.2a/15.1b | ✅ |
十一、已知限制与后续改进
延迟: 单次回答 1–2 分钟。建议加 SQLite 缓存,重复问题直接返回。
繁体/简体: 规则库繁体,用户简体。靠同义词表 + 硬过滤覆盖常见问题,生僻问题(「暂定球怎么打」「球停在 cart path 上」)可能检索不到。持续往 SYNONYMS 和 KEYWORD_RULE_MAP 加词。
规则版本: 当前用 2023 版 GAROC 中文版。R&A 更新规则后需重新拆条建库。
罚杆判定: 模型从条款里抠「罰一桿」「一般處罰」等字眼。条款没明确写数量时返回 null(「條款未明確」),不瞎猜。这是刻意设计,宁可少答不错答。
参考文件路径:
- `/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/chroma_db/` — 向量库
- `/etc/systemd/system/golf-rag.service` — 服务托管
- `/etc/nginx/sites-enabled/api.golforfun.net.conf` — 反代
📖 姊妹篇(完整版): 从零搭建高尔夫规则 AI 问答系统:完整记录与踩坑清单 —— 同一条链路的完整版,含 23 个踩坑清单、知识库管理脚本、端到端测试脚本与日常维护手册。
