AI 应用开发(上):从关键词检索到 TopK——手写一个最小 RAG 检索器

AI 应用开发(上):从关键词检索到 TopK------手写一个最小 RAG 检索器

学习路线:LLM → Streaming → RAG → Embedding → Vector Database → Agent

Day4 目标:继续深入 RAG 的 Retrieve 阶段,从最简单的关键词检索开始,逐步实现多知识召回和 TopK 排序,并理解为什么传统关键词检索最终会遇到瓶颈。


一、重新理解 RAG

前一天已经完成了一个最简单的 RAG:

markdown 复制代码
用户问题
    ↓
Retrieve:检索知识
    ↓
Augment:把知识加入 Prompt
    ↓
Generate:让 LLM 根据知识回答

也就是:

markdown 复制代码
Question
    ↓
Retrieve
    ↓
Context
    ↓
Prompt
    ↓
LLM
    ↓
Answer

Day4 不再关注 LLM 本身,而是重点研究:

Retrieve 到底应该怎么做?

因为 RAG 的效果,很大程度上取决于能不能找到正确的知识。


二、最简单的关键词检索

最开始,可以直接使用 Java 的 contains()。

例如知识库:

arduino 复制代码
List<String> docs = List.of(
        "Redis 是基于内存的高性能 Key-Value 数据库。",
        "MySQL 是一种关系型数据库。",
        "Spring Boot 用于快速开发 Java 应用。"
);

用户问题:

复制代码
Redis 是什么?

最简单的检索逻辑:

kotlin 复制代码
if (question.contains("Redis")) {
    return redisKnowledge;
}

这种方式非常简单。

本质上做的是:

markdown 复制代码
用户问题
    ↓
字符串匹配
    ↓
是否包含关键词?
    ↓
返回知识

例如:

sql 复制代码
问题:
Redis 为什么这么快?

contains("Redis")
        ↓
true
        ↓
找到 Redis 知识

对于简单场景,这已经能够工作。


三、为什么不能一直使用 contains()?

问题很快就出现了。

假设知识库中有:

vbnet 复制代码
Redis 是基于内存的高性能 Key-Value 数据库。

用户的问题是:

复制代码
为什么这种内存数据库性能这么高?

作为人类,我们很容易知道:

markdown 复制代码
内存数据库
    ↓
Redis

但是程序执行:

arduino 复制代码
question.contains("Redis")

结果是:

arduino 复制代码
false

因为问题里面根本没有出现:

复制代码
Redis

所以传统关键词检索失败了。

这暴露出了关键词检索的本质:

关键词检索判断的是"文字是否匹配",而不是"语义是否相似"。


四、先解决一个简单问题:知识库和检索逻辑分离

如果直接把知识写死在代码里:

kotlin 复制代码
if (question.contains("Redis")) {
    return "Redis 是基于内存的高性能 Key-Value 数据库。";
}

随着知识越来越多,代码会越来越难维护。

因此先把知识库单独抽出来:

typescript 复制代码
private final Map<String, String> knowledgeBase = Map.of(
        "Redis", "Redis 是基于内存的高性能 Key-Value 数据库。",
        "MySQL", "MySQL 是一种关系型数据库。",
        "Spring Boot", "Spring Boot 用于快速开发 Java 应用。"
);

这样就形成了:

markdown 复制代码
knowledgeBase
    ↓
保存知识

retrieve()
    ↓
负责检索

知识和检索逻辑开始解耦。


五、一个知识为什么可以对应多个关键词?

仅仅使用:

复制代码
Redis

一个关键词显然不够。

例如下面几个问题:

复制代码
Redis 是什么?

Redis 为什么这么快?

Redis 是数据库吗?

Redis 可以做缓存吗?

它们表达不同,但都和 Redis 有关。

于是可以人为建立一个简单的"语义词典":

typescript 复制代码
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 原理,人为建立的一组相关关键词。

可以把它理解成:

vbnet 复制代码
Redis
 ├── redis
 ├── 缓存
 ├── 内存数据库
 ├── 高性能
 └── key-value

六、从"命中"升级到"打分"

如果一个问题同时命中了多个关键词,那么它可能和这个知识更加相关。

例如:

复制代码
Redis 为什么这么快?

可能命中:

复制代码
redis
高性能

那么可以定义:

ini 复制代码
score = 2

而另一个知识:

复制代码
MySQL

可能完全没有命中:

ini 复制代码
score = 0

因此检索过程变成:

markdown 复制代码
遍历知识
    ↓
遍历该知识的关键词
    ↓
检查是否命中
    ↓
命中一次 score++

代码核心:

ini 复制代码
int score = 0;

for (String keyword : semanticKeywords) {

    if (lowerQuestion.contains(keyword.toLowerCase())) {
        score++;
    }
}

这时候,检索结果不再只是:

复制代码
找到 / 没找到

而是:

复制代码
知识 + 相关度分数

七、为什么需要 KnowledgeScore?

为了保存:

diff 复制代码
知识
+
score

创建一个简单的 DTO:

less 复制代码
@Data
@AllArgsConstructor
public class KnowledgeScore {

    private String knowledge;

    private Integer score;
}

于是:

复制代码
KnowledgeScore

就代表:

diff 复制代码
一条知识
+
这条知识和用户问题的相关程度

例如:

ini 复制代码
MySQL
score = 2

Redis
score = 1

八、从单知识召回到多知识召回

最开始的 RAG 可能是:

kotlin 复制代码
return docs.get(0);

也就是说:

找到一个知识就结束。

但现实中的一个问题,往往涉及多个知识。

例如:

复制代码
Redis 和 MySQL 有什么区别?

这个问题同时涉及:

复制代码
Redis
MySQL

所以不能找到 Redis 就停止。

需要继续遍历整个知识库:

ini 复制代码
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。

例如:

ini 复制代码
Redis        score=0.91
MySQL        score=0.23
Spring Boot  score=0.05
Docker       score=0.01
Linux        score=0.01

如果:

ini 复制代码
K = 3

那么:

css 复制代码
TOP 1 → Redis
TOP 2 → MySQL
TOP 3 → Spring Boot

代码:

less 复制代码
scores.sort(
        Comparator.comparingInt(
                KnowledgeScore::getScore
        ).reversed()
);

然后:

scss 复制代码
List<String> results =
        scores.stream()
                .limit(3)
                .map(KnowledgeScore::getKnowledge)
                .toList();

更推荐把 3 抽成常量:

arduino 复制代码
private static final int TOP_K = 3;

然后:

bash 复制代码
.limit(TOP_K)

这样以后修改 TopK 数量更加方便。


十、完整的检索链路

到这里,我们的 RAG Retrieve 已经从:

scss 复制代码
Question
    ↓
contains()
    ↓
找到知识

升级成:

markdown 复制代码
Question
    ↓
遍历知识库
    ↓
遍历每个知识的关键词
    ↓
计算 score
    ↓
Knowledge + Score
    ↓
排序
    ↓
TopK
    ↓
返回多个知识

完整流程:

ini 复制代码
用户问题
                   ↓
             遍历知识库
                   ↓
          ┌────────┴────────┐
          ↓                 ↓
       Redis              MySQL
          ↓                 ↓
      计算 Score         计算 Score
          ↓                 ↓
       score=1            score=2
          └────────┬────────┘
                   ↓
                 Sort
                   ↓
                 TopK
                   ↓
               Context
                   ↓
                Prompt
                   ↓
                  LLM

十一、实际测试

使用 Apifox 发送:

json 复制代码
{
    "message": "Redis和MySQL有什么区别?"
}

程序可以打印:

ini 复制代码
========== RAG Retrieve ==========

用户问题:Redis和MySQL有什么区别?

检查知识:MySQL
  -> 检查语义词:mysql
     √ 命中:mysql

  -> 检查语义词:sql
     √ 命中:sql

  => 总分:2


检查知识:Redis
  -> 检查语义词:redis
     √ 命中:redis

  => 总分:1

然后:

ini 复制代码
========== 排序前 ==========

MySQL | score=2
Redis | score=1

排序:

ini 复制代码
========== TopK 排序后 ==========

MySQL | score=2
Redis | score=1

最终:

vbnet 复制代码
MySQL 是一种关系型数据库。

Redis 是基于内存的高性能 Key-Value 数据库。

再把这些知识加入 Prompt:

vbnet 复制代码
已知知识:

MySQL 是一种关系型数据库。

Redis 是基于内存的高性能 Key-Value 数据库。

请根据以上知识回答问题:

Redis和MySQL有什么区别?

至此,一个最小的:

关键词 + Score + TopK + RAG

就真正跑通了。


十二、一次有意思的 Bug

在测试过程中出现了一个非常典型的问题:

复制代码
mysql

竟然命中了:

sql 复制代码
sql

原因非常简单:

sql 复制代码
"mysql".contains("sql")

实际上是:

arduino 复制代码
true

因为:

perl 复制代码
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",而是如何从大量知识中找到真正与问题相关的知识。

而关键词检索已经开始暴露问题:

markdown 复制代码
"Redis为什么这么快?"

        VS

"为什么这种内存数据库性能这么高?"

人类认为语义高度相关。

但:

scss 复制代码
contains()

无法理解。

因此下一部分的问题就非常自然:

有没有一种方法,可以不比较字符串,而是比较两个文本的"语义"?

答案就是:

Embedding

相关推荐
henrylin999926 分钟前
RD-AGENT 第一讲 · AI 因子工厂是怎么运转的
人工智能
高洁0137 分钟前
具身智能中的世界模型训练
人工智能·python·深度学习·机器学习·transformer
无线通信科研笔记39 分钟前
IEEE TVT 2026 论文精读与完整复现|相位误差如何重塑近场 RIS 的幅相响应
论文阅读·人工智能·python·算法·论文笔记
Devlive 开源社区42 分钟前
AuthX 正式更名 GrantForge:我们重新做了一遍权限管理系统
大数据·人工智能·架构
朝朝辞暮i1 小时前
VLA 系统学习第 1 课:VLA 到底在干什么?
人工智能·python·计算机视觉·vla
鲲穹AI种草1 小时前
AI 壁纸生成工具怎么选?鲲穹 AI 壁纸工具功能实测与横向对比
人工智能·壁纸生成工具
科技林总1 小时前
向量与重排模型
人工智能
楚楚2511 小时前
2026企业AI办公工具选型指南:落地评估与效果衡量体系
大数据·人工智能
小马9261 小时前
2026年9月30日热点速览:AI智能体大战升级、房贷贴息新政落地、硬科技多点突破
人工智能·科技·chatgpt
SPFFC189380330531 小时前
旋翼式水表工作原理智能水表工作原理
人工智能·显示器·智能手表·平板·大屏端