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

相关推荐
一次旅行1 小时前
2026‑09‑16 AI产业深度解读|GPT‑5.5即将下线迁移、AI安全路线大辩论、Perplexity自研数据库大幅降本
人工智能·安全·开源
llilian_161 小时前
北斗授时卡同步解决方案 gnss授时卡 计算机时间同步板卡
大数据·网络·人工智能·功能测试·51单片机
小星星_20261 小时前
16 万行代码是怎么“跑”出来的?
人工智能·全栈
Vicky_time1 小时前
跨境物流系统架构:美国海外仓与FBA中转海外仓选型技术方案评测
网络·人工智能
乔氪智造1 小时前
英伟达 129 亿买下开源模型的默认入口
人工智能
_夜半钟声到客船1 小时前
面试官:LangSmith 和 LangFuse 区别?我:先讲 OpenTelemetry 标准,他沉默了
人工智能·ai·langchain·可用性测试
ClouGence1 小时前
从 Prompt 到可复用作业辅导助手:我搭了一个 AI 作业批改工作流
人工智能·aigc·ai编程
m0_734571761 小时前
深入理解人工智能 大语言模型的训练方法
人工智能
小程故事多_802 小时前
从线性建模到网络语义重构,AI本体建模平台技术落地与实践复盘
人工智能