为什么 MySQL 的 LIKE 查询这么慢?Elasticsearch 倒排索引完全解析
摘要:在 MySQL 里用 LIKE '%关键词%' 搜 1000 万条记录,数据库只能逐行扫描,慢得让人崩溃。Elasticsearch 用倒排索引把"文档→词条"翻转成"词条→文档",实现海量文本下毫秒级全文检索。本文从倒排索引原理出发,拆解 ES 的索引设计、分词机制、DSL 查询,以及它在 RAG 混合检索中的角色。
📑 目录
- MySQL 的困境:为什么 LIKE 全文检索这么慢?
- 倒排索引:把"找书"变成"查目录"
- ES 核心概念:索引、文档、字段类型
- 分词机制:text vs keyword
- 中文分词:standard 不够用,IK 来救场
- ES 的 CRUD 操作:和 MySQL 有什么不同?
- 检索 API:全文检索、精确检索、分页排序
- ES 与 MySQL 的协作:存查分离
- ES 在 RAG 中的角色:关键词检索 + 语义检索的混合方案
- 互动讨论
MySQL 的困境:为什么 LIKE 全文检索这么慢?
MySQL 是关系型数据库,擅长字段查找和表关联。但当你需要在 content 这样的长文本字段里做全文检索时,MySQL 就力不从心了。
sql
sql
-- ❌ 不推荐:全文检索用 LIKE
SELECT * FROM articles WHERE content LIKE '%高血压%';
这条 SQL 的问题在于:
| 问题 | 原因 |
|---|---|
| 无法使用索引 | LIKE '%关键词%' 前置通配符导致 B+树索引失效 |
| 全表扫描 | 数据库只能逐行读取 content 字段,逐字匹配 |
| 数据量大就崩溃 | 1000 万条记录,每条都要读一遍,耗时不可接受 |
根本原因 :MySQL 使用的是正排索引------通过主键 ID 找到对应的行,再去字段里匹配内容。检索方向是"文档 → 关键词"。
而全文检索的需求是:给我一个关键词,快速找到所有包含它的文档。这个方向恰好是反的。
倒排索引:把"找书"变成"查目录"
倒排索引(Inverted Index) 是 ES 的核心底层机制,它把检索方向翻转了:
| 索引类型 | 方向 | 类比 |
|---|---|---|
| 正排索引(MySQL) | 文档 → 关键词 | 从第一页翻到最后一页,逐页找关键词 |
| 倒排索引(ES) | 关键词 → 文档 | 先查书末的"关键词索引表",直接跳到对应页码 |
物理实现细节
ES 的倒排索引并不是简单的"关键词 → 文档 ID",它有一套精密的存储结构:
- DocID:每个文档分配一个内部整数 ID(段内唯一)
- 倒排列表 :存储
词条 → [DocID...]的映射 - 词典 key :格式为
字段名:词条,区分不同字段(如title:高血压和content:高血压) _source:存储原始 JSON 文档,通过 DocID 可以取回完整数据
关键认知 :ES 自己存了完整的文档副本(
_source),不需要"回表"到 MySQL 取原始数据。数据是通过同步机制写入 ES 的。
为什么倒排索引快?
text
arduino
MySQL 正排索引:
用户输入"高血压" → 遍历 1000 万行 → 逐行匹配 content 字段 → 找到 500 条
ES 倒排索引:
用户输入"高血压" → 查词典 "content:高血压" → 直接拿到 [DocID_1, DocID_2, ...] → 毫秒级返回
时间复杂度从 O(n) (全表扫描)降到 O(1) (词典查找)。
ES 核心概念:索引、文档、字段类型
ES 是面向文档的搜索引擎,它的概念和 MySQL 有对应关系:
| ES 概念 | MySQL 对应 | 说明 |
|---|---|---|
| 索引(Index) | 表(Table) | 一类文档的集合,如 article 索引 |
| 文档(Document) | 行(Row) | 一条 JSON 数据 |
| 字段(Field) | 列(Column) | 文档中的一个属性 |
| 映射(Mapping) | 表结构(Schema) | 定义字段类型和分词方式 |
| DSL | SQL | ES 的查询语言,基于 JSON |
ES 存储的是索引,运行在 9200 端口。Kibana 是 ES 的可视化工具,类似于 phpMyAdmin 之于 MySQL。
创建索引与映射
在 Kibana 中创建 article 索引,定义各字段的类型:
text
css
PUT /article
{
"mappings": {
"properties": {
"title": {
"type": "text"
},
"content": {
"type": "text"
},
"author": {
"type": "keyword"
},
"createTime": {
"type": "date"
},
"viewCount": {
"type": "integer"
}
}
}
}
字段类型说明:
| 字段 | 类型 | 分词 | 用途 |
|---|---|---|---|
title |
text |
✅ 分词 | 全文检索 |
content |
text |
✅ 分词 | 全文检索 |
author |
keyword |
❌ 不分词 | 精确匹配、聚合 |
createTime |
date |
--- | 时间范围查询 |
viewCount |
integer |
--- | 数值排序、范围查询 |
查看索引信息:
text
bash
GET /article/_mapping # 查看映射
GET /article/_settings # 查看设置
GET /_cat/indices?v&h=health,status,index,docs.count # 查看所有索引
分词机制:text vs keyword
这是 ES 中最重要的设计决策。
| 类型 | 分词 | 适用场景 | 查询方式 |
|---|---|---|---|
text |
✅ 自动分词,拆成多个词条建倒排索引 | 全文检索(标题、正文) | match 查询 |
keyword |
❌ 整值存储,不分词 | 精确匹配(作者、标签、状态码) | term 查询 |
为什么 text 要分词?
假设 content 字段的值是 "Elasticsearch RAG 混合检索知识库",ES 会把它拆成多个词条:
text
arduino
"Elasticsearch RAG 混合检索知识库"
↓ 分词(standard)
["elasticsearch", "rag", "混", "合", "检", "索", "知", "识", "库"]
然后为每个词条建立倒排索引。这样用户搜"检索"时,就能匹配到包含这个词的文档。
为什么 keyword 不分词?
如果 author 字段的值是 "AI开发者",使用 keyword 类型后,整个字符串作为一个词条 存储。搜 "AI" 或 "开发" 都匹配不到,必须搜完整的 "AI开发者" 才能命中。
keyword 的典型用途:作者名、标签、状态码、枚举值------这些字段需要"完全相等"的匹配,不需要分词。
中文分词:standard 不够用,IK 来救场
ES 内置的 standard 分词器对英文很友好,但对中文几乎是把每个字拆开:
text
bash
POST /_analyze
{
"analyzer": "standard",
"text": "Elasticsearch RAG 混合检索知识库"
}
输出结果中,英文被合理分词,但中文被拆成了单字:混、合、检、索、知、识、库。这样搜"检索"时无法精确匹配,因为倒排索引里存的是"检"和"索"两个独立的字。
IK 分词器
生产环境通常安装 IK 分词器,它提供两种中文分词模式:
| 分词器 | 粒度 | 特点 | 适用场景 |
|---|---|---|---|
ik_max_word |
细粒度 | 尽可能多地拆分词汇 | 写入时建立索引(召回优先) |
ik_smart |
粗粒度 | 按语义拆分,性能更好 | 查询时使用(精度优先) |
使用 ik_smart 对同一段文本分词:
text
bash
POST /_analyze
{
"analyzer": "ik_smart",
"text": "Elasticsearch RAG 混合检索知识库"
}
输出结果会把中文按语义拆成 混合、检索、知识、库 等词条,而不是单字。
最佳实践 :写入时用 ik_max_word(建立更多索引,提高召回率),查询时用 ik_smart(精度更高,性能更好)。
ES 的 CRUD 操作:和 MySQL 有什么不同?
ES 的 API 看起来和 MySQL 很像,但底层操作完全不同------ES 会自动分词、建立倒排索引。
新增文档
text
bash
# POST 不指定 ID,自动生成
POST /article/_doc
{
"title": "Elasticsearch 全文检索入门",
"content": "ES 基于倒排索引与 BM25 实现全文索引,适用于文本检索场景",
"author": "后端开发者",
"createTime": "2026-09-14",
"viewCount": 120
}
# PUT 指定 ID(全量替换,ID 不存在则新增)
PUT /article/_doc/1001
{
"title": "RAG 混合检索实战",
"content": "ES 负责关键词检索,Milvus 负责向量语义检索,结合使用更佳",
"author": "AI开发者",
"createTime": "2026-09-14",
"viewCount": 236
}
注意:PUT 是全量替换------如果只传了部分字段,未传的字段会被覆盖为 null。局部修改要用 POST。
局部更新
text
bash
POST /article/_update/1001
{
"doc": {
"viewCount": 999
}
}
删除文档
text
bash
# 条件删除
POST /article/_delete_by_query
{
"query": {
"match": {
"author": "后端开发"
}
}
}
检索 API:全文检索、精确检索、分页排序
全文检索:match
match 查询会对查询文本进行分词,然后去倒排索引中匹配:
text
css
GET /article/_search
{
"query": {
"match": {
"content": "RAG 向量 检索"
}
}
}
查询文本 "RAG 向量 检索" 会被分词为 ["rag", "向量", "检索"],然后查找包含这些词条的文档。大小写不敏感,这在现实搜索中很实用。
多字段检索:multi_match
同时在多个字段中检索:
text
bash
GET /article/_search
{
"query": {
"multi_match": {
"query": "RAG",
"fields": ["title", "content"]
}
}
}
精确检索:term
term 查询不会对查询文本分词,直接拿整段去匹配倒排索引中的词条:
text
bash
GET /article/_search
{
"query": {
"term": {
"author": "AI开发者"
}
}
}
关键区别 :
term是索引的最小单位,意味着不分词。它只能匹配keyword类型字段,或者text字段分词后的单个词条(不能匹配整个原始文本)。
限制输出字段与分页排序
text
bash
GET /article/_search
{
"_source": ["title", "author"],
"from": 0,
"size": 10,
"sort": [
{ "viewCount": "desc" }
],
"query": {
"match_all": {}
}
}
| 参数 | 作用 |
|---|---|
_source |
限制返回的字段,减少网络传输 |
from + size |
分页(先排序再分页) |
sort |
按指定字段排序 |
DSL:领域特定语言
ES 的查询语言叫 DSL(Domain Specific Language) ------它不是通用编程语言,而是专门用于搜索领域的 JSON 格式查询语言。
| 技术 | 查询语言 |
|---|---|
| MySQL | SQL |
| Milvus | Embedding 向量检索 |
| Elasticsearch | DSL(JSON 格式) |
ES 与 MySQL 的协作:存查分离
核心思想:MySQL 负责"存",ES 负责"查"。
| 维度 | MySQL | Elasticsearch |
|---|---|---|
| 定位 | 业务数据存储,强事务 | 搜索加速层,全文检索 |
| 数据 | 完整业务数据 | 需要被搜索的字段副本 |
| 事务 | ✅ ACID | ❌ 不支持 |
| 写入可见性 | 实时 | 近实时(默认 1 秒) |
| 适用场景 | CRUD、关联查询 | 全文检索、日志分析 |
数据同步方案
ES 不会自动从 MySQL 同步数据,必须通过以下机制之一:
| 方案 | 实时性 | 侵入性 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 同步双写 | 高 | 高 | 低 | 简单项目 |
| 异步双写(MQ) | 较高 | 低 | 中 | 企业级推荐 |
| CDC 监听 Binlog | 高 | 零 | 高 | 工业界主流 |
| 定时批量同步 | 低(分钟级) | 零 | 低 | 报表分析 |
易错:以为 ES 会自动从 MySQL 同步数据。ES 不会主动拉取,必须通过双写、Canal、Logstash 等机制手动同步。
ES 在 RAG 中的角色:关键词检索 + 语义检索的混合方案
在 RAG(检索增强生成)系统中,ES 扮演着关键词检索 的角色,与 Milvus 的向量语义检索形成互补。
为什么需要混合检索?
向量数据库(Milvus)有一个问题:有时两个语义上十分接近的专业术语,所表达的意思几乎完全相反------比如"高血糖"和"低血糖"。纯语义检索容易导致匹配失准。
而关键词检索(ES)恰好能解决这个问题------它是精确匹配,不会把"高血糖"和"低血糖"混为一谈。
混合检索 = ES 关键词检索 + Milvus 相似度检索。二者并不冲突,而是相互合作才能实现最准确的检索。
ES vs Milvus
| 维度 | Elasticsearch | Milvus |
|---|---|---|
| 核心定位 | 全文搜索与日志分析 | 向量数据库,AI 相似度搜索 |
| 检索方式 | 关键词匹配(BM25) | 语义相似度(ANN) |
| 数据模型 | JSON 文档 | 高维向量 |
| 典型场景 | 商品搜索、日志分析 | RAG、图像检索、推荐 |
| 混合检索 | 8.x 原生支持(RRF) | 2.5+ 支持(需外接文本引擎) |
混合检索的完整链路
text
css
用户 query
→ 关键词检索(ES)+ 向量检索(Milvus)(召回优先,宁滥勿缺)
→ 融合排序(RRF / 加权)
→ Rerank 精排(精度优先,宁缺毋滥)
→ 最终 top 3~5 送给 LLM
RRF(Reciprocal Rank Fusion) 是常用的融合算法:
text
arduino
得分 = 1 / (k + 排名) # k 通常取 60
两路得分相加,按综合得分重新排序。融合是并集,不是交集------单路强推的文档也会被召回。
Rerank:精排阶段
Rerank 是对初步检索结果再进行精排:
| 类型 | 方法 | 成本 | 精度 |
|---|---|---|---|
| 规则重排 | 关键词计数、字段权重 | 极低 | 低 |
| Cross-Encoder | 专用重排模型(如 BGE-reranker) | 中 | 高 |
| LLM 打分 | 通用大模型逐篇打分 | 高 | 最高但不稳定 |
Rerank 是省 token 的主力:用 Cross-Encoder 把候选从 top 100 压到 top 5,不消耗 LLM token。如果让 LLM 对大量文档逐篇打分,token 成本会爆炸。
JavaScript 集成示例
typescript
php
import { Client } from '@elastic/elasticsearch';
const esClient = new Client({ node: 'http://localhost:9200' });
// 混合检索(BM25 + kNN + RRF 融合)
const response = await esClient.search({
index: 'medical_articles',
body: {
query: { match: { content: queryText } },
knn: {
field: 'content_vector',
query_vector: queryVector,
k: 10,
num_candidates: 100,
},
rank: { rrf: {} },
size: 5,
},
});
互动讨论
💬 ES 能完全替代 MySQL 吗?
不能。ES 不保证 ACID,写入是近实时(默认 1 秒可见),适合搜索但不适合强事务场景。生产环境用 MySQL 存业务数据,ES 存需要被搜索的字段副本。
💬 为什么 LIKE '%关键词%' 在 MySQL 中这么慢?
前置通配符导致 B+树索引失效,数据库只能全表扫描、逐行匹配。ES 的倒排索引把"文档→词条"翻转成"词条→文档",查关键词直接定位文档 ID 列表,时间复杂度从 O(n) 降到 O(1)。
💬 text 和 keyword 应该怎么选?
需要全文检索的字段(标题、正文)用 text(会分词,支持 match 查询);需要精确匹配的字段(作者、标签、状态码)用 keyword(不分词,支持 term 查询)。
💬 ES 的中文分词为什么需要 IK?
ES 内置的 standard 分词器对中文几乎按单字拆分,导致"检索"被拆成"检"和"索"两个独立词条,搜索时无法精确匹配。IK 分词器按语义拆词,ik_max_word 用于写入(召回优先),ik_smart 用于查询(精度优先)。
💬 混合检索中 RRF 融合是取交集吗?
不是。RRF 是并集------两路各自召回的结果都会被保留,然后按排名加权融合重新排序。单路强推的文档也会被召回,确保不会漏掉任何一路认为相关的内容。