生产级企业知识库智能客服 —— 真实中文语料 · 混合检索 + 重排 · 带引用问答 · 安全护栏 + 多租户 + 审计 · 多轮会话 · 全链路可观测 · 可复现评测(工具调用为辅)
「武道AI / AI Engineering Dojo」以阵制胜系列 · 阵 01 · 从 0 到 1 的真实业务系统
helpmate 是一个能「上线」的企业知识库客服系统。主线是知识问答:以 DJI 大疆公开中文文档(用户手册 / FAQ / 售后政策)为知识库,用混合检索 + 重排把问题答准并给出引用。Function Calling 是辅助,只为「查订单、查物流」这类少数实时问题补一条旁路,绝大多数问题走问答主路径。全流程在 Langfuse 可观测,并配一套人工校验 golden set + 评测门禁。它不是 demo —— 语料是真的,检索是混合的,指标是量化的。
挂件跑在演示宿主页上:答案逐句挂 [n] 引用,底下折叠「已参考 4 篇文档」可展开原文,答完再给三条追问推荐。挂件不抢焦点、不改宿主页样式。
- 🗂️ 真实中文语料 —— 17 篇 DJI 文档 → 926 个 chunk,中文为主、夹杂大量英文专有名词(OcuSync、RTH、DJI Care…)。
- 📄 结构化 ingestion —— HTML 清洗保留标题层级、PDF 表格用 PyMuPDF 抽取并转 Markdown、按章节切块并附元数据。
- 🔍 混合检索 —— dense(Qwen3-Embedding)+ sparse(Postgres 全文 + jieba 分词)经 RRF 融合,再由 Qwen3-Reranker 重排。中文语义靠向量,型号与参数靠全文,各司其职。
- 📌 带引用的回答 —— 每条知识库回答都标注来源
[n],无上下文即拒答。 - 🧰 Function Calling(辅助) —— 订单/物流类少数实时问题自动旁路到
query_order/query_logistics工具,其余走知识问答主路径。判意图交给小模型(Qwen3-8B),与生成模型分开配置。 - 🔭 全链路 Trace —— 每次请求在 Langfuse 记录 route / retrieve / rerank / tool / generate,含模型、token、延迟。
- 📊 可复现评测 —— 50 条人工校验 golden set(+3 条跨租户负例)+ recall@k / MRR / nDCG / 工具路由 / 租户隔离 / 引用指标 + 贴基线阈值;golden 支持内容锚点,重灌库不失效。CI 硬门禁是单测(pytest),指标门禁跑本地/夜跑。
- 🛡️ 安全护栏 —— 输入拦注入 / 越狱 / 越权诱导,输出脱敏 + 违规内容拦截;纯规则、零额外延迟。
- 🧾 审计留痕 —— 每次
/chat落一条不可变审计(租户 / 会话 / 决策 / 护栏结果 + 答案哈希);问题侧先脱敏再入库,可回溯而不留明文 PII。 - 🏢 身份与授权 —— 调用方身份来自 API Key(
X-API-Key)而非请求体:检索按tenant_id过滤,订单/物流按customer_id做行级归属校验,报一个陌生单号只会得到「未找到」。未配API_KEYS时为本地 dev 身份,克隆即跑。 - 💬 多轮会话 —— 会话历史改写指代(「它 / 这款」),改写只喂检索、不多花一次思考调用。
- 🧹 语料清洗 + 在线采样 —— 摄取阶段剥离导航/页脚 boilerplate;按比例采样线上流量回流待评。
用户问题 ─▶ 输入护栏 ─▶ [多轮改写] ─▶ route(Qwen3-8B 判意图,小模型)
├─ 知识类(主)─▶ retrieve 混合检索(按 tenant 过滤)─▶ rerank(Qwen3)─┐
│ dense pgvector(HNSW) + sparse tsvector(GIN) → RRF 融合
└─ 订单/物流(辅)─▶ act:query_order / query_logistics ─┤
└─ 插件委派(可选)─▶ delegate:外部系统逐字返回 ───────┘
▼
generate(GLM-4.7)─▶ 输出护栏 ─▶ 带[n]引用的答案 ─▶ 审计留痕 + 在线采样
- 模型分工:判意图是二选一的分类,不是生成 —— 交给小模型(Qwen3-8B)比让 GLM-4.7 兼职划算,路由 p50 从 ~13s 降到 ~3s 而 golden 集路由准确率仍是 1.00。
- 摄取路径:
loaders → clean → chunking → pipeline → Qwen3 embed → Postgres - 应答路径:
护栏(in) → [多轮改写] → route → (act | retrieve → rerank) → generate → 护栏(out) → 审计,由 LangGraph 编排,Langfuse 全程埋点。 - 插件委派:可选插件可独占一轮回复并绕过
generate。第一个实现是 Return Saver;未配置时图形状与工具 schema 都保持原样。
详细系统设计见
docs/architecture.md,产品需求见docs/prd.md。
| 关注点 | 选型 |
|---|---|
| API / 编排 | FastAPI · LangGraph |
| 生成模型 | GLM-4.7(z.ai,OpenAI 兼容) |
| 路由模型 | Qwen3-8B(SiliconFlow,thinking 关闭)—— 判意图专用 |
| 向量嵌入 | Qwen3-Embedding-8B(1024 维,SiliconFlow) |
| 重排 | Qwen3-Reranker-8B(SiliconFlow) |
| 存储 / 检索 | Postgres 16 + pgvector(HNSW)+ tsvector(GIN) |
| 摄取 | BeautifulSoup(HTML)· PyMuPDF(PDF + 表格)· 结构感知切块 |
| 可观测 | Langfuse(v4) |
| 评测 | 自建指标 + RAGAS(可选) |
所有 provider 均为 OpenAI 兼容,可通过
.env平滑切换。
前置:Python 3.12 · Postgres 16 + pgvector · GLM(z.ai) / SiliconFlow / Langfuse 三个 key。
# 1. 建库并应用 schema(含 governance/ops 表;维度需与 EMBED_DIM 一致)
psql "$DATABASE_URL" -v dim=1024 -f db/schema.sql
psql "$DATABASE_URL" -f db/seed.sql
# 已有旧库、不想重灌语料?改用非破坏迁移:
# psql "$DATABASE_URL" -f db/migrations/001_governance_ops.sql
# psql "$DATABASE_URL" -f db/migrations/002_order_ownership.sql # 订单归属列
# psql "$DATABASE_URL" -f db/migrations/003_cjk_fts.sql # 中文全文检索
# python -m scripts.backfill_tsv # 003 之后必跑:重算 content_tsv(纯本地,无 API 开销)
# 2. 配置密钥
cp .env.example .env # 填入 GLM / SiliconFlow / Langfuse key
# 3. 安装并构建知识库
pip install -e ".[dev]"
python scripts/fetch_corpus.py # 抓取 DJI 语料(corpus/sources.tsv)
python scripts/ingest_corpus.py # 摄取 → chunks
python scripts/backfill_embeddings.py # Qwen3 回填向量 + 建 HNSW 索引
# 4. 启动服务
make run # 等价于 uvicorn helpmate.app:app --reload --port 8000
# (可选)跑测试 —— CI 的硬门禁
make test # 等价于 pytest -q打开 http://localhost:8000,问一个知识库问题,或者「我的订单 A1001 到哪了?」。
本地 Return Saver 演示可通过 URL 选择种子客户,例如
http://localhost:8000/?customer=C-001。支持 C-001 到 C-010;切换客户参数会自动开启新会话。该参数仅在 localhost / 127.0.0.1 生效。
# dev 模式(未配 API_KEYS):身份 = DEFAULT_TENANT / DEFAULT_CUSTOMER
curl -X POST localhost:8000/chat -H 'Content-Type: application/json' \
-d '{"question":"DJI Care 随心换进水了能保修吗?"}'
# 配了 API_KEYS 之后,身份走凭证(请求体不再接受 tenant_id)
curl -X POST localhost:8000/chat -H 'X-API-Key: sk-dji-alice' \
-H 'Content-Type: application/json' -d '{"question":"我的订单 A1001 到哪了?"}'/chat 之外还有一条流式通道和三个推荐问题接口,供 web/widget/ 的客服挂件消费。/chat 行为不变,两条路径共用同一张编译好的 LangGraph 图。
入参与 /chat 相同(question + 可选 session_id)。事件序列:
| event | data | 说明 |
|---|---|---|
stage |
{"stage":"route"|"retrieve"|"retrieved"|"act"|"delegate"|"generate"} |
节点开工前推送,是真实进度而非定时动画;retrieved 额外带 count |
token |
{"text":"…"} |
答案增量。按句子边界成块吐出,不是逐字符 —— 见下 |
offer |
{"plugin":"return_saver","offer":{…},"escape_hatch":"…"} |
插件报价卡;按钮走 /plugin/{plugin}/{action} 代理 |
replace |
{"text":"…"} |
输出护栏终检判定拦截,客户端须把整条消息替换为此文案 |
done |
{"hits":[…],"tool_call":…,"guard":…} |
hits 只含 {n,title,section,url},不含正文 |
error |
{"message":"stream failed"} |
上游失败;已吐出的文字保留 |
为什么是逐句而不是逐字:check_output 要看到完整答案才能判定,而流式没有「完整答案」直到结束。所以文本先缓冲到句子边界、脱敏后放行,结束时再对全文终检。终检来得太晚,已经显示的内容收不回去,于是拦截表现为「替换」(replace)而不是「阻止」。
为什么审计不会漏:整轮对话跑在一个无 yield 的工作函数里,finally 里统一写审计、落多轮、采样 —— 正常结束、护栏拦截、上游失败、客户端中途断开,四条出口都会经过它。
curl localhost:8000/suggest/hot # 开场热门,一次 SQL
curl -G localhost:8000/suggest/match --data-urlencode 'q=限飞' # 输入联想,一次 SQL
curl -X POST localhost:8000/suggest/followups \
-H 'Content-Type: application/json' \
-d '{"question":"限飞区怎么解禁","answer":"需要提交申请。","hit_titles":["限飞政策"]}'前两个读同一个问题库:手写种子 + 本租户 audit_log 里的历史提问(写入时已脱敏),用 ILIKE 子串匹配。没有走全文检索,因为 content_tsv 用的是 to_tsvector('simple', …),Postgres 默认分词器不切中文 —— 整句会塌成一个 token,查不出东西。followups 走小模型(Qwen3-8B)一次调用,在答案渲染完之后异步请求,不占等待时间。
三个接口都是增强功能:任何一个失败都静默返回空列表,绝不影响对话。
<script type="module" src="/widget/index.js" data-api="/" data-api-key="sk-dji-alice"></script>data-api-key 只适用于本地 demo —— 密钥写在页面上等于公开。生产应由服务端下发短期 token,本仓不实现。未配 API_KEYS 时走 dev 模式,clone && run 即可。
会话语义:session_id 存 localStorage,刷新后上下文仍在(后端记着 session_turns,所以「那它续航呢」依然能指代);但对话内容不落浏览器存储,刷新后面板是空的。挂件顶栏的「⋯」换新 session_id。
helpmate 可以把某一轮对话整段交给外部系统处理,回复逐字返回、绕过
generate 节点。第一个实现是 Return Saver(退货挽留)。
为什么不是“再注册一个工具”:现有工具产出的是 context,再由 generate 用
RAG 提示词改写。对自带护栏的外部系统,改写会让输出扫描和签名凭证绑定到
另一段文本或金额。
三条保证:
- 未配置
RETURN_SAVER_URL时,不加载插件 schema,图也不出现delegate节点。 - 外部系统超时、拒连、5xx 时,
/chat与/chat/stream落回 RAG,不返回 5xx。 - 除
src/helpmate/plugins/return_saver.py外,helpmate 只认识通用概念:plugin_sessions与/plugin/{plugin}/{action}。
先启动 Return Saver。默认端口是 8777:
cd /Users/miaomiao/ai_projects/return-saver
make install
RS_STORE_BACKEND=postgres make run首次使用 Helpmate 插件桥接时执行迁移:
psql "$DATABASE_URL" -f db/migrations/004_plugin_sessions.sql单客户本地演示不需要在页面暴露 API key。Helpmate 使用 C-001 作为默认客户:
cd /Users/miaomiao/ai_projects/helpmate
RETURN_SAVER_URL=http://127.0.0.1:8777 \
RETURN_SAVER_API_KEY=rs-local-demo \
RETURN_SAVER_TIMEOUT_S=60 \
DEFAULT_TENANT=public \
DEFAULT_CUSTOMER=C-001 \
make runRETURN_SAVER_API_KEY 必须非空,否则 Helpmate 不加载插件;Return Saver 本地 dev
模式不会校验该占位值。真实 GLM 回复可能超过默认 20 秒,因此演示环境使用 60 秒。
本地服务调用会忽略桌面 HTTP 代理,避免 localhost 被代理错误返回 503。
需要在同一个页面切换 10 个种子客户时,为 Helpmate 配置演示身份:
API_KEYS='{"sim-c001":"public:C-001","sim-c002":"public:C-002","sim-c003":"public:C-003","sim-c004":"public:C-004","sim-c005":"public:C-005","sim-c006":"public:C-006","sim-c007":"public:C-007","sim-c008":"public:C-008","sim-c009":"public:C-009","sim-c010":"public:C-010"}' \
RETURN_SAVER_URL=http://127.0.0.1:8777 \
RETURN_SAVER_API_KEY=rs-local-demo \
RETURN_SAVER_TIMEOUT_S=60 \
make run然后打开 http://localhost:8000/?customer=C-001。参数支持 C-001 到
C-010,切换客户时挂件会自动创建新 session;无参数的 localhost 默认使用
C-001。该便利入口只在 localhost / 127.0.0.1 生效,生产环境不会根据 URL
生成凭证。
输入明确的英文或中文售后诉求会自动唤起 Return Saver。建议首轮同时给出订单号,
例如 The tee from ORD-1001 is too tight. Can you arrange a free size exchange?,
系统可在同一轮完成订单确认并返回 offer 卡。点击 Accept 才会调用
/plugin/return_saver/accept 执行签名方案;点击 I still want to return it
会立即进入标准退货。仅输入 I accept 不会执行 offer。
生产环境使用 RETURN_SAVER_API_KEYS={"tenant":"key"} 按租户配置服务密钥。
映射非空时不会回退到共享 key,避免未列出的租户静默借用其他租户凭证。
golden set 驱动的可复现评测闭环,一条命令出报告与门禁:
python eval/run_eval.py # → eval/report.md最新基线(53 条 = 50 条问答 + 3 条跨租户负例,生成 glm-4.7 / 路由 Qwen3-8B,k=5):
| 指标 | 值 | 阈值 | 结果 |
|---|---|---|---|
| recall@5 | 0.93 | 0.88 | ✅ |
| tool_routing | 1.00 | 0.95 | ✅ |
| tenant_isolation | 1.00 | 1.00 | ✅ |
| MRR | 0.80 | — | — |
| nDCG@5 | 0.83 | — | — |
recall@5 从 0.91 升到 0.93,是修好中文全文检索换来的:
content_tsv原本是to_tsvector('simple', content), 而 Postgres 默认解析器按数据库 ctype 判断字符类别 —— macOS 上 CJK 被判成blank直接丢弃(tsvector 为空), glibc 上则整段塌成一个 token。两种情况下中文查询都是 0 命中,所谓「混合检索」对中文实际只有 dense 一条腿。 现在改成 jieba 分词 + 非 ASCII 词映射为 ASCII 代词,「限飞」的全文命中从 0 变成 38 条。
阈值贴着基线留 ~3 个点余量:0.70 的旧阈值退化 20 个点仍会 PASS,那不是门禁。
tool_routing换小模型后重跑仍是 53/53:小模型不加约束会在知识类问题上编一个占位单号误调工具(0.98), 路由系统提示写死「没有真实单号就不调工具」后回到 1.00,顺带把路由 p50 又压掉 1 秒多。tenant_isolation是新增的跨租户负例(同一问题以外部租户身份检索必须零命中)。 单测(make test)是 CI 硬门禁;make gate这套指标需要真库 + 真 key,跑在本地/夜跑,不在 CI 内。
生成类指标(引用正确率 + RAGAS faithfulness/answer_relevancy)由
eval_generate=True开启(judge=GLM、embeddings=Qwen3)。golden set 见eval/golden.jsonl。
每次 /chat 都是 Langfuse 里的一条 chat-response trace,观测树类型清晰:
generation——route-select-tool(Qwen3-8B)/generate-answer(GLM-4.7),自动记模型 + token,两段延迟可分开看embedding—— Qwen3 向量化retriever——retrieve-context(混合检索)span——rerank-candidatestool——tool:query_logistics/tool:query_order
trace 带 session_id、tags、environment,并对 PII/密钥递归脱敏。
helpmate/
├── README.md
├── pyproject.toml
├── docker-compose.yml
├── .env.example
├── corpus/ # DJI 语料来源清单 + manifest(raw 抓取物 gitignore)
│ ├── sources.tsv
│ └── manifest.jsonl
├── .github/workflows/ci.yml # CI:pytest 硬门禁
├── Makefile # make run / test / gate / ci
├── db/
│ ├── schema.sql # documents / chunks(+tenant) / orders / shipments / audit_log / session_turns / online_eval
│ ├── migrations/ # 001_governance_ops · 002_order_ownership(非破坏迁移)
│ └── seed.sql
├── docs/
│ ├── prd.md # 产品需求
│ └── architecture.md # 系统设计
├── scripts/
│ ├── fetch_corpus.py # 抓取语料
│ ├── ingest_corpus.py # 批量摄取
│ ├── backfill_embeddings.py # 回填向量 + 建 HNSW
│ └── backfill_tsv.py # 重算 content_tsv(改分词规则后重跑,纯本地零 API 开销)
├── src/helpmate/
│ ├── app.py # FastAPI:/ingest /chat /chat/stream /suggest/*
│ ├── suggest.py # 推荐问题:热门(SQL) · 联想(SQL) · 追问(小模型)
│ ├── config.py # pydantic-settings 配置
│ ├── obs.py # Langfuse 初始化 + 脱敏
│ ├── providers.py # LLM 客户端:生成(GLM) + 路由(Qwen3-8B)(langfuse.openai drop-in)
│ ├── tools.py # Function Calling 工具 + 分发
│ ├── graph.py # LangGraph 应答流
│ ├── auth.py # API Key → Principal(tenant, customer)
│ ├── db.py # 检索(租户过滤) / 订单(归属校验) / 审计 / 会话 / 采样
│ ├── ops.py # 确定性在线采样门
│ ├── security/
│ │ └── guardrails.py # 输入/输出护栏(注入·越狱·越权·脱敏·违规)
│ ├── session/
│ │ └── memory.py # 多轮指代消解 · 历史改写检索查询
│ ├── ingest/
│ │ ├── loaders.py # HTML 清洗 · PDF+表格抽取 · 表格转 MD
│ │ ├── clean.py # 语料 boilerplate 清洗
│ │ ├── chunking.py # 分节 + 元数据分块(含 tenant_id)
│ │ └── pipeline.py # 摄取编排(load → clean → chunk → write)
│ └── retrieve/
│ ├── embed.py # Qwen3 嵌入客户端
│ ├── fuse.py # RRF 融合
│ ├── rerank.py # Qwen3 重排
│ ├── segment.py # 中文分词 → ASCII 代词(Postgres 解析器不认中文)
│ ├── hybrid.py # dense+FTS→RRF→rerank 编排
│ └── context.py # 带引用的上下文拼装
├── eval/
│ ├── golden.jsonl # 50 条人工校验评测集 + 3 条跨租户负例
│ ├── relink_golden.py # chunk_id → 内容锚点(重灌库不失效)
│ ├── metrics.py # recall@k / MRR / nDCG / 工具 / 引用(TDD)
│ ├── run_eval.py # 评测 runner + 报告 + 门禁
│ ├── ragas_eval.py # RAGAS 生成质量指标
│ └── report.md # 最新评测报告
├── web/
│ ├── index.html # 演示宿主页(假官网),一行 script 引入挂件
│ └── widget/ # 客服挂件(零构建原生 ES Module)
│ ├── index.js # 入口:Shadow DOM 装配 + 自动弹窗策略
│ ├── api.js # 传输:SSE 解析 · session · 三个推荐接口
│ ├── ui.js # 渲染:气泡 · chips · 联想 · 引用折叠
│ └── style.css # 挂件样式(注入 Shadow Root)
└── tests/ # pytest 单元测试
.env(见 .env.example)关键项:
| 变量 | 说明 |
|---|---|
DATABASE_URL |
Postgres 连接串 |
LLM_PROVIDER / LLM_MODEL |
生成模型:glm / glm-4.7(z.ai,base URL 内置默认) |
GLM_API_KEY |
GLM(z.ai) 密钥 |
ROUTER_PROVIDER / ROUTER_MODEL |
路由模型:siliconflow / Qwen/Qwen3-8B;设 ROUTER_PROVIDER=llm 则退回用 LLM_MODEL 判意图 |
EMBED_MODEL / EMBED_DIM |
Qwen/Qwen3-Embedding-8B / 1024 |
RERANK_MODEL |
Qwen/Qwen3-Reranker-8B |
SILICONFLOW_API_KEY |
SiliconFlow 密钥 |
LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY / LANGFUSE_BASE_URL |
Langfuse |
TOP_K / retrieve_candidates |
最终/候选检索条数 |
eval_recall_k / eval_generate / eval_thresholds |
评测参数 |
API_KEYS |
JSON:API Key → "tenant" 或 "tenant:customer";留空 = dev 模式 |
default_tenant / default_customer |
dev 模式下的租户 / 订单归属身份 |
guardrails_enabled |
是否启用输入输出护栏 |
llm_timeout_s / llm_max_retries |
LLM 单次超时 / 重试次数 |
ingest_max_chunks |
单次 /ingest 回填向量的 chunk 上限 |
session_history_turns / online_sample_rate |
多轮改写载入的历史轮数 / 在线采样比例(%) |
- ✅ 阶段① 真实 DJI 中文语料 + 结构化 ingestion(含 boilerplate 清洗)
- ✅ 阶段② Qwen3 嵌入回填 + HNSW + dense/FTS/RRF + Qwen3 重排
- ✅ 阶段③ 50 条 golden set + 指标 + 报告门禁(recall@5=0.93)+ CI(pytest 硬门禁)
- ✅ 阶段④ Langfuse v4 全链路 trace
- ✅ 阶段⑤ 治理层:输入/输出护栏 · 审计留痕(问题侧脱敏) · 多租户过滤 · API Key 身份 + 订单行级归属校验
- ✅ 阶段⑥ 运营层:多轮会话 + 指代消解 · 在线采样回流
- ⬜ 专题:检索父子块 + 元数据过滤(拉起 manual recall)
- ⬜ 专题:延迟分级路由 + 流式(p50 38s → 面客可接受)—— 判意图已下放小模型(route p50 13s → 3s),生成侧流式待做
- ⬜ 生成类指标默认开启(需更快的 judge 模型)
- ⬜ 真多模态(图像 OCR / 视频转写)· 完整在线 A/B —— 阵 04
MIT © helpmate