Dify 中级实验(10):知识库深度调优——如何科学评估检索质量?
1. 业务场景
先讲一个我们实际遇到的场景。
一家做企业软件的公司,把 500 页产品技术文档做成知识库,上线 RAG 问答。用户问「怎么重置密码」,回答牛头不对马嘴;问「错误码 E1001 是什么」,答非所问。技术支持团队每天收到「AI 答得不对」的投诉,但问题出在哪——分段?检索模式?TopK?——没人说得清,因为没有任何数据。
我们第一次接这类需求时,第一反应也是「把 TopK、分段策略、Rerank 挨个调一遍,看哪个效果好」。真正动手才发现——改了一堆参数,效果原地踏步,因为「好不好」根本没有数字:不知道现在多差,就不知道改没改对。后来才想明白:调优的前提是评估——先有尺子,才有调参。
这不是个例。任何 RAG 系统的调优都是这个模式:改一个参数,好不好,得用数字说话——没有评估体系的调优就是瞎调。产品文档知识库、客服 FAQ、RAG 问答系统,上线前调优与持续监控都需要一把「尺子」。
2. 场景痛点
这个流程的痛点,在知识库建设团队身上体现得最直接:
- 召回差但说不清差在哪:用户反馈答非所问,定位不到是「哪类查询召不回」——操作类?错误码类?概念类?全靠猜——猜着调参,越调越心虚,没有数字连「改对了」都无法定义。
- 调参靠感觉:TopK、分段策略、Rerank 改来改去,不知道哪个参数真正生效——改了一堆,效果原地踏步。
- 回归没有基线:改了分段策略,旧的能答的问题会不会变差,无从对比——调优像打地鼠。
- 数据与检索脱节:种子文档不含用户真实问法,检索「看似正常实则全空」,关键词命中率恒 0,还误判「检索坏了」。
本质上,调优的前提是评估——先能量化「现在多差」,才能知道「改没改对」。参数可以慢慢调,尺子必须一开始就有。
3. 方案:为什么是评估工作流
本实验不调某个单一参数,而是先搭一把「尺子」——一个检索质量评估工作流,用测试集自动打分:
- 测试集驱动:10 组典型查询硬编码在代码节点(query + expected_keywords 期望关键词 + expected_doc 期望命中文档),覆盖操作类(重置密码)、错误码类(E1001)、概念类(什么是 RAG);
- 量化评分:每组用例算关键词命中率 + Top1 文档匹配,综合评分 = 0.5 × 关键词命中率 + 0.5 × Top1 匹配——数字说话;
- 回归闭环:同一测试集反复跑,调整分段策略/TopK/Rerank 前后对比 avg_score——分数上升说明改对了,这就是「评估比配置重要」的落地方式。
这篇文章我们就用它搭一个「检索质量评估器」:10 组用例自动检索、自动评分、自动汇总。
4. 整体架构
链路很清晰:生成测试集 → 迭代逐条检索并评估 → 汇总综合评分。迭代把 10 组用例逐条过一遍 KB 检索,每条算一个分数,最后汇总成 avg_score/avg_keyword_rate/top1_matched——一把可复用的尺子。
5. 模块设计
5.1 测试集(Code)
测试用例要覆盖典型查询形态:操作类(重置密码)、错误码类(E1001)、概念类(什么是 RAG):
def main() -> dict:
test_cases = [
{"query": "如何重置密码", "expected_keywords": ["密码重置", "忘记密码", "重置步骤"], "expected_doc": "用户管理_密码重置"},
{"query": "API 调用频率限制", "expected_keywords": ["限流", "Rate Limit", "API配额"], "expected_doc": "API文档_限流策略"},
{"query": "错误码 E1001 是什么", "expected_keywords": ["E1001", "错误码", "排查"], "expected_doc": "排障手册_错误码"},
# ... 共 10 组
]
return {"test_cases": test_cases}5.2 迭代内知识库检索
迭代内 KB
节点需要五件套(isInIteration/iteration_id/parentId/sourcePosition/targetPosition):
- data:
dataset_ids:
- 459c4981-6b76-43e7-b44f-c430f33ef035 # 你的知识库 ID
isInIteration: true
iteration_id: iter_test
output_retrieval_result: true # 必须 true,否则 result 为空
parentId: iter_test
query_attachment_selector: []
query_variable_selector: [cd_split, query]
retrieval_mode: single
score_threshold: 0.0
title: 技术文档库检索
top_k: 3
type: knowledge-retrieval
id: kb_retrieve5.3 评估节点(Code)
KB 结果传给代码节点,参数类型必须声明为
list(检索结果是 list[dict]),评分 = 0.5 ×
关键词命中率 + 0.5 × Top1 文档匹配:
def main(query: str, expected_keywords: str, expected_doc: str, retrieved: list) -> dict:
import json
retrieved = retrieved if isinstance(retrieved, list) else []
texts = [str(r.get("content", "")) for r in retrieved if isinstance(r, dict)]
all_text = " ".join(texts).lower()
try:
kws = json.loads(expected_keywords or "[]")
except Exception:
kws = []
kws = [str(k).lower() for k in kws if k]
hits = sum(1 for k in kws if k in all_text)
keyword_rate = round(hits / len(kws), 2) if kws else 0.0
top1_title = str(retrieved[0].get("title", "")) if retrieved and isinstance(retrieved[0], dict) else ""
top1_match = bool(top1_title) and str(expected_doc or "").lower() in top1_title.lower()
score = round(keyword_rate * 0.5 + (1.0 if top1_match else 0.0) * 0.5, 2)
return {"eval_text": json.dumps({...}, ensure_ascii=False)} # 序列化成 string 供迭代收集5.4 迭代输出与汇总
迭代 output_selector
只能选可见类型(array[object]
收集不到),所以评估节点输出 eval_text
字符串、output_type: array[string];汇总节点再逐个
json.loads 聚合:
def main(results: list) -> dict:
import json
total = 0; score_sum = 0.0; kw_sum = 0.0; top1_ok = 0
for r in results:
try:
d = json.loads(r) if isinstance(r, str) else {}
except Exception:
d = {}
total += 1
score_sum += float(d.get("score", 0) or 0)
kw_sum += float(d.get("keyword_hit_rate", 0) or 0)
if d.get("top1_match"):
top1_ok += 1
return {
"total_cases": total,
"avg_score": round(score_sum / total, 2) if total else 0.0,
"avg_keyword_rate": round(kw_sum / total, 2) if total else 0.0,
"top1_matched": top1_ok,
"summary_text": "检索质量评估:共 {} 组用例,综合评分 {},关键词命中率均值 {},Top1 文档匹配 {} 组".format(
total, round(score_sum / total, 2) if total else 0.0,
round(kw_sum / total, 2) if total else 0.0, top1_ok),
}6. 运行验证
| 检查项 | 预期 | 实测 |
|---|---|---|
| total_cases | 10 | 10 |
| avg_keyword_rate | 0~1 之间,反映关键词召回水平 | 视知识库质量而定 |
| top1_matched | 期望文档命中 Top1 的组数 | 视知识库质量而定 |
| summary_text | 「共 10 组用例,综合评分 X,关键词命中率均值 Y,Top1 文档匹配 Z 组」 | 与预期一致 |
调优闭环:跑出基线 → 调整知识库分段策略/TopK/是否开 Rerank → 重跑同一测试集 → 对比 avg_score。分数上升说明改对了——这就是「评估比配置重要」的落地方式。
7. 实战坑
| 坑 | 现象 | 修复 |
|---|---|---|
| KB 结果传给 code 签名写 str | 运行报
'list' object has no attribute ... |
参数类型声明 list,用
r.get("content", "") 提取 |
| 忘了 output_retrieval_result | [kb, result] 返回空,评估全
0 |
设为 true,结果才是结构化
list[dict] |
| 把 Rerank 当工作流节点 | 找不到 rerank 节点类型 | Rerank 是知识库检索设置(dataset 层面 reranking_enable + reranking_model),不在工作流里配置 |
| 迭代 output_selector 选 array[object] | 迭代输出为空数组 | 评估节点输出 json.dumps
字符串,output_type: array[string] |
| 测试集超过 30 组 | 迭代报
then length of var "item" must be less than 30 elements |
测试集留余量(本实验 10 组) |
| 种子文档不含测试关键词 | 关键词命中率恒 0,误判「检索坏了」 | 测试集与知识库种子文档对齐,先验证数据再调参 |
💡 调优顺序建议:① 先保证文档质量(预处理清洗、分段语义完整)——垃圾进垃圾出;② 再选检索模式(混合 > 单一,有 rerank 模型就开);③ 最后调 TopK/Score 阈值,每次只改一个变量并用同一测试集回归。
8. 实验文档及源码获取
- 实验文档(完整操作步骤):DIFY-11:知识库深度调优.md
- 源码(可直接导入):dify102_11_知识库调优评估.yml
文章聚焦核心配置与采坑点;实验的完整分步操作(节点搭建/参数表/调试指引)见实验文档原文。
- Dify 中级实验(01):参数提取器实战——如何从自然语言中提取结构化数据?
- Dify 中级实验(02):问题分类器——智能路由引擎如何四路分发?
- Dify 中级实验(03):模板转换实战——如何用零 Token 完成文本加工?
- Dify 中级实验(04):迭代进阶——如何批量处理数据并守住性能边界?
- Dify 中级实验(05):并行执行——如何让多路任务同时跑?
- Dify 中级实验(06):变量聚合——如何确定性合并多路分支结果?
- Dify 中级实验(07):子工作流——如何把公共逻辑做成可复用积木?
- Dify 中级实验(08):代码节点进阶——如何用标准库处理文件与数据?
- Dify 中级实验(09):HTTP 节点进阶——如何搞定认证、分页与错误重试?
- Dify 中级实验(10):知识库深度调优——如何科学评估检索质量?
- Dify 中级实验(11):高级 RAG 流水线——如何搭建多路检索与精排?
- Dify 中级实验(12):Agent 深度配置——如何让智能体自主调用工具?
- Dify 中级实验(13):多 Agent 协作——如何编排多个智能体分工干活?
- Dify 中级实验(14):对话变量与状态管理——如何让工作流记住多轮对话的状态?
- Dify 中级实验(15):条件分支高阶策略——多条件路由如何避免分支爆炸?
- Dify 中级实验(16):错误处理与降级——工作流如何有尊严地失败?
- Dify 中级实验(17):调试监控与性能优化——响应慢和 Token 超支如何定位?
- Dify 中级实验(18):插件开发入门——如何把工作流变成 Agent 可调用的工具?
- Dify 中级实验(19):综合实战——如何把 19 个实验串成一条生产级流水线?
- Dify 中级实验(20):综合实战——自动化报告生成流水线如何从数据到周报一步到位?