Dify 实战:私有化部署与知识库应用

把整套应用搭在自己的机器上:文档不出内网、模型自己挑、检索链路每一颗旋钮都归自己管。

30″30 秒看懂私有化知识库

把这件事想成在自家院子里盖一座私人图书馆。公共图书馆当然更大更全,但你手上这批东西——员工手册、内部合同、客户名单、还没公开的法务意见——是不能拿出院子的。于是你自己盖楼、自己上书架、自己雇馆员。

盖楼这件事有现成的图纸:一条命令把楼、水电、消防一次建好,这就是容器化部署。上书架的过程才是真功夫:书整本堆着没法查,要拆成一张张卡片;每张卡片要编一个语义坐标,意思相近的卡片坐标就挨得近;卡片按坐标摆进目录柜。等有人来问问题,先把问题也换算成坐标,找回坐标最近的一摞卡片,让馆员重新排一遍序,最后交给那个读完卡片替你写答案的人

他不会自己去翻书。他手上有几张卡片,就只能答出几张卡片里的东西。

知识库 = 一座自建的私人图书馆 院墙之内是自己的机器:书不出门,问答也不出门 院墙 = 自己的内网 入库(离线做一次) 一摞书 规章、合同、法条原文 还没拆,整本没法查 拆成卡片 chunk 一张卡片装一个意思 拆得好不好,定生死 编语义坐标 Embedding 每张卡片换算成一串数 意思相近,坐标就相近 放进目录柜 索引 按坐标摆好,便于快查 柜子建法决定查法 提问(每次问都走一遍) 读者提问 一句大白话 也换算成坐标 按坐标找卡片 检索 取回坐标最近的一摞 取几张由 TopK 定 馆员重排 Rerank 把真正相关的挪到最前 只排序,不找新卡片 写答案的人 LLM 只读手上这几张卡片 不另外去翻书 答复 附上卡片出处 查的就是这个柜子 ⛔ 铁律:检索不回来的内容,写答案的人永远答不出来 效果的上限落在「卡片怎么拆」和「能不能找回来」这两步,不在提示词上。
图① 30 秒看懂:知识库就是一座自建的私人图书馆
图书馆里的角色对应的技术概念它到底干了什么
院墙私有化部署楼盖在自己的机器上,书和问答全程不出内网
一次建好的楼Docker Compose把应用、数据库、向量库、缓存打成一套,一条命令拉起来
一摞书原始文档整本堆着谁也查不动,必须先拆
拆成的卡片chunk(分段)一张卡片装一个完整的意思,拆法直接决定后面所有环节的上限
卡片背面写着属于哪一章父子分段正面短句用来精确匹配,背面整章用来补齐上下文
给卡片编的语义坐标Embedding把一段文字换算成一串数,意思近的坐标就近
目录柜索引柜子怎么建,决定了只能按关键词查,还是能按语义查
按坐标找卡片检索(向量 / 全文 / 混合)取回坐标最近或关键词最匹配的一摞
馆员重排这一摞Rerank只排序,不去找新卡片;召回时漏掉的,它救不回来
读卡片写答案的人LLM手上有几张卡片就答几张的内容,不另外翻书
⛔ 整讲只有一条铁律 检索不回来的内容,模型永远答不出来。效果的上限落在「卡片怎么拆」和「能不能找回来」这两步上,不在提示词上。答得不好时先去看召回了什么,别一头扎进提示词里改措辞——那是在给一个没拿到资料的人重写命题作文。
这一讲为什么值得学,而不是背 界面几个月就会换一版,按钮位置记住也没用。真正能带走的是四个判断:什么该拆、拆多大用哪种柜子和查法什么时候值得雇馆员哪些东西根本不该进图书馆而该去查数据库。换成任何一个平台,这四个判断都成立。

01概念:自己盖这座楼,到底在换什么

自建的真实动机、容器化解决的老问题、三类模型各管一段,以及「开源」两个字下面藏的条款

1.1 为什么要把平台搬到自己机器上

先把话说清楚:自己部署不是更高级,是更麻烦。托管服务注册就能用,升级、备份、扩容都不用管。愿意承担这份麻烦,只会是因为下面这几条里至少中了一条。

01数据不能出门

合同、病历、客户名单、内部制度——这类东西一旦上传到别人的服务器,合规那一关就过不去。自建之后,文档存在自己的磁盘上,检索在自己的进程里发生,只有最后那次模型调用可能走外网,而这一步也可以换成内网的模型服务。

02要连内网系统

业务数据库、内部接口、文件服务器往往只在内网可达。跑在公有云上的编排根本连不到这些地址,改也改不了。这不是配置问题,是网络拓扑问题。

03要控制模型与版本

自建意味着模型供应商由自己挑、随时可换,也意味着升级节奏由自己定:线上跑得好好的版本,不会在某个早晨被平台方悄悄换掉。

反过来,下面这些情况自建就是自找麻烦:只是想快速验证一个想法、团队里没人能长期维护这套服务、数据本来就是公开资料、用量小到根本没有成本压力。先判断要不要院墙,再决定要不要盖楼,顺序反了会浪费几周时间。

维度用托管服务自己部署
文档存放位置平台的服务器自己的磁盘
能否访问内网不能
模型选择平台给什么用什么自己接,随时换
升级与故障平台负责自己负责,包括备份与恢复
起步成本注册即用要一台机器、一个会运维的人
合规审查要过数据出境与第三方评估只要证明数据没出内网

1.2 容器化:让环境跟着应用一起搬

自己部署的第一个坎,从来不是技术难,而是装不上。同一套代码,在开发同学机器上跑得好好的,换台服务器就各种缺库、版本冲突、路径不对。「在我这儿是好的」这句话,是所有部署事故的开场白。

