最新下载
热门教程
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
AI 应用开发入门:从关键词匹配到 TopK,手写最小 RAG 检索器
时间:2026-09-17 18:40:01 编辑:袖梨 来源:一聚教程网
RAG 系统能否给出可靠回答,首先取决于检索阶段有没有找到真正相关的知识。为了看清这一过程,可以暂时绕开复杂的向量模型,从 Java 字符串匹配开始搭建一个最小检索器,再逐步加入相关度计分、多知识召回和 TopK 排序,并观察关键词方案在语义理解上的边界。
AI 应用开发(上):从关键词检索到 TopK——手写一个最小 RAG 检索器
学习路线:LLM → Streaming → RAG → Embedding → Vector Database → Agent
Day4 目标:继续深入 RAG 的 Retrieve 阶段,从最简单的关键词检索开始,逐步实现多知识召回和 TopK 排序,并理解为什么传统关键词检索最终会遇到瓶颈。
一、重新理解 RAG
前一天已经完成了一个最简单的 RAG:
用户问题
↓
Retrieve:检索知识
↓
Augment:把知识加入 Prompt
↓
Generate:让 LLM 根据知识回答
也就是:
Question
↓
Retrieve
↓
Context
↓
Prompt
↓
LLM
↓
Answer
Day4 不再关注 LLM 本身,而是重点研究:
Retrieve 到底应该怎么做?
因为 RAG 的效果,很大程度上取决于能不能找到正确的知识。
二、最简单的关键词检索
最开始,可以直接使用 Java 的 contains()。
例如知识库:
List<String> docs = List.of(
"Redis 是基于内存的高性能 Key-Value 数据库。",
"MySQL 是一种关系型数据库。",
"Spring Boot 用于快速开发 Java 应用。"
);
用户问题:
Redis 是什么?
最简单的检索逻辑:
if (question.contains("Redis")) {
return redisKnowledge;
}
这种方式非常简单。
本质上做的是:
用户问题
↓
字符串匹配
↓
是否包含关键词?
↓
返回知识
例如:
问题:
Redis 为什么这么快?
contains("Redis")
↓
true
↓
找到 Redis 知识
对于简单场景,这已经能够工作。
三、为什么不能一直使用 contains()?
问题很快就出现了。
假设知识库中有:
Redis 是基于内存的高性能 Key-Value 数据库。
用户的问题是:
为什么这种内存数据库性能这么高?
作为人类,我们很容易知道:
内存数据库
↓
Redis
但是程序执行:
question.contains("Redis")
结果是:
false
因为问题里面根本没有出现:
Redis
所以传统关键词检索失败了。
这暴露出了关键词检索的本质:
关键词检索判断的是“文字是否匹配”,而不是“语义是否相似”。
四、先解决一个简单问题:知识库和检索逻辑分离
如果直接把知识写死在代码里:
if (question.contains("Redis")) {
return "Redis 是基于内存的高性能 Key-Value 数据库。";
}
随着知识越来越多,代码会越来越难维护。
因此先把知识库单独抽出来:
private final Map<String, String> knowledgeBase = Map.of(
"Redis", "Redis 是基于内存的高性能 Key-Value 数据库。",
"MySQL", "MySQL 是一种关系型数据库。",
"Spring Boot", "Spring Boot 用于快速开发 Java 应用。"
);
这样就形成了:
knowledgeBase
↓
保存知识
retrieve()
↓
负责检索
知识和检索逻辑开始解耦。
五、一个知识为什么可以对应多个关键词?
仅仅使用:
Redis
一个关键词显然不够。
例如下面几个问题:
Redis 是什么?
Redis 为什么这么快?
Redis 是数据库吗?
Redis 可以做缓存吗?
它们表达不同,但都和 Redis 有关。
于是可以人为建立一个简单的“语义词典”:
private final Map<String, List<String>> semanticMap = Map.of(
"Redis", List.of(
"redis",
"缓存",
"内存数据库",
"高性能",
"key-value"
),
"MySQL", List.of(
"mysql",
"关系型数据库",
"sql",
"表",
"数据库"
),
"Spring Boot", List.of(
"spring boot",
"java框架",
"微服务",
"spring"
)
);
这里需要特别注意:
这个 semanticMap 并不是真正的 Embedding。
它只是为了学习 RAG 原理,人为建立的一组相关关键词。
可以把它理解成:
Redis
├── redis
├── 缓存
├── 内存数据库
├── 高性能
└── key-value
六、从“命中”升级到“打分”
如果一个问题同时命中了多个关键词,那么它可能和这个知识更加相关。
例如:
Redis 为什么这么快?
可能命中:
redis
高性能
那么可以定义:
score = 2
而另一个知识:
MySQL
可能完全没有命中:
score = 0
因此检索过程变成:
遍历知识
↓
遍历该知识的关键词
↓
检查是否命中
↓
命中一次 score++
代码核心:
int score = 0;
for (String keyword : semanticKeywords) {
if (lowerQuestion.contains(keyword.toLowerCase())) {
score++;
}
}
这时候,检索结果不再只是:
找到 / 没找到
而是:
知识 + 相关度分数
七、为什么需要 KnowledgeScore?
为了保存:
知识
+
score
创建一个简单的 DTO:
@Data
@AllArgsConstructor
public class KnowledgeScore {
private String knowledge;
private Integer score;
}
于是:
KnowledgeScore
就代表:
一条知识
+
这条知识和用户问题的相关程度
例如:
MySQL
score = 2
Redis
score = 1
八、从单知识召回到多知识召回
最开始的 RAG 可能是:
return docs.get(0);
也就是说:
找到一个知识就结束。
但现实中的一个问题,往往涉及多个知识。
例如:
Redis 和 MySQL 有什么区别?
这个问题同时涉及:
Redis
MySQL
所以不能找到 Redis 就停止。
需要继续遍历整个知识库:
Question
↓
Redis → score=1
MySQL → score=2
Spring Boot → score=0
最终保留:
Redis
MySQL
然后一起放入 Context。
这就是:
多知识召回。
九、TopK 到底是什么?
如果知识库只有 3 条:
Redis
MySQL
Spring Boot
全部返回似乎没什么问题。
但真实项目中可能有:
10,000 条
100,000 条
1,000,000 条
不可能把所有知识都塞进 Prompt。
因此需要:
Score
↓
排序
↓
取前 K 条
这就是 TopK。
例如:
Redis score=0.91
MySQL score=0.23
Spring Boot score=0.05
Docker score=0.01
Linux score=0.01
如果:
K = 3
那么:
TOP 1 → Redis
TOP 2 → MySQL
TOP 3 → Spring Boot
代码:
scores.sort(
Comparator.comparingInt(
KnowledgeScore::getScore
).reversed()
);
然后:
List<String> results =
scores.stream()
.limit(3)
.map(KnowledgeScore::getKnowledge)
.toList();
更推荐把 3 抽成常量:
private static final int TOP_K = 3;
然后:
.limit(TOP_K)
这样以后修改 TopK 数量更加方便。
十、完整的检索链路
到这里,我们的 RAG Retrieve 已经从:
Question
↓
contains()
↓
找到知识
升级成:
Question
↓
遍历知识库
↓
遍历每个知识的关键词
↓
计算 score
↓
Knowledge + Score
↓
排序
↓
TopK
↓
返回多个知识
完整流程:
用户问题
↓
遍历知识库
↓
┌────────┴────────┐
↓ ↓
Redis MySQL
↓ ↓
计算 Score 计算 Score
↓ ↓
score=1 score=2
└────────┬────────┘
↓
Sort
↓
TopK
↓
Context
↓
Prompt
↓
LLM
十一、实际测试
使用 Apifox 发送:
{
"message": "Redis和MySQL有什么区别?"
}
程序可以打印:
========== RAG Retrieve ==========
用户问题:Redis和MySQL有什么区别?
检查知识:MySQL
-> 检查语义词:mysql
√ 命中:mysql
-> 检查语义词:sql
√ 命中:sql
=> 总分:2
检查知识:Redis
-> 检查语义词:redis
√ 命中:redis
=> 总分:1
然后:
========== 排序前 ==========
MySQL | score=2
Redis | score=1
排序:
========== TopK 排序后 ==========
MySQL | score=2
Redis | score=1
最终:
MySQL 是一种关系型数据库。
Redis 是基于内存的高性能 Key-Value 数据库。
再把这些知识加入 Prompt:
已知知识:
MySQL 是一种关系型数据库。
Redis 是基于内存的高性能 Key-Value 数据库。
请根据以上知识回答问题:
Redis和MySQL有什么区别?
至此,一个最小的:
关键词 + Score + TopK + RAG
就真正跑通了。
十二、一次有意思的 Bug
在测试过程中出现了一个非常典型的问题:
mysql
竟然命中了:
sql
原因非常简单:
"mysql".contains("sql")
实际上是:
true
因为:
mysql
↓
my + sql
这说明:
contains() 本质上只是字符串匹配,并不是真正的语义理解。
这也是今天非常重要的一个认识:
关键词命中 ≠ 语义相关
十三、今天真正学到的东西
今天并不是简单地写了一个关键词搜索。
实际上我们把 RAG 的检索过程拆开了:
Retrieve
↓
候选知识
↓
相关度计算
↓
Score
↓
Ranking
↓
TopK
其中:
Score
代表相关度。
Ranking
代表按照相关度排序。
TopK
代表只保留最相关的 K 条知识。
而真正的 Embedding RAG,依然会保留这套骨架。
真正改变的是:
Score 到底怎么计算。
今天我们使用:
关键词命中数量
以后会变成:
向量相似度
这就是下一阶段学习 Embedding 的入口。
Day4 上半场总结
今天的 RAG:
用户问题
↓
关键词匹配
↓
Score
↓
Sort
↓
TopK
↓
Context
↓
LLM
虽然简单,但它让我们真正理解了:
RAG 的核心不是“把知识库接给 LLM”,而是如何从大量知识中找到真正与问题相关的知识。
而关键词检索已经开始暴露问题:
“Redis为什么这么快?”
VS
“为什么这种内存数据库性能这么高?”
人类认为语义高度相关。
但:
contains()
无法理解。
因此下一部分的问题就非常自然:
有没有一种方法,可以不比较字符串,而是比较两个文本的“语义”?
答案就是: