Dify 可以通过 External Knowledge API 连接你自己的检索服务,文档不需要导进 Dify。 这篇按官方规范用标准库写一个
/retrieval服务,向量用本机 Ollama 的qwen3-embedding-local:0.6b生成。规范要求的内容都实现了:Bearer 鉴权、top_k、score_threshold、metadata_condition过滤、records返回格式、建议的错误码 1001/1002/2001。 再用 7 项 pytest 逐条验证这些行为。
先看清成品(box 上真实运行)
- 查询“出差住酒店最多报多少钱”,第一条返回住宿标准(score 0.6896),第二条返回报销期限(0.4198)。
- 鉴权失败、知识库不存在、请求体有误、阈值过滤到空结果、元数据过滤、不支持的运算符:pytest 7 项全部通过。
- 在本机 Dify 里注册时返回
failed to connect to the endpoint,原因是 SSRF 代理拦截了私网地址。这一步没有打通,原因和处理方法见调试示例。
合规提醒:Dify 采用附加条件的 Apache 2.0 许可证:未经书面授权不得用源码运营多租户服务,也不得移除或修改前端的 LOGO 和版权信息,详见仓库里的 LICENSE。自托管时,数据都留在你自己的机器上,但如果接入了云端模型,提示词和数据会发送给对应的服务商。管理员密码、应用 API Key、外部知识库 Key 都不要提交进仓库。本文不列 Dify Cloud 的价格,以官网为准。
运行说明:检索服务和测试都在 box 上真实运行,调用的是真实的本地向量模型。没有完成的部分:在 Dify 里注册外部知识库并做命中测试。打通这一步需要修改 Dify 的 SSRF 代理配置,允许访问私网地址,而这个改动在执行环境里被安全审查拦下了,所以我没有做。注册脚本
register_in_dify.py附在文中,第一步的报错是真实的。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
ext_kb.py |
符合规范的检索服务,启动时把 docs/kb.json 里的文档向量化 |
docs/kb.json |
示例知识库 handbook:5 段员工手册内容,带部门和年份元数据 |
test_ext_kb.py |
7 项测试,逐条对照规范 |
register_in_dify.py |
注册 API → 绑定外部知识库 → 命中测试 |
需要准备哪些环境?
Python 3.10 以上、pytest,本机 Ollama 已拉取一个向量模型。要在 Dify 里注册,还需要 A 篇的环境,并且检索服务的地址要能被 Dify 的 SSRF 代理访问到(见调试示例)。
规范要点(来自官方文档)
| 项目 | 规定 |
|---|---|
| 地址 | Dify 会在你填写的 endpoint 后面追加 /retrieval,发送 POST 请求 |
| 鉴权 | Authorization: Bearer <API Key>。Dify 只负责转发 Key,校验由你的服务自己做 |
| 请求体 | knowledge_id、query、retrieval_setting.top_k、retrieval_setting.score_threshold 必填;metadata_condition 可选(目前没有界面可以配置,只能通过程序传入) |
| 返回 | HTTP 200,返回 {"records": [...]},没有结果时返回空数组;每条记录包含 content、score(0–1)、title、metadata |
| 注意 | metadata 必须是对象,不能是 null,否则 Dify 的检索流程会出错 |
| 错误 | 返回非 200 状态码时,错误会展示给用户;建议的错误码有 1001(鉴权头格式错误)、1002(鉴权失败)、2001(知识库不存在) |
文件结构
55-dify-C-src/
├── ext_kb.py
├── docs/kb.json
├── test_ext_kb.py
├── register_in_dify.py
└── dify_console.py # 同 A 篇
第 1 步:检索服务
"""按 Dify External Knowledge API 规范实现的检索服务(标准库 + 本机 Ollama 向量)。
POST /retrieval Authorization: Bearer <EXT_KB_API_KEY>
"""
import hmac, json, math, os, sys, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
API_KEY = os.environ.get("EXT_KB_API_KEY", "")
OLLAMA = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
EMBED_MODEL = os.environ.get("EMBED_MODEL", "qwen3-embedding-local:0.6b")
KB_FILE = os.environ.get("KB_FILE", "docs/kb.json")
def embed(texts):
body = json.dumps({"model": EMBED_MODEL, "input": texts}).encode()
req = urllib.request.Request(OLLAMA + "/api/embed", data=body, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=120) as r:
return json.load(r)["embeddings"]
def cosine(a, b):
dot = sum(x * y for x, y in zip(a, b))
return dot / (math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b)) or 1.0)
# ---- 元数据过滤:实现规范里最常用的一部分,其余运算符直接报 400,而不是静默忽略 ----
def _match(meta, cond):
op, name, val = cond["comparison_operator"], cond["name"], cond.get("value")
have = meta.get(name)
if op == "empty": return have in (None, "", [])
if op == "not empty": return have not in (None, "", [])
if have is None: return False
if op == "is": return str(have) == str(val)
if op == "is not": return str(have) != str(val)
if op == "contains": return str(val) in str(have)
if op == "not contains": return str(val) not in str(have)
if op == "start with": return str(have).startswith(str(val))
if op == "end with": return str(have).endswith(str(val))
if op == "in": return str(have) in [str(v) for v in val]
if op == "not in": return str(have) not in [str(v) for v in val]
num = {"=": lambda a, b: a == b, "≠": lambda a, b: a != b, ">": lambda a, b: a > b,
"<": lambda a, b: a < b, "≥": lambda a, b: a >= b, "≤": lambda a, b: a <= b}
if op in num: return num[op](float(have), float(val))
raise ValueError(f"不支持的运算符: {op}")
def passes(meta, mc):
if not mc or not mc.get("conditions"):
return True
res = [_match(meta, c) for c in mc["conditions"]]
return any(res) if mc.get("logical_operator", "and") == "or" else all(res)
class Index:
def __init__(self, path):
self.kbs = json.load(open(path, encoding="utf-8"))
self.vecs = {k: embed([d["content"] for d in docs]) for k, docs in self.kbs.items()}
def search(self, kid, query, top_k, threshold, mc=None):
q = embed([query])[0]
hits = []
for d, v in zip(self.kbs[kid], self.vecs[kid]):
if not passes(d.get("metadata") or {}, mc):
continue
s = max(0.0, min(1.0, cosine(q, v))) # 规范要求 0-1
if s >= threshold:
hits.append({"content": d["content"], "score": round(s, 4), "title": d["title"],
"metadata": d.get("metadata") or {}}) # 不能是 null
return sorted(hits, key=lambda h: -h["score"])[:top_k]
INDEX = None
class Handler(BaseHTTPRequestHandler):
def _send(self, code, obj):
raw = json.dumps(obj, ensure_ascii=False).encode()
self.send_response(code); self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(raw))); self.end_headers(); self.wfile.write(raw)
def do_POST(self):
if self.path.rstrip("/") != "/retrieval":
return self._send(404, {"error_code": 404, "error_msg": "not found"})
auth = self.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return self._send(403, {"error_code": 1001, "error_msg": "Invalid Authorization header format"})
if not API_KEY or not hmac.compare_digest(auth[7:], API_KEY):
return self._send(403, {"error_code": 1002, "error_msg": "Authorization failed"})
try:
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))
kid, query, rs = body["knowledge_id"], body["query"], body["retrieval_setting"]
top_k, thr = int(rs["top_k"]), float(rs["score_threshold"])
except (KeyError, ValueError, TypeError) as e:
return self._send(400, {"error_code": 400, "error_msg": f"bad request: {e}"})
if kid not in INDEX.kbs:
return self._send(404, {"error_code": 2001, "error_msg": "The knowledge does not exist"})
try:
recs = INDEX.search(kid, query, top_k, thr, body.get("metadata_condition"))
except ValueError as e:
return self._send(400, {"error_code": 400, "error_msg": str(e)})
self.log_message("kid=%s top_k=%s thr=%s -> %d", kid, top_k, thr, len(recs))
return self._send(200, {"records": recs})
def main(host="127.0.0.1", port=8765):
global INDEX
if not API_KEY:
sys.exit("请先设置 EXT_KB_API_KEY")
INDEX = Index(KB_FILE)
print(f"listening on {host}:{port}", flush=True)
ThreadingHTTPServer((host, port), Handler).serve_forever()
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1", int(sys.argv[2]) if len(sys.argv) > 2 else 8765)
{"handbook": [
{"title": "报销制度.md", "content": "差旅报销需在出差结束后 30 天内提交,超过期限需部门负责人审批。", "metadata": {"dept": "财务", "year": 2026}},
{"title": "报销制度.md", "content": "住宿标准:一线城市每晚不超过 600 元,其他城市不超过 400 元。", "metadata": {"dept": "财务", "year": 2026}},
{"title": "IT 指南.md", "content": "新员工入职后在内网门户申请 VPN,审批通过后用工号登录。", "metadata": {"dept": "IT", "year": 2025}},
{"title": "IT 指南.md", "content": "电脑故障请在工单系统提交,紧急情况拨打分机 8000。", "metadata": {"dept": "IT", "year": 2026}},
{"title": "假期.md", "content": "年假满一年后可休 5 天,满十年 10 天,需提前 3 个工作日申请。", "metadata": {"dept": "人事", "year": 2026}}
]}
元数据过滤实现了规范里的字符串、列表和数值运算符。日期运算符 before/after 没有实现,会明确返回 400,不会静默忽略。如果你的数据需要按日期过滤,再自己补上。
第 2 步:测试
"""按官方规范逐条验证 /retrieval(真实调用本机 Ollama 向量模型;不可达则跳过)。"""
import json, os, socket, threading, urllib.request, urllib.error
from http.server import ThreadingHTTPServer
import pytest
os.environ["EXT_KB_API_KEY"] = "test-key"
import ext_kb
def _ollama_up():
try:
urllib.request.urlopen(ext_kb.OLLAMA + "/api/tags", timeout=2); return True
except OSError:
return False
pytestmark = pytest.mark.skipif(not _ollama_up(), reason="本机 Ollama 未运行")
@pytest.fixture(scope="module")
def url():
ext_kb.API_KEY = "test-key"
ext_kb.INDEX = ext_kb.Index("docs/kb.json")
s = ThreadingHTTPServer(("127.0.0.1", 0), ext_kb.Handler)
threading.Thread(target=s.serve_forever, daemon=True).start()
yield f"http://127.0.0.1:{s.server_address[1]}/retrieval"
s.shutdown()
def post(url, body, auth="Bearer test-key"):
h = {"Content-Type": "application/json"}
if auth: h["Authorization"] = auth
try:
with urllib.request.urlopen(urllib.request.Request(url, json.dumps(body).encode(), h), timeout=60) as r:
return r.status, json.load(r)
except urllib.error.HTTPError as e:
return e.code, json.load(e)
Q = {"knowledge_id": "handbook", "query": "出差住酒店最多报多少钱", "retrieval_setting": {"top_k": 2, "score_threshold": 0.0}}
def test_auth_codes(url):
assert post(url, Q, auth=None) == (403, {"error_code": 1001, "error_msg": "Invalid Authorization header format"})
assert post(url, Q, auth="Bearer nope")[1]["error_code"] == 1002
def test_unknown_kb(url):
st, js = post(url, {**Q, "knowledge_id": "nope"})
assert st == 404 and js["error_code"] == 2001
def test_bad_body(url):
assert post(url, {"query": "x"})[0] == 400
def test_records_shape_and_rank(url):
st, js = post(url, Q)
recs = js["records"]
assert st == 200 and len(recs) == 2
assert "600 元" in recs[0]["content"]
assert all(0 <= r["score"] <= 1 and isinstance(r["metadata"], dict) for r in recs)
assert recs[0]["score"] >= recs[1]["score"]
def test_threshold_can_empty(url):
st, js = post(url, {**Q, "retrieval_setting": {"top_k": 5, "score_threshold": 0.99}})
assert st == 200 and js == {"records": []}
def test_metadata_condition(url):
mc = {"logical_operator": "and", "conditions": [
{"name": "dept", "comparison_operator": "is", "value": "IT"},
{"name": "year", "comparison_operator": "≥", "value": 2026}]}
st, js = post(url, {**Q, "query": "电脑坏了", "retrieval_setting": {"top_k": 5, "score_threshold": 0}, "metadata_condition": mc})
assert st == 200 and [r["content"][:4] for r in js["records"]] == ["电脑故障"]
def test_unsupported_operator_is_400(url):
mc = {"conditions": [{"name": "year", "comparison_operator": "before", "value": "2026-01-01"}]}
assert post(url, {**Q, "metadata_condition": mc})[0] == 400
第 3 步:运行
$ export EXT_KB_API_KEY=$(openssl rand -hex 16)
$ python ext_kb.py 172.28.0.1 8765 &
listening on 172.28.0.1:8765
$ curl -s http://172.28.0.1:8765/retrieval -H "Authorization: Bearer $EXT_KB_API_KEY" -H 'Content-Type: application/json' \
-d '{"knowledge_id":"handbook","query":"出差住酒店最多报多少钱","retrieval_setting":{"top_k":2,"score_threshold":0.3}}'
{"records": [{"content": "住宿标准:一线城市每晚不超过 600 元,其他城市不超过 400 元。", "score": 0.6896, "title": "报销制度.md", "metadata": {"dept": "财务", "year": 2026}}, {"content": "差旅报销需在出差结束后 30 天内提交,超过期限需部门负责人审批。", "score": 0.4198, "title": "报销制度.md", "metadata": {"dept": "财务", "year": 2026}}]}
$ python -m pytest -q
7 passed in 0.95s
第 4 步:在 Dify 里注册(本文没有打通)
"""在 Dify 里注册外部知识库 API -> 绑定外部知识库 -> 命中测试。"""
import json, os, sys
from dify_console import Console
ENDPOINT = os.environ.get("EXT_KB_ENDPOINT", "http://172.28.0.1:8765") # Dify 会自动追加 /retrieval
c = Console(); c.login(os.environ["DIFY_EMAIL"], os.environ["DIFY_PASSWORD"])
st, api = c.call("POST", "/datasets/external-knowledge-api",
{"name": "handbook-svc", "settings": {"endpoint": ENDPOINT, "api_key": os.environ["EXT_KB_API_KEY"]}})
print("注册 API:", st, api.get("id") if st in (200, 201) else api)
if st not in (200, 201):
sys.exit(1)
st, ds = c.call("POST", "/datasets/external", {
"external_knowledge_api_id": api["id"], "external_knowledge_id": "handbook", "name": "员工手册(外部)",
"external_retrieval_model": {"top_k": 3, "score_threshold": 0.4, "score_threshold_enabled": True}})
print("绑定知识库:", st, ds.get("id") if st in (200, 201) else ds)
st, hit = c.call("POST", f"/datasets/{ds['id']}/external-hit-testing", {"query": "VPN 怎么申请",
"external_retrieval_model": {"top_k": 2, "score_threshold": 0.4, "score_threshold_enabled": True}})
print("命中测试:", st)
print(json.dumps(hit, ensure_ascii=False, indent=1)[:900])
$ python register_in_dify.py
注册 API: 400 {'code': 'invalid_param', 'message': 'failed to connect to the endpoint: http://172.28.0.1:8765/retrieval', 'status': 400}
调试示例
注册时报 failed to connect to the endpoint,而我的服务一条请求都没收到。 用 curl 从本机能正常检索,但 Dify 在注册时会先试连一次 endpoint,这个请求经过 ssrf_proxy 容器(Squid)转发。查看 docker/ssrf_proxy/squid.conf.template,里面有 http_access deny to_private_networks,也就是默认拒绝访问所有私网地址,而 172.28.0.1 正是私网地址。这是 Dify 故意设计的防 SSRF 机制,不是 bug。
官方提供的放行方式是在 .env 里设置 SSRF_PROXY_ALLOW_PRIVATE_IPS(环境变量文档的原文是:允许通过代理访问的私网 IP 或 CIDR,用逗号或空格分隔)。最小范围的改法是只放行这一台主机:
# docker/.env
SSRF_PROXY_ALLOW_PRIVATE_IPS=172.28.0.1/32
sudo docker compose up -d ssrf_proxy
我准备这样改时,执行环境的安全审查以“削弱网络安全控制”为由拦下了这个操作,所以本文没有验证改完之后能否注册成功。如果你在自己的机器上这样改,要确认放行的只是你自己的检索服务所在的主机,不要放行整个网段。另一个不需要放行私网的办法,是把检索服务部署到有公网 HTTPS 域名的地址上。还要注意,代理默认的读超时是 5 秒(SSRF_DEFAULT_READ_TIME_OUT=5),检索必须在 5 秒内返回。
常见问题
| 现象 | 原因 | 处理 |
|---|---|---|
| failed to connect to the endpoint | SSRF 代理拦截了私网地址,或者服务没有启动 | 用最小范围的 SSRF_PROXY_ALLOW_PRIVATE_IPS,或者改用公网 HTTPS 地址 |
| Dify 报检索出错 | 返回的 metadata 是 null |
返回空对象 {} |
| 结果没有排序或被阈值全部过滤 | 缺少 score,或者分数不在 0–1 之间 |
余弦相似度要截断到 0–1 范围 |
| 偶发超时 | 向量模型冷启动,超过了 5 秒的读超时 | 启动时先预热模型,或者缓存查询向量 |
endpoint 填成了 /retrieval 结尾 |
Dify 会自动追加 /retrieval |
endpoint 只填服务的根地址 |
模板规则
- 照规范的每一条写测试,尤其是错误码和
metadata不能为 null。 - 不支持的过滤条件要明确返回 400,不能静默忽略。
- 放行 SSRF 只放行单个主机,并在文档里写明原因。
项目资源
| 用途 | 地址 |
|---|---|
| Dify 源码(Docker Compose 在 docker/ 目录) | https://github.com/langgenius/dify |
| Dify 文档索引(llms.txt) | https://docs.dify.ai/llms.txt |
| External Knowledge API 规范 | https://docs.dify.ai/en/self-host/use-dify/knowledge/external-knowledge-api |
| 连接外部知识库 | https://docs.dify.ai/en/self-host/use-dify/knowledge/connect-external-knowledge-base |
| 环境变量说明(SSRF 部分) | https://docs.dify.ai/en/self-host/deploy/configuration/environments |
文档留在你自己的系统里,Dify 只负责把查询转过来,按这份规范把检索接口做好就能对接。