Dify 实战:私有化部署与知识库应用
把整套应用搭在自己的机器上:文档不出内网、模型自己挑、检索链路每一颗旋钮都归自己管。
30″30 秒看懂私有化知识库
把这件事想成在自家院子里盖一座私人图书馆。公共图书馆当然更大更全,但你手上这批东西——员工手册、内部合同、客户名单、还没公开的法务意见——是不能拿出院子的。于是你自己盖楼、自己上书架、自己雇馆员。
盖楼这件事有现成的图纸:一条命令把楼、水电、消防一次建好,这就是容器化部署。上书架的过程才是真功夫:书整本堆着没法查,要拆成一张张卡片;每张卡片要编一个语义坐标,意思相近的卡片坐标就挨得近;卡片按坐标摆进目录柜。等有人来问问题,先把问题也换算成坐标,找回坐标最近的一摞卡片,让馆员重新排一遍序,最后交给那个读完卡片替你写答案的人。
他不会自己去翻书。他手上有几张卡片,就只能答出几张卡片里的东西。
| 图书馆里的角色 | 对应的技术概念 | 它到底干了什么 |
|---|---|---|
| 院墙 | 私有化部署 | 楼盖在自己的机器上,书和问答全程不出内网 |
| 一次建好的楼 | Docker Compose | 把应用、数据库、向量库、缓存打成一套,一条命令拉起来 |
| 一摞书 | 原始文档 | 整本堆着谁也查不动,必须先拆 |
| 拆成的卡片 | chunk(分段) | 一张卡片装一个完整的意思,拆法直接决定后面所有环节的上限 |
| 卡片背面写着属于哪一章 | 父子分段 | 正面短句用来精确匹配,背面整章用来补齐上下文 |
| 给卡片编的语义坐标 | Embedding | 把一段文字换算成一串数,意思近的坐标就近 |
| 目录柜 | 索引 | 柜子怎么建,决定了只能按关键词查,还是能按语义查 |
| 按坐标找卡片 | 检索(向量 / 全文 / 混合) | 取回坐标最近或关键词最匹配的一摞 |
| 馆员重排这一摞 | Rerank | 只排序,不去找新卡片;召回时漏掉的,它救不回来 |
| 读卡片写答案的人 | LLM | 手上有几张卡片就答几张的内容,不另外翻书 |
01概念:自己盖这座楼,到底在换什么
自建的真实动机、容器化解决的老问题、三类模型各管一段,以及「开源」两个字下面藏的条款
1.1 为什么要把平台搬到自己机器上
先把话说清楚:自己部署不是更高级,是更麻烦。托管服务注册就能用,升级、备份、扩容都不用管。愿意承担这份麻烦,只会是因为下面这几条里至少中了一条。
合同、病历、客户名单、内部制度——这类东西一旦上传到别人的服务器,合规那一关就过不去。自建之后,文档存在自己的磁盘上,检索在自己的进程里发生,只有最后那次模型调用可能走外网,而这一步也可以换成内网的模型服务。
业务数据库、内部接口、文件服务器往往只在内网可达。跑在公有云上的编排根本连不到这些地址,改也改不了。这不是配置问题,是网络拓扑问题。
自建意味着模型供应商由自己挑、随时可换,也意味着升级节奏由自己定:线上跑得好好的版本,不会在某个早晨被平台方悄悄换掉。
反过来,下面这些情况自建就是自找麻烦:只是想快速验证一个想法、团队里没人能长期维护这套服务、数据本来就是公开资料、用量小到根本没有成本压力。先判断要不要院墙,再决定要不要盖楼,顺序反了会浪费几周时间。
| 维度 | 用托管服务 | 自己部署 |
|---|---|---|
| 文档存放位置 | 平台的服务器 | 自己的磁盘 |
| 能否访问内网 | 不能 | 能 |
| 模型选择 | 平台给什么用什么 | 自己接,随时换 |
| 升级与故障 | 平台负责 | 自己负责,包括备份与恢复 |
| 起步成本 | 注册即用 | 要一台机器、一个会运维的人 |
| 合规审查 | 要过数据出境与第三方评估 | 只要证明数据没出内网 |
1.2 容器化:让环境跟着应用一起搬
自己部署的第一个坎,从来不是技术难,而是装不上。同一套代码,在开发同学机器上跑得好好的,换台服务器就各种缺库、版本冲突、路径不对。「在我这儿是好的」这句话,是所有部署事故的开场白。
解决思路一直是同一个:把应用连同它的运行环境一起打包,换台机器直接把这个包跑起来。
容器不模拟一台完整的计算机,它只是给进程套了一层壳:壳里的进程看到的文件系统、网络、进程号都是虚拟的,但它直接跑在宿主机的内核上,本质上仍是宿主机的一个普通进程。这一点决定了它和虚拟机的全部差别。
| 特性 | 虚拟机 | 容器 |
|---|---|---|
| 隔离级别 | 操作系统级 | 进程级 |
| 怎么跑 | 运行在 Hypervisor 上 | 直接跑在宿主机内核里 |
| 额外资源开销 | 明显(要跑一整套系统) | 很小(只是多几个进程) |
| 启动速度 | 分钟级 | 秒级 |
| 体积 | GB 起步 | 按需打包,小得多 |
| 单机能跑几个 | 十几个 | 上百个 |
再厘清两个天天被说混的词:
| 概念 | 是什么 | 类比 |
|---|---|---|
| 镜像 image | 只读模板,一层层叠起来的文件集合 | 面向对象里的类:负责存储和分发 |
| 容器 container | 镜像跑起来的实例,上面多一层可写层 | 面向对象里的对象:负责运行 |
一个镜像可以创建很多容器,容器可以随便删了重建——只要数据不在容器里。这就引出了自建最要命的一件事:数据卷。知识库的原始文档、分段、向量、应用配置全都落在数据卷上,容器删了它们还在,卷删了就什么都不剩。所以部署脚本写完的下一个动作,永远是备份脚本。
一整套应用往往不止一个容器:后端、前端、关系数据库、向量库、缓存、反向代理,各是一个。逐个 docker run 手敲参数既记不住也传不下去,于是用一份 compose 文件把这些服务、它们的端口、依赖关系、数据卷一次写清楚,docker compose up -d 一条命令全部拉起。这份文件就是图书馆的建筑图纸,它能进版本库、能评审、能在另一台机器上复现出一模一样的一套。
1.3 三类模型,各管一段,别混着配
平台里的「接模型」不是接一个模型,而是按用途分成几类分别接。这一节是后面所有内容的前提:配错类别,知识库会直接建不起来,而报错信息往往指向别处。
就是平常说的大模型。应用里的 LLM 节点、意图分类、自动生成对话标题、追问建议,用的都是它。它负责写字。
把文字换算成语义坐标。知识库建库时,每个 chunk 都要过一次;用户提问时,问题也要过一次。它负责编坐标,不产出任何人能读的文字。
把召回的一摞分段与问题逐条比对、重新排序。它负责排队,不生成内容,也不去找新的分段。
还有语音转文字之类的模型,用到再接。关键是记住这三类的边界:
再强调一条会在半年后咬人的规则:换了 Embedding 模型,整个知识库必须重建。坐标系换了,旧向量和新问题的向量根本不在同一个空间里,算出来的距离没有任何意义。所以模型选型要在灌库之前定,别等灌完几千份文档才想起来换一个「效果更好的」。
1.4 「开源」两个字,不足以作为决策依据
自建的前提是这套东西你有权自建。很多人看到「开源」就默认可以随便用,这一步跳过去,问题会在商业化的那一天集中爆发。
Dify 的许可证不是标准的 Apache-2.0。代码托管平台对它的识别结果是「非标准协议」,实际是 Apache-2.0 的修改版加上附加条款。两条附加条款直接影响商业判断:
⚠️ 两条必须先看清的附加条款
- 多租户限制。未经书面授权,不得用它的源码去运营多租户环境——一个 workspace 就算一个 tenant。也就是说,自己公司内部用没问题,拿它改一改对外卖 SaaS、给每个客户开一个空间,属于被限制的用法。
- LOGO 与版权信息不得移除。用到它前端的场景下,控制台与应用里的标识和版权信息不能删改。想做成完全白标的产品,这一条就是硬墙。(不涉及其前端的用法不受此限。)
- 除这两条外,其余权利义务仍按 Apache-2.0 执行。
LICENSE 原文里的限制性措辞。下面这个脚本把这三件事做成一条命令,随时可以复核。
#!/usr/bin/env bash
# =============================================================================
# license-check.sh —— 自建之前先把许可证和活跃度查清楚
#
# 「开源」两个字不是能直接拿来做决策的结论。要回答三个问题:许可证是标准协议
# 还是标准协议 + 附加条款?附加条款限制了什么(多租户转售?前端标识不得移
# 除?)?项目还活着吗?答案会随时间变,所以这里给的是「怎么查」。
#
# bash license-check.sh langgenius/dify infiniflow/ragflow
#
# 可选 GITHUB_TOKEN 提高速率上限;令牌永远走环境变量,不写进脚本。
# =============================================================================
set -uo pipefail
REPOS=("$@")
[ "${#REPOS[@]}" -eq 0 ] && REPOS=(langgenius/dify infiniflow/ragflow)
AUTH=()
[ -n "${GITHUB_TOKEN:-}" ] && AUTH=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
api() { curl -sS --max-time 20 -H "Accept: application/vnd.github+json" "${AUTH[@]}" "$1"; }
# 没有 jq 时退回 grep/sed,保证在最小化的服务器上也能跑
field() {
if command -v jq >/dev/null 2>&1; then
printf '%s' "$1" | jq -r "$2 // \"-\""
else
local plain="${2#.}"; plain="${plain%%.*}"
printf '%s' "$1" | tr ',' '\n' | grep -m1 "\"${plain}\"" \
| sed 's/.*: *//; s/^"//; s/"$//' || echo "-"
fi
}
for repo in "${REPOS[@]}"; do
echo "=============================================================="
echo "仓库:$repo"
meta="$(api "https://api.github.com/repos/${repo}")"
if printf '%s' "$meta" | grep -q '"message": *"Not Found"'; then
echo " 查不到这个仓库,检查拼写"; continue
fi
spdx="$(field "$meta" '.license.spdx_id')"
echo " star 数 $(field "$meta" '.stargazers_count')"
echo " 最后一次提交 $(field "$meta" '.pushed_at')"
echo " 许可证标识 ${spdx}"
echo " 许可证名称 $(field "$meta" '.license.name')"
# 关键一步:标识为 NOASSERTION / Other 意味着这不是标准协议原文,
# 项目方在标准协议之外加了自己的条款,必须去读 LICENSE 原文。
case "$spdx" in
NOASSERTION|Other|-|null)
echo
echo " [注意] 不是标准协议,必读 LICENSE 原文中的附加条款。抓取限制性措辞:"
raw=""
for branch in main master; do
raw="$(curl -sS --max-time 20 \
"https://raw.githubusercontent.com/${repo}/${branch}/LICENSE" || true)"
[ -n "$raw" ] && break
done
if [ -n "$raw" ]; then
printf '%s\n' "$raw" | grep -n -i -E \
"multi-tenant|tenant|logo|copyright information|commercial|written permission|may not" \
| head -n 20 | sed 's/^/ /'
else
echo " 抓不到 LICENSE,去仓库页面手动读一遍"
fi ;;
*)
echo
echo " 标准协议 ${spdx};仍建议扫一眼 LICENSE 与 NOTICE 是否另有说明。" ;;
esac
echo
done
cat <<'TIP'
==============================================================
读数的方法比数本身重要:star 只看得出热度,看不出能不能商用;最后一次
提交超半年,维护成本要按「自己接手」估;标识为 NOASSERTION 等于项目方说
「我改过条款」,必须逐条读。以上均为运行这一刻的快照,要引用就连同查询
时间一起写下来并说明复核办法 —— 过几个月它们一定会变。
TIP
脚本里刻意没有写任何具体数字。star 数、提交时间、甚至许可证本身都会变,任何一份资料上的数字都只是某一刻的快照。要引用就连查询时间一起写下来,并把复核办法留给下一个人。
02原理:一次问答,在图书馆里走过的每一步
RAG 的三步、分段策略、索引方式、检索方式与 Rerank、TopK 与 Score、量化评测,以及把检索整段换掉的解耦办法
2.1 RAG 的三步:检索、增强、生成
RAG(Retrieval-Augmented Generation,检索增强生成)要解决的是模型的三个硬伤:训练数据有截止时间所以不知道最近发生了什么、没见过你公司的内部资料、以及在不知道的时候倾向于编一个像样的答案出来。
办法很朴素——回答之前先去查资料,把查到的原文塞进提示词里,让模型照着写。拆成三步:
拿用户的问题去知识库里找相关的分段。这一步是离线准备好的:文档提前拆分、编码、建索引,查询时只做一次相似度匹配。
把找回来的分段拼进提示词,作为「参考资料」交给模型。模型的知识范围在这一刻被临时扩展了。
模型结合问题和资料写出答案。因为资料是真的,编造的空间被压缩;因为资料可溯源,答案可以附上出处。
注意三步之间的依赖是单向且不可逆的:第一步没找回来的东西,第二步塞不进去,第三步自然写不出来。这就是本讲铁律的由来,也是后面每一个参数的意义所在——它们全都在服务于第一步。
2.2 分段:一本书要拆成什么样的卡片
分段是整条链路里最便宜、也最要命的一步。便宜是因为它只是文本处理,不花模型钱;要命是因为它决定了「可检索的最小单位」,后面所有环节都只能在这个单位上工作。
为什么不能不拆?两个理由:一是整篇文档编成一个坐标,等于把一本书的全部意思平均成一个点,问什么都不像;二是就算匹配上了,整本书塞不进模型的上下文窗口。
拆了之后,一对矛盾立刻出现:
两种分段模式,正是对这对矛盾的两种处理方式。
通用分段:折中
按规则一刀切成大小相近的块,靠三个参数控制:
| 参数 | 作用 | 怎么定 |
|---|---|---|
| 分段标识符 | 优先在哪里下刀 | 默认按换行;结构化文档可以换成空行、标题标记、条款编号。下刀位置对了,一半问题自动消失 |
| 分段最大长度 | 一块最多多长 | 超过就强制切开。短条款、问答对可以小;叙述性长文要大一些 |
| 分段重叠长度 | 相邻块共享多少内容 | 经验值取块长的 10%~25%。设成 0 必踩坑:答案正好横跨两块时,两块各拿半句,谁都匹配不上 |
还有一组文本预处理开关——合并连续空格换行制表符、删除 URL 与邮箱。别小看它们:扫描件转出来的文本里,页眉页脚和空白往往比正文还多,不清掉就会在向量里占权重。
父子分段:把矛盾拆成两半分别解决
既然「用来匹配」和「用来作答」要求相反,那就让两件事用不同的单位:
- 子块(child-chunk)切到句子级别,短、集中,只负责被检索命中;
- 父块(parent-chunk)保持段落或章节级别,只负责提供上下文。
检索时用子块去比坐标,一旦命中,递给模型的不是那一小句,而是它所属的整个父块。用比喻说:卡片正面写着一句精确的话,背面写着「本卡出自第几章第几节」,馆员找到卡片后,连同那一章一起抱给写答案的人。
| 对比项 | 通用分段 | 父子分段 |
|---|---|---|
| 匹配单位 | 整个块 | 子块(短) |
| 交给模型的单位 | 整个块 | 父块(长) |
| 擅长 | 结构均匀的短文本:问答对、产品参数、FAQ | 长文档:法条、制度、手册、判例 |
| 上下文完整性 | 靠重叠打补丁 | 天然完整 |
| 上下文窗口消耗 | 可预测 | 明显更大,TopK 要设得更克制 |
| 建库耗时 | 较短 | 较长(子块数量更多,Embedding 次数更多) |
参数到底该设多少,翻界面一块块看是看不出来的。更靠谱的做法是在灌库之前先在本地把分段跑一遍,看长度分布、碎块、巨块、被切断的句子:
# -*- coding: utf-8 -*-
"""
chunking_preview.py —— 在本地预演分段,看清楚「书被拆成了什么样的卡片」
分段是整条链路里最便宜也最要命的一步:它决定检索能拿回什么,
而检索拿不回来的内容,模型再强也答不出来。
但分段效果在平台上只能一块块翻着看,翻二十块就没耐心了。
纯标准库,不依赖任何服务。给出长度分布、碎块、巨块、被切断的句子 ——
灌库之前就知道参数往哪调。token 按「中文字 1、英文词 1」粗估,够判断量级。
python chunking_preview.py 劳动合同法.txt --max-tokens 300 --overlap 60
python chunking_preview.py 劳动合同法.txt --mode hierarchical --dump 3
"""
import argparse
import re
import sys
SENT_END = re.compile(r"(?<=[。!?;!?;])\s*")
CJK = r"[\u3400-\u9fff\u3000-\u303f\uff00-\uffef]"
def est_tokens(text):
return len(re.findall(CJK, text)) + len(re.sub(CJK, " ", text).split())
def _by_sentence(text, limit):
"""按句子聚成不超过 limit 的块。单段超长时只能这样硬切。"""
out, cur, tok = [], [], 0
for s in [x for x in SENT_END.split(text) if x.strip()]:
n = est_tokens(s)
if cur and tok + n > limit:
out.append("".join(cur))
cur, tok = [], 0
cur.append(s)
tok += n
if cur:
out.append("".join(cur))
return out
def split_general(text, sep="\n\n", max_tokens=500, overlap=80):
"""通用分段:按分隔符切开、按上限合并、相邻块留重叠。
重叠救的是「答案正好横跨两块」:没有重叠时边界上的句子被一刀两断,
两块各拿半句,谁都匹配不上。
"""
chunks, buf, tok = [], [], 0
for piece in [p.strip() for p in text.split(sep) if p.strip()]:
n = est_tokens(piece)
if n > max_tokens: # 单段就超长,通用分段绕不开的损失
if buf:
chunks.append("\n\n".join(buf))
buf, tok = [], 0
chunks += _by_sentence(piece, max_tokens)
continue
if buf and tok + n > max_tokens:
chunks.append("\n\n".join(buf))
buf, tok = [], 0
buf.append(piece)
tok += n
if buf:
chunks.append("\n\n".join(buf))
if overlap <= 0 or len(chunks) < 2:
return chunks
return [chunks[0]] + [chunks[i - 1][-overlap * 2:] + "\n" + chunks[i]
for i in range(1, len(chunks))]
def split_hierarchical(text, sep="\n\n", parent_max=1024, child_max=256):
"""父子分段:父块段落级,父块内部再切子块句子级。返回 [(父块, [子块...])]。
检索用子块匹配,命中后把它所属的父块整块交给模型 ——
就是「卡片正面写一句话、背面写着它属于哪一章」的那个背面。
父块也要有上限,否则一个父块就能把上下文窗口撑爆。
"""
parents = []
for para in [p.strip() for p in text.split(sep) if p.strip()]:
parents += (_by_sentence(para, parent_max)
if est_tokens(para) > parent_max else [para])
return [(p, _by_sentence(p, child_max) or [p]) for p in parents]
def stats(lengths, label):
if lengths:
s = sorted(lengths)
print(" %-6s 数量 %-5d 最短 %-5d 中位 %-5d 最长 %-5d 平均 %.0f"
% (label, len(s), s[0], s[len(s) // 2], s[-1], sum(s) / len(s)))
def warn_chunks(chunks, limit):
"""三类问题是召回变差的主要来源。"""
tiny = sum(1 for c in chunks if est_tokens(c) < 30)
huge = sum(1 for c in chunks if est_tokens(c) > limit * 1.3)
cut = sum(1 for c in chunks
if c.strip() and c.strip()[-1] not in "。!?;:」』)】!?;:.)]")
print("\n 体检:")
print(" 过短块(<30 token) %d 个 —— 语义不完整,容易被噪声挤掉" % tiny)
print(" 超长块(>上限1.3倍) %d 个 —— 一块混了多个主题,向量被摊平" % huge)
print(" 尾部被切断的块 %d 个 —— 句子断在中间,重叠没设够" % cut)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("path", help="纯文本文件(先把 pdf/docx 转成 txt)")
ap.add_argument("--mode", choices=["general", "hierarchical"], default="general")
ap.add_argument("--separator", default="\n\n")
ap.add_argument("--max-tokens", type=int, default=500)
ap.add_argument("--overlap", type=int, default=80)
ap.add_argument("--parent-max", type=int, default=1024)
ap.add_argument("--child-max", type=int, default=256)
ap.add_argument("--dump", type=int, default=0)
args = ap.parse_args()
text = open(args.path, encoding="utf-8").read()
print("原文约 %d token,%d 字符\n" % (est_tokens(text), len(text)))
if args.mode == "general":
chunks = split_general(text, args.separator, args.max_tokens, args.overlap)
print("通用分段:上限 %d token,重叠 %d token" % (args.max_tokens, args.overlap))
stats([est_tokens(c) for c in chunks], "块")
warn_chunks(chunks, args.max_tokens)
for i, c in enumerate(chunks[:args.dump], 1):
print("\n--- 第 %d 块(%d token)---\n%s" % (i, est_tokens(c), c[:400]))
else:
pairs = split_hierarchical(text, args.separator, args.parent_max, args.child_max)
kids = [c for _p, cs in pairs for c in cs]
print("父子分段:父块上限 %d,子块上限 %d" % (args.parent_max, args.child_max))
stats([est_tokens(p) for p, _c in pairs], "父块")
stats([est_tokens(c) for c in kids], "子块")
print("\n 平均每个父块带 %.1f 个子块" % (len(kids) / max(len(pairs), 1)))
print(" 命中的是子块,送进模型的是父块 —— "
"子块要短到能精准匹配,父块要长到能把话说完整。")
warn_chunks(kids, args.child_max)
for i, (p, cs) in enumerate(pairs[:args.dump], 1):
print("\n--- 父块 %d(%d token,%d 个子块)---\n%s"
% (i, est_tokens(p), len(cs), p[:300]))
return 0
if __name__ == "__main__":
sys.exit(main())
2.3 索引:柜子怎么建,决定了能怎么查
卡片拆好了,要摆进柜子。柜子有两种建法,它决定了后面能用哪几种查法,选错了要整库重建。
| 索引方式 | 怎么建 | 能用的检索方式 | 代价 |
|---|---|---|---|
| 经济 | 每个块抽取若干关键词,建倒排索引 | 只有关键词检索一条路 | 不过 Embedding 模型,不产生模型调用开销;但同义词、换个说法就搭不上 |
| 高质量 | 用 Embedding 模型把每块编成向量 | 向量、全文、混合三种随便挑 | 建库要过一遍模型,耗时与开销都更高;换模型必须整库重建 |
判断很简单:用户会不会换着说法问同一件事。内部术语表、型号手册这类「问法和原文用词高度一致」的场景,经济索引够用;只要用户会说「被辞退了怎么办」而文档里写的是「用人单位单方解除劳动合同」,就只能上高质量索引——这正是向量检索存在的全部理由。
2.4 检索方式与 Rerank:找回来,再排一遍
三种查法
| 检索方式 | 原理 | 什么时候它更强 |
|---|---|---|
| 向量检索(语义) | 把问题编成向量,找向量距离最近的分段 | 用户用自己的话提问、同义词多、口语化。缺点是专有名词容易糊——型号「A-200」和「A-300」在向量空间里几乎挨着 |
| 全文检索(关键词) | 倒排索引,按明文词匹配,和搜索引擎同一套 | 条款号、型号、人名、错误码这类必须一字不差的东西。缺点是换个说法就零命中 |
| 混合检索 | 两路都跑,合并候选,再按权重配比或交给 Rerank 裁决 | 不确定用户会怎么问时的默认选择。代价是两套都要跑,慢一些 |
Rerank:馆员重新排一摞卡片
向量检索为了快,用的是「把问题和文档各自编成向量再比距离」的办法——问题和文档从来没有被放在一起看过。Rerank 模型换了一种做法:把问题和每一个候选分段成对送进模型,逐条判断「这一条到底回不回答得了这个问题」。更准,但也更慢更贵,所以它只能用在少量候选上,不能拿来扫全库。
那什么时候值得上 Rerank?看三个条件:
- 候选里确实混着不少不相关的。用混合检索或大 TopK 捞回一批,靠它精筛,这是最标准的用法。
- 只需要少数几条精确结果。比如查法条原文,返回十条反而稀释答案,这时候先多召回再重排取前几条,比直接小 TopK 更准。
- 已经量化验证过它带来了收益。它增加一次模型调用、增加延迟;没测出提升就别上。
2.5 TopK 与 Score 阈值:两个收口的旋钮
| 参数 | 含义 | 调大 / 调高的后果 |
|---|---|---|
| TopK | 最终留几条分段交给模型 | 调大:召回更全,但上下文更长、噪声更多,模型容易被无关内容带跑;调小:更干净,但漏答风险上升 |
| Score 阈值 | 相似度低于这个值就丢掉 | 调高:结果更干净,但很容易一条不剩,表现为「模型说资料里没有」;调低:什么都捞回来,等于没设 |
还有一层容易忽略的动态调整:系统会参考所选模型的上下文窗口来调节实际送进去的片段数量。也就是说,换一个上下文窗口更小的模型,同样的 TopK 也可能塞不进那么多。参数不是孤立的。
2.6 召回测试:从「感觉还行」到「有数字」
知识库建好之后,平台一般都提供一个召回测试功能:输入一个问题,看召回了哪些分段、每条的分数是多少。这是排查问题的第一现场——答得不对时,先来这里看看到底找回了什么,而不是去改提示词。
但它一次只能问一个问题。换分段方式、换 Embedding 模型、开不开 Rerank、TopK 调大调小,到底哪个更好?靠一条条手点是比不出来的,必须用同一批问题跑同一套指标。
| 指标 | 算法 | 怎么用 |
|---|---|---|
| hit@k | 前 k 条召回里有没有命中至少一条 | 最该看的一个。它直接等于答案质量的上限 |
| MRR | 第一条命中的名次的倒数,全部取平均 | 衡量排序好不好。Rerank 的价值就体现在这个数字上 |
| 平均相似度 | 召回分段的分数均值 | 只用来给 Score 阈值找一个合理起点,不能拿来判断好坏 |
评测集怎么攒?一行一条:问题 + 这个问题的答案应该出现在哪份文档里。真实用户问过的问题最值钱,其次是业务同事凭经验写的。二三十条就足够开始比较,别等攒到完美再开始。
# -*- coding: utf-8 -*-
"""
retrieval_eval.py —— 召回质量评测:用一批问题给知识库打分
界面上的召回测试一次只能问一个问题,看到「命中了」很容易自我感觉良好。
但换分段、换 Embedding、开不开 Rerank、TopK 调大调小,到底哪个更好?
必须用同一批问题跑同一套指标,一次只改一个变量。
hit@k 前 k 条里有没有命中 —— 最该看的,它等于答案质量的上限
MRR 第一条命中名次的倒数平均 —— 衡量排序,Rerank 的价值在这里
平均分 只用来给 Score 阈值找起点,不能拿来判断好坏
评测集一行一条 JSON(真实用户问过的最值钱,二三十条就能开始):
{"query": "试用期最长能约多久", "doc": "劳动合同法.pdf", "must_contain": "试用期不得超过"}
环境变量:DIFY_BASE_URL / DIFY_DATASET_KEY / DIFY_DATASET_ID
python retrieval_eval.py evalset.jsonl --top-k 8 --rerank gitee_ai:bge-reranker-v2-m3
python retrieval_eval.py evalset.jsonl --compare # 三种检索方式横向对比
"""
import argparse
import json
import os
import sys
import requests
BASE_URL = os.environ.get("DIFY_BASE_URL", "http://127.0.0.1/v1").rstrip("/")
DATASET_KEY = os.environ.get("DIFY_DATASET_KEY")
DATASET_ID = os.environ.get("DIFY_DATASET_ID")
# 界面上分别叫向量检索、全文检索、混合检索
METHODS = ["semantic_search", "full_text_search", "hybrid_search"]
def _headers():
if not DATASET_KEY or not DATASET_ID:
raise RuntimeError("缺少 DIFY_DATASET_KEY / DIFY_DATASET_ID")
return {"Authorization": "Bearer %s" % DATASET_KEY,
"Content-Type": "application/json"}
def retrieve(query, method="hybrid_search", top_k=4, score=None, rerank=None):
"""调检索接口,返回 [(文档名, 分段文本, 分数)]。就是界面「召回测试」背后那一个。"""
cfg = {"search_method": method, "reranking_enable": bool(rerank),
"top_k": top_k, "score_threshold_enabled": score is not None}
if score is not None:
cfg["score_threshold"] = score
if rerank:
cfg["reranking_model"] = {"reranking_provider_name": rerank[0],
"reranking_model_name": rerank[1]}
resp = requests.post("%s/datasets/%s/retrieve" % (BASE_URL, DATASET_ID),
headers=_headers(), timeout=90,
json={"query": query, "retrieval_model": cfg})
if resp.status_code >= 400:
raise RuntimeError("HTTP %s: %s" % (resp.status_code, resp.text[:300]))
return [((s.get("document") or {}).get("name", "?"), s.get("content", ""),
rec.get("score", 0.0))
for rec in (resp.json().get("records") or [])
for s in [rec.get("segment") or {}]]
def judge(case, hits):
"""返回第几条命中,没命中返回 0。
判定刻意放宽:文档名对上、或关键串出现在分段里都算命中。
严格的 chunk 级标注要人工投入,起步阶段不值得。
"""
for rank, (doc, text, _s) in enumerate(hits, 1):
if case.get("doc") and case["doc"] in doc:
return rank
if case.get("must_contain") and case["must_contain"] in text:
return rank
return 0
def evaluate(cases, method, top_k, score, rerank, verbose=False):
n = len(cases)
hit1 = hit3 = hitk = empty = cnt = 0
rr_sum = score_sum = 0.0
for case in cases:
try:
hits = retrieve(case["query"], method, top_k, score, rerank)
except Exception as exc: # noqa: BLE001
print(" [错误] %s -> %s" % (case["query"], exc))
continue
empty += not hits
for _d, _t, sc in hits:
score_sum += float(sc or 0.0)
cnt += 1
rank = judge(case, hits)
if rank:
rr_sum += 1.0 / rank
hitk += 1
hit3 += rank <= 3
hit1 += rank == 1
if verbose:
print(" %-8s %s" % ("命中@%d" % rank if rank else "未命中", case["query"]))
return {"样本数": n, "hit@1": hit1 / n if n else 0.0,
"hit@3": hit3 / n if n else 0.0,
"hit@%d" % top_k: hitk / n if n else 0.0,
"MRR": rr_sum / n if n else 0.0,
"平均相似度": score_sum / cnt if cnt else 0.0, "空召回数": empty}
def load_cases(path):
cases = []
for ln, line in enumerate(open(path, encoding="utf-8"), 1):
line = line.strip()
if not line or line.startswith("#"):
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
print(" [跳过] 第 %d 行不是合法 JSON" % ln)
continue
if "query" in obj:
cases.append(obj)
return cases
def show(title, result):
print("\n== %s ==" % title)
for k, v in result.items():
print(" %-12s %s" % (k, "%.3f" % v if isinstance(v, float) else v))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("evalset")
ap.add_argument("--method", choices=METHODS, default="hybrid_search")
ap.add_argument("--top-k", type=int, default=4)
ap.add_argument("--score", type=float, default=None, help="Score 阈值;不传表示不启用")
ap.add_argument("--rerank", default="off", help="off 或 '供应商:模型名'")
ap.add_argument("--compare", action="store_true")
ap.add_argument("-v", "--verbose", action="store_true")
args = ap.parse_args()
rerank = None
if args.rerank != "off":
if ":" not in args.rerank:
print("--rerank 格式是 供应商:模型名")
return 2
rerank = tuple(args.rerank.split(":", 1))
cases = load_cases(args.evalset)
if not cases:
print("评测集是空的,先攒 20 条真实问题再来")
return 1
print("评测集 %d 条,TopK=%d,Rerank=%s,Score 阈值=%s"
% (len(cases), args.top_k, args.rerank,
args.score if args.score is not None else "未启用"))
if args.compare:
# 横向对比时唯一变量只能是检索方式,其余参数必须一致,否则结论不成立
for m in METHODS:
show(m, evaluate(cases, m, args.top_k, args.score, rerank, args.verbose))
print("\n看 hit@k 选检索方式,看 MRR 判断要不要上 Rerank。")
print("差距小于两三个百分点就别换 —— 样本这么小,那是噪声不是改进。")
else:
show(args.method, evaluate(cases, args.method, args.top_k,
args.score, rerank, args.verbose))
return 0
if __name__ == "__main__":
sys.exit(main())
灌完库还有一件必做的事:把分段拉出来体检一遍。界面显示「嵌入完成」不等于知识库可用——空分段、页眉页脚噪声、重复文档都会显示成功,却已经把召回质量废掉一半。
要查的就是五类:空分段(文档解析失败,原文根本没进来)、碎块(不到三十个 token,语义不完整)、巨块(表格没被切开,向量被摊平)、噪声块(整块只有页眉页脚页码)、重复块(同一份文档传了两次,白白挤占 TopK 名额)。用知识库密钥调分段列表接口把内容拉下来,按这五类各数一遍、再抽几块原文眼测,比在界面上一页页翻快得多。这一步是只读的,可以直接对生产库跑。
2.7 外部知识库:把最容易过期的一段关进盒子里
平台自带的知识库有它的短板,最典型的是复杂文档解析——扫描版 PDF、跨页表格、多栏排版,解析出来的文本可能已经乱成一团。文本都错了,后面拆得再好也是白搭。
这时候有两条路:换一个解析能力更强的引擎重建整套系统,或者——只把检索这一段换掉。
后者的做法是接「外部知识库」:编排里的检索节点不再查本地的库,而是去调一个符合约定的 HTTP 接口。约定极简,只有一个端点:
这层薄薄的契约带来的好处,比它看起来大得多:
- 检索侧想换什么都行:换成解析能力更强的 RAG 引擎、换成公司已有的搜索集群、换成带权限过滤的数据库查询——编排那一侧一行都不用改。
- 权限可以做进去。同一个知识库,不同部门的人该看到不同的内容,这种逻辑塞不进通用平台,但在自己的服务里写起来很自然。
- 它把最容易过期的一段隔离了。检索技术迭代最快,编排结构相对稳定。用接口把两者分开,任何一侧的更新都不会牵动另一侧。
与其记住配置界面上要填哪几个框,不如自己实现一遍这个接口。它的全部要求就三条:一个 POST /retrieval 端点,接收知识库 id、问题和检索参数;返回一个数组,每条带原文、分数、出处标题;没查到也返回 200 加空数组,不要报错码——没有匹配结果是正常的业务结果,不是故障。用标准库的 http.server 几十行就能跑起来,实现过一次,就再也不会被任何平台的界面改版困住。
127.0.0.1 指的是容器自己,不是宿主机。所以配置外部服务地址时必须填宿主机网卡上的 IP。这个坑几乎人人踩一次,现象是「我在浏览器里明明能打开,平台就是连不上」。
03最小代码:从空机器到能回答内部资料
四步走完最短的一条路:把楼盖起来 → 接三类模型 → 灌一批文档 → 四个节点连成一条线
这一节只做一件事:把最短的路走通一遍。走通之后再回头看第 02 节的参数,每一个都会突然变得具体。四步的顺序不能换,每一步都有一个「怎么算成功」的判定。
第一步:把楼盖起来
前置条件很低:一台能跑容器的机器,两核、四 GB 内存起步。步骤只有三个动作——拿到仓库里的部署目录、把环境变量样例改名成正式文件、一条命令拉起。
下面的脚本把这三步连同验活一起做了。它刻意遵守两条纪律:已存在的配置绝不覆盖(二次部署最容易把改好的口令盖回默认值),端口通过环境变量覆盖而不是改文件(同一台机器上要部署第二套系统时不用回头改)。
#!/usr/bin/env bash
# =============================================================================
# compose-up.sh —— 用 Docker Compose 把服务拉起来,并自证「它真的起来了」
#
# 两条纪律写进脚本,而不是写在文档里靠人记:
# 1. .env 只在不存在时生成,绝不覆盖已有配置(二次部署最容易把口令盖回默认值)
# 2. 端口通过环境变量覆盖,不改 compose 文件(同机部署第二套时不用回头改)
#
# DIFY_DOCKER_DIR=/opt/dify/docker DIFY_WEB_PORT=8080 bash compose-up.sh
# =============================================================================
set -euo pipefail
DIFY_DOCKER_DIR="${DIFY_DOCKER_DIR:-$(pwd)}"
DIFY_WEB_PORT="${DIFY_WEB_PORT:-80}"
WAIT_SECONDS="${WAIT_SECONDS:-180}"
log() { printf '[%s] %s\n' "$(date '+%H:%M:%S')" "$*"; }
die() { printf '[FATAL] %s\n' "$*" >&2; exit 1; }
# --- 0. 前置条件。注意是 `docker compose`(v2 插件),不是老的 docker-compose ---
command -v docker >/dev/null 2>&1 || die "没有 docker"
docker compose version >/dev/null 2>&1 || die "没有 compose v2 插件"
docker info >/dev/null 2>&1 || die "docker daemon 没跑起来"
cd "$DIFY_DOCKER_DIR" || die "目录不存在:$DIFY_DOCKER_DIR"
[ -f docker-compose.yaml ] || [ -f docker-compose.yml ] \
|| die "这里没有 compose 文件;要进的是仓库里的 docker/ 子目录,不是仓库根目录"
# --- 1. .env:只补不盖 ---
if [ -f .env ]; then
log ".env 已存在,保持原样"
else
[ -f .env.example ] || die "既没有 .env 也没有 .env.example"
cp .env.example .env
log "已从 .env.example 生成 .env —— 首次部署请先改 SECRET_KEY 与数据库口令"
fi
if [ "$DIFY_WEB_PORT" != "80" ]; then
export EXPOSE_NGINX_PORT="$DIFY_WEB_PORT"
log "Web 端口覆盖为 $DIFY_WEB_PORT(环境变量方式,不改 compose 文件)"
fi
# --- 2. 起服务。不加 --force-recreate,避免无谓重建已经健康的容器 ---
log "开始拉取镜像并启动(首次会比较慢,镜像体积以 GB 计)"
docker compose up -d
# --- 3. 验活:轮询到真能响应为止 ---
# 「容器 Up」不等于「服务可用」:迁移数据库、初始化向量库都要时间。
url="http://127.0.0.1:${DIFY_WEB_PORT}/install"
log "等待服务就绪:$url"
deadline=$(( $(date +%s) + WAIT_SECONDS ))
code=000
while [ "$(date +%s)" -lt "$deadline" ]; do
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$url" || echo 000)"
case "$code" in 200|302|307) log "服务已就绪,HTTP $code"; break ;; esac
sleep 5
done
echo; log "容器状态:"; docker compose ps
case "$code" in
200|302|307) ;;
*)
cat <<'TIP'
[WARN] 超时仍未拿到正常响应。按这个顺序查,不要瞎重启:
1) docker compose ps 有没有容器 Restarting / Exited
2) docker compose logs --tail=120 api 后端报错基本都在这里
3) docker compose logs --tail=80 db 改过口令但没清卷,会一直认证失败
4) ss -ltnp | grep ':80 ' 宿主机端口被别的服务占了
TIP
exit 1 ;;
esac
cat <<TIP
[OK] 打开 http://127.0.0.1:${DIFY_WEB_PORT}/install 完成首次管理员注册。
日常命令:ps 查状态;logs -f api 跟踪日志;stop / down 保留数据卷;
docker compose down -v 连数据卷一起删 —— 知识库与向量数据一并消失,慎用
TIP
拉镜像慢或直接失败,是这一步最常见的阻塞。处理办法是给容器运行时配国内镜像源,再重试几次——镜像文件比一般的依赖包大得多,网络抖动的影响被放大了。起不来时按固定顺序查,别瞎重启:
| 现象 | 先查什么 | 多半是什么 |
|---|---|---|
| 拉镜像卡住或超时 | 容器运行时的镜像源配置 | 网络到不了默认仓库,换源后重试 |
| 某个容器一直 Restarting | docker compose logs --tail=120 api | 配置错、依赖服务没起、口令不对 |
| 数据库容器反复认证失败 | 是否改过口令但没清旧数据卷 | 卷里还是旧口令初始化的数据 |
| 端口起不来,日志写着地址已占用 | 宿主机监听 + 容器端口映射 | 端口冲突,见下面的脚本 |
端口冲突值得单独说:一台机器上同时跑两套这类系统时,它们默认都占 80,还各自带一套缓存和数据库,双双启动必然打架。排查顺序是固定的三步,别跳步:
- 先看宿主机上这个端口有没有人在听:
ss -ltnp | grep ':80 '(没有ss就用netstat -ltnp)。 - 再看是不是某个容器的端口映射占的:
docker ps --format '{{.Names}}\t{{.Ports}}' | grep ':80->'。 - 最后改后来者:在
.env里把EXPOSE_NGINX_PORT改成没人用的端口再重启,不要去动已经在跑的那一套——它后面可能还挂着别的服务。
docker compose down -v 一条命令就能全部抹掉。所以部署脚本写完,下一个文件就该是备份脚本,而且备份完要立刻验一遍归档能不能读。
#!/usr/bin/env bash
# =============================================================================
# backup-volumes.sh —— 私有化部署的命门:把数据卷备份出来
#
# 容器可以随便删了重建,但这几类数据删掉就没了:
# 关系数据库(应用配置、工作流版本、会话)、向量数据(重灌要重花 Embedding 算力)、
# 对象存储(上传的原始文档)。`docker compose down -v` 一条命令就能全部抹掉。
#
# 做法:不直接 tar 宿主机目录(named volume 不在 compose 目录下),
# 而是起一个临时容器把卷挂进去打包 —— 与卷的存储位置无关,两种挂载都适用。
#
# bash backup-volumes.sh
# BACKUP_DIR=/data/backup VOLUME_FILTER=dify bash backup-volumes.sh
# =============================================================================
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-$(pwd)/dify-backup}"
VOLUME_FILTER="${VOLUME_FILTER:-dify}"
OUT="${BACKUP_DIR}/$(date '+%Y%m%d-%H%M%S')"
command -v docker >/dev/null 2>&1 || { echo "[FATAL] 没有 docker" >&2; exit 1; }
mkdir -p "$OUT"
mapfile -t VOLUMES < <(docker volume ls --format '{{.Name}}' | grep -i "$VOLUME_FILTER" || true)
if [ "${#VOLUMES[@]}" -eq 0 ]; then
echo "[FATAL] 没匹配到数据卷(过滤词:$VOLUME_FILTER),先看 docker volume ls" >&2
exit 1
fi
echo "将备份 ${#VOLUMES[@]} 个数据卷到 $OUT"
printf ' - %s\n' "${VOLUMES[@]}"
# 备份期间建议停服,保证落盘一致。不停机就至少对数据库单独做一次逻辑导出。
if [ "${STOP_FIRST:-1}" = "1" ]; then
echo "先停服务以保证一致(STOP_FIRST=0 可跳过,但备份可能不一致)"
docker compose stop || true
fi
for v in "${VOLUMES[@]}"; do
echo "打包 $v ..."
docker run --rm -v "${v}:/src:ro" -v "${OUT}:/dst" alpine:3 \
tar czf "/dst/${v}.tar.gz" -C /src .
echo " -> ${v}.tar.gz $(du -h "${OUT}/${v}.tar.gz" | cut -f1)"
done
[ "${STOP_FIRST:-1}" = "1" ] && { echo "恢复服务"; docker compose start || true; }
# 备份完立刻验,否则等到要恢复时才发现是个空壳就晚了。
echo
echo "== 校验归档完整性 =="
fail=0
for f in "$OUT"/*.tar.gz; do
if tar tzf "$f" >/dev/null 2>&1; then
printf ' [OK] %-44s %s 个条目\n' "$(basename "$f")" "$(tar tzf "$f" | wc -l)"
else
printf ' [BAD] %s 无法读取\n' "$(basename "$f")"; fail=1
fi
done
( cd "$OUT" && sha256sum ./*.tar.gz > SHA256SUMS )
echo " 校验和已写入 $OUT/SHA256SUMS"
cat <<TIP
恢复办法(目标机器上,卷为空时):
docker volume create <卷名>
docker run --rm -v <卷名>:/dst -v ${OUT}:/src alpine:3 \\
tar xzf /src/<卷名>.tar.gz -C /dst
两地各存一份:服务器本地一份,再拉回另一台机器一份。
只存在同一台机器上的备份,在这台机器出事时等于没有。
TIP
exit "$fail"
第二步:接模型
在设置里找到模型供应商功能,按第 1.3 节的三类分别接:推理模型必接,Embedding 模型要用高质量索引就必接,Rerank 模型可以先不接、后面验证有收益再加。
这一步唯一的纪律是:接之前先用命令行把接口调通。密钥对不对、地址有没有写错、账号有没有权限,用一次真实请求就能验完;接口都不通就往界面里填,只会得到一个语焉不详的报错,然后在界面上反复试探。
第三步:灌文档
找到知识库功能,新建一个库,选数据源导入本地文本,然后就到了第 2.2 与 2.3 节那两个选择题:分段模式(通用还是父子)和索引方式(经济还是高质量)。长文档、法条、制度手册,选父子 + 高质量;短问答对、参数表,通用 + 高质量也够用。
界面上传适合试水,几十上百份文档就得走接口——不只是为了省事,更是为了保证同一批文档用同一套分段参数。手点最容易出现的事故是前五十个文件用了父子分段,后五十个忘了改,召回效果差一大截却查不出原因。
# -*- coding: utf-8 -*-
"""
kb_bulk_upload.py —— 批量把一个目录里的文档灌进知识库
界面上传一次只能选有限几个文件,几百份规章制度靠手点不现实。
更重要的是:走脚本才能保证「同一批文档用同一套分段参数」。
手点最容易出现的事故是前五十个用父子分段、后五十个忘了改,
召回效果差一大截却查不出原因。
环境变量:DIFY_BASE_URL / DIFY_DATASET_KEY(知识库密钥,不是应用密钥)/ DIFY_DATASET_ID
python kb_bulk_upload.py ./docs --mode hierarchical
python kb_bulk_upload.py ./docs --dry-run
这是写操作,先在空知识库上试,别直接对生产库跑。
"""
import argparse
import json
import os
import sys
import time
import requests
BASE_URL = os.environ.get("DIFY_BASE_URL", "http://127.0.0.1/v1").rstrip("/")
DATASET_KEY = os.environ.get("DIFY_DATASET_KEY")
DATASET_ID = os.environ.get("DIFY_DATASET_ID")
SUPPORTED = (".txt", ".md", ".mdx", ".pdf", ".html", ".htm",
".docx", ".csv", ".xlsx", ".xls")
_PRE = [{"id": "remove_extra_spaces", "enabled": True},
{"id": "remove_urls_emails", "enabled": False}]
# 两种分段规则。差别只在 parent_mode 与有没有 subchunk_segmentation,
# 但对召回效果的影响,比换一个更贵的模型大得多。
RULES = {
# 通用分段:一刀切成等长块。separator 优先按空行切,尽量不切断自然段;
# chunk_overlap 约为上限的 16%,落在 10~25% 的经验区间内。
"general": {"indexing_technique": "high_quality", "process_rule": {
"mode": "custom", "rules": {"pre_processing_rules": _PRE, "segmentation": {
"separator": "\n\n", "max_tokens": 500, "chunk_overlap": 80}}}},
# 父子分段:父块按段落(full_doc 表示整篇当父块),子块切到句子级
"hierarchical": {"indexing_technique": "high_quality",
"doc_form": "hierarchical_model", "process_rule": {
"mode": "hierarchical", "rules": {
"pre_processing_rules": _PRE, "parent_mode": "paragraph",
"segmentation": {"separator": "\n\n", "max_tokens": 1024},
"subchunk_segmentation": {"separator": "\n", "max_tokens": 256}}}},
}
def _headers():
if not DATASET_KEY or not DATASET_ID:
raise RuntimeError("缺少 DIFY_DATASET_KEY / DIFY_DATASET_ID")
return {"Authorization": "Bearer %s" % DATASET_KEY}
def collect(folder):
ok, skipped = [], []
for root, _dirs, files in os.walk(folder):
for name in sorted(files):
path = os.path.join(root, name)
if os.path.splitext(name)[1].lower() not in SUPPORTED:
skipped.append((path, "格式不支持"))
elif os.path.getsize(path) == 0:
skipped.append((path, "空文件"))
else:
ok.append(path)
return ok, skipped
def upload_one(path, rule):
"""multipart 上传:文件走 files,分段规则走名为 data 的表单字段(JSON 字符串)。
把规则塞进 json= 是最常见的写法错误:接口收下了文件却用默认分段规则,且不报错。
"""
with open(path, "rb") as fh:
resp = requests.post(
"%s/datasets/%s/document/create-by-file" % (BASE_URL, DATASET_ID),
headers=_headers(), timeout=300,
files={"file": (os.path.basename(path), fh)},
data={"data": json.dumps(rule, ensure_ascii=False)})
if resp.status_code >= 400:
raise RuntimeError("HTTP %s: %s" % (resp.status_code, resp.text[:300]))
body = resp.json()
return (body.get("document") or {}).get("id"), body.get("batch")
def wait_indexing(batch, poll=5, limit=600):
"""等这一批完成索引。
「上传成功」不等于「可以检索」:文本要切块,再逐块过 Embedding。
灌完立刻做召回测试只会得到空结果,然后误以为是分段参数配错了。
"""
url = "%s/datasets/%s/documents/%s/indexing-status" % (BASE_URL, DATASET_ID, batch)
waited = 0
while waited < limit:
resp = requests.get(url, headers=_headers(), timeout=60)
if resp.status_code >= 400:
return "查询失败 HTTP %s" % resp.status_code
items = resp.json().get("data") or []
done = sum(1 for i in items if i.get("indexing_status") == "completed")
bad = [i for i in items if i.get("indexing_status") == "error"]
if bad:
return "有 %d 个文档索引失败:%s" % (len(bad), bad[0].get("error") or "原因未返回")
if items and done == len(items):
return "全部完成(%d 个)" % done
print(" 索引中 %d/%d ..." % (done, len(items)))
time.sleep(poll)
waited += poll
return "等待超时,去知识库页面看具体进度"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("folder")
ap.add_argument("--mode", choices=list(RULES), default="hierarchical")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--sleep", type=float, default=1.0, help="每个文件间隔秒数")
args = ap.parse_args()
files, skipped = collect(args.folder)
print("待上传 %d 个,跳过 %d 个,分段方式:%s" % (len(files), len(skipped), args.mode))
for path, why in skipped:
print(" [跳过] %s (%s)" % (path, why))
if args.dry_run:
for p in files:
print(" [待传] %s" % p)
return 0
if not files:
return 1
rule, last_batch, failed = RULES[args.mode], None, []
for i, path in enumerate(files, 1):
print("[%d/%d] %s" % (i, len(files), os.path.basename(path)))
try:
doc_id, batch = upload_one(path, rule)
last_batch = batch or last_batch
print(" 已提交,文档 id=%s" % doc_id)
except Exception as exc: # noqa: BLE001
print(" 失败:%s" % exc)
failed.append((path, str(exc)))
time.sleep(args.sleep)
if last_batch:
print("\n等待索引完成 ...\n %s" % wait_indexing(last_batch))
print("\n完成:成功 %d,失败 %d" % (len(files) - len(failed), len(failed)))
for path, why in failed:
print(" [失败] %s -> %s" % (path, why))
print("\n下一步:跑一次召回评测,别凭感觉认为灌好了。")
return 0 if not failed else 1
if __name__ == "__main__":
sys.exit(main())
第四步:四个节点连成一条线
在工作室里新建一个对话流(chatflow)应用。它和普通工作流的区别是带会话上下文——用户说「那第二种情况呢」时,这一点是刚需。
最小结构只有四个节点:
入口。用户这一轮说的话由系统变量承载,不需要自己再声明一个输入字段;这里加的是额外表单项,比如让用户选部门、选文档范围。
本讲的主角。选知识库、设检索方式、TopK、要不要 Rerank。它的输出是一组分段。
把检索结果挂到上下文上,提示词里用占位符引用它。只连线不挂上下文,占位符就是空的。
把模型输出吐给用户。引用的是「节点 + 变量名」,改了节点名这里要同步改。
界面上这四个节点是拖出来的,但它们在底层是一份结构化的定义文件,可以导出、可以进版本库、可以在另一套环境里导入还原。读这份文件比记按钮位置有用得多——界面会改版,字段名相对稳定:
# =============================================================================
# wf_kb_minimal.yml —— 最小的「知识库问答」对话流(chatflow)
#
# 开始 → 知识检索 → LLM → 直接回复
#
# 可以直接用「导入 DSL 文件」创建应用。导入前改两处(已用 TODO 标出):
# TODO-1 dataset_ids:换成自己知识库的 ID(知识库详情页地址栏里那串 UUID)
# TODO-2 模型:换成自己已经接好的推理模型与 Rerank 模型
# 没接 Rerank 就把 reranking_enable 改成 false
#
# 读 DSL 的价值:界面上的每个开关,在这里都是一个可 diff、可进版本库、
# 可在多套环境之间搬运的字段。界面会改版,字段名相对稳定。
# =============================================================================
app:
description: 最小知识库问答:检索命中的分段作为上下文交给模型作答
mode: advanced-chat # 对话流(带会话上下文);workflow 是普通工作流
name: 知识库问答(最小版)
kind: app
version: 0.1.5
workflow:
features:
file_upload: {enabled: false}
opening_statement: 你好,这里是私有知识库问答。问题越具体,检索越准。
# 开启后回答下方会列出「引用了哪些分段」,排查召回问题全靠它
retriever_resource: {enabled: true}
graph:
# 连线用节点 id,不是标题。改节点时连线要一起改,
# 否则画布上会出现孤儿节点,而流程静默跑不通。
edges:
- {id: e1, source: 'node_start', sourceHandle: source, target: 'node_retrieval', type: custom}
- {id: e2, source: 'node_retrieval', sourceHandle: source, target: 'node_llm', type: custom}
- {id: e3, source: 'node_llm', sourceHandle: source, target: 'node_answer', type: custom}
nodes:
# 对话流入口。用户这一轮说的话由系统变量 sys.query 承载,不用自己再声明;
# variables 里加的是额外表单项(比如让用户选部门、选文档范围)。
- id: 'node_start'
type: custom
position: {x: 30, y: 250}
data: {title: 开始, type: start, variables: []}
# 这一页的主角。
# query_variable_selector 用哪个变量去检索
# retrieval_mode: multiple 多路召回
# top_k / reranking_enable 召回条数与是否重排
# 注意:TopK 与 Score 阈值只在 Rerank 这一步生效。不开 Rerank 就调这两个值,
# 调了也不会按预期起作用 —— 这是最容易浪费半小时的地方。
- id: 'node_retrieval'
type: custom
position: {x: 334, y: 250}
data:
title: 知识检索
type: knowledge-retrieval
dataset_ids: ['TODO-1-换成你自己的知识库-ID']
query_variable_selector: ['node_start', 'sys.query']
retrieval_mode: multiple
multiple_retrieval_config:
top_k: 4
reranking_enable: true
reranking_mode: reranking_model
reranking_model: {provider: 'TODO-2-Rerank-供应商', model: 'TODO-2-Rerank-模型'}
# 把检索结果当上下文,让模型写答案。
# context.variable_selector 指向检索节点的 result,提示词里的 {{#context#}}
# 才会被替换成召回的分段。只连线、不在 context 里挂上 result,占位符就是空的 ——
# 现象是「模型一本正经地瞎答」,而画布上看起来一切正常。
- id: 'node_llm'
type: custom
position: {x: 638, y: 250}
data:
title: LLM
type: llm
model:
provider: 'TODO-2-推理模型供应商'
name: 'TODO-2-推理模型名'
mode: chat
completion_params: {temperature: 0.3} # 知识库问答要忠于原文,温度调低
context: {enabled: true, variable_selector: ['node_retrieval', 'result']}
prompt_template:
- role: system
text: |
你是一个严谨的知识库问答助手。
只依据下面提供的资料回答问题,先给结论,再给出资料原文作为依据。
资料中没有提到的内容,直接回答「资料中没有相关内容」,不要自行推测。
资料:
{{#context#}}
- role: user
text: '{{#sys.query#}}'
vision: {enabled: false}
# 把 LLM 的输出吐给用户。引用的是「节点 id + 变量名」,改了 id 这里要同步改。
- id: 'node_answer'
type: custom
position: {x: 942, y: 250}
data: {title: 直接回复, type: answer, answer: '{{#node_llm.text#}}'}
viewport: {x: 0, y: 0, zoom: 0.8}
{{#context#}}。现象是模型一本正经地瞎答,而画布上一切正常、也不报错。判断方法:把引用来源展示打开,看回答下方有没有列出引用的分段——一条都没有就说明资料根本没送进去。
验收与接出去
验收标准要选得刁钻一点:问一个只有内部资料才答得出的问题。问「劳动合同是什么」证明不了任何事,模型本来就知道;要问「我们公司试用期转正的审批要几个人签字」这种外面查不到的。
能答对之后,应用就是一个可以接进业务系统的 HTTP 接口了。对话型和工作流型的接口路径与参数不同,别写混:
# -*- coding: utf-8 -*-
"""
dify_chat_api.py —— 用 HTTP API 调用已经发布的应用
界面上搭好的对话流,本质上就是一个 HTTP 接口。接进自己的业务系统只需要三样:
服务地址(私有化部署就是自己那台机器)、应用密钥、用户标识(用来隔离会话)。
两种应用类型对应两组接口,路径和参数不同,别写混:
对话型(chatflow / agent / 聊天助手) POST /v1/chat-messages
工作流型(workflow) POST /v1/workflows/run
环境变量:DIFY_BASE_URL(要带 /v1)、DIFY_API_KEY
pip install requests
python dify_chat_api.py "试用期最长能约多久"
"""
import json
import os
import sys
import uuid
import requests
BASE_URL = os.environ.get("DIFY_BASE_URL", "http://127.0.0.1/v1").rstrip("/")
API_KEY = os.environ.get("DIFY_API_KEY")
TIMEOUT = int(os.environ.get("DIFY_TIMEOUT", "120"))
def _headers():
if not API_KEY:
raise RuntimeError("缺少 DIFY_API_KEY,先在应用的访问 API 处生成密钥")
return {"Authorization": "Bearer %s" % API_KEY, "Content-Type": "application/json"}
def chat_blocking(query, user, conversation_id="", inputs=None):
"""阻塞模式:一次拿到完整答案。写脚本、跑批、做评测时最省事,前端直接用会显得卡。
conversation_id 传空表示新会话;把上一轮返回的值原样传回来就能接着聊 ——
记忆靠它,不要自己拼历史。
"""
resp = requests.post("%s/chat-messages" % BASE_URL, headers=_headers(),
timeout=TIMEOUT,
json={"inputs": inputs or {}, # 开始节点的自定义表单变量
"query": query, # 对应 sys.query
"response_mode": "blocking",
"conversation_id": conversation_id,
"user": user}) # 必填,同一 user 的会话才互相可见
# 出错时先把响应体打出来 —— 只看状态码排查不了「知识库没挂上」这类问题
if resp.status_code >= 400:
raise RuntimeError("HTTP %s: %s" % (resp.status_code, resp.text[:500]))
data = resp.json()
return data.get("answer", ""), data.get("conversation_id", ""), data
def chat_streaming(query, user, conversation_id="", inputs=None, on_chunk=None):
"""流式模式:按 SSE 逐块推送,前端体验好。事件类型不止一种,按需扩展。"""
parts, conv_id, resources = [], conversation_id, []
with requests.post("%s/chat-messages" % BASE_URL, headers=_headers(),
stream=True, timeout=TIMEOUT,
json={"inputs": inputs or {}, "query": query,
"response_mode": "streaming",
"conversation_id": conversation_id,
"user": user}) as resp:
if resp.status_code >= 400:
raise RuntimeError("HTTP %s: %s" % (resp.status_code, resp.text[:500]))
for raw in resp.iter_lines(decode_unicode=True):
if not raw or not raw.startswith("data:"):
continue
chunk = raw[5:].strip()
if not chunk or chunk == "[DONE]":
continue
try:
evt = json.loads(chunk)
except json.JSONDecodeError:
continue # 半截的包跳过,下一轮会补齐
etype = evt.get("event")
if etype in ("message", "agent_message"):
parts.append(evt.get("answer", ""))
if on_chunk:
on_chunk(evt.get("answer", ""))
elif etype == "message_end":
conv_id = evt.get("conversation_id", conv_id)
# 引用了哪些分段藏在这里:排查召回问题第一个要看的字段
resources = (evt.get("metadata") or {}).get("retriever_resources") or []
elif etype == "error":
raise RuntimeError("流式返回错误:%s" % evt.get("message"))
return "".join(parts), conv_id, resources
def run_workflow(inputs, user, response_mode="blocking"):
"""工作流型:没有 query,只有 inputs;入参名就是开始节点里定义的变量名。
返回结构也不同:结果在 data.outputs 里。
"""
resp = requests.post("%s/workflows/run" % BASE_URL, headers=_headers(),
timeout=TIMEOUT,
json={"inputs": inputs, "response_mode": response_mode,
"user": user})
if resp.status_code >= 400:
raise RuntimeError("HTTP %s: %s" % (resp.status_code, resp.text[:500]))
data = resp.json()
return (data.get("data") or {}).get("outputs", {}), data
def print_sources(resources):
"""私有知识库问答的可信度,全靠「答案能溯源到哪一段原文」撑着。"""
if not resources:
print("\n[没有引用任何分段] —— 要么没命中,要么根本没挂知识库")
return
print("\n== 引用的分段(%d 条)==" % len(resources))
for i, r in enumerate(resources, 1):
print(" %d. %s 相似度:%s" % (i, r.get("document_name", "?"), r.get("score", "?")))
text = (r.get("content") or "").replace("\n", " ")
print(" %s%s" % (text[:110], "…" if len(text) > 110 else ""))
def main():
question = sys.argv[1] if len(sys.argv) > 1 else "介绍一下你能做什么"
user = os.environ.get("DIFY_USER") or "debug-%s" % uuid.uuid4().hex[:8]
print("提问:%s\n\n回答:" % question, end="", flush=True)
_answer, conv_id, resources = chat_streaming(
question, user=user, on_chunk=lambda s: print(s, end="", flush=True))
print()
print_sources(resources)
print("\n会话 id:%s(下一轮传回去就能接上上下文)" % conv_id)
if __name__ == "__main__":
main()
04完整案例:律所的法律问答助手
四个模块、两种数据来源、一个意图分流,最后挂在律所官网的右下角
4.1 需求长什么样,以及为什么只能自建
一家律所想在官网首页加一个在线助手,要能干四件事:
查法律条文原文。业务集中在民事诉讼、企业法律顾问、知识产权,条文范围相对明确。
用户用一段话描述纠纷经过,或者直接传一份材料,助手结合条文梳理争议焦点、分析责任。
上传合同文件,找出风险条款并给修改建议。
问「打一场劳动仲裁大概多少钱」,给出报价区间,最后转人工。
选型的过程比结论更值得看。三条硬约束直接淘汰了另外两种方案:
| 约束 | 托管式平台 | 纯代码框架自研 | 编排平台私有化 |
|---|---|---|---|
| 要读内网的业务数据库(资费表在本地) | 连不到 | 可以 | 可以 |
| 要嵌到律所自己的官网上 | 受限 | 可以,但前端要自己写 | 给一段脚本就能挂 |
| 项目复杂度与预算 | 低 | 性价比低:四个模块都要自己写编排、会话、文件解析 | 复杂度刚好落在编排能覆盖的范围内 |
注意最后一列的判断依据不是「这个平台更好」,而是这个项目的复杂度刚好落在编排能覆盖的范围内。如果需求再复杂几个量级——需要复杂的状态机、需要精细的并发控制、需要把每一步都写单元测试——结论会反过来。
4.2 分流:把四件事从入口就分开
整个助手只用了一条对话流,但进门第一件事就是意图识别,把用户分到四条互不干扰的支路上。
为什么不做成一条通用链路?因为这四件事对检索的要求正好相反:
| 模块 | 数据来源 | 召回条数 | 为什么 |
|---|---|---|---|
| 法律咨询 | 知识库 | 少(开 Rerank) | 用户要的是准确的那一两条原文,返回十条反而稀释答案 |
| 案件分析 | 知识库 | 多 | 案情分析需要尽量多的条文做参照,宁滥勿缺 |
| 合同分析 | 知识库 | 多 | 同上,而且检索之后还要再按合同类型分一次流 |
| 资费查询 | 业务数据库 | 不适用 | 价格是结构化事实,压根不该进知识库 |
硬塞进一条链路,这些参数只能取一个折中值,四个模块一起变差。分流的本质是让每一路能独立调参。
分类描述怎么写,直接决定分得准不准。经验是:写得像真实用户会说的话,而不是像业务分类目录。「咨询法律条文、法律规定、某种行为是否合法」比「法律咨询类」有用得多。另外分类节点的温度要调到最低——这一步要的是稳定,不是创造力。
4.3 四个模块,各自的门道
模块一:法律咨询——为什么要先改写一次问题
这一路有两个 LLM 节点,中间夹一个检索节点:查询改写 → 条文检索 → 结论 + 原文。
查询改写这一步经常被当成多余而省掉,但它解决的是一个实实在在的问题:用户原话里常常包含大量与检索无关的情绪和铺垫。「我在公司干了三个月,老板突然说我不合适要辞退我,还不给补偿,这合法吗」——真正该拿去检索的是「试用期解除劳动合同的条件与经济补偿」,而原话里的「老板突然说」「不合适」这些词,在向量空间里只会把方向带偏。
改写节点要开记忆。用户接着问「那第二种情况呢」时,没有上文根本改写不出可检索的问题。
检索节点则反过来:召回条数压小,开 Rerank。查条文原文的场景下,多返回的每一条都是噪声。
最后的作答节点,提示词里有一句是整段的关键——先给结论,再给法律原文依据。用户要的是判断,但律所的专业性体现在依据上,两个都不能少。再加一句「知识库中没有对应条文时明确告知未检索到」,把编造的口子堵上。
模块二:案件分析——不要拿几千字直接去检索
流程:文档提取 → 检索条件提取 → 条文检索 → 分析报告。
用户可能贴一段文字,也可能传一份判决书,所以先过一个文档提取器把文件内容读成文本。要注意提取器对格式的支持范围有限,某些常见办公格式可能不在支持之列,遇到就得在上传环节限定格式,或者在流程里加一步格式转换——这一点上线前必须实测,别假设。
然后是这一路最关键的设计:案情有几千字,不能直接拿去检索。几千字编成一个向量,等于把整个案子的意思平均成一个点,什么都匹配不准。所以中间加一个 LLM 节点,先让模型从案情里提炼出「该查哪些条文」的检索条件,再拿这个条件去检索。
分析节点的提示词写得很长,但真正值钱的只有几句约束:
⚠️ 面向外部用户的分析类输出,三句话不能少
- 所有分析需严格基于用户提供的材料和法律条文,不臆断或虚构。这是抑制编造最有效的一句。
- 不替代律师提供正式法律意见,不预测判决结果。把能力边界写进提示词,而不是只写在免责声明里。
- 如信息不足或存在矛盾,需明确说明局限性。允许模型说「资料不够」,它才不会硬编。
输出结构也固定下来:案件基本信息 → 争议焦点 → 事实与证据分析 → 法律适用分析 → 综合结论与建议。固定结构不只是为了好看,它让模型的输出可预期、可校验,也让用户每次拿到的东西格式一致。
模块三:合同分析——检索之后还要再分一次流
流程:文档提取 → 条文检索 → 合同分类 → 分类审核。
为什么检索完还要再分一次?因为不同类型的合同,审查要点完全不同。劳动合同盯的是试用期、工资构成、解除条件、竞业限制;技术服务合同盯的是工作范围、知识产权归属、付款节点与验收标准。用一套提示词覆盖两者,只会两边都审得很浅。
所以按律所擅长的类别分出劳动合同、技术服务合同两支单独处理,剩下的走通用兜底。三支的用户提示词完全一样(合同原文 + 用户输入 + 检索到的条文),只有系统提示词不同——分流分的就是这一段。
把三份提示词从画布里抄出来集中管理,是这个项目里回报最高的一个工程动作:
# -*- coding: utf-8 -*-
"""
law_prompts.py —— 法律助手四个模块的提示词,集中放在一个文件里
写在画布节点里的提示词,改一个字要开浏览器、找节点、点开、改、发布:
没有 diff、没有版本、没法回滚,出了问题说不清是哪一版改坏的。
收进一个文件后,改动能进版本库、能评审、能回滚,再同步回节点。
它同时是一份「提示词怎么写」的样本:角色 / 任务 / 输出格式 / 约束条件
四段式;必写「资料里没有就说没有」(抑制编造最有效的一句);检索结果用
{{#context#}} 占位,让提示词与检索解耦。
python law_prompts.py # 列出所有模块
python law_prompts.py contract_labor # 打印某个模块的完整提示词
"""
import sys
CONTEXT = "{{#context#}}" # 检索节点的输出会替换到这里
USER_QUERY = "{{#sys.query#}}" # 用户这一轮说的话
DOC_TEXT = "{{#文档提取器.text#}}" # 上传文件解析出的文本
# --- 模块一:法律咨询 -------------------------------------------------------
# 先改写再检索:用户原话里的情绪和铺垫会在向量空间里把方向带偏。
QUERY_REWRITE = {
"title": "法律咨询 · 查询改写",
"system": "你是一个法律条文查询助手,能够根据用户的提问进行优化,"
"转化成更适合查询知识库的问题。只输出改写后的问题本身,不要解释。",
"user": "用户输入:\n%s" % USER_QUERY,
"note": "必须开记忆:用户说「那第二种情况呢」时,没有上文就改写不出可检索的问题。",
}
LEGAL_QA = {
"title": "法律咨询 · 条文作答",
"system": "你是一个法律咨询助手,能够根据用户的提问和从知识库里查询出来的结果回答用户。"
"回答时先直接给出结论,再给出对应的法律原文依据。"
"知识库中没有对应条文时,明确告知未检索到,不要凭印象作答。",
"user": "用户的问题:\n%s\n知识库:\n%s" % (USER_QUERY, CONTEXT),
"note": "检索要开 Rerank 并把召回条数压小 —— 用户要的是准确的那一两条原文。",
}
# --- 模块二:案件分析 -------------------------------------------------------
CASE_QUERY_EXTRACT = {
"title": "案件分析 · 检索条件提取",
"system": "你是一个法律专家,能够根据用户上传的案例内容和用户的输入拆分出"
"对应的法律条文查询条件,接下来我将进行知识库的查询。",
"user": "用户输入:\n%s\n用户上传的案例:\n%s" % (USER_QUERY, DOC_TEXT),
"note": "案情往往几千字,整段拿去检索等于把意思平均成一个点;先提炼再检索。",
}
CASE_ANALYSIS = {
"title": "案件分析 · 出具分析报告",
"system": """你是一名专业的案情分析专家,名为「案理」。
一、核心定位
· 角色:专业、中立、严谨的法律分析助手,梳理案件事实、识别争议焦点、结合条文分析。
· 底线:所有分析严格基于用户提供的材料和法律条文,不臆断或虚构;
不替代律师提供正式法律意见,不预测判决结果;
信息不足或存在矛盾时,明确说明局限性。
二、输出结构(按此顺序)
1. 案件基本信息:当事人、案由、时间、核心事实
2. 争议焦点:归纳核心争议点
3. 事实与证据分析:梳理法律事实,评估证据链完整性
4. 法律适用分析:结合条文分析行为性质、责任及后果
5. 综合结论与建议:分析意见与后续行动方向
三、表达要求
· 专业清晰的法律语言,避免「我认为」等主观表述,关键结论加粗
· 引用条文须注明名称与条款序号;发现信息矛盾或依据不足时单独说明""",
"user": "用户输入:\n%s\n用户上传的案例:\n%s\n\n参考法律条文:\n%s"
% (USER_QUERY, DOC_TEXT, CONTEXT),
"note": "「不臆断或虚构」「信息不足需说明局限性」是整份提示词里最值钱的两句。",
}
# --- 模块三:合同分析 -------------------------------------------------------
# 三支共用同一套用户提示词,只有 system 不同 —— 分流分的就是这一段。
_CONTRACT_USER = "用户的案情:\n%s\n用户提供的输入:\n%s\n法律条文:\n%s" % (
DOC_TEXT, USER_QUERY, CONTEXT)
CONTRACT_LABOR = {
"title": "合同分析 · 劳动合同",
"system": """# 角色
你是一名资深劳动法律师,帮助企业审查劳动合同的合规性,规避用工法律风险。
# 工作流
1. 核对合同是否包含法定必备条款,明确指出缺失项及其风险。
2. 重点审查:试用期期限与工资标准是否合法;工作地点与岗位调整条款是否过于宽泛;
劳动报酬是否明确工资构成、支付时间、加班费基数;解除条件是否合法、补偿与
赔偿计算是否清晰;竞业限制补偿金是否有明确约定。
# 输出格式
一、总体评价 二、分项分析与修改建议(表格:问题条款 / 风险等级 / 法律依据 /
风险分析 / 修改建议文本) 三、核心风险摘要(3-5 个) 四、谈判策略建议
# 约束条件
- 严禁编造不存在的法条,所有判断必须注明依据出处。
- 合法性存在普遍争议的条款应予提示,并建议咨询执业律师。
- 以下文提供的法律条文为主。""",
"user": _CONTRACT_USER,
}
CONTRACT_TECH = {
"title": "合同分析 · 技术服务合同",
"system": """# 角色
你是科技公司的法务顾问,审查技术服务协议,控制风险、保障项目交付。
# 工作流
1. 工作范围与交付物是否清晰、具体、可量化、可验证。
2. 知识产权:背景知识产权与项目中产生的知识产权归属、许可范围、期限与地域。
3. 付款条件:付款节点是否与关键里程碑强关联。
4. 保密与合规:保密信息定义与期限,数据安全与个人信息保护要求。
5. 违约责任是否对等,责任上限是否合理。
# 输出格式
一、总体风险评估 二、关键条款深度剖析 三、谈判优先级清单
(必须修改 / 建议修改 / 可接受)
# 约束条件
- 基于合同法相关规定,同时侧重商业实践的合理性与风险控制。
- 修改建议应具可操作性,并给出修改后的范例文本。
- 以下文提供的法律条文为主。""",
"user": _CONTRACT_USER,
}
# 分类判不出类型时走这一支。任何分流都必须有兜底分支,
# 否则遇到没预料到的输入,流程会直接断在这里。
CONTRACT_GENERAL = {
"title": "合同分析 · 通用兜底",
"system": """# 角色
你是一名擅长设计风险隔离方案的法律风险控制专家。
# 工作流
1. 兜底事项描述是否清晰无歧义。 2. 责任触发条件是否客观可验证。
3. 责任性质与金额上限是否明确。 4. 是否赋予核查权与追偿权。
# 输出格式
一、协议效力评估 二、核心条款风险提示 三、修订建议与文本
# 约束条件
- 重点提示无限责任风险,强调约定不明可能导致条款无效。
- 以下文提供的法律条文为主。""",
"user": _CONTRACT_USER,
}
# --- 模块四:资费查询 -------------------------------------------------------
# 上下文来自数据库而不是知识库:结构与前三路一模一样,只是灌进去的料换了来源。
PRICING = {
"title": "资费查询 · 报价区间",
"system": "你是一名专业的法律助手,负责律所的价格咨询业务,"
"能够根据用户输入的问题,结合律所定价细则,给出一个报价区间。"
"定价表里没有的服务,如实说明需要人工确认,不要自行估算。",
"user": "用户问题:\n%s\n\n定价:\n%s" % (USER_QUERY, CONTEXT),
"note": "报价是会产生纠纷的输出,末尾必须挂人工确认的出口。",
}
# 每个输出节点末尾都要挂的边界说明。对外产品里这不是客套话,是必需品。
DISCLAIMER = {
"legal_qa": "以上内容来自律所智能大脑,建议联系律所获取专业的咨询建议。",
"case": "以上分析结果仅供参考,建议联系律所获取专业的咨询建议。",
"contract": "以上分析内容来自律所智能大脑,建议联系律所获取专业的咨询建议。",
"pricing": "具体报价请联系人工客服确认。",
}
ALL_MODULES = {
"query_rewrite": QUERY_REWRITE, "legal_qa": LEGAL_QA,
"case_query": CASE_QUERY_EXTRACT, "case_analysis": CASE_ANALYSIS,
"contract_labor": CONTRACT_LABOR, "contract_tech": CONTRACT_TECH,
"contract_general": CONTRACT_GENERAL, "pricing": PRICING,
}
def main():
if len(sys.argv) > 1:
mod = ALL_MODULES.get(sys.argv[1])
if not mod:
print("没有这个模块。可选:%s" % ", ".join(ALL_MODULES))
return 1
print("== %s ==\n\n[system]\n%s\n\n[user]\n%s"
% (mod["title"], mod["system"], mod["user"]))
if mod.get("note"):
print("\n[要点]\n%s" % mod["note"])
return 0
print("共 %d 个提示词模块:\n" % len(ALL_MODULES))
for key, mod in ALL_MODULES.items():
print(" %-18s %s" % (key, mod["title"]))
print("\n查看某一个:python law_prompts.py contract_labor")
return 0
if __name__ == "__main__":
sys.exit(main())
4.4 资费查询:结构化的事实不要往知识库里塞
第四个模块的数据来源和前三个完全不同——它查数据库,不查知识库。
判断标准只有一句话:这条信息改了之后,要不要重新灌一遍库?
- 要重新灌 → 它是非结构化知识,放知识库。法条、制度、判例都是。
- 不用,改完立刻生效 → 它是业务事实,放数据库。价格、库存、订单状态、工单进度都是。
把价格表灌进知识库,是这类项目最常见的一次性设计失误:调价之后答案还停在旧价,而且查不出原因——因为向量库里那份旧价格分段仍然安安静静地躺在那儿,检索照样能命中。
业务库这边要做的事很简单:建表、写进真实数据、在表和字段上写清楚中文注释。
-- =============================================================================
-- law_assistant_schema.sql —— 律所资费查询模块的业务库
--
-- 这张表是「资费查询」模块唯一的事实来源,名目与数字均为演示用示例。它刻意不放进知识库:
-- 价格是结构化数据,天天在变,灌进向量库就得每次改价都重新分段、重新
-- Embedding,还答不准。结构化的事实查数据库,非结构化的条文查知识库。
--
-- 执行:mysql -h <host> -u <user> -p < law_assistant_schema.sql
-- 口令不写进这个文件,也不写进任何提交到仓库的文件。
-- =============================================================================
CREATE DATABASE IF NOT EXISTS law_assistant
DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_0900_ai_ci;
USE law_assistant;
-- billing_unit 用枚举而不是自由文本:上下文越规整,模型算出来的报价越稳。
-- is_active 保留下架服务的记录,不做物理删除 —— 报价争议时要能追溯。
CREATE TABLE IF NOT EXISTS legal_service_pricing (
service_id INT AUTO_INCREMENT PRIMARY KEY,
service_name VARCHAR(255) NOT NULL COMMENT '服务名称',
service_description TEXT COMMENT '服务说明,模型据此判断用户问的是哪一项',
price DECIMAL(10, 2) NOT NULL COMMENT '单价',
billing_unit ENUM('按小时', '按次', '按项目') NOT NULL COMMENT '计费单位',
lawyer_id INT NOT NULL COMMENT '负责律师',
is_active BOOLEAN DEFAULT TRUE COMMENT '是否在售',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_active (is_active),
KEY idx_name (service_name)
) ENGINE=InnoDB COMMENT='律所服务定价';
-- 字段注释与中文服务名一起构成了模型的「阅读材料」。
-- 服务名如果写成 SVC_001 这种代号,模型就没法把用户口语里的「打官司」
-- 对应到「民事诉讼一审代理」,整个模块的准确率会直接塌掉。
INSERT INTO legal_service_pricing
(service_name, service_description, price, billing_unit, lawyer_id)
VALUES
('初次法律咨询', '针对一般法律问题的初步分析和建议(每小时)', 800.00, '按小时', 101),
('合同审查(标准)', '对标准商业合同进行合规性与风险审查(按次)', 2500.00, '按次', 102),
('民事诉讼一审代理', '代理普通民事纠纷案件的一审程序(全案)', 30000.00, '按项目', 105),
('律师函出具', '就特定事务起草并发出正式律师函', 1500.00, '按次', 103),
('劳动争议仲裁代理', '代理用人单位或员工参与劳动仲裁程序', 12000.00, '按项目', 107),
('法律顾问服务(月度)', '提供月度日常法律咨询及简单文件审阅', 5000.00, '按小时', 102),
('数据合规方案辅导', '帮助企业初步建立数据合规框架', 15000.00, '按项目', 111);
-- 实际业务里还会有股权激励、尽职调查、知识产权维权等几十行,写法同上。
-- 自证数据进来了,别跳过这一步
SELECT COUNT(*) AS 服务条数, MIN(price) AS 最低价, MAX(price) AS 最高价
FROM legal_service_pricing WHERE is_active = TRUE;
SVC_001 这种代号,这个模块的准确率会直接塌掉。给模型看的表,要按给人看的标准来写。
编排里的数据库节点,本质上是「把一段 SQL 的结果塞进模型的上下文」。所以两件事都要管:
连接必须用只读账号,授权只给到视图、不给到底层表。这样连表结构都看不到,查询范围被彻底钉死。用有写权限的账号去跑「只是查一下」的节点,等于把删库的按钮接到了一个由自然语言驱动的流程上。
查回来的内容会原样占用上下文窗口。数据涨到几千行时,没有 LIMIT 的查询会把上下文撑爆,表现是「昨天还好好的,今天开始答非所问」,而且没有任何报错。
落到实现上就是两句:建一个只含可公开字段的视图(上面表结构里的 v_pricing_for_llm),再把权限只授到这个视图:GRANT SELECT ON law_assistant.v_pricing_for_llm TO 'dify_ro'@'%';。底层表不授权,编排里就算把 SQL 写错也跑不出范围。
还有一个不起眼但会毁掉整个模块的细节:连接串上的字符集。中文服务名走错字符集会变成问号或乱码,模型拿到乱码只能瞎答。在插件的连接属性里显式指定字符集与排序规则,别依赖默认值。
最后是资费计算节点。这里把数据库查出来的定价明细作为上下文,让模型结合用户问题算一个区间。提示词里有两句必须写:「定价表里没有的服务,如实说明需要人工确认」,以及输出末尾挂上人工客服的出口。报价是会产生纠纷的输出,把边界说清楚比多答对一道题重要得多。
4.5 把整条流程拼起来,挂到官网上
四个模块加一个分流,就是完整的对话流。整份结构导出来是这样——它可以进版本库、可以 diff、可以在另一套环境里还原:
# =============================================================================
# wf_law_assistant.yml —— 法律问答助手的对话流骨架(结构示意,不含任何真实凭据)
#
# 开始 ─► 意图识别 ┬─► 法律咨询:查询改写 → 检索(top_k=3,开 Rerank) → 作答 → 回复
# ├─► 案件分析:文档提取 → 条件提取 → 检索(top_k=10) → 分析 → 回复
# ├─► 合同分析:文档提取 → 检索(top_k=10) → 合同分类 → 分类审核 → 回复
# ├─► 资费查询:数据库查询 → 报价计算 → 回复
# └─► 其它:兜底回复
#
# 三个结构性决定:
# 1. 分流放最前面 —— 四路的检索参数、提示词、数据来源都不同,合成一条只会互相打架
# 2. 每一路自带回复节点 —— 共用出口时改一路文案会影响其它三路
# 3. 分流必须有兜底分支 —— 没有「其它」,意外输入会让流程断在半路
#
# 下面完整给出「意图识别 + 法律咨询」这一路,其余三路结构同构、差异见注释。
# 提示词全文见 law_prompts.py。
# =============================================================================
app: {name: 法律问答助手, mode: advanced-chat} # advanced-chat = 对话流,带会话上下文
kind: app
version: 0.1.5
# 复用锚点,省掉四路重复的模型配置
x-llm: &llm {provider: 'TODO-推理模型供应商', name: 'TODO-推理模型名', mode: chat}
x-rr: &rr {provider: 'TODO-Rerank-供应商', model: 'TODO-Rerank-模型'}
x-kb: &kb ['TODO-法律条文知识库-ID']
workflow:
features:
# 案件与合同两路都要收文件,这里不开,那两路永远拿不到内容
file_upload: {enabled: true, allowed_file_types: [document],
allowed_file_extensions: ['.pdf', '.docx', '.txt', '.md']}
opening_statement: 你好,我是「明德同学」智能助手。可以咨询法律法规、进行案情分析、分析合同,也可以咨询律所的资费。
retriever_resource: {enabled: true} # 展示引用来源,排查召回全靠它
graph:
nodes:
- {id: start, data: {title: 开始, type: start, variables: []}}
# 分类描述写得像真实用户的话,比写成业务目录准得多;温度压到 0 求稳定
- id: intent
data:
title: 意图识别
type: question-classifier
query_variable_selector: [start, sys.query]
model: {<<: *llm, completion_params: {temperature: 0.0}}
classes:
- {id: cls_law, name: 咨询法律条文、法律规定、某种行为是否合法}
- {id: cls_case, name: 描述了一段纠纷经历,希望分析案情、判断责任}
- {id: cls_contract, name: 提供了合同文本,希望审查条款风险}
- {id: cls_price, name: 询问律师费、代理费、收费标准、报价}
- {id: cls_other, name: 其它与上述业务无关的问题}
# --- 路线一:法律咨询 ------------------------------------------------
# 先改写再检索:用户原话里的情绪和铺垫会在向量空间里把方向带偏
- id: law_rewrite
data:
title: 查询改写
type: llm
model: *llm
memory: {enabled: true} # 接住「那第二种情况呢」
prompt_template:
- {role: system, text: 把用户提问改写成更适合检索知识库的问题,只输出改写后的问题}
- {role: user, text: '{{#sys.query#}}'}
- id: law_retrieval
data:
title: 法律条文检索
type: knowledge-retrieval
dataset_ids: *kb
query_variable_selector: [law_rewrite, text] # 用改写后的问题去检索
retrieval_mode: multiple
# 查条文原文:多返回的每一条都是噪声,所以压小 top_k 并开 Rerank。
# 案件分析与合同分析两路相反,用 top_k: 10 —— 参照的条文越多越好。
multiple_retrieval_config: {top_k: 3, reranking_enable: true, reranking_model: *rr}
- id: law_answer_llm
data:
title: 条文作答
type: llm
model: *llm
context: {enabled: true, variable_selector: [law_retrieval, result]}
memory: {enabled: true}
prompt_template:
- {role: system, text: 先给结论,再给法律原文依据;知识库中没有对应条文时明确告知未检索到}
- {role: user, text: "用户的问题:\n{{#sys.query#}}\n知识库:\n{{#context#}}"}
# 每一路自带出口,改一路文案不会影响其它三路
- {id: law_reply, data: {title: 输出法律条文, type: answer,
answer: "{{#law_answer_llm.text#}}\n\n以上内容来自律所智能大脑,建议联系律所获取专业的咨询建议。"}}
# --- 路线二:案件分析(结构同上,多两处差异) ------------------------
# 1) 开头多一个文档提取器:type: document-extractor,
# variable_selector: [start, sys.files],is_array_file: true
# 2) 检索前多一个 LLM 节点「检索条件提取」:几千字案情直接去检索,
# 等于把整个案子的意思平均成一个点,先提炼再检索命中率才上得去
# --- 路线三:合同分析(比案件分析多一次分流) ------------------------
# 检索之后再接一个 question-classifier,把合同分成劳动 / 技术服务 / 其它三支
# (classes 写法同上面的 intent 节点),每支各挂一个 LLM 节点:
# user 提示词完全一致,只有 system 不同 —— 分流分的就是这一段
# --- 路线四:资费查询(查数据库,不查知识库) ------------------------
# 价格是结构化事实,改完要立刻生效,灌进知识库只会答出旧价
- id: price_db
data:
title: 运行数据库查询
type: tool
desc: 连接必须用只读账号,查询只指向视图,且必须带 LIMIT
tool_parameters:
db_properties: 'charset=utf8mb4&collation=utf8mb4_0900_ai_ci'
query: 'SELECT * FROM v_pricing_for_llm LIMIT 50'
- id: price_llm
data:
title: 资费计算
type: llm
model: *llm
context: {enabled: true, variable_selector: [price_db, text]} # 上下文来自数据库
prompt_template:
- {role: system, text: 结合定价细则给出报价区间;定价表里没有的服务,如实说明需要人工确认}
- {role: user, text: "用户问题:\n{{#sys.query#}}\n定价:\n{{#context#}}"}
# --- 兜底:把能力边界讲清楚,别让用户对着空气发问 --------------------
- {id: other_reply, data: {title: 兜底回复, type: answer,
answer: 这个问题超出了我的服务范围。我可以帮你查询法律条文、分析案情、审查合同条款,或者介绍律所的收费标准。}}
# sourceHandle 填的是分类的 id,填错整路走不到,而画布上看不出异常
edges:
- {source: start, sourceHandle: source, target: intent}
- {source: intent, sourceHandle: cls_law, target: law_rewrite}
- {source: intent, sourceHandle: cls_case, target: case_doc}
- {source: intent, sourceHandle: cls_contract, target: contract_doc}
- {source: intent, sourceHandle: cls_price, target: price_db}
- {source: intent, sourceHandle: cls_other, target: other_reply}
- {source: law_rewrite, sourceHandle: source, target: law_retrieval}
- {source: law_retrieval, sourceHandle: source, target: law_answer_llm}
- {source: law_answer_llm, sourceHandle: source, target: law_reply}
- {source: price_db, sourceHandle: source, target: price_llm}
读这份文件时重点看三处:每一路都从分类节点的对应出口接出去(接错会导致整路走不到,而画布上看不出异常)、每一路都自带一个回复节点(共用一个出口时,改一路的文案会影响另外三路)、四路的检索参数各不相同(这正是分流的意义)。
开场白也要认真写。它是用户看到的第一句话,承担着说明能力边界的职责——讲清楚这里能问什么,用户才不会拿它当通用聊天机器人,然后失望地关掉。
最后一步是嵌到官网上。应用发布之后会给出一段脚本,放进页面里,右下角就会出现一个浮动按钮,点开是一个聊天窗口。有三件事要做对:
- 应用必须先发布。草稿状态的应用嵌进来是打不开的。
- 把按钮配色对齐站点主色。默认配色几乎一定和站点撞色,看上去就像挂了个第三方广告。取色直接从页面已有的主色变量里拿。
- 主站是 HTTPS 时,嵌入地址也必须是 HTTPS。混合内容会被浏览器拦掉,现象是浮窗根本不出现,而控制台里那条报错很容易被忽略。
<!doctype html>
<!--
embed_widget.html —— 把发布好的对话应用嵌进自己的网站
嵌入的本质:平台给一段 script,它在页面右下角挂一个浮动按钮,点开是一个 iframe。
· 页面本身不用任何后端改造,纯静态站也能嵌
· 聊天流量走的是配置里那个地址 —— 私有化部署时它是自己的服务器,
「数据不出内网」才成立;用公有云版本要重新算一遍合规账
· 应用必须先「发布」,草稿状态嵌进来打不开
-->
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>明德律师事务所</title>
<style>
/* 站点主色。聊天按钮配色要与它对齐,否则浮窗会像贴上去的补丁 */
:root{ --brand:#2c5282; --ink:#1a202c; --mut:#5a6b80; }
*{box-sizing:border-box}
body{margin:0;font:16px/1.75 "PingFang SC","Microsoft YaHei",system-ui,sans-serif;color:var(--ink)}
header{background:var(--brand);color:#fff;padding:18px 28px;display:flex;align-items:center;gap:14px}
nav{margin-left:auto;display:flex;gap:22px;font-size:14px;opacity:.92}
main{max-width:960px;margin:0 auto;padding:40px 24px 90px}
h1{font-size:30px;margin:0 0 12px}
p.sub{color:var(--mut);margin:0 0 30px}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:16px}
.c{border:1px solid #dfe6ef;border-radius:12px;padding:18px 20px;background:#fff}
.c h3{margin:0 0 8px;font-size:16px;color:var(--brand)}
.c p{margin:0;font-size:14px;color:var(--mut)}
</style>
</head>
<body>
<header><b>明德律师事务所</b>
<nav><span>业务领域</span><span>专业团队</span><span>联系我们</span></nav>
</header>
<main>
<h1>专注民事诉讼、企业法律顾问与知识产权</h1>
<p class="sub">右下角的在线助手可以查条文、分析案情、审合同、问资费。</p>
<div class="cards">
<div class="c"><h3>民事诉讼</h3><p>合同纠纷、侵权责任、婚姻家庭等案件代理。</p></div>
<div class="c"><h3>企业顾问</h3><p>常年法律顾问、用工合规、股权与治理结构。</p></div>
<div class="c"><h3>知识产权</h3><p>商标注册、侵权维权、商业秘密保护制度建设。</p></div>
</div>
</main>
<!-- 嵌入代码放在 </body> 之前,不要放进 <head> -->
<script>
window.difyChatbotConfig = {
// TODO-1:应用发布后在「嵌入网站」处拿到的 token
token: 'TODO-1-填入应用的嵌入-token',
// TODO-2:私有化部署的服务地址。对外开放时必须是 HTTPS ——
// 主站 HTTPS 而嵌入地址是 HTTP 时,浏览器按混合内容拦掉,浮窗根本不出现。
baseUrl: 'TODO-2-https://你的服务地址',
draggable: true
};
</script>
<script async src="TODO-2-https://你的服务地址/embed.min.js"
id="TODO-1-填入应用的嵌入-token"></script>
<style>
/* 覆盖浮动按钮配色,让它融进站点。这一步别省:
默认配色几乎一定和站点撞色,看上去就像挂了个第三方广告。 */
#dify-chatbot-bubble-button{ background-color: var(--brand) !important; }
#dify-chatbot-bubble-window{ width: 24rem !important; height: 40rem !important; }
/* 窄屏上固定尺寸的浮窗会顶出屏幕,必须单独收一下 */
@media (max-width: 640px){
#dify-chatbot-bubble-window{
width: calc(100vw - 24px) !important; height: 70vh !important; right: 12px !important;
}
}
</style>
</body>
</html>
05骨架模板:拿去改就能用
部署与运维三件套、知识库三件套、编排两份骨架、接入两份,共十份可复制文件
下面这些文件按「一个项目从零到上线」的顺序排。每一份都标了 TODO 占位,凡是环境相关的地址、密钥、口令一律走环境变量,没有一处硬编码。
5.1 部署与运维
| 文件 | 什么时候用 | 它替你守住的纪律 |
|---|---|---|
compose-up.sh | 第一次部署、换机器重建 | 已有配置绝不覆盖;端口用环境变量覆盖而不改文件;轮询到服务真能响应才报成功 |
backup-volumes.sh | 上线前、每次大改前 | 停服保证一致、打包所有卷、打完立刻验归档能不能读、写校验和 |
license-check.sh | 技术选型阶段 | 不停在「它是开源的」这个结论上,逐条查许可证与活跃度 |
backup-volumes.sh。容器随时可以重建,数据卷删了就没了。两地各存一份:服务器本地一份,再拉回另一台机器一份——只存在同一台机器上的备份,在这台机器出事时等于没有。
5.2 知识库:灌、测、体检
这三份构成一个闭环:本地定分段 → 灌进去 → 量化召回。任何一次调参之后都该把后两步再跑一遍,否则改动的效果只是感觉。
| 文件 | 改哪里 | 产出 |
|---|---|---|
kb_bulk_upload.py | RULES 里的分段参数;命令行选 --mode | 一批参数完全一致的文档,外加索引完成的确认 |
retrieval_eval.py | 评测集 jsonl;--compare 一次比三种检索方式 | hit@1 / hit@3 / hit@k / MRR / 平均相似度 |
chunking_preview.py | 纯本地,不连任何服务 | 灌库之前就能看到分段长度分布与被切断的句子 |
评测集的格式刻意做得很轻,一行一条,二三十条就能开始用:
{"query": "试用期最长能约多久", "doc": "劳动合同法.pdf", "must_contain": "试用期不得超过"}
{"query": "被辞退了有没有补偿", "doc": "劳动合同法.pdf"}
{"query": "商标注册代理怎么收费", "doc": "服务定价表.xlsx"}doc 与 must_contain 至少写一个:文档名对上,或者关键串出现在召回的分段里,都算命中。判定刻意放宽——严格的 chunk 级标注需要人工投入,起步阶段不值得。
5.3 编排骨架
| 模板 | 适用 | 特点 |
|---|---|---|
wf_kb_minimal.yml | 单一知识库问答 | 四个节点一条直线,三处 TODO:知识库 id、推理模型、Rerank 模型 |
wf_law_assistant.yml | 多业务分流的助手 | 意图分流 + 四条支路 + 兜底;每一路有独立的检索参数和回复节点 |
5.4 接入与替换
| 文件 | 解决什么 | 关键点 |
|---|---|---|
dify_chat_api.py | 把应用接进自己的程序 | 对话型走一套接口、工作流型走另一套;会话靠返回的会话 id 续上,不要自己拼历史;引用来源在流式结束事件里 |
embed_widget.html | 嵌到自己的网站 | 先发布、配色对齐主色、HTTPS 对齐、窄屏单独收浮窗尺寸 |
law_prompts.py | 提示词脱离画布管理 | 四段式写法;「资料里没有就说没有」这一句必留 |
chunking_preview.py 在本地定,再写进灌库脚本。④ 评测集:换成自己业务的真实问题。⑤ 提示词:角色、输出结构、约束条件三段按自己场景改,「不臆断、信息不足要说明」这两句原样保留。
06易错点汇总
按「部署 / 模型 / 分段与索引 / 检索与 Rerank / 编排 / 数据与安全 / 上线」七类归并,踩过一次就别再踩
⚠️ 一、部署与容器
- 进错目录。要进的是仓库里的部署子目录,不是仓库根目录。根目录下没有编排文件,命令会直接报找不到。
- 忘了把环境变量样例文件改名。编排读的是正式文件名,样例文件放着不动等于没配。这是第一次部署最高频的失败。
- 二次部署把改好的配置覆盖了。重新拉代码或重跑脚本时,把已经改过口令和密钥的配置盖回默认值。脚本必须写成「只在文件不存在时才生成」。
- 改了数据库口令但没清旧数据卷。卷里的数据是用旧口令初始化的,新口令连不上,现象是数据库容器反复重启。要么恢复旧口令,要么连卷一起重建(重建前先备份)。
- 把「容器 Up」当成「服务可用」。首次启动要迁移数据库、初始化向量库,都要时间。判定标准应该是 Web 入口返回正常状态码,不是命令没报错。
- 端口冲突时去改在跑的那一套。改后来者,先来后到能省掉一整轮回归验证。而且要三处一起改:Web 端口、内部依赖映射到宿主机的端口、所有写了地址的配置项。
- 把
down -v当成普通的停服务。它连数据卷一起删,知识库、向量、会话记录一并消失。停服务用stop,删容器用down,-v只在确认要清空时才加。 - 没有备份就开始做实验。部署脚本写完的下一个文件就该是备份脚本,而且备份完要立刻验一遍归档能不能读。
⚠️ 二、模型配置
- 只接了推理模型就去建知识库。没有 Embedding 模型,高质量索引根本选不了,或者建库卡住不动。
- 把推理模型填到 Embedding 的位置。接口返回的不是向量,建库必然失败,而错误信息常常只是一句含糊的调用异常,看着完全不像模型配错了。
- 没配 Rerank 模型却打开了重排开关。检索节点报错,或者 TopK 与 Score 阈值设了不生效。
- 灌完几千份文档才想起来换 Embedding 模型。换模型必须整库重建——坐标系换了,旧向量和新问题的向量不在同一个空间,算出来的距离没有意义。模型选型要在灌库之前定死。
- 不先用命令行验接口就往界面里填。密钥、地址、权限,一次真实请求就能验完;跳过这一步只能对着界面上的模糊报错反复试探。
⚠️ 三、分段与索引
- 重叠长度设成 0。答案正好横跨两块时,两块各拿半句,谁都匹配不上。经验值取块长的 10%~25%。
- 重叠设得过大。相邻块大面积重复,等于把同一段内容灌了好几遍,既占空间又挤占 TopK 名额。
- 分隔符不管文档结构,一律用默认值。条款型文档按条款编号切、表格型按行切,效果差异比换模型大得多。下刀位置对了,一半问题自动消失。
- 把长文档用通用分段切成小块。前提、例外、上下条全丢了,模型拿到半截条文照样敢下结论。长文档、法条、制度手册应该用父子分段。
- 父子分段配大 TopK。召回十条、每条都带一个长父块,上下文瞬间撑满。父子分段的 TopK 要比通用分段更克制。
- 不清洗就直接灌。扫描件转出来的文本里页眉页脚页码往往比正文还多,这些噪声会实实在在地占权重。预处理开关要打开,必要时先清洗再上传。
- 把「嵌入完成」当成知识库可用。空分段、噪声块、重复文档都会显示成功。灌完必须把分段拉出来体检一遍。
- 同一批文档用了不同的分段参数。界面手点最容易出现:前五十个用父子、后五十个忘了改。召回效果差一大截却查不出原因。批量灌库要走脚本,把参数写死。
- 该用经济索引的场景上了高质量,或者反过来。判断只看一句:用户会不会换着说法问同一件事。会 → 必须高质量索引;不会(内部术语表、型号手册)→ 经济索引够用。
⚠️ 四、检索与 Rerank
- 指望 Rerank 救召回。它只排序,不去找新卡片。召回阶段没拿回来的,重排一百遍也不会出现。「开了 Rerank 还是差」的绝大多数情况,问题在召回甚至在分段。
- 没开 Rerank 就去调 TopK 和 Score 阈值。这两个参数在重排这一步生效,不开重排调了也不按预期起作用。现象特别迷惑:改了、保存了、不报错,但结果一点没变。
- Score 阈值调得过高。很容易一条都过不了,表现为「模型说资料里没有」。先用评测脚本看一眼实际的分数分布再定阈值,别拍脑袋。
- TopK 一路调大来「提高召回」。噪声跟着一起进上下文,模型反而被带跑;上下文也更贵更慢。TopK 是收口,不创造召回。
- 用向量检索去查型号、条款号、错误码。这类必须一字不差的东西,向量空间里几乎挨着。该用全文检索,或者混合检索。
- 只用一个问题试了一下就下结论。换分段、换模型、开不开 Rerank 的效果差异,必须用同一批问题跑同一套指标才能比。而且样本只有几十条时,两三个百分点是噪声不是改进。
- 答得不对就去改提示词。顺序错了。先去召回测试里看到底找回了什么——这是排查的第一现场。资料没拿到,提示词写成什么样都没用。
- 调参从下游往上游调。正确顺序是:修分段 → 比索引与检索方式 → 决定要不要 Rerank → 最后才动 TopK 与 Score。跳过第一步直接调最后一步,是最费时间的弯路。
⚠️ 五、编排与提示词
- LLM 节点连了线,但没把检索结果挂到上下文上。提示词里的占位符是空的,模型一本正经地瞎答,而画布上一切正常、也不报错。判断方法:打开引用来源展示,看回答下面有没有列出分段,一条都没有就说明资料根本没送进去。
- 分流没有兜底分支。遇到没预料到的输入,流程直接断在半路,用户看到空白或技术错误。任何分类节点都必须留一个「其它」。
- 分类描述写成业务目录。「法律咨询类」远不如「咨询法律条文、某种行为是否合法」分得准。写得像真实用户会说的话。
- 分类节点温度没调低。这一步要的是稳定,不是创造力,温度应该压到最低。
- 拿几千字的长输入直接去检索。整段编成一个向量,意思被平均成一个点,什么都匹配不准。先用一个节点提炼出检索条件,再去检索。
- 查询改写节点没开记忆。用户说「那第二种情况呢」时,没有上文就改写不出可检索的问题。
- 改了节点 id 却没同步改引用。回复节点和提示词里引用的是「节点 + 变量名」,改了一处另一处就成了孤儿,流程静默跑不通。
- 四个模块共用一个回复节点。改一路的文案会影响另外三路。每一路自带出口,改动范围才可控。
- 提示词只写在画布里。没有 diff、没有版本、没法回滚,出了问题说不清是哪一版改坏的。收进一个文件集中管理。
- 提示词里不写「资料里没有就说没有」。这是抑制编造最有效的一句话,省掉它等于主动给模型留了编造的口子。
- 文档提取器的格式支持范围没实测。某些常见办公格式可能不在支持之列。上线前必须真传一份试,别假设。
⚠️ 六、数据与安全
- 把价格、库存这类结构化事实灌进知识库。改完还得重新灌库,而且旧分段仍然会被命中,调价之后答案还停在旧价、查不出原因。判断标准:改了之后要不要重新灌库?
- 数据库节点用了有写权限的账号。等于把删库的按钮接到一个由自然语言驱动的流程上。必须只读账号,而且授权只给到视图。
- 查询不带 LIMIT。数据涨到几千行时把上下文撑爆,表现是「昨天还好好的,今天开始答非所问」,没有任何报错。
- 连接串没指定字符集。中文变问号或乱码,模型拿到乱码只能瞎答。
- 表和字段没有中文注释、服务名用代号。模型靠这些名字把用户口语对应到具体记录。给模型看的表,要按给人看的标准写。
- 密钥、口令写进源码或配置文件提交上去。一律走环境变量。应用密钥和知识库密钥不是同一把,别混用。
- 自建了检索服务却不鉴权。一个裸奔的检索接口等于把内部资料全量开放。令牌校验是最低要求。
- 跨系统调用地址写
127.0.0.1。容器里的本地回环指的是容器自己。必须填宿主机网卡 IP。这个坑几乎人人踩一次。
⚠️ 七、上线与合规
- 应用没发布就去嵌入。草稿状态嵌进来打不开。
- 主站 HTTPS 而嵌入地址是 HTTP。混合内容被浏览器拦掉,浮窗根本不出现,控制台里那条报错很容易被忽略。
- 浮窗用默认配色。几乎一定和站点撞色,看上去像挂了个第三方广告。取色从页面已有的主色变量里拿。
- 窄屏没单独收浮窗尺寸。固定宽高的窗口会顶出屏幕,手机上直接没法用。
- 看到「开源」两个字就默认可以随便用。许可证标识为非标准协议时,意味着在标准协议之外加了附加条款,必须逐条读。多租户限制和标识不得移除这两条,直接决定能不能拿去做对外产品。
- 把某一时刻的 star 数、提交时间当成固定事实引用。这些数字随时在变。要引用就连同查询时间一起写,并把复核办法留下来。
- 对外的分析类输出不写能力边界。「不替代专业意见」「信息不足要说明局限性」「转人工的出口」,这三件在面向外部用户的产品里都是必需品,不是客套话。
07自测题
点击题目展开答案;能把这 14 题说清楚,这一讲就通了
什么情况下才值得自己部署一整套平台?什么情况下自建是自找麻烦?
值得自建的三种情况:数据不能出门(合同、病历、客户名单,上传到别人的服务器就过不了合规)、要连内网系统(业务数据库和内部接口只在内网可达,这是网络拓扑问题,配置改不了)、要控制模型与升级节奏(模型自己挑、版本不会某天被悄悄换掉)。
反过来,只想快速验证想法、团队没人长期维护、数据本来就是公开资料、用量小到没有成本压力时,自建就是自找麻烦。先判断要不要院墙,再决定要不要盖楼。
看到一个项目写着「开源」,还需要查什么才能下商业化的判断?
查三件事:许可证标识是标准协议还是非标准协议、最后一次提交时间(还有没有人维护)、以及非标准协议时 LICENSE 原文里的附加条款。
以本讲用到的这套平台为例,它的许可证被识别为非标准协议,实际是 Apache-2.0 的修改版加附加条款,两条直接影响商业判断:未经书面授权不得用其源码运营多租户环境(一个 workspace 算一个 tenant,所以改一改对外卖 SaaS 属于被限制的用法)、使用其前端时不得移除或修改控制台与应用中的 LOGO 和版权信息(想做完全白标就撞墙)。其余仍按 Apache-2.0 执行。
这些结论会变,所以要记的是查法,不是结论。
平台里为什么要按三类分别接模型?各自负责什么?配错会有什么现象?
推理模型负责写字(LLM 节点、意图分类、标题生成);Embedding 模型负责把文字编成语义坐标(建库时每个 chunk 过一次,提问时问题也过一次),它不产出人能读的文字;Rerank 模型负责把召回的一摞分段重新排序,不生成内容也不找新分段。
配错的现象:没配 Embedding → 选不了高质量索引或建库卡住;没配 Rerank 却开了重排开关 → 检索节点报错或 TopK/Score 不生效;把推理模型填到 Embedding 的位置 → 返回的不是向量,建库必然失败,而报错常常含糊得不像模型配错了。
容器和虚拟机的本质区别是什么?为什么说「容器可以随便删,数据卷不能」?
虚拟机虚拟的是一整台计算机,每台要跑一套完整操作系统,启动按分钟算、资源开销明显;容器只做进程级隔离,直接跑在宿主机内核上,本质仍是宿主机的一个进程,启动秒级、开销很小。
镜像是只读模板(相当于类,负责存储分发),容器是镜像跑起来的实例(相当于对象,负责运行),容器上多一层可写层。所以容器删了重建毫无压力——前提是数据不在容器里。知识库的原始文档、分段、向量、应用配置全落在数据卷上,卷删了就什么都不剩,down -v 一条命令就能全抹掉。因此部署脚本写完,下一个文件就该是备份脚本。
为什么必须分段?分大分小各自的代价是什么?父子分段解决的是哪个矛盾?
不分段有两个死结:整篇编成一个坐标等于把一本书的意思平均成一个点,问什么都不像;就算匹配上了整本也塞不进上下文窗口。
分了之后矛盾出现:块小匹配更精准,但话说不完整,前提和例外都丢了;块大上下文完整,但一块混了多个主题、坐标被摊平,谁也匹配不准。
通用分段只能折中(靠重叠打补丁)。父子分段把矛盾拆成两半分别解决:子块切到句子级,只负责被检索命中;父块保持段落或章节级,只负责提供上下文。命中子块后,递给模型的是它所属的整个父块。代价是父块很吃上下文窗口,所以 TopK 要设得更克制。
分段重叠长度设成 0 会怎样?设太大又会怎样?经验值取多少?
设成 0:答案正好横跨两块时,两块各拿半句,谁都匹配不上——这是最典型的「明明文档里有,就是搜不到」。
设太大:相邻块大面积重复,等于把同一段内容灌了好几遍,既占空间,又在召回时挤占 TopK 的名额。
经验值取块长的 10%~25%。
经济索引和高质量索引怎么选?为什么说「换 Embedding 模型必须整库重建」?
判断只看一句:用户会不会换着说法问同一件事。内部术语表、型号手册这类问法与原文用词高度一致的场景,经济索引够用(抽关键词建倒排,不过 Embedding,不产生模型开销);只要用户会说「被辞退了怎么办」而文档写的是「用人单位单方解除劳动合同」,就只能上高质量索引——这正是向量检索存在的全部理由。高质量索引还额外解锁了向量、全文、混合三种检索方式。
换模型要重建,是因为坐标系换了:旧向量和新问题的向量根本不在同一个空间里,算出来的距离没有任何意义。所以 Embedding 的选型要在灌库之前定死。
Rerank 到底能做什么、不能做什么?什么时候才值得上?
向量检索为了快,把问题和文档各自编成向量再比距离,两者从没被放在一起看过;Rerank 把问题和每个候选分段成对送进模型逐条判断,更准但更慢更贵,所以只能用在少量候选上。
它只排序,不去找新卡片——召回阶段没拿回来的,重排一百遍也不会出现。所以「开了 Rerank 还是差」的绝大多数情况,问题在召回甚至在分段。
值得上的三个条件:候选里确实混着不少不相关的(先多召回再精筛);只需要少数几条精确结果(比如查条文原文);已经用评测量化验证过它带来了收益。
TopK 和 Score 阈值调了却完全没效果,最先该检查什么?调参的正确顺序是什么?
先检查 Rerank 有没有真的配置并打开——这两个参数在重排这一步生效,不开重排调了也不按预期起作用。现象特别迷惑:改了、保存了、不报错,但结果一点没变。
调参顺序必须从上游往下游:① 先修分段(碎块、巨块、噪声块清掉,收益最大)→ ② 比索引与检索方式(用同一批问题看 hit@k)→ ③ 再决定要不要上 Rerank(看 MRR 有没有真的变好)→ ④ 最后才动 TopK 与 Score(它们只是收口,不创造召回)。跳过 ① 直接调 ④,是最常见也最费时间的弯路。
怎么证明「换了参数之后效果确实变好了」?三个指标分别看什么?
攒一批固定的问题(真实用户问过的最值钱,二三十条就能开始),一行一条写清「问题 + 答案应该出现在哪份文档里或含有哪个关键串」,然后用同一批问题跑同一套指标,一次只改一个变量。
hit@k(前 k 条里有没有命中)最该看,它直接等于答案质量的上限;MRR(第一条命中名次的倒数平均)衡量排序,Rerank 的价值就体现在这个数字上;平均相似度只用来给 Score 阈值找起点,不能拿来判断好坏。
还有一条纪律:样本只有几十条时,两三个百分点的差距是噪声不是改进,别据此换方案。
知识库问答的回答明显在瞎编,但流程没报错。按什么顺序排查?
顺序不能反:
① 看引用来源。把引用展示打开,看回答下方有没有列出分段。一条都没有,说明资料根本没送进去——最常见的原因是 LLM 节点只连了线,没把检索结果挂到上下文上,提示词里的占位符是空的,而画布上一切正常。
② 去召回测试里问同一个问题,看召回了什么。召回的内容本身就不对 → 问题在检索或分段,回到调参顺序的第 ①②步。
③ 召回是对的但答得不对,这时才轮到改提示词。
铁律在这里生效:资料没拿到,提示词写成什么样都没用。
律所助手为什么要在入口就做意图分流,而不是搭一条通用链路?
因为四个模块对检索的要求正好相反:法律咨询要少而准(召回条数压小 + 开 Rerank,用户要的是准确的那一两条原文);案件分析和合同分析要多(需要尽量多的条文做参照);资费查询根本不查知识库,查的是业务数据库。
硬塞进一条链路,这些参数只能取一个折中值,四个模块一起变差。分流的本质是让每一路能独立调参,而且每一路要自带回复节点,否则改一路文案会影响另外三路。另外,任何分流都必须留「其它」兜底分支,否则遇到没预料到的输入流程会直接断在半路。
价格表该放知识库还是数据库?判断标准是什么?放错了会出现什么现象?
放数据库。判断标准只有一句:这条信息改了之后,要不要重新灌一遍库?要重新灌 → 非结构化知识,放知识库(法条、制度、判例);不用、改完立刻生效 → 业务事实,放数据库(价格、库存、订单状态)。
放错的现象很隐蔽:调价之后答案还停在旧价,而且查不出原因——向量库里那份旧价格分段仍然安静地躺着,检索照样能命中。
另外数据库节点必须配三件套:只读账号、授权只给到视图、查询带 LIMIT,再加上连接串显式指定字符集(中文变乱码时模型只能瞎答)。
外部知识库这层接口解决了什么问题?为什么说它把「最容易过期的一段」隔离了?
契约极简,只有一个端点:传「知识库 id + 问题 + TopK + 阈值」,返回「若干条:原文 + 分数 + 出处」。怎么查是服务方自己的自由。
价值有三层:检索侧想换什么都行(换解析能力更强的引擎、换公司已有的搜索集群、换带权限过滤的数据库查询),编排侧一行不用改;权限逻辑可以做进去(同一个库不同部门看到不同内容,这种逻辑塞不进通用平台);它把迭代最快的一段关进了可替换的盒子——检索技术变得快,编排结构相对稳定,用接口把两者分开,任何一侧的更新都不牵动另一侧。
实现时两个细节:空结果返回 200 加空数组而不是错误码(「没查到」是业务结果不是故障,返回错误会让整条编排中断);地址必须填宿主机网卡 IP,容器里的本地回环指的是容器自己。
词术语表
| 术语 | 含义 |
|---|---|
| RAG | 检索增强生成。回答之前先去知识库检索,把原文塞进提示词再让模型作答;分检索、增强、生成三步 |
| chunk(分段) | 文档被拆开后的最小可检索单位。一张卡片装一个完整的意思,拆法决定整条链路的上限 |
| 父子分段 | 子块切到句子级只负责匹配,父块保持段落级只负责提供上下文;命中子块,交给模型的是父块 |
| 分段重叠 | 相邻分段共享的一截内容,用来救「答案横跨两块」的情况;经验值取块长的 10%~25% |
| Embedding | 把一段文字换算成一串数(语义坐标),意思相近的文本坐标相近。换模型必须整库重建 |
| 索引 | 分段的组织方式。经济索引抽关键词建倒排;高质量索引用 Embedding 建向量 |
| 向量检索 | 比较问题向量与分段向量的距离,找语义最接近的分段;擅长换说法提问,不擅长型号编号 |
| 全文检索 | 倒排索引按明文关键词匹配;擅长条款号、型号、错误码这类必须一字不差的内容 |
| 混合检索 | 向量与全文两路都跑,合并候选后按权重配比或交给 Rerank 裁决 |
| Rerank | 把问题与每个候选分段成对送模型逐条打分、重新排序。只排序,不找新分段 |
| TopK | 最终留几条分段交给模型。调大更全但噪声更多、上下文更贵;它是收口,不创造召回 |
| Score 阈值 | 相似度低于这个值就丢掉。调高更干净但容易一条不剩;与 TopK 一样在 Rerank 这一步生效 |
| 召回测试 | 输入一个问题看召回了哪些分段、分数多少;排查答案不准时的第一现场 |
| hit@k / MRR | 前 k 条里有没有命中 / 第一条命中名次的倒数平均。前者看召回上限,后者看排序好坏 |
| 外部知识库 | 把检索整段换成一个自定义 HTTP 服务:传问题与参数,返回若干条原文与分数 |
| chatflow(对话流) | 带会话上下文的编排应用;用户说「那第二种情况呢」时靠它接住上文 |
| 意图识别 / 分类节点 | 在入口把用户分到不同支路,让每一路独立调参;必须留一个兜底分支 |
| 镜像 / 容器 | 镜像是只读模板(负责存储分发),容器是跑起来的实例(负责运行),关系类似类与对象 |
| 数据卷 | 容器外部的持久化存储。文档、分段、向量、配置都在这里,容器可删,卷删了就没了 |
| Docker Compose | 用一份文件描述整套服务、端口、依赖与数据卷,一条命令拉起;它就是这座楼的建筑图纸 |