写在前面:前三篇我们搭了 RAG、打了三个补丁(分诊、拆题、联网兜底)。但有一块硬伤还没修——"高血糖"和"低血糖"在向量数据库里几乎是同一个东西。 readme 把这个问题的本质说得非常准:"专业术语、精确实体更适合关键词检索,纯语义检索容易匹配不准。" 更狠的是 readme 里那句点评——"Mysql 是原始数据,es 是特种兵(关键词搜索)。" 这篇我们看三件事:ES 的 CRUD 怎么用、为什么需要"关键词 + 语义"两条腿走路、以及 ReRank 这个裁判是干嘛的。以下所有代码和概念均来自课堂真实文件。
一、病根:为什么"高血糖"和"低血糖"几乎一样
先把这个病讲透,因为它决定了后面所有方案。
Embedding 的视角
向量检索的原理是把文字转成 1024 维的坐标点,然后找"距离最近"的。那么:
"高血糖" → [0.12, -0.34, 0.56, ..., 0.78]
"低血糖" → [0.13, -0.33, 0.55, ..., 0.77]
↑
几乎重合
两个意思完全相反的词,向量坐标几乎一样。
为什么会这样?因为 Embedding 模型学的是"这些词在什么语境里出现":
- 两个词都跟"血糖""胰岛素""糖尿病""饮食控制"这些概念强关联
- 出现的位置、搭配的动词、所在的句子结构都极度相似
- 唯一的区别是那个"高"和"低"
模型眼里的世界是"语义场",它不太擅长捕捉"一字之差"的方向性差异。
readme 直接把这个例子写进了笔记:
"高血糖、低血糖 自然语义相似度 相近"
这在专业场景里有多致命
| 领域 | 差一个字的后果 |
|---|---|
| 医疗 | 高血糖要控糖,低血糖要补糖——方案完全相反 |
| 法律 | "有期徒刑三年" vs "十年" |
| 代码 | useState vs useEffect |
| 金融 | 买入建议 vs 卖出建议 |
| 化学 | 一氧化碳 vs 二氧化碳 |
在通用闲聊场景,"差不多就行"能接受。但在专业场景,"差不多"等于"错了"。
readme 给的解药
"解决方案:同时结合关键词检索和语义检索,由模型统一融合多路结果,提升专业场景准确率。"
翻译一下——别指望一个检索器包打天下,让两个各有长短的检索器同时上,再把结果合起来。
而关键词检索这一路的选手,就是 ElasticSearch。
二、ES 的两把武器:text 和 keyword
create.mjs 是一个真实可跑的 ES 建索引脚本。我们从它入手。
先建索引:这里没有"表"
import { Client } from '@elastic/elasticsearch';
const client = new Client({
node: 'http://localhost:9200',
})
const INDEX_NAME = 'travel-journal';
这就是 ES 的"数据库连接"。然后:
async function createIndex() {
const exists = await client.indices.exists({
index: INDEX_NAME,
})
if (!exists) {
console.log(`索引已存在 ${INDEX_NAME}`)
return;
}
await client.indices.create({
index: INDEX_NAME,
mappings: {
properties: {
// ... 字段定义
}
}
});
console.log(`索引创建成功 ${INDEX_NAME}`)
}
对比一下 MySQL 的建表:
| MySQL | ES |
|---|---|
CREATE DATABASE | 索引(Index) |
CREATE TABLE | 索引(Index)下的 mappings |
字段名 类型 | properties: { 字段名: { type: ... } } |
注意 ES 里没有"库-表"两级——一个 Index 既是"表"也是"库"。 这也解释了 readme 里那句容易看懵的话:
// mysql 行列,milvus 语义 es 关键词索引 id
三种存储各管一段:MySQL 管行列结构化数据、Milvus 管语义、ES 管关键词和 ID 索引。
mappings:字段类型决定"怎么搜"
mappings: {
properties: {
note_title: {
type: 'text',
analyzer: 'ik_max_word',
search_analyzer: 'ik_max_smart',
},
note_body: {
type: 'text',
analyzer: 'ik_max_word',
search_analyzer: 'ik_max_smart',
},
tags: {type: 'keyword'},
mood: {type: 'keyword'},
priority: {type: "integer"},
create_time: {type: 'date'},
update_time: {type: 'date'},
}
}
七个字段,三种类型,各有分工:
| 字段 | 类型 | 为什么 |
|---|---|---|
note_title | text | 标题要能被搜到关键词 → 分词 |
note_body | text | 正文要全文检索 → 分词 |
tags | keyword | 标签是精确值 → 不分词 |
mood | keyword | 心情是个枚举值 → 不分词 |
priority | integer | 数字能比大小 → 用数值类型 |
create_time / update_time | date | 时间能排序、能范围查 → 用日期类型 |
text 和 keyword 的区别是这篇最重要的知识点之一。
readme 里那句话点得很清楚:
"title,content 分词 type: text 全文检索。author 不分词 精确匹配。"
用《天龙八部》举例,假设标题是"虚竹破珍珑棋局":
text 类型(分词后存入):
["虚竹", "破", "珍珑", "棋局", "珍珑棋局"]
→ 搜"虚竹"能搜到,搜"棋局"也能搜到 ✅
keyword 类型(整体存入):
["虚竹破珍珑棋局"]
→ 搜"虚竹"搜不到,必须整串完全匹配 ❌
反过来,标签用 keyword 就对了:
tags: ["旅行", "周末", "杭州"]
keyword 类型 → 存成 ["旅行", "周末", "杭州"]
→ 精确匹配"杭州",一击即中 ✅
如果 tags 用 text → 存成 ["旅", "行", "周", "末", "杭", "州"]
→ 搜"杭"也能命中,全是噪音 ❌
记住这个判断口诀:要"搜内容"用 text,要"精确匹配"用 keyword。
ik 分词器:存用细的,查用粗的
上面那个 ik_max_word 和 search_analyzer 是什么?
readme 有一句很精准的总结:
"ik_max_word + ik_smart 中文友好分词器的两种。存的时候 ik_max_word 尽量多的存索引,粒度更细。ik_smart 去的时候,粒度粗一点。"
| 场景 | 分词器 | 粒度 | 为什么 |
|---|---|---|---|
| 存入时 | ik_max_word | 细(尽可能多切词) | 切得越细,能被搜到的机会越多 |
| 查询时 | ik_smart | 粗(切最合理的) | 查询词切太细会引入噪音 |
用"中华人民共和国"举例:
ik_max_word(存):中华人民共和国 / 中华人民 / 中华 / 华人 / 人民 / 共和国 / 共和 / 国
↑ 切得很碎,各种搜法都能命中
ik_smart(查):中华人民共和国 / 中华 / 人民 / 共和国
↑ 切得克制,避免噪音
"存的时候撒网,查的时候收网" ——这个不对称设计很有讲究:
- 存得细 → 用户用任何切法搜,都可能命中(提高召回率)
- 查得粗 → 避免查询词被切碎后匹配到一堆无关内容(提高准确率)
这就是 readme 说的"存的时候尽量多存索引"的用意——用存储空间换检索命中率。
一个容易记混的提醒:ES 的中文分词器只有两个名字——ik_max_word 和 ik_smart。课堂这份配置里 search_analyzer 写成了 ik_max_smart,这个名字在 ES 里不存在——两个分词器的名字很像,改配置时容易串,建议直接查官方文档核对。
三条种子数据
async function seedData() {
const now = new Date().toISOString();// 当前时间
const docs = [
{
note_title: '杭州西湖半日游',
note_body: '早上绕湖慢跑,中午吃片儿川,下午在断桥拍照放松。',
tags: ['旅行', '周末', '杭州'],
mood: 'relaxed',
priority: 2,
create_time: now,
update_time: now
},
{
note_title: '城市骑行计划',
note_body: '周六沿江骑行 20 公里,带上水和简易修车工具。',
tags: ['运动', '骑行'],
mood: 'energetic',
priority: 3,
create_time: now,
update_time: now
},
{
note_title: '雨天宅家阅读',
note_body: '下雨天在家看书,整理本周笔记并做晚餐。',
tags: ['生活', '阅读'],
mood: 'calm',
priority: 1,
create_time: now,
update_time: now
}
];
三条"旅行日志"——西湖慢跑、城市骑行、雨天阅读。数据设计得很贴近真实场景:有标签、有心情、有优先级、有时间戳。
new Date().toISOString() 生成标准 ISO 时间格式(2026-09-23T10:30:00.000Z)——这正好匹配 mappings 里声明的 type: 'date'。
bulk 批量插入:一个巧妙的数组构造
const operations =
docs.flatMap((doc) => [{index: {_index: INDEX_NAME}}, doc])
// 批量插入
console.log(operations);
await client.bulk({ // 批量插入
operations,
refresh: true, // 立即刷新索引
})
}
这是 ES bulk API 的规定格式,值得单独讲。
ES 的批量接口要求的是"动作行 + 数据行"交替出现的扁平数组:
[
{ index: { _index: 'travel-journal' } }, // ← 动作:要写入
{ note_title: '杭州西湖半日游', ... }, // ← 数据:写什么
{ index: { _index: 'travel-journal' } },
{ note_title: '城市骑行计划', ... },
{ index: { _index: 'travel-journal' } },
{ note_title: '雨天宅家阅读', ... },
]
flatMap 干的就是这个——一条数据变两行。 它比 map 多一步"拍平":
map: [{doc1}] → [[动作1, doc1], [动作2, doc2], [动作3, doc3]] ← 嵌套数组
flatMap: [{doc1}] → [动作1, doc1, 动作2, doc2, 动作3, doc3] ← 拍平成一维
一次 bulk 请求,三条数据一次写完——比循环调三次单条插入快得多,也少两次网络往返。
refresh: true 是另一个关键参数:
refresh: true, // 立即刷新索引
ES 的写入默认是"准实时"的——数据先写到内存缓冲,隔一段时间(默认 1 秒)才真正"可见"。加 refresh: true 强制立即刷新,保证脚本跑完马上就能搜到数据。
开发调试时这个参数很有用(不然你会以为"插入失败了");但生产环境批量写入时要慎用——频繁 refresh 会严重影响性能。
三、CRUD:ES 的操作跟想的一样
operate.mjs 演示了四个基本操作。
查:get 一个文档
async function getDocument(docId) {
const res = await client.get({
index: INDEX_NAME,
id: docId,
});
console.log('查询结果', res._source);
}
res._source —— ES 里存的那份原始 JSON 就叫 _source。 这个名字很形象:ES 会给数据加一堆元信息(索引、版本号、分数等),_source 是"你当初存进去的那份原始数据"。
增:index 一个文档
async function createDocument() {
const now = new Date().toISOString();
const res = await client.index({
index: INDEX_NAME,
document: {
note_title: '夜跑复盘',
note_body: '昨晚夜跑 10 公里,感觉很累。',
tags: ['运动', '夜跑'],
mood: 'focused',
priority: 2,
create_time: now,
update_time: now
}
});
console.log(`新增 成功,ID=`, res._id);
return res._id;
}
ES 里没有 insert,用的是 index。
这个命名差异很有意思——index 的语义是"建立索引条目",它同时覆盖了"新增"和"覆盖"两种情况:给定 ID 就是替换,不给 ID 就新建。
res._id 是 ES 自动生成的 ID。代码里注释掉的那行长这样:
// const docId = 'R3bgvaABnL1oJIcDzsG6'
这就是 ES 自动 ID 的真实长相——一长串看似随机的字符(内部是 URL-safe 的 base64 编码 UUID)。它唯一,但不携带任何语义,这正是我们上一篇讲"手动生成可读 ID"时对比的那种反面例子。
改:update 局部字段
async function updateDocument(docId) {
const res = await client.update({
index: INDEX_NAME,
id: docId,
doc: {
note_body:'昨晚夜跑 6 公里,状态不错,拉伸后恢复很快',
tags: ['运动', '夜跑', '训练'],
update_time: new Date().toISOString(),
},
refresh: true
});
console.log('更新成功', res);
}
注意 doc 里只写了三个字段——这是局部更新(partial update)。 没写的字段(note_title、mood、priority)保持原样。
对比一下前后数据的变化:
| 字段 | 原值 | 新值 |
|---|---|---|
note_body | 昨晚夜跑 10 公里,感觉很累。 | 昨晚夜跑 6 公里,状态不错,拉伸后恢复很快 |
tags | ['运动', '夜跑'] | ['运动', '夜跑', '训练'] |
update_time | 创建时间 | 当前时间 |
这次修改很有意思——跑量从 10 公里改成了 6 公里。 大概是跑完一查,发现记错了。用一个"更正记录"的场景来演示 update,比用"改个标题"自然得多。
搜:match 查询 + ik_smart
async function searchDocument() {
const res = await client.search({
index: INDEX_NAME,
query: {
match: {
note_body: {
query: '夜跑',
analyzer: 'ik_smart',
},
}
}
});
console.log(res);
const rows = res.hits.hits.map(item => ({
id: item._id,
...item._source
}));
console.log('查询结果', rows);
}
这就是查询时的分词器——analyzer: 'ik_smart'。 前面 mappings 里那个 search_analyzer 是"这个字段默认用什么查询分词器",这里则是在具体查询里显式指定一次。
查询结构层数有点多,拆开看:
client.search
├── index: 'travel-journal' 在哪个索引里搜
└── query
└── match match 查询(会分词)
└── note_body 搜哪个字段
├── query: '夜跑' 搜什么
└── analyzer: ik_smart 用哪个分词器
为什么用 match 而不是 term?
| 查询类型 | 行为 | 适合 |
|---|---|---|
match | 先分词,再匹配 | 搜 text 字段(全文本) |
term | 不分词,精确匹配 | 搜 keyword 字段(标签、枚举) |
你要搜"夜跑"这两个字出现在正文里,就得用 match——因为它会先把查询词分词,再跟正文的分词结果比对。
结果放在 res.hits.hits 里——两层 hits:
res.hits.total 有多少条命中
res.hits.hits 命中的文档数组
└── [0]._id 文档 ID
[0]._score 相关度分数 ← BM25 打分!
[0]._source 原始数据
注意 _score ——ES 的全文检索结果天然带相关性分数,这个分数用的是 BM25 算法(readme 里提到的)。这意味着 ES 不只是"筛出含这个词的文档",还会按相关程度排序。
代码把结果整理成扁平对象:
const rows = res.hits.hits.map(item => ({
id: item._id,
...item._source
}));
...item._source 展开原始数据,加上 id——又是一个"展开合并"的用法(前面在 Python 的 **model_dump() 里见过对称写法)。
一个值得注意的细节
async function main() {
// const docId = await createDocument();
// const docId = 'R3bgvaABnL1oJIcDzsG6'
// console.log(docId);
// await getDocument(docId);
await searchDocument();
// await updateDocument(docId);
}
main()
.catch(console.error)
增、查、改三个操作全被注释掉了,只保留了搜索。
这不是偷懒——这是调试现场的真实样子。 先把数据准备好(跑一遍 create.mjs),然后集中验证"检索"这一个动作。用完了就把其他操作注掉,避免每次跑都重复写入数据。
读代码的时候,被注释掉的部分往往比留下的部分信息量更大——它记录了调试过程。
四、两条腿走路:混合检索
ES 学会了,现在回到那个病根——为什么要两个检索器。
readme 有一句极简的公式,值得全文抄下:
"混合检索 = es 关键词搜索 + milvus 相似度检索"
还有一句补充:
"混合检索RAG = Milvus 向量检索(语义,缺点是不准确)+ ES 文本检索(关键词,缺点语义不够丰富)"
注意括号里的"缺点"——两边都有短板,而且是互补的短板。
| 检索器 | 强在哪 | 弱在哪 |
|---|---|---|
| Milvus(向量) | 语义理解——"马铃薯"能搜到"土豆" | 不精确——高血糖/低血糖混淆 |
| ES(关键词) | 精确匹配——差一个字都不算命中 | 不聪明——换个说法就搜不到 |
readme 用三组词把这层关系画了出来:
=== 关键词 es 土豆 马铃薯 跨语言 tomato
+
like embedding 语义相近
同一个概念的两条检索路径:
用户搜"土豆"
ES 关键词路径: 字面匹配 → 命中所有含"土豆"的文档
❌ 搜不到只写了"马铃薯"的文档
Milvus 语义路径: 向量相似 → 命中"马铃薯"(语义相近)
✅ 跨词、跨语言都能找到(连 tomato 都有机会)
合起来,才是"既找得全、又找得准"。
readme 那句比喻也很传神:
"Mysql 原始数据的,es 是特种兵(关键词搜索)"
MySQL 是守仓库的(存原始数据),ES 是特种兵(精确打击关键词)。
五、裁判登场:ReRank 重排
两条腿都上了,新问题来了——腿多了,路也乱了。
召回多了反而更糟
readme 把这个矛盾写得很清楚:
"混合检索给到大模型特别多 document,我们需要筛选一下,混合召回的文档做一次重排序(rerank)把最相关、最有用、最能支撑回答的文档筛选出来,再增强 Prompt。"
为什么"召回更多"反而变成问题? readme 列了四条原因:
| 原因 | 后果 |
|---|---|
| 混合召回带来大量冗余信息 | 重复、相似的文档堆在一起 |
| 大模型上下文窗口有限 | 塞不下 |
| 噪声太多会让模型答非所问、逻辑混乱,幻觉增加 | 答得反而更差 |
| 先过滤,再精简,才能回答准确 | 结论 |
第三条是核心——信息多了不一定更好,噪声会干扰判断。 这跟前面"重复文档被误读为强调"是同一个道理:模型对"哪些内容更重要"的判断,会被无关内容稀释。
ReRank 是什么
readme 对 ReRank 模型的描述非常清晰:
"重排模型就是输入用户问题 + 一段文档,输出一个相关度分数。专门来给 RAG 做去噪、筛选,重新排序。体积小,推理快,成本极低。"
它的工作模式:
输入:{ 用户问题, 一段文档 }
↓
输出:一个分数(0.87)
然后按分数排序,取前 N 条。
几个关键特点:
| 特点 | 含义 |
|---|---|
| 输入是"问题+文档"成对 | 能看到两边的交互,比单纯的向量相似度更准 |
| 输出一个分数 | 结果是可比较、可排序的 |
| 体积小、推理快 | 100 条文档打 100 次分,也扛得住 |
| 成本极低 | 适合放在召回后做过滤 |
为什么它比向量相似度更准? 因为向量检索是"提前算好、之后只比对距离"——它不知道你今天问的是什么。而 ReRank 是"现场把问题和文档放一起过一遍模型"——能捕捉到上下文相关的细微匹配。
readme 把这个分工总结得很到位:
| 模型类型 | 干什么 |
|---|---|
| AIGC 模型 | 生成内容 |
| embedding 模型 | 把文本转成向量 |
| ReRank 模型 | 输入问题+文档,输出相关度分数 |
| jev 模型 | 100 倍速度、1/100 价格(课堂笔记里的另一类效率优化模型) |
注意 ES 那边的分工也顺带被点明了——readme 说 ES 全文检索的排序"BM25 给查询出来的文档打分"。
所以一条完整的检索链上,其实有三层打分:
| 层次 | 谁在打分 | 打什么分 |
|---|---|---|
| 第一层 | ES 的 BM25 | 关键词相关度 |
| 第一层 | Milvus 的 COSINE | 语义相似度 |
| 第二层 | ReRank 模型 | 问题+文档的精细相关度 |
前两层是"粗筛"(快但糙),第三层是"精排"(慢但准)。 这就是搜索引擎领域经典的"召回-排序"两阶段架构。
六、完整的混合检索流水线
readme 把整个流程写成了清晰的步骤:
用户输入 query
↓
拆分不同角度,三个子问题
↓
多路召回
├── 向量检索(Milvus)
└── 关键词检索(ES)
↓
结果合并 → 去重
↓
重排序、相关性打分(ReRank)
├── 最相关的排前面
└── 取 N 条
↓
把高质量文档送入大模型
↓
生成最终回答
还有一个更简练的版本:
"全新 Agentic RAG 流程检索逻辑:ES 关键词召回一批;Milvus 向量召回一批;合并去重;丢给 ReRank 模型打分排序;只取前几条"
以及那个更完整的版本(带查询改写):
"用户问题 → 大模型改写 → 生成 3-5 个不同角度问题(召回更多文档)→ 每个问题都去 ES + Milvus 检索 → 合并 → 去重 → 重排模型 ReRank 排序 → 增强 Prompt → 最终输出"
注意流程里那个"改写"环节——它跟前面那篇的"子问题拆解"是同一个思路的两种用法:
| 手段 | 目的 |
|---|---|
| 多跳拆解(第 2 篇) | 拆成有顺序的子问题,一步步推理 |
| 查询改写(本篇) | 拆成不同角度的问题,扩大召回面 |
readme 把这两个目标总结成了两个问题:
"rag 中怎么提升召回率?改写 3-5 个不同角度问题,更多的文档。"
"rag 中怎么提升召回质量?es + milvus,ReRank。"
召回率 = 找得全不全;召回质量 = 找得准不准。
- 想找得全 → 多角度改写问题、多路召回
- 想找得准 → 关键词+语义双路 + ReRank 精排
这是两个不同的问题,需要两套不同的手段。 混在一起谈"怎么提升检索效果",往往就说不清了。
readme 里还有一句看似不起眼但很重要的备注:
"类似的内容存入 es 和 Milvus"
同一份内容,要同时写进两个库。 因为一个管关键词、一个管语义——这是"混合检索"的前提条件。 数据管道的设计要从此多一条分支。
七、三次迭代,一张完整的图
现在把四篇文章串起来,看 RAG 这个系统是怎么长起来的:
| 版本 | 结构 | 能力 |
|---|---|---|
| 朴素 RAG | retrieve → generate | 能查能答 |
| + 路由 | route → (直答 | 检索) | 会分诊 |
| + 多跳 | 拆解 → 循环检索 → 决策 | 会拆题、会循环 |
| + 评估 | 评估 → 联网 → 再评估 | 知道自己不知道 |
| + 混合检索 | ES + Milvus → 去重 → ReRank | 又全又准 |
readme 最后那句总结,现在读起来完整了:
"Agentic RAG,基于 LangGraph 实现大模型决策的 RAG 闭环系统。"
五个要点,对应五篇文章:
- RAG 线性 ← 第 1 篇(起点)
- 所有问题都走检索,区分出简单问题,省资源 ← 第 2 篇(路由)
- 多步检索的复杂问题 拆分问题 ← 第 2 篇(拆解)
- 评估机制 ← 第 3 篇(评估+兜底)
- 网络搜索兜底 ← 第 3 篇(联网)
- 多路检索 重排 提升了召回率和质量 ← 第 4 篇(混合检索+ReRank)
readme 末尾还列了几个后续方向,都是这个系统继续长大的路:
| 方向 | 是什么 |
|---|---|
| PSQL = mysql + milvus | 关系数据 + 向量共存的方案 |
| LangSmith | 全链路 Agent 观测 |
| DeepAgents | 开箱即用的 skill、上下文压缩等中间件 |
| Redis | 后端缓存 Agent 短期记忆 |
| LangFuse | 部署 Agent |
八、这一篇的三个结论
结论一:没有万能的检索器。
向量检索擅长"懂你意思",但栽在"一字之差";关键词检索擅长"精确命中",但栽在"换个说法"。专业场景里两者缺一不可——这就是 readme 说"提升专业场景准确率"必须混合检索的原因。
结论二:召回和排序是两件事。
混合召回阶段追求"宁可多找"(召回率),ReRank 阶段追求"只留最准"(召回质量)。分开设计、各司其职,比试图用一个检索器同时满足两个目标现实得多。
结论三:ES 的 mappings 是"提前决定未来能不能搜到"。
text 还是 keyword、用 ik_max_word 还是别的分词器——这些在建索引时就定死了。 上线后再想改,往往要重建索引、重新灌数据。
建 mappings 时多想十分钟"以后会怎么搜",能省下上线后十个小时的返工。
PS:四篇写完,回看整个系列,最有趣的是那个"高血糖 vs 低血糖"的例子——它朴素到不像个技术问题,但它精准地把向量检索的边界划了出来。所有技术方案都有它的"高血糖时刻":看起来无所不能,直到遇到一个一字之差的case。承认边界、补上短板,比吹嘘一个"全能方案"实用得多。