解决思路一直是同一个:把应用连同它的运行环境一起打包,换台机器直接把这个包跑起来。

最早的办法虚拟机:连整个操作系统一起打包
问题几百 MB 内存起步,启动按分钟算
容器不虚拟整台机器,只隔离进程

容器不模拟一台完整的计算机,它只是给进程套了一层壳:壳里的进程看到的文件系统、网络、进程号都是虚拟的,但它直接跑在宿主机的内核上,本质上仍是宿主机的一个普通进程。这一点决定了它和虚拟机的全部差别。

特性虚拟机容器
隔离级别操作系统级进程级
怎么跑运行在 Hypervisor 上直接跑在宿主机内核里
额外资源开销明显(要跑一整套系统)很小(只是多几个进程)
启动速度分钟级秒级
体积GB 起步按需打包,小得多
单机能跑几个十几个上百个

再厘清两个天天被说混的词:

概念是什么类比
镜像 image只读模板,一层层叠起来的文件集合面向对象里的:负责存储和分发
容器 container镜像跑起来的实例,上面多一层可写层面向对象里的对象:负责运行

一个镜像可以创建很多容器,容器可以随便删了重建——只要数据不在容器里。这就引出了自建最要命的一件事:数据卷。知识库的原始文档、分段、向量、应用配置全都落在数据卷上,容器删了它们还在,卷删了就什么都不剩。所以部署脚本写完的下一个动作,永远是备份脚本。

一整套应用往往不止一个容器:后端、前端、关系数据库、向量库、缓存、反向代理,各是一个。逐个 docker run 手敲参数既记不住也传不下去,于是用一份 compose 文件把这些服务、它们的端口、依赖关系、数据卷一次写清楚,docker compose up -d 一条命令全部拉起。这份文件就是图书馆的建筑图纸,它能进版本库、能评审、能在另一台机器上复现出一模一样的一套。

1.3 三类模型,各管一段,别混着配

平台里的「接模型」不是接一个模型,而是按用途分成几类分别接。这一节是后面所有内容的前提:配错类别,知识库会直接建不起来,而报错信息往往指向别处。

01推理模型

就是平常说的大模型。应用里的 LLM 节点、意图分类、自动生成对话标题、追问建议,用的都是它。它负责写字

02Embedding 模型

把文字换算成语义坐标。知识库建库时,每个 chunk 都要过一次;用户提问时,问题也要过一次。它负责编坐标,不产出任何人能读的文字。

03Rerank 模型

把召回的一摞分段与问题逐条比对、重新排序。它负责排队,不生成内容,也不去找新的分段。

还有语音转文字之类的模型,用到再接。关键是记住这三类的边界:

三个后果,对应三种配错 没配 Embedding 模型:知识库选不了高质量索引,或者建库卡在处理中不动。没配 Rerank 模型却开了重排开关:检索节点直接报错,或者 TopK 与 Score 阈值设了不起作用。把推理模型填到 Embedding 的位置:接口返回的不是向量,建库必然失败,而错误信息常常只是一句含糊的调用异常。

再强调一条会在半年后咬人的规则:换了 Embedding 模型,整个知识库必须重建。坐标系换了,旧向量和新问题的向量根本不在同一个空间里,算出来的距离没有任何意义。所以模型选型要在灌库之前定,别等灌完几千份文档才想起来换一个「效果更好的」。

1.4 「开源」两个字,不足以作为决策依据

自建的前提是这套东西你有权自建。很多人看到「开源」就默认可以随便用,这一步跳过去,问题会在商业化的那一天集中爆发。

Dify 的许可证不是标准的 Apache-2.0。代码托管平台对它的识别结果是「非标准协议」,实际是 Apache-2.0 的修改版加上附加条款。两条附加条款直接影响商业判断:

⚠️ 两条必须先看清的附加条款

  • 多租户限制。未经书面授权,不得用它的源码去运营多租户环境——一个 workspace 就算一个 tenant。也就是说,自己公司内部用没问题,拿它改一改对外卖 SaaS、给每个客户开一个空间,属于被限制的用法
  • LOGO 与版权信息不得移除。用到它前端的场景下,控制台与应用里的标识和版权信息不能删改。想做成完全白标的产品,这一条就是硬墙。(不涉及其前端的用法不受此限。)
  • 除这两条外,其余权利义务仍按 Apache-2.0 执行。
把「查证」变成一个可重复的动作 别记结论,记方法。判断一个开源项目能不能用、还活不活,就看三个字段:许可证标识(是标准协议还是非标准)、最后一次提交时间(还有没有人维护)、以及非标准协议时 LICENSE 原文里的限制性措辞。下面这个脚本把这三件事做成一条命令,随时可以复核。
license-check.sh —— 查许可证与活跃度,别只看「开源」两个字自证脚本
#!/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 数、提交时间、甚至许可证本身都会变,任何一份资料上的数字都只是某一刻的快照。要引用就连查询时间一起写下来,并把复核办法留给下一个人。

✅ 这一节的三个判断 ① 数据要不要留在内网,决定要不要自建;② 容器化让「环境跟着应用走」,但数据在卷上,备份先于一切;③ 三类模型分工明确,Embedding 一旦选定就别中途换;④ 开源协议要逐条读,尤其是被标成非标准协议的那些。

02原理:一次问答,在图书馆里走过的每一步

RAG 的三步、分段策略、索引方式、检索方式与 Rerank、TopK 与 Score、量化评测,以及把检索整段换掉的解耦办法

2.1 RAG 的三步:检索、增强、生成

RAG(Retrieval-Augmented Generation,检索增强生成)要解决的是模型的三个硬伤:训练数据有截止时间所以不知道最近发生了什么、没见过你公司的内部资料、以及在不知道的时候倾向于编一个像样的答案出来。

办法很朴素——回答之前先去查资料,把查到的原文塞进提示词里,让模型照着写。拆成三步:

R检索 Retrieval

拿用户的问题去知识库里找相关的分段。这一步是离线准备好的:文档提前拆分、编码、建索引,查询时只做一次相似度匹配。

A增强 Augmentation

把找回来的分段拼进提示词,作为「参考资料」交给模型。模型的知识范围在这一刻被临时扩展了。

G生成 Generation

模型结合问题和资料写出答案。因为资料是真的,编造的空间被压缩;因为资料可溯源,答案可以附上出处。

注意三步之间的依赖是单向且不可逆的:第一步没找回来的东西,第二步塞不进去,第三步自然写不出来。这就是本讲铁律的由来,也是后面每一个参数的意义所在——它们全都在服务于第一步

一个必须先摆正的期待 很多人把知识库理解成「把公司资料喂给模型,模型就学会了」。没有任何东西被学会。模型参数一个字节都没变,只是每次回答前临时递给它几张卡片。所以资料改了立刻生效(重新灌库即可),也所以模型永远不会「记住」上次说过什么,除非你把历史也一起递给它。

2.2 分段:一本书要拆成什么样的卡片

分段是整条链路里最便宜、也最要命的一步。便宜是因为它只是文本处理,不花模型钱;要命是因为它决定了「可检索的最小单位」,后面所有环节都只能在这个单位上工作。

为什么不能不拆?两个理由:一是整篇文档编成一个坐标,等于把一本书的全部意思平均成一个点,问什么都不像;二是就算匹配上了,整本书塞不进模型的上下文窗口。

拆了之后,一对矛盾立刻出现:

卡片拆小匹配更精准
但是话说不完整,前提和例外都丢了
卡片拆大上下文更完整
但是一张卡混了多个主题,坐标被摊平,谁也匹配不准

两种分段模式,正是对这对矛盾的两种处理方式。

通用分段:一刀切成等长卡片 整篇文档按分隔符与长度上限切开 卡片 1:试用期的一般规定 …期限不得超过… 卡片 2:命中的这一张 「试用期工资不得低于…」 卡片 3:违约金与赔偿 …用人单位应当… 命中 送给模型的内容 只有命中的那一张卡片 前后文都没跟过来 卡片外的限定条件 模型无从得知 适合:结构均匀的文本 问答对、产品参数、短条款、FAQ —— 一条就是一个完整意思 块调大:上下文更全,但一块里混进多个主题,坐标被摊平 块调小:匹配更准,但话说不完整,答案容易缺前提 代价:精确与完整,只能二选一 重叠长度是补丁:让相邻卡片首尾各留一截, 救的是「答案正好横跨两张卡片」的情况, 经验值取块长的 10%~25%,设 0 必踩,设太大等于重复灌库。 父子分段:卡片背面写着它属于哪一章 父块留住上下文,子块负责精确匹配 父块(一段 / 一章) 劳动合同法 第十九、二十条 子块:试用期期限的上限 子块:试用期工资的下限 ←命中 子块:违反约定的后果 命中子块 送给模型的内容 不是那一小句, 而是它所在的 整个父块 前提、例外、 上下条一并到齐 适合:长文档、法条、制度、手册 用短句去匹配,命中率高;用长段去作答,上下文全 同一个矛盾,通用分段只能折中,父子分段把两头分开解决 父块按段落切最常用;整篇当父块只适合短文档 代价:父块会把上下文窗口吃掉 召回 10 条、每条都带一个长父块,上下文瞬间撑满; 所以父子分段的 TopK 要比通用分段设得更克制。 父块越大,答得越全,也越贵、越慢。
图② 通用分段与父子分段:同一个矛盾的两种解法

通用分段:折中

按规则一刀切成大小相近的块,靠三个参数控制:

参数作用怎么定
分段标识符优先在哪里下刀默认按换行;结构化文档可以换成空行、标题标记、条款编号。下刀位置对了,一半问题自动消失
分段最大长度一块最多多长超过就强制切开。短条款、问答对可以小;叙述性长文要大一些
分段重叠长度相邻块共享多少内容经验值取块长的 10%~25%。设成 0 必踩坑:答案正好横跨两块时,两块各拿半句,谁都匹配不上

还有一组文本预处理开关——合并连续空格换行制表符、删除 URL 与邮箱。别小看它们:扫描件转出来的文本里,页眉页脚和空白往往比正文还多,不清掉就会在向量里占权重。

父子分段:把矛盾拆成两半分别解决

既然「用来匹配」和「用来作答」要求相反,那就让两件事用不同的单位

  • 子块(child-chunk)切到句子级别,短、集中,只负责被检索命中
  • 父块(parent-chunk)保持段落或章节级别,只负责提供上下文

检索时用子块去比坐标,一旦命中,递给模型的不是那一小句,而是它所属的整个父块。用比喻说:卡片正面写着一句精确的话,背面写着「本卡出自第几章第几节」,馆员找到卡片后,连同那一章一起抱给写答案的人。

对比项通用分段父子分段
匹配单位整个块子块(短)
交给模型的单位整个块父块(长)
擅长结构均匀的短文本:问答对、产品参数、FAQ长文档:法条、制度、手册、判例
上下文完整性靠重叠打补丁天然完整
上下文窗口消耗可预测明显更大,TopK 要设得更克制
建库耗时较短较长(子块数量更多,Embedding 次数更多)

参数到底该设多少,翻界面一块块看是看不出来的。更靠谱的做法是在灌库之前先在本地把分段跑一遍,看长度分布、碎块、巨块、被切断的句子:

chunking_preview.py —— 本地预演分段,灌库前先看清楚卡片长什么样纯标准库
# -*- 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())
分段自检的三条硬指标 碎块(几十个 token 以下):语义不完整,检索时容易被噪声挤掉,多半是分隔符设得太细。巨块(远超上限):表格或代码块没被切开,一块里混了多个主题。尾部被切断:句子断在中间,说明重叠给少了。三项都接近零,再去调检索参数才有意义。

2.3 索引:柜子怎么建,决定了能怎么查

卡片拆好了,要摆进柜子。柜子有两种建法,它决定了后面能用哪几种查法,选错了要整库重建。

索引方式怎么建能用的检索方式代价
经济每个块抽取若干关键词,建倒排索引只有关键词检索一条路不过 Embedding 模型,不产生模型调用开销;但同义词、换个说法就搭不上
高质量用 Embedding 模型把每块编成向量向量、全文、混合三种随便挑建库要过一遍模型,耗时与开销都更高;换模型必须整库重建

判断很简单:用户会不会换着说法问同一件事。内部术语表、型号手册这类「问法和原文用词高度一致」的场景,经济索引够用;只要用户会说「被辞退了怎么办」而文档里写的是「用人单位单方解除劳动合同」,就只能上高质量索引——这正是向量检索存在的全部理由。

2.4 检索方式与 Rerank:找回来,再排一遍

一次检索,四个可调的旋钮 索引方式决定能用哪些检索方式;检索方式决定召回什么;Rerank 决定排序;TopK 与 Score 决定最后剩几条 用户问题 一句自然语言 先做查询改写更好 向量检索(语义) 把问题换算成坐标,找坐标最近的卡片 「辞退」能搭上「解除劳动合同」 代价:必须用高质量索引,要过 Embedding 全文检索(关键词) 倒排索引,按明文词命中,像搜索引擎 型号、条款号、人名、专有名词最稳 代价:换个说法就搭不上 混合检索 两路都跑,合并候选 按权重配比,或 直接交给 Rerank 裁决 Rerank 重排 馆员把取回的一摞卡片 逐张与问题比对,重新排序 比向量更准,也更慢更贵 不找新卡片:没召回的救不回来 上下文 剩下的几条 填进提示词的 那个洞里 再交给模型作答 TopK 与 Score 阈值在这一步生效 TopK:最后留几条。调大召回更全,上下文更贵,噪声更多 Score:低于这个分就丢掉。调高更干净,也更容易一条不剩 上游前提:索引方式先定了能用哪几种检索 经济索引 每块抽若干关键词建倒排 不过 Embedding,不产生模型开销 只有关键词一条路,语义搭不上 高质量索引 用 Embedding 模型把每块变成向量 向量 / 全文 / 混合三种检索随便挑 换 Embedding 模型必须整库重建 调参顺序:从上游往下游调,不要反过来 1 先看分段:碎块、巨块、噪声块清理掉 —— 这一步收益最大 2 再看索引与检索方式:靠一批固定问题横向比 hit@k 3 再决定要不要上 Rerank:看排序指标有没有真的变好 4 最后才动 TopK 与 Score 阈值:它们只是收口,不创造召回 跳过 1 直接调 4,是最常见也最费时间的弯路
图③ 检索链路:索引、检索方式、Rerank、TopK 与 Score 各管一段

三种查法

检索方式原理什么时候它更强
向量检索(语义)把问题编成向量,找向量距离最近的分段用户用自己的话提问、同义词多、口语化。缺点是专有名词容易糊——型号「A-200」和「A-300」在向量空间里几乎挨着
全文检索(关键词)倒排索引,按明文词匹配,和搜索引擎同一套条款号、型号、人名、错误码这类必须一字不差的东西。缺点是换个说法就零命中
混合检索两路都跑,合并候选,再按权重配比或交给 Rerank 裁决不确定用户会怎么问时的默认选择。代价是两套都要跑,慢一些

Rerank:馆员重新排一摞卡片

向量检索为了快,用的是「把问题和文档各自编成向量再比距离」的办法——问题和文档从来没有被放在一起看过。Rerank 模型换了一种做法:把问题和每一个候选分段成对送进模型,逐条判断「这一条到底回不回答得了这个问题」。更准,但也更慢更贵,所以它只能用在少量候选上,不能拿来扫全库。

⛔ Rerank 的能力边界 它只排序,不去找新卡片。召回阶段没拿回来的分段,重排一百遍也不会出现。所以「开了 Rerank 效果还是差」的绝大多数情况,问题根本不在排序,而在召回——甚至在分段。

那什么时候值得上 Rerank?看三个条件:

  • 候选里确实混着不少不相关的。用混合检索或大 TopK 捞回一批,靠它精筛,这是最标准的用法。
  • 只需要少数几条精确结果。比如查法条原文,返回十条反而稀释答案,这时候先多召回再重排取前几条,比直接小 TopK 更准。
  • 已经量化验证过它带来了收益。它增加一次模型调用、增加延迟;没测出提升就别上。

2.5 TopK 与 Score 阈值:两个收口的旋钮

参数含义调大 / 调高的后果
TopK最终留几条分段交给模型调大:召回更全,但上下文更长、噪声更多,模型容易被无关内容带跑;调小:更干净,但漏答风险上升
Score 阈值相似度低于这个值就丢掉调高:结果更干净,但很容易一条不剩,表现为「模型说资料里没有」;调低:什么都捞回来,等于没设
一个会白白浪费半小时的细节 TopK 与 Score 阈值是在 Rerank 这一步生效的。没有配置并开启 Rerank 模型时,调这两个值不会按预期起作用。现象特别迷惑:参数改了、保存了、也没报错,但召回结果一点没变。遇到这种情况先回头检查 Rerank 有没有真的打开。

还有一层容易忽略的动态调整:系统会参考所选模型的上下文窗口来调节实际送进去的片段数量。也就是说,换一个上下文窗口更小的模型,同样的 TopK 也可能塞不进那么多。参数不是孤立的。

