从零搭建高尔夫规则 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 个踩坑清单、知识库管理脚本、端到端测试脚本与日常维护手册。

发表回复

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