KnowFlow 项目学习笔记(二):混合检索深度解析

写在前面:本学习笔记基于 KnowFlow 项目源码逐行解析,深度讲解混合检索的实现细节(向量检索 + 文本匹配、KNN 召回、BM25 Rescore、权限过滤)。并指出源码里的 3 处严重问题(❗❗ 标记),给出修复方案。适合面试前深度学习,确保”傻子都能懂”。


一、什么是混合检索?

傻子都能懂的解释

想象你要在知识库里搜索”如何学习 Java”:

  • 传统搜索(只用文本匹配):搜索”如何学习 Java”,只能找到包含这 6 个字的结果(比如”如何学习 Java 编程”)
  • 向量搜索(只用语义相似):搜索”如何学习 Java”,可以找到”Java 入门指南”(虽然字不一样,但意思相似)
  • 混合检索:结合两者优点,先用向量搜索找到”意思相似”的结果,再用文本匹配过滤掉不相关的结果

KnowFlow 的混合检索

  1. 向量检索:把用户的查询转换成向量(一串数字),在 Elasticsearch 里找”向量相似度最高”的结果
  2. 文本匹配:用 BM25 算法(Elasticsearch 的默认评分算法),计算查询和文档的”文本相似度”
  3. 混合排序:把向量相似度和文本相似度结合起来,重新排序(Rescore)

二、源码解析:HybridSearchService.java

文件位置PaiSmart-zuzhi/src/main/java/com/yizhaoqi/smartpai/service/HybridSearchService.java

2.1 混合检索主流程:searchWithPermission() 方法