2.6 召回测试:从「感觉还行」到「有数字」

知识库建好之后,平台一般都提供一个召回测试功能:输入一个问题,看召回了哪些分段、每条的分数是多少。这是排查问题的第一现场——答得不对时,先来这里看看到底找回了什么,而不是去改提示词。

但它一次只能问一个问题。换分段方式、换 Embedding 模型、开不开 Rerank、TopK 调大调小,到底哪个更好?靠一条条手点是比不出来的,必须用同一批问题跑同一套指标

指标算法怎么用
hit@k前 k 条召回里有没有命中至少一条最该看的一个。它直接等于答案质量的上限
MRR第一条命中的名次的倒数,全部取平均衡量排序好不好。Rerank 的价值就体现在这个数字上
平均相似度召回分段的分数均值只用来给 Score 阈值找一个合理起点,不能拿来判断好坏

评测集怎么攒?一行一条:问题 + 这个问题的答案应该出现在哪份文档里。真实用户问过的问题最值钱,其次是业务同事凭经验写的。二三十条就足够开始比较,别等攒到完美再开始。

retrieval_eval.py —— 用一批问题给知识库打分,横向比检索方式评测脚本
# -*- 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())
调参顺序:从上游往下游,别反着来 ① 先修分段(碎块巨块噪声块清掉,收益最大)→ ② 再比索引与检索方式 → ③ 再决定要不要上 Rerank → ④ 最后才动 TopK 与 Score。跳过 ① 直接调 ④,是最常见也最费时间的弯路。另外:样本量只有几十条时,两三个百分点的差距是噪声不是改进,别据此换方案。

灌完库还有一件必做的事:把分段拉出来体检一遍。界面显示「嵌入完成」不等于知识库可用——空分段、页眉页脚噪声、重复文档都会显示成功,却已经把召回质量废掉一半。

要查的就是五类:空分段(文档解析失败,原文根本没进来)、碎块(不到三十个 token,语义不完整)、巨块(表格没被切开,向量被摊平)、噪声块(整块只有页眉页脚页码)、重复块(同一份文档传了两次,白白挤占 TopK 名额)。用知识库密钥调分段列表接口把内容拉下来,按这五类各数一遍、再抽几块原文眼测,比在界面上一页页翻快得多。这一步是只读的,可以直接对生产库跑。

2.7 外部知识库:把最容易过期的一段关进盒子里

平台自带的知识库有它的短板,最典型的是复杂文档解析——扫描版 PDF、跨页表格、多栏排版,解析出来的文本可能已经乱成一团。文本都错了,后面拆得再好也是白搭。

这时候有两条路:换一个解析能力更强的引擎重建整套系统,或者——只把检索这一段换掉

后者的做法是接「外部知识库」:编排里的检索节点不再查本地的库,而是去调一个符合约定的 HTTP 接口。约定极简,只有一个端点:

请求知识库 id + 问题 + TopK + 阈值
你的服务怎么查是你的自由
响应若干条:原文 + 分数 + 出处

这层薄薄的契约带来的好处,比它看起来大得多:

  • 检索侧想换什么都行:换成解析能力更强的 RAG 引擎、换成公司已有的搜索集群、换成带权限过滤的数据库查询——编排那一侧一行都不用改。
  • 权限可以做进去。同一个知识库,不同部门的人该看到不同的内容,这种逻辑塞不进通用平台,但在自己的服务里写起来很自然。
  • 它把最容易过期的一段隔离了。检索技术迭代最快,编排结构相对稳定。用接口把两者分开,任何一侧的更新都不会牵动另一侧。

与其记住配置界面上要填哪几个框,不如自己实现一遍这个接口。它的全部要求就三条:一个 POST /retrieval 端点,接收知识库 id、问题和检索参数;返回一个数组,每条带原文、分数、出处标题;没查到也返回 200 加空数组,不要报错码——没有匹配结果是正常的业务结果,不是故障。用标准库的 http.server 几十行就能跑起来,实现过一次,就再也不会被任何平台的界面改版困住。

跨系统互调,地址永远不能写 127.0.0.1 编排平台跑在容器里,容器里的 127.0.0.1 指的是容器自己,不是宿主机。所以配置外部服务地址时必须填宿主机网卡上的 IP。这个坑几乎人人踩一次,现象是「我在浏览器里明明能打开,平台就是连不上」。
✅ 这一节的骨架,换平台仍然成立 拆卡片(分段)→ 编坐标(Embedding)→ 进柜子(索引)→ 按坐标找(检索)→ 馆员排队(Rerank)→ 收口(TopK / Score)→ 交给写答案的人(LLM)。每一环都有明确的职责边界和明确的失败现象。记住这条链,任何一个平台的界面你都能自己对上号。

03最小代码:从空机器到能回答内部资料

四步走完最短的一条路:把楼盖起来 → 接三类模型 → 灌一批文档 → 四个节点连成一条线

这一节只做一件事:把最短的路走通一遍。走通之后再回头看第 02 节的参数,每一个都会突然变得具体。四步的顺序不能换,每一步都有一个「怎么算成功」的判定。

① 盖楼容器编排拉起整套服务
② 接模型推理 + Embedding(+ Rerank)
③ 灌文档选分段与索引,等索引完成
④ 连四个节点开始 → 知识检索 → LLM → 回复
验收问一个只有内部资料才答得出的问题
接出去HTTP API 或嵌入网页

第一步:把楼盖起来

前置条件很低:一台能跑容器的机器,两核、四 GB 内存起步。步骤只有三个动作——拿到仓库里的部署目录、把环境变量样例改名成正式文件、一条命令拉起

