从 MySQL 的 LIKE 到 ES 倒排索引:一次把全文检索和混合检索讲透

写在前面

做后端的朋友大概率都遇到过这个场景:产品经理跑过来跟你说,"能不能给文章加个搜索功能?就按内容搜,跟百度那样。"

你第一反应可能是:

复制代码
SELECT * FROM article WHERE content LIKE '%关键词%';

跑一下,几条数据的时候挺快,等到表里躺了几十万条、content 字段动辄几百字的时候,你会发现------查询直接奔着秒级去了,稍微并发一下数据库 CPU 拉满。

于是你就被逼着去了解 ElasticSearch。

这篇文章不打算把 ES 讲成大部头教材,我只想带你走完这条线:

为什么 MySQL 扛不住 → 倒排索引到底是怎么回事 → ES 怎么上手 → 最后怎么和向量检索组合成混合检索,落到 RAG / Agentic RAG 场景里。

读完你应该能把一个"关键词搜索 + 语义搜索"的服务真正跑起来。


一、MySQL 为什么不适合全文检索?

先说清楚一件事:MySQL 不是不好,它是被用错了地方。

MySQL 是关系型数据库,它的强项是什么?

  • 按字段精确查找(WHERE id = 1)
  • 表关联(JOIN)
  • 事务、一致性、约束

这些都是它吃饭的本事,快得一批。

但到了"按文本内容模糊搜索"这件事上,它就很难受了。原因在于它的存储和检索方式:

MySQL 是按行存储 的,一行数据是一个完整单位。当你用 LIKE '%关键词%' 时,它没有任何捷径可走,只能逐行扫描、逐字匹配。

说白了就是:一条一条看过去,看完才知道有没有。

数据量越大、文本越长、模糊匹配越复杂,它就越慢。这不是调优能解决的问题,这是数据结构决定的先天缺陷。

所以结论很直接:

大范围的关键词/文本检索,别用 LIKE,交给专门的搜索引擎来做。

这个"专门的搜索引擎",就是 ElasticSearch。


二、核心差异:正向索引 vs 倒排索引

ES 相比 MySQL,最大的杀手锏不是"它是个新数据库",而是它底层的 倒排索引(Inverted Index) 机制。

我们用一个具体的例子感受一下。假设有这样一段文本:

复制代码
可以在浏览器中运行机器学习模型,支持图像、文本和声音等多种应用场景

MySQL 的思路(正向索引)是:

复制代码
文档 → 关键词

存的是"这句话",你搜的时候再去这句话里翻。

ES 的思路(倒排索引)是:

复制代码
关键词 → 文档

它会先对 text 类型字段做分词(tokenization) ,把句子拆成一个个独立词条:

复制代码
可以 / 浏览器 / 机器学习 / 模型 / 图像 / 文本 / 声音 / 应用场景...

然后以词条为核心,反向记录"哪些文档包含这个词":

复制代码
浏览器     → [文档1, 文档2]
机器学习   → [文档1, 文档5]
图像       → [文档1]

用户输入关键词时,ES 只需要拿着这个词去词条表里秒查 ,直接拿到文档 ID 列表,完全不需要全表遍历。

这就是为什么 ES 能在海量文本 下做到毫秒级全文检索。

一句话记忆:MySQL 是"给你一本书,一页页翻着找词";ES 是"先给你建好目录,直接翻到那一页"。


三、先把环境跑起来:docker-compose 三件套

理论说完了,动手。ES 用 Docker 起是最省事的。

先明确几个概念,别被 Docker 的名词吓到:

  • 镜像 image:代码 + 环境依赖,打包好的"模板"
  • 容器 container:镜像运行起来之后的实例
  • compose :把多个镜像编排 到一起启动,配置文件就是 docker-compose.yml

我们要起两个东西:

服务 端口 作用
Elasticsearch 9200 存索引、提供检索 API
Kibana 5601 可视化操作 ES,类似 phpMyAdmin 之于 MySQL

docker-compose.yml 大概长这样:

复制代码
version: '3.8'

services:
  # Elasticsearch 最新稳定版:8.17.0
  es:
    build: ./elasticsearch
    container_name: es-dev
    ports:
      - "9200:9200" # ES 对外提供服务的端口
    environment:
      - discovery.type=single-node # 单节点运行(开发环境)
      - xpack.security.enabled=false # 关闭安全认证,免密码访问
      - xpack.security.http.ssl.enabled=false # 关闭 HTTPS 加密
      - xpack.security.transport.ssl.enabled=false # 关闭节点传输加密
      - ES_JAVA_OPTS=-Xms512m -Xmx512m # JVM 内存配置,避免占用过高
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/es:/usr/share/elasticsearch/data
    restart: always

  # Kibana 最新稳定版:8.17.0(必须与 ES 版本完全一致)
  kibana:
    image: kibana:8.17.0
    container_name: kibana-dev
    ports:
      - "5601:5601" # Kibana 网页控制台端口
    environment:
      - ELASTICSEARCH_HOSTS=http://es:9200 # 连接 ES 容器内部地址
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/kibana:/usr/share/kibana/data
    restart: always
    depends_on:
      - es # 等待 ES 启动完成后再启动 Kibana

networks:
  default:
    name: common-network

启动命令就一行:

复制代码
docker compose up -d

拆解一下这条命令:

  • docker compose:在当前目录找 docker-compose.yml
  • up:把服务启动起来
  • -d:后台运行(detached)

起来之后,浏览器打开 http://localhost:5601 就能看到 Kibana,后面所有操作都可以在它的 Dev Tools 里直接敲。


四、ES 请求心智模型:方法、路径、参数、Body

很多同学第一次看 ES 命令会懵:

  • 为什么有的用 GET,有的用 POST,有的用 PUT?
  • /_doc、/_search、/_mapping 这些路径到底怎么拼?
  • Kibana 里能跑,换成 curl 或 Java 客户端怎么写?
  • POST 和 PUT 到底有什么区别?
  • 为什么有时候查不到刚写入的数据?

这一节就把 ES 的 RESTful 请求方式彻底拆开。

4.1 ES 的本质:一切皆 HTTP 请求

ES 对外暴露的是一套 RESTful JSON API。你可以把它理解成一个 HTTP 服务:

HTTP 方法决定"做什么",路径决定"操作谁",查询参数决定"细节",请求体里的 DSL 决定"怎么做"。

一个完整的 ES 请求由四部分组成:

复制代码
<HTTP 方法> /<路径>?<查询参数>
{
  "请求体 JSON"
}

例如:

复制代码
POST /article/_search?pretty
{
  "query": {
    "match": { "title": "Elasticsearch" }
  }
}

拆开看:

部分 内容 作用
HTTP 方法 POST 提交一次搜索请求
路径 /article/_search 对 article 索引执行搜索
查询参数 ?pretty 返回结果格式化
请求体 query.match... 查询 DSL

Kibana Dev Tools 里之所以能省略 http://localhost:9200,是因为它已经帮你连好了 ES。换成 curl,就必须补全地址和请求头。

4.2 HTTP 方法速查:GET / POST / PUT / DELETE / HEAD

ES 里最常用的 HTTP 方法就这几个:

HTTP 方法 语义 ES 典型用途 是否幂等
GET 读取 查询文档、搜索、查看 mapping/settings、_cat 是
POST 提交/执行 自动生成 ID 新增文档、_update、_search、_bulk、_delete_by_query 通常不幂等
PUT 创建/覆盖 创建索引、指定 ID 写入文档、更新 mapping/settings 是
DELETE 删除 删除索引、删除文档 是
HEAD 探测 判断索引/文档是否存在 是

一句话记忆:

GET 查,POST 提交,PUT 覆盖,DELETE 删,HEAD 探。

GET:读操作,但也能带 body

GET 在 ES 里最常用来查:

复制代码
GET /article/_doc/1001
GET /article/_mapping
GET /article/_settings
GET /_cat/indices?v
GET /article/_search
{
  "query": { "match_all": {} }
}

注意:ES 是支持 GET 带请求体的 ,所以上面 GET /article/_search 带 JSON 在 Kibana 里能跑。

但这里有个坑:

虽然 HTTP 规范没有完全禁止 GET 带 body,但很多代理、网关、HTTP 客户端会忽略 GET 的 body。

所以复杂搜索建议用 POST,兼容性更好。

也就是说,下面两种写法在 ES 里都合法:

复制代码
GET /article/_search
{
  "query": { "match": { "title": "ES" } }
}

POST /article/_search
{
  "query": { "match": { "title": "ES" } }
}

生产环境更推荐 POST /article/_search。

POST:新增、更新、搜索、批量

POST 是 ES 里最灵活的方法,常见用途:

复制代码
POST /article/_doc          # 自动生成 ID 新增文档
POST /article/_update/1001  # 部分更新文档
POST /article/_search       # 搜索
POST /_bulk                 # 批量操作
POST /article/_delete_by_query  # 按条件删除

为什么新增文档有时用 POST 有时用 PUT?看下面。

PUT:指定 ID 写入,幂等

PUT 通常表示"创建一个确定 ID 的资源":

复制代码
PUT /article
{
  "mappings": { ... }
}

创建索引 article。

复制代码
PUT /article/_doc/1001
{
  "title": "ES 入门",
  "content": "倒排索引..."
}

指定文档 ID 为 1001。

PUT 的特点是幂等 :你执行一次和一百次,结果都是 ID 为 1001 的文档被写成同样的内容。

但注意:

PUT /article/_doc/1001 是全量覆盖 。

如果你原来文档有 title、content、author,这次只传了 title,那么其他字段会丢失。

想部分更新,用 POST /article/_update/1001。

DELETE:删除索引或文档

复制代码
DELETE /article/_doc/1001   # 删除单条文档
DELETE /article             # 删除整个索引

删除也是幂等的:删完之后再删一次,会返回 not_found,但不会产生额外副作用。

HEAD:只关心存在不存在

复制代码
HEAD /article
HEAD /article/_doc/1001

HEAD 不返回 body,只看 HTTP 状态码:

  • 200:存在
  • 404:不存在

适合在程序里做存在性判断,省带宽。

4.3 ES 路径结构:索引、文档、搜索、映射

ES 的路径其实很有规律,记住几个核心模板就行:

复制代码
/<index>                        # 索引本身
/<index>/_doc/<id>              # 单条文档
/<index>/_search                # 搜索
/<index>/_mapping               # 映射
/<index>/_settings              # 设置
/_cat/...                       # 集群/索引查看
/_bulk                          # 批量
/_mget                          # 多文档查询

举几个例子:

复制代码
GET /article/_doc/1001
GET /article/_search
GET /article/_mapping
GET /article/_settings
GET /_cat/indices?v
POST /_bulk
GET /_mget

在 ES 7.x 之前,路径里还有"类型 type"的概念,比如 /<index>/<type>/<id>。

7.x 之后 type 被废弃,统一用 _doc:

复制代码
PUT /article/_doc/1001

你可以把 _doc 理解成固定端点,不用再纠结 type。

4.4 HTTP 状态码:看返回值判断结果

ES 返回的 HTTP 状态码很有用:

状态码 含义 常见场景
200 OK 成功 查询、更新、删除成功
201 Created 创建成功 新建索引、写入新文档
400 Bad Request 请求错误 DSL 语法错误、字段类型不匹配
401 Unauthorized 未认证 没带用户名密码
403 Forbidden 无权限 用户权限不足
404 Not Found 不存在 索引/文档不存在
409 Conflict 冲突 版本冲突、索引已存在
413 Payload Too Large 请求体太大 单次 bulk 太大
429 Too Many Requests 请求过多 限流、线程池满
500 Internal Server Error 服务端错误 分片失败、脚本异常

程序里不要只看 body,HTTP 状态码是第一道判断。


五、索引操作:建表、映射、设置

5.1 索引(Index)≈ MySQL 的表

ES 里没有"表"的概念,对应的叫索引(Index) 。

先看看当前有哪些索引:

复制代码
GET /_cat/indices?v&h=health,status,index,docs.count

创建索引的时候需要定义 mappings------这玩意儿就相当于 MySQL 的建表 schema。

复制代码
PUT /article
{
  "settings": {
    "number_of_shards": 1,
    "number_of_replicas": 0
  },
  "mappings": {
    "properties": {
      "title":      { "type": "text" },
      "content":    { "type": "text" },
      "author":     { "type": "keyword" },
      "createTime": { "type": "date" },
      "viewCount":  { "type": "integer" }
    }
  }
}

这里有一个新手最容易踩的坑,也是 ES 字段类型设计的核心:

类型 行为 适用场景
text 会分词,拆成词条再建索引 标题、正文等内容字段
keyword 不分词,整体作为一个词条精确匹配 作者、状态、标签、ID

看上面的定义:title 和 content 用 text,因为你要按关键词搜;author 用 keyword,因为你要的是"作者恰好等于 AI开发"这种精确匹配。

金句:text 负责"全文检索",keyword 负责"精确过滤",两者搭配才是完整的搜索体验。

5.2 查看映射与设置

复制代码
GET /article/_mapping     # 查看字段结构
GET /article/_settings    # 查看索引配置

5.3 判断索引是否存在

复制代码
HEAD /article

返回 200 表示存在,404 表示不存在。

5.4 新增字段

复制代码
PUT /article/_mapping
{
  "properties": {
    "status": { "type": "keyword" }
  }
}

注意:ES 不支持直接修改已有字段类型,只能新增字段。要改类型,一般要重建索引 + reindex。

5.5 修改设置

复制代码
PUT /article/_settings
{
  "number_of_replicas": 1
}

5.6 删除索引

复制代码
DELETE /article

删库慎用。

5.7 关闭 / 打开索引

复制代码
POST /article/_close
POST /article/_open

5.8 别名操作

复制代码
POST /_aliases
{
  "actions": [
    { "add": { "index": "article_v1", "alias": "article" } }
  ]
}

别名的好处是:重建索引时,应用层不用改代码,只切别名。


六、文档操作:新增、查询、更新、删除

6.1 新增:自动 ID vs 指定 ID

自动生成 ID:

复制代码
POST /article/_doc
{
  "title": "Elasticsearch 全文检索入门",
  "content": "ES 基于倒排索引与 BM25 实现全文索引,适用于文本检索场景",
  "author": "后端开发",
  "createTime": "2026-09-20",
  "viewCount": 120
}

指定 ID:

复制代码
PUT /article/_doc/1001
{
  "title": "Elasticsearch 全文检索入门",
  "content": "ES 基于倒排索引与 BM25 实现全文索引,适用于文本检索场景",
  "author": "后端开发",
  "createTime": "2026-09-20",
  "viewCount": 120
}

区别:

操作 方法 路径 ID 幂等
自动 ID POST /_doc ES 生成 否
指定 ID PUT /_doc/1001 手动指定 是

6.2 查询单条文档

复制代码
GET /article/_doc/1001

6.3 判断文档是否存在

复制代码
HEAD /article/_doc/1001

6.4 部分更新

复制代码
POST /article/_update/1001
{
  "doc": {
    "viewCount": 121
  }
}

只会更新 viewCount,其他字段不变。

也可以用脚本更新:

复制代码
POST /article/_update/1001
{
  "script": {
    "source": "ctx._source.viewCount += params.inc",
    "params": {
      "inc": 1
    }
  }
}

6.5 删除文档

复制代码
DELETE /article/_doc/1001

6.6 多文档查询 _mget

复制代码
GET /_mget
{
  "docs": [
    { "_index": "article", "_id": "1001" },
    { "_index": "article", "_id": "1002" }
  ]
}

也可以指定索引:

复制代码
GET /article/_mget
{
  "docs": [
    { "_id": "1001" },
    { "_id": "1002" }
  ]
}

七、搜索操作:match / term / multi_match / bool

搜索是 ES 最核心的操作。两种方式:

GET:

复制代码
GET /article/_search
{
  "query": {
    "match": { "title": "ES" }
  }
}

POST:

复制代码
POST /article/_search
{
  "query": {
    "match": { "title": "ES" }
  }
}

前面说过,复杂 DSL 推荐 POST。

7.1 URI 查询:简单场景

复制代码
GET /article/_search?q=title:检索&size=10

适合临时调试,不适合复杂业务。

7.2 match ------ 会分词

复制代码
GET /article/_search
{
  "query": {
    "match": { "content": "全文检索" }
  }
}

match 会把你输入的内容也分词 ,然后按词条去匹配。适合 text 字段。

7.3 term ------ 不分词,精确匹配

