know-engine 采用多路召回 + RRF(Reciprocal Rank Fusion,倒数排序融合)+ Re-rank 的检索架构:
1. 多个 ContentRetriever(向量检索、全文检索、混合检索、SQL 结构化检索)针对同一 Query 各自返回一份 List
问题表现
1. 结果重复
同一段 chunk(同一 chunkId)在最终融合结果中多次出现。例如向量检索与全文检索都召回了 chunkId=abc 的片段,融合后仍能在结果列表里看到两条几乎相同的文本,挤占了 Top-K 名额。同一个现象,还会导致: - 在某一路检索中排名靠前的 chunk,其 RRF 分并未被正确累加; - 多路同一 chunk 被独立计分,最终排序和"加权后真实热度"不一致,靠前的不是真正高频/多路命中的片段。
2. 排序乱序
检索到的结果并没有按照分数倒序排列,实际上是乱序。
根因分析
langchain4j 原版 ReciprocalRankFuser.fuse 的核心逻辑:
Map
for
scores
}
其依赖 Map
解决方案
核心思路:把"内容相等性"显式定义为 chunkId 相等,让 RRF 内部的 Map 能正确把多路同一 chunk 折叠到同一个 key 上累加得分。1. 引入 KnowEngineDefaultContent继承 DefaultContent,仅基于元数据中的 chunkId 重写 equals/hashCode:
public
}
- 定制 KnowEngineReciprocalRankFuser完整复刻 langchain4j 的 RRF 算法,但把入参类型收窄为 List
,从签名上强制调用方先做包装:
public
scores
fused
}
由于 Map 的 key 此时使用的是 KnowEngineDefaultContent,equals/hashCode 已生效,多路同 chunk 会被自动合并,分数叠加,最后按合并后的 RRF 分一次性降序排序,乱序与重复同时被消除。 在 KnowEngineReRankingContentAggregator.aggregate(...) 中,先用原版 RRF 融合"同一 Query 跨 Retriever"的结果(这一步同 chunk 通常已经天然不重复),再把"跨 Query"的所有列表统一包装成 KnowEngineDefaultContent 后调用定制 RRF:
Map
List
List
随后再交给 ScoringModel 做 Re-rank、按阈值过滤、limit(maxResults) 截断。 检索器检索结果的有序保障 为了让"进入 RRF 的每一路列表本身就是按相关性排序的",KnowEngineElasticsearchContentRetriever 在拼装父子/兄弟补全结果之后,追加了一道按 ES 原始 score 的降序兜底:
finalContents
RRF 的得分公式与"列表内相对排名"强相关,输入有序 → RRF 才有意义。
效果
修复后: | 现象 | 修复前 | 修复后 | | --- | --- | --- | | 同一 chunk 是否会出现多次 | 是 | 否(按 | | 多路同 chunk 的 RRF 分 | 各算各的,未叠加 | 正确累加为 | | 最终顺序 | 受重复条目干扰,与"多路热度"不一致 | 严格按累加后 RRF 分降序 | | Top-K 信息密度 | 被重复内容稀释 | 显著提升 |