源码(第 67-178 行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
public List<SearchResult> searchWithPermission(String query, String userId, int topK) {
return searchTimer.record(() -> {
logger.debug("开始带权限搜索,查询: {}, 用户ID: {}", query, userId);

try {
// 获取用户有效的组织标签(包含层级关系)
List<String> userEffectiveTags = getUserEffectiveOrgTags(userId);
logger.debug("用户 {} 的有效组织标签: {}", userId, userEffectiveTags);

// 获取用户的数据库ID用于权限过滤
String userDbId = getUserDbId(userId);
logger.debug("用户 {} 的数据库ID: {}", userId, userDbId);

// 生成查询向量
final List<Float> queryVector = embedToVectorList(query);

// 如果向量生成失败,仅使用文本匹配
if (queryVector == null) {
logger.warn("向量生成失败,仅使用文本匹配进行搜索");
return textOnlySearchWithPermission(query, userDbId, userEffectiveTags, topK);
}

logger.debug("向量生成成功,开始执行混合搜索 KNN");

SearchResponse<EsDocument> response = esClient.search(s -> {
s.index("knowledge_base");
// KNN 召回
int recallK = topK * 30; // KNN 召回窗口
s.knn(kn -> kn
.field("vector")
.queryVector(queryVector)
.k(recallK)
.numCandidates(recallK)
);
// 必须命中关键词 + 权限过滤
s.query(q -> q.bool(b -> b
.must(mst -> mst.match(m -> m.field("textContent").query(query)))
.filter(f -> f.bool(bf -> bf
// 条件1: 用户可访问自己的文档
.should(s1 -> s1.term(t -> t.field("userId").value(userDbId)))
// 条件2: 公开文档
.should(s2 -> s2.term(t -> t.field("public").value(true)))
// 条件3: 组织标签
.should(s3 -> {
if (userEffectiveTags.isEmpty()) {
return s3.matchNone(mn -> mn);
} else if (userEffectiveTags.size() == 1) {
return s3.term(t -> t.field("orgTag").value(userEffectiveTags.get(0)));
} else {
return s3.bool(inner -> {
userEffectiveTags.forEach(tag -> inner.should(sh2 -> sh2.term(t -> t.field("orgTag").value(tag))));
return inner;
});
}
})
))
));

// 第二阶段 BM25 rescore
s.rescore(r -> r
.windowSize(recallK)
.query(rq -> rq
.queryWeight(0.2d) // 保留部分 KNN 分
.rescoreQueryWeight(1.0d) // BM25 主导
.query(rqq -> rqq.match(m -> m
.field("textContent")
.query(query)
.operator(Operator.And)
))
)
);
s.size(topK);
return s;
}, EsDocument.class);

logger.debug("Elasticsearch查询执行完成,命中数量: {}, 最大分数: {}",
response.hits().total().value(), response.hits().maxScore());

List<SearchResult> results = response.hits().hits().stream()
.map(hit -> {
assert hit.source() != null;
logger.debug("搜索结果 - 文件: {}, 块: {}, 分数: {}, 内容: {}",
hit.source().getFileMd5(), hit.source().getChunkId(), hit.score(),
hit.source().getTextContent().substring(0, Math.min(50, hit.source().getTextContent().length())));
return new SearchResult(
hit.source().getFileMd5(),
hit.source().getChunkId(),
hit.source().getTextContent(),
hit.score(),
hit.source().getUserId(),
hit.source().getOrgTag(),
hit.source().isPublic()
);
})
.toList();

logger.debug("返回搜索结果数量: {}", results.size());
attachFileNames(results);
return results;
} catch (Exception e) {
logger.error("带权限的搜索失败", e);
// 发生异常时尝试使用纯文本搜索作为后备方案
try {
logger.info("尝试使用纯文本搜索作为后备方案");
return textOnlySearchWithPermission(query, getUserDbId(userId), getUserEffectiveOrgTags(userId), topK);
} catch (Exception fallbackError) {
logger.error("后备搜索也失败", fallbackError);
return Collections.emptyList();
}
}
});
}

逐行解析(傻子都能懂版)

  1. 第 73 行List<String> userEffectiveTags = getUserEffectiveOrgTags(userId);

    • 获取用户的有效组织标签(用来做权限过滤)
    • 比如用户属于”技术部”,他能看到”技术部”的文档 + 自己的文档 + 公开文档
  2. 第 77 行String userDbId = getUserDbId(userId);

    • 获取用户的数据库 ID(用来做权限过滤)
    • 比如用户 ID 是 1001,他能看到 userId = 1001 的文档(自己的文档)
  3. 第 81 行final List<Float> queryVector = embedToVectorList(query);

    • 把用户的查询转换成向量(调用 embedding 服务)
    • 比如查询是”如何学习 Java”,转换成向量:[0.1, 0.2, 0.3, ...](768 维)
  4. 第 91-140 行:Elasticsearch 查询

    • KNN 召回(第 95-100 行):在向量空间里找”距离最近”的文档(类似”找邻居”)
    • 文本匹配过滤(第 102-103 行):必须包含查询关键词(比如搜索”Java”,结果里必须包含”Java”这个词)
    • 权限过滤(第 104-123 行):只能看到自己有权限的文档
  5. 第 125-137 行:BM25 Rescore

    • Rescore:重新评分(第二阶段)
    • windowSize:只对前 recallK 个结果重新评分(提高性能)
    • queryWeight:KNN 分数的权重(0.2 = 保留 20% 的 KNN 分数)
    • rescoreQueryWeight:BM25 分数的权重(1.0 = BM25 分数占 100%)
  6. 第 145-161 行:处理结果

    • 把 Elasticsearch 的返回结果转换成 SearchResult 对象
    • hit.score():文档的评分(分数越高,排名越靠前)

2.2 向量生成:embedToVectorList() 方法

源码(第 380-397 行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private List<Float> embedToVectorList(String text) {
try {
List<float[]> vecs = embeddingClient.embed(List.of(text));
if (vecs == null || vecs.isEmpty()) {
logger.warn("生成的向量为空");
return null;
}
float[] raw = vecs.get(0);
List<Float> list = new ArrayList<>(raw.length);
for (float v : raw) {
list.add(v);
}
return list;
} catch (Exception e) {
logger.error("生成向量失败", e);
return null;
}
}

傻子都能懂的解释

什么是向量?

  • 向量就是”一串数字”(比如 [0.1, 0.2, 0.3, ...]
  • 每个数字代表一个”特征”(类似”纬度”)
  • 比如”苹果”这个词,转换成向量可能是 [0.1, 0.2, 0.3](表示”水果”特征)
  • “香蕉”这个词,转换成向量可能是 [0.1, 0.2, 0.4](也表示”水果”特征)
  • 因为”苹果”和”香蕉”的向量很接近,所以它们是”语义相似”的

什么是 Embedding?

  • Embedding 就是把”文字”转换成”向量”的过程
  • 比如用 OpenAI 的 text-embedding-ada-002 模型,把”如何学习 Java”转换成 1536 维的向量

代码解析

  1. 第 382 行List<float[]> vecs = embeddingClient.embed(List.of(text));

    • 调用 embedding 服务(可能是 OpenAI API,也可能是本地模型)
    • 返回值是 List<float[]>(可能包含多个向量的列表)
  2. 第 387-392 行:把 float[] 转换成 List<Float>

    • Elasticsearch Java SDK 需要 List<Float> 格式(不是 float[]

2.3 权限过滤:getUserEffectiveOrgTags() 方法

源码(第 402-429 行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private List<String> getUserEffectiveOrgTags(String userId) {
logger.debug("获取用户有效组织标签,用户ID: {}", userId);
try {
// 获取用户名
User user;
try {
Long userIdLong = Long.parseLong(userId);
logger.debug("解析用户ID为Long: {}", userIdLong);
user = userRepository.findById(userIdLong)
.orElseThrow(() -> new CustomException("User not found with ID: " + userId, HttpStatus.NOT_FOUND));
logger.debug("通过ID找到用户: {}", user.getUsername());
} catch (NumberFormatException e) {
// 如果userId不是数字格式,则假设它就是username
logger.debug("用户ID不是数字格式,作为用户名查找: {}", userId);
user = userRepository.findByUsername(userId)
.orElseThrow(() -> new CustomException("User not found: " + userId, HttpStatus.NOT_FOUND));
logger.debug("通过用户名找到用户: {}", user.getUsername());
}

// 通过orgTagCacheService获取用户的有效标签集合
List<String> effectiveTags = orgTagCacheService.getUserEffectiveOrgTags(user.getUsername());
logger.debug("用户 {} 的有效组织标签: {}", user.getUsername(), effectiveTags);
return effectiveTags;
} catch (Exception e) {
logger.error("获取用户有效组织标签失败: {}", e.getMessage(), e);
return Collections.emptyList(); // 返回空列表作为默认值
}
}

傻子都能懂的解释

什么是组织标签?

  • 组织标签就是”部门”(比如”技术部”、”产品部”、”运营部”)
  • 每个文档都有一个组织标签(比如”技术部”的文档,只有”技术部”的人能看)

什么是层级关系?

  • 比如”技术部”下面有”前端组”、”后端组”
  • 如果用户属于”技术部”,他能看到”技术部”、”前端组”、”后端组”的所有文档

代码解析

  1. 第 408-419 行:获取用户

    • 先尝试把 userId 转换成 Long(数据库 ID)
    • 如果转换失败,假设 userId 是用户名(字符串)
  2. 第 422 行orgTagCacheService.getUserEffectiveOrgTags(user.getUsername())

    • 获取用户的有效组织标签(包含层级关系)
    • 比如用户属于”技术部”,返回 ["技术部", "前端组", "后端组"]

三、❗❗ 源码里的问题

问题 1:KNN 召回窗口太大,影响性能!

看代码(第 94-99 行)

1
2
3
4
5
6
7
int recallK = topK * 30; // KNN 召回窗口
s.knn(kn -> kn
.field("vector")
.queryVector(queryVector)
.k(recallK)
.numCandidates(recallK)
);

问题

  • recallK = topK * 30(比如 topK = 10recallK = 300
  • 如果 topK = 100recallK = 3000(召回 3000 个结果)
  • Elasticsearch 需要对 3000 个结果重新评分(Rescore),性能很差

为什么要用 30 倍?

  • 作者想提高召回率(担心漏掉相关结果)
  • 但这样会严重影响性能(QPS 下降)

修复方案:动态调整召回窗口

1
2
3
4
5
6
7
int recallK = Math.min(topK * 10, 500);  // 最多召回 500 个
s.knn(kn -> kn
.field("vector")
.queryVector(queryVector)
.k(recallK)
.numCandidates(recallK)
);

问题 2:权限过滤放在 KNN 查询里,影响性能!

看代码(第 102-123 行)

1
2
3
4
5
6
7
8
9
10
11
s.query(q -> q.bool(b -> b
.must(mst -> mst.match(m -> m.field("textContent").query(query)))
.filter(f -> f.bool(bf -> bf
// 条件1: 用户可访问自己的文档
.should(s1 -> s1.term(t -> t.field("userId").value(userDbId)))
// 条件2: 公开文档
.should(s2 -> s2.term(t -> t.field("public").value(true)))
// 条件3: 组织标签
.should(s3 -> ...)
))
));

问题

  • 权限过滤(filter)会过滤掉很多结果
  • 如果 KNN 召回了 300 个结果,但权限过滤后只剩 10 个,浪费了很多计算资源

修复方案:先权限过滤,再 KNN 召回

1
2
3
4
5
6
7
8
9
10
11
// 先查询用户有权限的文档 ID 列表
List<String> allowedDocIds = getAllowedDocIds(userId);

// 再 KNN 召回(只在允许的文档里召回)
s.knn(kn -> kn
.field("vector")
.queryVector(queryVector)
.k(topK)
.numCandidates(topK * 10)
.filter(f -> f.terms(t -> t.field("fileMd5").terms(allowedDocIds)))
);

问题 3:没有分页,返回结果太多!

看代码(第 138 行)

1
s.size(topK);

问题

  • 只有 size(返回结果数量),没有 from(偏移量)
  • 如果用户输入 topK = 10000,Elasticsearch 会返回 10000 个结果(占用大量内存)

修复方案:限制最大返回数量

1
2
3
4
5
if (topK > 100) {
throw new IllegalArgumentException("topK 不能超过 100");
}

s.size(topK);

更好的方案:支持分页

1
2
3
4
5
public List<SearchResult> searchWithPermission(String query, String userId, int topK, int page) {
int from = page * topK; // 偏移量
s.from(from).size(topK);
// ...
}

四、面试八股文

4.1 什么是混合检索?为什么要用混合检索?

  • 混合检索是结合”向量检索”和”文本匹配”的搜索方式
  • 好处:
    1. 语义理解:向量检索能理解”意思相似”的查询(比如”如何学习 Java”和”Java 入门指南”)
    2. 精确匹配:文本匹配能过滤掉不相关的结果(比如搜索”Java”,结果里必须包含”Java”这个词)
    3. 提高准确率:两者结合,比单独用一种方法更准确

4.2 什么是 KNN 召回?什么是 BM25 Rescore?

  • KNN 召回:在向量空间里找”距离最近”的文档(类似”找邻居”)
  • BM25 Rescore:对 KNN 召回的结果重新评分(用 BM25 算法),提高准确率

4.3 什么是 Embedding?为什么要用 Embedding?

  • Embedding 是把”文字”转换成”向量”的过程
  • 好处:
    1. 语义理解:向量相似的文本,意思也相似
    2. 跨语言:不同语言的文本,可以转换成同一个向量空间(比如中文”苹果”和英文”apple”的向量很接近)

4.4 什么是权限过滤?为什么要用权限过滤?

  • 权限过滤是确保用户只能看到自己有权限的文档
  • 好处:
    1. 安全性:防止用户看到敏感文档(比如”技术部”的文档,不能让”产品部”的人看到)
    2. 合规性:符合数据保护法规(比如 GDPR)

五、总结

KnowFlow 的混合检索

  1. 向量检索:用 KNN 算法召回”语义相似”的结果
  2. 文本匹配:用 BM25 算法过滤不相关的结果
  3. 权限过滤:确保用户只能看到自己有权限的文档
  4. Rescore:结合向量相似度和文本相似度,重新排序

源码里的问题(❗❗)

  1. KNN 召回窗口太大,影响性能
  2. 权限过滤放在 KNN 查询里,影响性能
  3. 没有分页,返回结果太多

修复方案

  1. 动态调整召回窗口(topK * 10,最多 500 个)
  2. 先权限过滤,再 KNN 召回
  3. 支持分页(from + size

下一篇预告:《KnowFlow 项目学习笔记(三):安全 + 部署深度解析》


参考资料


最后更新:2026-06-08 22:00:00


KnowFlow 项目学习笔记(二):混合检索深度解析
https://whyalwaysme.lol/2026/06/08/2026-06-08-knowflow-hybrid-search-learn/
作者
Cassiur
发布于
2026年6月8日
许可协议