复制代码
GET /article/_search
{
  "query": {
    "term": { "author": "AI开发" }
  }
}

term 不做任何分词 ,拿整个字符串去词条表里精确查。专门用于 keyword 字段。

记住这条规则:match 配 text,term 配 keyword,配错了就是查不出结果。

7.4 multi_match ------ 多字段匹配

复制代码
GET /article/_search
{
  "query": {
    "multi_match": {
      "query": "检索",
      "fields": ["title", "content"]
    }
  }
}

一次查多个字段,用户搜"检索"的时候,标题或正文命中都算,非常实用。

7.5 bool 查询:must / filter / should / must_not

复制代码
POST /article/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "content": "检索" } }
      ],
      "filter": [
        { "term": { "author": "AI开发" } }
      ],
      "should": [
        { "match": { "title": "Elasticsearch" } }
      ],
      "must_not": [
        { "term": { "status": "deleted" } }
      ]
    }
  },
  "_source": ["title", "author", "createTime"],
  "from": 0,
  "size": 10,
  "sort": [
    { "createTime": "desc" }
  ],
  "highlight": {
    "fields": {
      "content": {}
    }
  }
}

这段基本覆盖了日常搜索:

  • must:必须匹配,参与打分
  • filter:必须匹配,不参与打分,可缓存,适合精确过滤
  • should:可选匹配,命中加分
  • must_not:必须不匹配
  • _source:返回哪些字段
  • from/size:分页
  • sort:排序
  • highlight:高亮

7.6 只返回部分字段(省流量)

复制代码
GET /article/_search
{
  "_source": ["title", "author"],
  "query": { "match_all": {} }
}

_source 就相当于 SQL 里的 SELECT title, author,返回大字段(比如 content)非常吃带宽,按需指定。

7.7 分页:from/size 与深分页

浅分页:

复制代码
"from": 0,
"size": 10

深分页不建议用大 from,比如 from: 100000,性能很差。生产深分页用 search_after + PIT。

7.8 聚合查询

复制代码
POST /article/_search
{
  "size": 0,
  "aggs": {
    "authors": {
      "terms": {
        "field": "author",
        "size": 10
      }
    }
  }
}

size: 0 表示不返回文档,只要聚合结果。

五、从 ES 到混合检索:为什么单靠关键词还不够?

到这里你已经能搭起一个可用的全文检索服务了。但如果你在做 RAG(检索增强生成)或者 Agent,只上 ES 会撞到一堵墙。

先看两个典型问题:

问题一:语义相近但字面不同,ES 搜不到。

用户搜"土豆怎么做",但你的文档里写的是"马铃薯的烹饪方法"。ES 基于词条, "土豆"和"马铃薯"是两个完全不同的词条,匹配不上。

问题二:纯语义检索(向量检索)也不完美。

如果把文档灌进向量数据库(比如 Milvus),用 embedding 做相似度检索,上面那个问题就解决了------因为"土豆"和"马铃薯"在语义空间里离得很近。

但是反过来,专业术语、精确实体、代号、ID 这类东西,纯语义检索经常"匹配不准",甚至"糊成一团"。

举个例子:用户明确要找"型号 X200",向量检索可能给你返回一堆"X100、X300、X500",语义都很像,但全都不是他要的。

于是答案就清楚了:

关键词检索(ES)+ 语义检索(Milvus)= 混合检索(Hybrid Search)

六、混合检索架构长什么样?

大致流程:

复制代码
                    ┌─────────────────┐
用户 Query  ────┬──▶│  ES 关键词检索   │──▶ 命中结果 A
                │   └─────────────────┘
                │
                │   ┌─────────────────┐
                └──▶│ Milvus 向量检索  │──▶ 命中结果 B
                    └─────────────────┘
                            │
                            ▼
                    ┌─────────────────┐
                    │  模型统一融合    │──▶ 最终 Top-K
                    │ (RRF / rerank)  │
                    └─────────────────┘
                            │
                            ▼
                      交给 LLM 生成

关键点:

  1. 两路并行:ES 走关键词,Milvus 走向量,各自召回一批候选
  2. 统一融合:用 RRF(Reciprocal Rank Fusion)或者重排模型(reranker)把两路结果合并、打分、排序
  3. 再喂给 LLM:融合后的 Top-K 作为上下文

