系列文章:(一)缓存与初始化优化 · (二)评分与引用组装优化
上一篇记录了供应商声明解析、查询向量缓存和模型信息复用的优化。这一次,我继续沿着知识检索的调用链向后看:文档已经召回,为什么评分和引用组装还要花不少时间?
在一个自部署 Dify 1.17.1 的 Chatflow 中,我使用 Grafana Tempo 查看四个内置知识库的混合检索追踪,发现两处可以避免的工作:已有语义分数时仍准备 embedding 模型与查询向量;构造来源引用时仍加载完整文档记录。
这成为 Issue #43664 和 PR #43665 的起点。PR 已于 2026 年 10 月 15 日合并到上游 main。
1. 从追踪里找到后处理开销
当时的部署使用 Docker、PostgreSQL,Chatflow 配置了四个内置知识库,混合检索中涉及加权评分和模型重排。
追踪中的检索分支已经存在时间重叠,因此这次工作的重点是召回后的处理成本。最初的本地实验记录如下:
| 观测位置 | 优化前 | 优化后 |
|---|---|---|
| 加权评分后处理,分支 A | 170.71 ms | 2.91 ms |
| 加权评分后处理,分支 B | 157.58 ms | 5.01 ms |
| 来源引用的文档 SQL | 71.45 ms | 0.90 ms |
这些是本地修改过的 1.17.1 部署中的单次 trace 观测,两次运行都已经包含其他检索优化。其中,引用查询返回的不同文档数还从 3 个变为 1 个,不能把 SQL 的全部耗时差异都归因于精简字段。
这组数据提供了排查方向,但不能代替受控 benchmark,也不能据此推算整条请求的 P95、并发容量或 main 分支的生产收益。观测条件与原始记录
2. 已经有分数,就不必先准备查询向量
第一处改动位于 WeightRerankRunner._calculate_cosine()。
原来的执行顺序是:先获取 ModelManager、构造 embedding 模型实例并解析查询向量,再遍历文档。遍历时,如果文档元数据中已经带有 score,又会直接复用这个分数。
也就是说,当所有文档都已经有分数时,前面的向量准备没有被用到。
改动把准备工作推迟到首次遇到没有分数的文档时:
- 有
score的文档,直接沿用其分数。 - 遇到无分数文档,先读取已有的查询向量缓存。
- 缓存未命中,才初始化 embedding 模型并走原有计算路径。
- 本次调用后续的无分数文档复用同一个查询向量。
下面是控制流的简化示意,省略具体接口参数和余弦计算细节:
query_vector = None
for document in documents:
if has_score(document):
scores.append(existing_score(document))
continue
if query_vector is None:
query_vector = read_existing_cache(query)
if query_vector is None:
query_vector = initialize_model_and_embed(query)
scores.append(cosine(query_vector, document.vector))
最终实现的几种路径可以直接区分:
| 输入情况 | 需要进行的工作 |
|---|---|
| 空文档列表 | 不准备 embedding,也不读查询向量缓存 |
| 全部文档已有分数 | 直接沿用分数,不准备 embedding |
| 部分文档无分数,查询向量缓存命中 | 读取并复用向量,跳过模型初始化 |
| 部分文档无分数,缓存未命中 | 初始化模型,解析向量,本次调用内复用 |
这里有一个容易忽略的边界:分数为 0,不等于没有分数。 实现检查的是元数据中是否存在 score,而不是对分数做真假判断。回归测试也覆盖了 0.0 和负分数,避免优化时误入重新计算路径。
这部分没有增加新的持久缓存,也没有修改评分公式;它改变的是准备查询向量的时机。固定版本源码
3. 组装来源引用,只读取需要的字段
第二处改动在知识检索结果的来源引用组装阶段。
原来通过 ORM 查询整个 Document。但构造引用用不到文档处理信息、错误文本等字段,读取这些内容会增加数据传输和对象加载的工作。
PR 新增了 get_retrieval_source_documents(),将文档查询集中到一个明确的读取入口。它接收调用方传入的 Session,只加载六个字段:
| 字段 | 用途 |
|---|---|
id |
建立文档映射、填写引用文档 ID |
tenant_id |
保留租户归属信息 |
dataset_id |
保留知识库归属信息 |
name |
引用标题与文档名称 |
data_source_type |
来源类型 |
doc_metadata |
文档元数据 |
查询同时按租户、可用知识库范围和文档 ID 过滤;知识库 ID、文档 ID 经过去重,空输入直接返回空结果。
引用中的分段正文、分数等其他信息,继续由原有的分段处理与引用组装逻辑提供。精简的是文档读取范围,原有的引用输出仍需保持一致。仓库读取实现
避免把省下的字段变成 N+1 查询
只设置按需加载还不够。以后组装代码若访问了一个未加载字段,ORM 可能再发一次 SQL;当返回多篇文档时,就可能形成不易察觉的逐文档查询。
因此,这里使用 load_only(..., raiseload=True)。对本次查询未加载的列属性,后续访问会触发异常,让依赖变化显式暴露出来,而不是悄悄补查。
对应测试创建了两个分段,引用同一篇文档;文档的 data_source_info 和 error 各放入约 100 万字符。测试监听 SQL,核对引用的标题、正文、元数据和顺序,确认文档查询只有一次,也没有选取这些无关字段。
另一组测试核对租户与知识库过滤、重复 ID、空输入,并确认访问未加载字段会触发 raiseload 异常。这些检查约束了“少读字段”的效果,也保护了后续维护时的查询行为。
4. 怎样确认优化没有改变结果
对检索链路来说,少花时间只是其中一个目标。还要确认文档分数、返回顺序、引用内容和元数据没有随着执行顺序变化而出错。
这次新增或调整的回归覆盖主要集中在:
- 空输入和全部已有分数的输入,不触发向量准备。
- 混合输入在缓存命中、未命中时,都得到预期分数,查询向量只解析一次。
- 零分、负分保留原值。
- 引用结果的标题、内容、元数据和顺序符合预期。
- 文档读取范围正确,组装引用不产生额外的文档懒加载查询。
PR 说明记录了 206 项相关单元测试通过,覆盖 reranker、dataset retrieval、citation query 和 data post-processor 套件;同时记录了 Ruff、格式检查、三份生产代码的定向 Pyrefly 检查与 diff 检查通过。
这些本地验证对应提交 729900285d,使用独立 Python 3.12 环境,通过 uv run --project api 执行。最终获批的 PR head 为 e041c4c9。全仓库 lint/type-check 与 CI 负责的集成测试未在该本地验证中运行;本文也没有重新执行 Dify 后端测试。验证与源码核对记录
5. 两次优化,共同的问题是什么
第一篇减少的是供应商声明的重复解析,以及缓存命中前的模型初始化;这一篇继续减少评分与来源引用组装中的准备和读取工作。
它们共同指向一个问题:当前结果真正需要哪些工作,哪些只是因为执行得太早、读取得太多而发生?
对于类似的 RAG 系统,我会沿着请求链路继续检查三个位置:已有结果能否复用、昂贵操作能否延后、数据库读取是否与返回内容匹配。每做一处调整,就用对应的结果与查询行为测试固定边界。
下一步值得做的是在固定版本、固定输入和相同返回文档数的条件下复测,分别观察缓存命中与未命中、多知识库并发、端到端分位数和错误率。当前 PR 已合并,但尚没有 main 分支的生产性能 benchmark;具体发行版本是否包含该改动,需要另行核对。