下面的脚本把这三步连同验活一起做了。它刻意遵守两条纪律:已存在的配置绝不覆盖(二次部署最容易把改好的口令盖回默认值),端口通过环境变量覆盖而不是改文件(同一台机器上要部署第二套系统时不用回头改)。

compose-up.sh —— 拉起服务并自证它真的起来了部署脚本
#!/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
「容器 Up」不等于「服务可用」 第一次启动时,后端要迁移数据库、初始化向量库,这些都要时间。所以脚本不是启动完就报成功,而是轮询 Web 入口直到拿到正常的 HTTP 状态码。这是「进度要带证据」的最小实践:报成功的依据是状态码,不是「命令没报错」。

拉镜像慢或直接失败,是这一步最常见的阻塞。处理办法是给容器运行时配国内镜像源,再重试几次——镜像文件比一般的依赖包大得多,网络抖动的影响被放大了。起不来时按固定顺序查,别瞎重启:

现象先查什么多半是什么
拉镜像卡住或超时容器运行时的镜像源配置网络到不了默认仓库,换源后重试
某个容器一直 Restartingdocker compose logs --tail=120 api配置错、依赖服务没起、口令不对
数据库容器反复认证失败是否改过口令但没清旧数据卷卷里还是旧口令初始化的数据
端口起不来,日志写着地址已占用宿主机监听 + 容器端口映射端口冲突,见下面的脚本

端口冲突值得单独说:一台机器上同时跑两套这类系统时,它们默认都占 80,还各自带一套缓存和数据库,双双启动必然打架。排查顺序是固定的三步,别跳步:

  1. 先看宿主机上这个端口有没有人在听:ss -ltnp | grep ':80 '(没有 ss 就用 netstat -ltnp)。
  2. 再看是不是某个容器的端口映射占的:docker ps --format '{{.Names}}\t{{.Ports}}' | grep ':80->'
  3. 最后改后来者:在 .env 里把 EXPOSE_NGINX_PORT 改成没人用的端口再重启,不要去动已经在跑的那一套——它后面可能还挂着别的服务。
盖完楼的第一件事是备份,不是建知识库 容器可以随便删了重建,数据卷删了就什么都不剩:应用配置、工作流版本、会话记录、上传的原始文档、以及花了算力才生成的向量。docker compose down -v 一条命令就能全部抹掉。所以部署脚本写完,下一个文件就该是备份脚本,而且备份完要立刻验一遍归档能不能读。
backup-volumes.sh —— 备份数据卷并校验归档完整性运维脚本
#!/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 节那两个选择题:分段模式(通用还是父子)和索引方式(经济还是高质量)。长文档、法条、制度手册,选父子 + 高质量;短问答对、参数表,通用 + 高质量也够用。

界面上传适合试水,几十上百份文档就得走接口——不只是为了省事,更是为了保证同一批文档用同一套分段参数。手点最容易出现的事故是前五十个文件用了父子分段,后五十个忘了改,召回效果差一大截却查不出原因。

kb_bulk_upload.py —— 批量灌文档,分段规则写死在代码里保证一致批处理
# -*- 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())
等索引完成,再做召回测试 「上传成功」只是文件收下了。文本要先切块,再逐块过 Embedding 模型,文档多的时候这一步要跑很久。灌完立刻测召回只会得到空结果,然后误判成分段参数配错了。所以脚本最后会轮询索引状态,全部完成才收工。

第四步:四个节点连成一条线

在工作室里新建一个对话流(chatflow)应用。它和普通工作流的区别是带会话上下文——用户说「那第二种情况呢」时,这一点是刚需。

最小结构只有四个节点:

1开始

入口。用户这一轮说的话由系统变量承载,不需要自己再声明一个输入字段;这里加的是额外表单项,比如让用户选部门、选文档范围。

2知识检索

本讲的主角。选知识库、设检索方式、TopK、要不要 Rerank。它的输出是一组分段。

3LLM

把检索结果挂到上下文上,提示词里用占位符引用它。只连线不挂上下文,占位符就是空的

4直接回复

把模型输出吐给用户。引用的是「节点 + 变量名」,改了节点名这里要同步改。

界面上这四个节点是拖出来的,但它们在底层是一份结构化的定义文件,可以导出、可以进版本库、可以在另一套环境里导入还原。读这份文件比记按钮位置有用得多——界面会改版,字段名相对稳定:

wf_kb_minimal.yml —— 最小知识库对话流,导入前改三处 TODO可导入
# =============================================================================
# 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}
最小版里最容易犯的一个错 LLM 节点的上下文里没有挂检索结果,但提示词里写了 {{#context#}}。现象是模型一本正经地瞎答,而画布上一切正常、也不报错。判断方法:把引用来源展示打开,看回答下方有没有列出引用的分段——一条都没有就说明资料根本没送进去。

验收与接出去

验收标准要选得刁钻一点:问一个只有内部资料才答得出的问题。问「劳动合同是什么」证明不了任何事,模型本来就知道;要问「我们公司试用期转正的审批要几个人签字」这种外面查不到的。

能答对之后,应用就是一个可以接进业务系统的 HTTP 接口了。对话型和工作流型的接口路径与参数不同,别写混:

dify_chat_api.py —— 用 HTTP API 调用已发布的应用,含流式与引用溯源接入代码
# -*- 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()
✅ 走到这里你已经有了什么 一套跑在自己机器上的服务、一个灌好文档的知识库、一个能答内部问题的对话应用、一个可以接进任何系统的 HTTP 接口,以及一份能把它们全部还原出来的备份。剩下的都是在这个骨架上加模块。

04完整案例:律所的法律问答助手

四个模块、两种数据来源、一个意图分流,最后挂在律所官网的右下角

4.1 需求长什么样,以及为什么只能自建

一家律所想在官网首页加一个在线助手,要能干四件事:

1法律咨询

查法律条文原文。业务集中在民事诉讼、企业法律顾问、知识产权,条文范围相对明确。

2案件分析

用户用一段话描述纠纷经过,或者直接传一份材料,助手结合条文梳理争议焦点、分析责任。

3合同分析

上传合同文件,找出风险条款并给修改建议。

4资费查询

问「打一场劳动仲裁大概多少钱」,给出报价区间,最后转人工。

选型的过程比结论更值得看。三条硬约束直接淘汰了另外两种方案

约束托管式平台纯代码框架自研编排平台私有化
要读内网的业务数据库(资费表在本地)连不到可以可以
要嵌到律所自己的官网上受限可以,但前端要自己写给一段脚本就能挂
项目复杂度与预算性价比低:四个模块都要自己写编排、会话、文件解析复杂度刚好落在编排能覆盖的范围内

注意最后一列的判断依据不是「这个平台更好」,而是这个项目的复杂度刚好落在编排能覆盖的范围内。如果需求再复杂几个量级——需要复杂的状态机、需要精细的并发控制、需要把每一步都写单元测试——结论会反过来。

一条对话流,四个模块,两种数据来源 分流放在最前面:四路的检索参数、提示词、数据来源都不一样,合成一条链路只会互相打架 律所官网 一段嵌入脚本 右下角浮窗 按钮配色对齐主色 开始 收用户这一轮的话 以及上传的文件 开场白说清能力边界 意图识别 分类描述写得像用户原话 温度调到最低求稳定 必须留「其它」兜底分支 四条互不干扰的支路 ① 法律咨询 查询改写 → 条文检索 → 结论 + 原文 召回条数压小并开 Rerank:要的是准确的那一两条 改写节点开记忆,才接得住「那第二种情况呢」 ② 案件分析 文档提取 → 条件提取 → 检索 → 分析报告 召回条数放大:案情要尽量多的条文做参照 先提炼检索条件,别拿几千字案情直接去检索 ③ 合同分析 文档提取 → 检索 → 合同分类 → 分类审核 劳动 / 技术服务 / 通用三支,system 提示词各不同 检索之后再分一次流:审查要点本来就不一样 ④ 资费查询 数据库查询 → 报价区间 → 转人工客服 不查知识库:价格是结构化事实,天天改 只读账号 + 只查视图 + 带 LIMIT 知识库:非结构化的条文 法条原文、判例、内部指引 解析能力不够时接外部知识库: 检索整段换掉,编排一行不用改 前三路都查它 业务数据库:结构化的事实 价格、库存、订单、工单状态 改一个字就生效,不用重新灌库 第四路查它 判断数据该放哪一边,只问一句话:这条信息改了之后,要不要重新灌一遍库? 要 → 它是非结构化知识,放知识库;不要、改完立刻生效 → 它是业务事实,放数据库。 把价格表灌进知识库,是这类项目最常见的一次性设计失误:调价之后答案还停在旧价,而且查不出原因。
图④ 法律问答助手:四个模块与两种数据来源

4.2 分流:把四件事从入口就分开

整个助手只用了一条对话流,但进门第一件事就是意图识别,把用户分到四条互不干扰的支路上。

为什么不做成一条通用链路?因为这四件事对检索的要求正好相反:

模块数据来源召回条数为什么
法律咨询知识库少(开 Rerank)用户要的是准确的那一两条原文,返回十条反而稀释答案
案件分析知识库案情分析需要尽量多的条文做参照,宁滥勿缺
合同分析知识库同上,而且检索之后还要再按合同类型分一次流
资费查询业务数据库不适用价格是结构化事实,压根不该进知识库

硬塞进一条链路,这些参数只能取一个折中值,四个模块一起变差。分流的本质是让每一路能独立调参

分流必须留兜底分支 分类节点的类别里一定要有一个「其它」。没有它,遇到没预料到的问题,流程会直接断在半路——用户看到的是一片空白或者一个技术错误。兜底分支不需要多聪明,把能力边界讲清楚就够了。

分类描述怎么写,直接决定分得准不准。经验是:写得像真实用户会说的话,而不是像业务分类目录。「咨询法律条文、法律规定、某种行为是否合法」比「法律咨询类」有用得多。另外分类节点的温度要调到最低——这一步要的是稳定,不是创造力。

4.3 四个模块,各自的门道

模块一:法律咨询——为什么要先改写一次问题

这一路有两个 LLM 节点,中间夹一个检索节点:查询改写 → 条文检索 → 结论 + 原文

查询改写这一步经常被当成多余而省掉,但它解决的是一个实实在在的问题:用户原话里常常包含大量与检索无关的情绪和铺垫。「我在公司干了三个月,老板突然说我不合适要辞退我,还不给补偿,这合法吗」——真正该拿去检索的是「试用期解除劳动合同的条件与经济补偿」,而原话里的「老板突然说」「不合适」这些词,在向量空间里只会把方向带偏。

改写节点要开记忆。用户接着问「那第二种情况呢」时,没有上文根本改写不出可检索的问题。

检索节点则反过来:召回条数压小,开 Rerank。查条文原文的场景下,多返回的每一条都是噪声。

最后的作答节点,提示词里有一句是整段的关键——先给结论,再给法律原文依据。用户要的是判断,但律所的专业性体现在依据上,两个都不能少。再加一句「知识库中没有对应条文时明确告知未检索到」,把编造的口子堵上。

模块二:案件分析——不要拿几千字直接去检索

流程:文档提取 → 检索条件提取 → 条文检索 → 分析报告

用户可能贴一段文字,也可能传一份判决书,所以先过一个文档提取器把文件内容读成文本。要注意提取器对格式的支持范围有限,某些常见办公格式可能不在支持之列,遇到就得在上传环节限定格式,或者在流程里加一步格式转换——这一点上线前必须实测,别假设。

然后是这一路最关键的设计:案情有几千字,不能直接拿去检索。几千字编成一个向量,等于把整个案子的意思平均成一个点,什么都匹配不准。所以中间加一个 LLM 节点,先让模型从案情里提炼出「该查哪些条文」的检索条件,再拿这个条件去检索。

分析节点的提示词写得很长,但真正值钱的只有几句约束:

⚠️ 面向外部用户的分析类输出,三句话不能少

  • 所有分析需严格基于用户提供的材料和法律条文,不臆断或虚构。这是抑制编造最有效的一句。
  • 不替代律师提供正式法律意见,不预测判决结果。把能力边界写进提示词,而不是只写在免责声明里。
  • 如信息不足或存在矛盾,需明确说明局限性。允许模型说「资料不够」,它才不会硬编。

输出结构也固定下来:案件基本信息 → 争议焦点 → 事实与证据分析 → 法律适用分析 → 综合结论与建议。固定结构不只是为了好看,它让模型的输出可预期、可校验,也让用户每次拿到的东西格式一致。

模块三:合同分析——检索之后还要再分一次流

流程:文档提取 → 条文检索 → 合同分类 → 分类审核

为什么检索完还要再分一次?因为不同类型的合同,审查要点完全不同。劳动合同盯的是试用期、工资构成、解除条件、竞业限制;技术服务合同盯的是工作范围、知识产权归属、付款节点与验收标准。用一套提示词覆盖两者,只会两边都审得很浅。

所以按律所擅长的类别分出劳动合同、技术服务合同两支单独处理,剩下的走通用兜底。三支的用户提示词完全一样(合同原文 + 用户输入 + 检索到的条文),只有系统提示词不同——分流分的就是这一段。

把三份提示词从画布里抄出来集中管理,是这个项目里回报最高的一个工程动作:

law_prompts.py —— 四个模块的提示词集中版本化管理可复用
# -*- 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())
为什么提示词要从画布里抄出来 写在节点里的提示词,改一个字要开浏览器、找节点、点开、改、发布。没有 diff、没有评审、没有版本,出了问题说不清是哪一版改坏的。收进一个文件后,改动能进版本库、能回滚,再由人工或脚本同步回节点。这不是洁癖,是这类项目唯一能长期维护下去的方式。

4.4 资费查询:结构化的事实不要往知识库里塞

第四个模块的数据来源和前三个完全不同——它查数据库,不查知识库

判断标准只有一句话:这条信息改了之后,要不要重新灌一遍库?

  • 要重新灌 → 它是非结构化知识,放知识库。法条、制度、判例都是。
  • 不用,改完立刻生效 → 它是业务事实,放数据库。价格、库存、订单状态、工单进度都是。

把价格表灌进知识库,是这类项目最常见的一次性设计失误:调价之后答案还停在旧价,而且查不出原因——因为向量库里那份旧价格分段仍然安安静静地躺在那儿,检索照样能命中。

业务库这边要做的事很简单:建表、写进真实数据、在表和字段上写清楚中文注释。

law_assistant_schema.sql —— 资费表结构与初始数据可下载
-- =============================================================================
-- 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

查回来的内容会原样占用上下文窗口。数据涨到几千行时,没有 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 —— 法律助手对话流骨架,TODO 处按自己环境填结构示意
# =============================================================================
# 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。混合内容会被浏览器拦掉,现象是浮窗根本不出现,而控制台里那条报错很容易被忽略。
embed_widget.html —— 官网页面 + 嵌入脚本 + 配色与窄屏适配可下载
<!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 知识库:灌、测、体检

这三份构成一个闭环:本地定分段 → 灌进去 → 量化召回。任何一次调参之后都该把后两步再跑一遍,否则改动的效果只是感觉。

chunking_preview.py本地预演分段
kb_bulk_upload.py批量灌,参数一致
retrieval_eval.pyhit@k / MRR 横向比
文件改哪里产出
kb_bulk_upload.pyRULES 里的分段参数;命令行选 --mode一批参数完全一致的文档,外加索引完成的确认
retrieval_eval.py评测集 jsonl;--compare 一次比三种检索方式hit@1 / hit@3 / hit@k / MRR / 平均相似度
chunking_preview.py纯本地,不连任何服务灌库之前就能看到分段长度分布与被切断的句子

评测集的格式刻意做得很轻,一行一条,二三十条就能开始用:

evalset.jsonl —— 评测集格式
{"query": "试用期最长能约多久", "doc": "劳动合同法.pdf", "must_contain": "试用期不得超过"}
{"query": "被辞退了有没有补偿", "doc": "劳动合同法.pdf"}
{"query": "商标注册代理怎么收费", "doc": "服务定价表.xlsx"}

docmust_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提示词脱离画布管理四段式写法;「资料里没有就说没有」这一句必留
✅ 复制之后先改这五处 ① 环境变量:服务地址、应用密钥、知识库密钥、数据库口令,全部从环境注入。② 知识库 id 与模型名:两份 yml 里的 TODO。③ 分段参数:先用 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 把问题和每个候选分段成对送进模型逐条判断,更准但更慢更贵,所以只能用在少量候选上。
它只排序,不去找新卡片——召回阶段没拿回来的,重排一百遍也不会出现。所以「开了 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用一份文件描述整套服务、端口、依赖与数据卷,一条命令拉起;它就是这座楼的建筑图纸
✅ 一句话收束本讲 私有化知识库没有魔法:它只是把文档拆成卡片、编上坐标、摆进柜子,问答时按坐标取回一摞、重新排序、交给写答案的人。所有参数都在服务于「能不能把对的那张卡片取回来」这一件事——因为取不回来的内容,模型永远答不出来