这样做的好处非常直接:专业术语/精确实体靠 ES 保证不跑偏,语义泛化靠向量保证不漏召。

放到 Agentic RAG 的场景里会更明显。比如用 LangGraph 搭一个闭环 Agent:

  • Agent 自主决策这次要不要检索
  • 检索的话用哪个工具:Web Search / Milvus / ES
  • 拿到结果后判断信息够不够 、效果好不好
  • 不够就换个检索方式再搜一轮

比如用户问"型号 X200 的语义相似产品",Agent 第一轮可以直接走 ES 锁定 X200,再用 Milvus 找语义相近的品类。

工具组合是死的,Agent 的判断是活的。真正要理解的是这个"决策---检索---评估---再检索"的闭环思路,具体怎么实现一定是要贴着业务场景设计的。


七、几个容易忽略的落地建议

最后给几条实战经验,都是踩过坑总结的:

1. 字段类型别乱设

author、status、tag 这类字段一律 keyword。如果你既想精确匹配又想分词,可以用 text + keyword 多字段:

复制代码
"title": {
  "type": "text",
  "fields": {
    "keyword": { "type": "keyword" }
  }
}

2. 中文分词要用 IK 插件

ES 默认分词器对中文是"一字一词",效果很差。上 IK 分词器:

用docker

复制代码
RUN elasticsearch-plugin install --batch \
  https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-8.17.0.zip

然后在 mapping 里指定 "analyzer": "ik_max_word"。

3. 别把 ES 当主数据库用

ES 更像个"特种兵"------专门做关键词检索的。原始数据、事务、强一致,还是老老实实放 MySQL。ES 里的数据是从 MySQL 同步过来的副本。

4. Kibana 是开发期神器

http://localhost:5601 里的 Dev Tools 可以直接敲上面所有命令,比 Postman 顺手太多。它之于 ES,就像 phpMyAdmin 之于 MySQL。


总结

把这条线再串一遍:

  • MySQL 的 LIKE 慢,是因为正向索引下只能全表逐行扫描
  • ES 快,是因为它用倒排索引把"文档 → 词"反过来了,变成"词 → 文档"
  • text 分词、keyword 不分词 ,match 配 text、term 配 keyword
  • 单靠 ES 搜不了语义,单靠向量搜不准实体 ,所以要有混合检索
  • 混合检索的融合层才是准确率的关键,RRF / rerank 都值得试
  • Agentic RAG 本质上是把"选哪种检索"这件事交给 Agent 自主决策

一句话收尾:

关键词检索解决"找得到",语义检索解决"找得准",两者融合加上 Agent 的自主决策,才是真正能落地的 RAG。

相关推荐
oradh1 小时前
Oracle SQL Tuning Advisor调优工具
数据库·sql·oracle
染指11101 小时前
129.Agent-LangChain核心组件-自定义中间件-通过类实现(AgentMiddleware)
人工智能·中间件·langchain·agent·agents
JaydenAI1 小时前
[DeepSeek Harness深度拆解-16]系统提示词的组装流程
ai·agent·plugin·deepseek·harness
MayBaymax2 小时前
ES 基础总结
大数据·elasticsearch·搜索引擎
Elastic 中国社区官方博客2 小时前
两个依赖和一个配置块:通过 Prometheus 远程写入将 Spring Boot 指标发送到 Elasticsearch
大数据·数据库·spring boot·elasticsearch·搜索引擎·全文检索·prometheus
海南java第二人2 小时前
LangChain 消息与提示词模板全解析:从 Message 标准到 ChatPromptTemplate 实战
langchain·agent·提示词
deepseek232 小时前
DeepSeek-V4.1-Flash 拆解:8B/16B 非对称激活,KV 缓存压到 890 字节/Token 才是 1M Agent 经济账
agent·kv缓存·deepseek·稀疏注意力·moe架构·v4.1-flash
-madongyu-2 小时前
GaussDB 性能调优:等待事件、慢 SQL 定位与关键参数
数据库·sql·gaussdb
SelectDB技术团队3 小时前
ELK 太占磁盘、ES 总报写入拒绝:从归因到可执行的优化清单
大数据·clickhouse·elk·elasticsearch·全文检索·复杂查询·实时更新