🚨 从 LIKE 到倒排索引:MySQL 搜不动的内容,ES 是怎么接住的

21 阅读10分钟

写在前面:上篇讲 FastAPI 怎么写接口,这篇讲数据怎么搜。readme 一上来就给了一个非常具体的判断——"content 内容字段全文搜索,mysql 扛不住,性能很差。select like 建议不要用的,用 es。" 这话有点绝对,但方向没错。今天就顺着这个判断,搞清三件事:MySQL 的 LIKE 为什么慢、ES 的倒排索引为什么快、以及怎么用一份 docker-compose.yml 把整套检索设施(ES + Kibana + Milvus)一次拉起来。以下所有代码和配置均来自课堂真实文件。


一、LIKE 的困境:从第一个字符开始扫

readme 用一句话点破了场景:

"MYSQL 关系型数据库,字段查找,表关联 适合的,块。content 内容字段全文搜索,mysql 扛不住,性能很差。"

先说清楚——MySQL 不是不行,是不擅长。

MySQL 擅长什么?readme 说得很准确:

擅长例子
字段查找WHERE id = 123
表关联JOIN 两张表

但"在一篇文章内容里找关键词"这件事,MySQL 的方法很笨——挨个扫。

SELECT * FROM articles WHERE content LIKE '%机器学习%';

问题在于开头那个 %

LIKE '机器学习%'     → 可以用索引(从头匹配)
LIKE '%机器学习%'   → 索引失效!只能全表扫描

只要 % 在前面,索引就直接废掉。 因为索引是按"开头"排序的——B+ 树能快速定位"以某字符开头"的记录,但没法回答"哪条记录中间某处包含某词"。

于是数据库只能一条条读、一条条比对。100 万篇文章?扫 100 万次。 这就是 readme 说的"性能很差"。


二、问题的本质:正排 vs 倒排

要理解 ES 为什么快,得先理解"方向"这件事。

readme 用两张图对比了 MySQL 和 ES 的结构:

Mysql  database -> table -> row -> column -> 文本
like 性能不好

ES   分词 -> 文本 -> row -> table -> database  倒过来的

这两个方向,就是正排索引倒排索引的区别。

正排索引:从"书"找"内容"

MySQL 的组织方式是"正着"的:

库 → 表 → 行 → 列 → 文本内容

你想验证"第 5 行有没有'机器学习'这个词",必须先定位到第 5 行,再把 content 字段整段读出来,然后字符串匹配。

这是"从记录找词"的方向。

就像图书馆里没有检索系统——你想找哪本书讲了"机器学习",只能把每本书翻开、逐页读一遍。

倒排索引:从"词"找"书"

ES 的做法是把方向倒过来

分词 → 文本 → rowtable → database

先把所有文本切成词,然后建一张"词 → 出现在哪些文档"的索引表。

readme 给了一个极简的示例:

浏览器  索引 [1, 2]  倒排索引
机器学习

翻译成表格就是:

出现在哪些文档
浏览器[1, 2]
机器学习[1, 3, 7, ...]

这就是倒排索引。 搜索时不再扫全部内容——直接查这个表,拿到文档编号列表,一步到位。

回到图书馆的类比:倒排索引就是图书馆的"主题卡片柜"。 你找"机器学习",直接翻到写有"机器学习"的那张卡片,上面列着所有相关书籍的编号。不用翻书。

为什么叫"倒"排?

索引类型映射方向
正排文档 → 词(这篇文档里有哪些词)
倒排词 → 文档(这个词出现在哪些文档里)

搜索需要的恰好是"倒"的这个方向。 所以 ES 把索引倒着建——这就是它快的根本原因。


三、分词:倒排索引的前置工序

倒排索引的前提是"先把文本切成词"。readme 的链条写得很清楚:

"把 text 分词 tokenzation"

没有分词,就没有"词 → 文档"这张表。

中文分词的难处

英文分词很简单——按空格切就行:

"machine learning is fun" → ["machine", "learning", "is", "fun"]

中文没有空格:

"可以在浏览器中运行机器学习模型"

怎么切?可以有多种切法:

切法结果问题
按字切可/以/在/浏/览/器/中/运/行...太碎,"机器"和"学习"被拆散
按词切可以/在/浏览器/中/运行/机器学习/模型需要词典和算法

所以 ES 要装"中文分词器"。 这就是后面 docker-compose 里出现 IK 的原因——IK 是 Elasticsearch 最常用的中文分词插件,后面细说。

readme 里那段被索引的示例文本,正好是真机上找来的素材:

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

分完词大概是:

可以 / 浏览器 / 中 / 运行 / 机器学习 / 模型 / 支持 / 图像 / 文本 / 声音 / 场景

于是倒排索引里就有了:浏览器 → [1, 2]机器学习 → [...]

这也解释了 readme 里那句"浏览器 机器学习 ... 索引"——分词的结果,就是索引的原料。


四、Docker Compose:把整套检索设施一次拉起

索引原理搞懂了,接下来是"怎么装 ES"。readme 复习了一下 Docker 基础:

"compose:docker 容器化技术。image 镜像(代码和环境依赖)运行起来 → 容器 container。将多个容器编排到一起,docker-compose.yml 就是这个多镜像编排配置文件。"

启动命令:

docker compose up -d

readme 逐字解释:

- docker compose 寻找目录下的 docker-compose.yml
- up 启动起来
- -d 后台运行

一条命令,一整个技术栈。 这次的 docker-compose.yml 里住着五个服务,我们一个个看。

服务一:Elasticsearch(本地构建,自带中文分词)

services:
  # Elasticsearch 8.17.0 + IK 中文分词(内置到镜像)
  es:
    build: ./elasticsearch       # 从本地 Dockerfile 构建镜像(自带IK)
    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/data:/usr/share/elasticsearch/data
    restart: always

这段配置信息量很大,逐条拆:

配置为什么
build: ./elasticsearch从本地 Dockerfile 构建不是拉官方镜像,而是自己构建——为了内置 IK 中文分词
container_namees-dev容器名叫 es-dev,方便识别
ports"9200:9200"ES 的默认访问端口
discovery.typesingle-node单节点模式(开发环境,不需要集群)
xpack.security.enabledfalse关掉认证,免密码访问
ES_JAVA_OPTS-Xms512m -Xmx512mJVM 堆内存固定 512MB
volumes挂载到 volumes/es/data数据持久化,容器删了数据还在
restart: always总是重启开机自启 / 崩溃自动拉起

几个关键点值得展开:

1. build 而不是 image——为了中文分词

注意这里用的是 build: ./elasticsearch(从本地 Dockerfile 构建),而不是 image: elasticsearch:8.17.0(直接拉官方镜像)。

为什么费这个劲?因为官方镜像不带中文分词器。中文进了 ES 默认会按单字切,效果很差。所以要基于官方镜像写个 Dockerfile,把 IK 插件塞进去,再构建成自己的镜像。

注释写得很清楚:

"Elasticsearch 8.17.0 + IK 中文分词(内置到镜像)"

这就是"镜像即交付"的思路——把"ES + 中文分词"的环境固化成镜像,谁都能一键复现,不用每台机器手动装插件。(还记得前面学的 Docker 比喻吗——Dockerfile 是施工图纸。)

2. 三个安全开关全关了

- xpack.security.enabled=false
- xpack.security.http.ssl.enabled=false
- xpack.security.transport.ssl.enabled=false

ES 8.x 默认开启安全认证(要账号密码、要 HTTPS)。开发环境嫌麻烦,三个全关——注释也老实交代了"免密码访问"。

但这里必须提醒一句:这三行是开发环境专用。生产环境开着这配置,等于把数据库裸奔在公网上(9200 端口直接对外开放)。开发图省事,上线要命。

3. JVM 内存卡死在 512MB

- ES_JAVA_OPTS=-Xms512m -Xmx512m  # JVM 内存配置,避免占用过高

ES 是 Java 写的,跑在 JVM 上。默认情况下 JVM 会"贪心"地占用大量内存。这行配置把初始堆(-Xms)和最大堆(-Xmx)都锁在 512MB——开发机不吃紧,也避免 ES 把内存吃光。

-Xms-Xmx 设成一样值是常见做法——避免 JVM 运行时动态扩缩堆带来的性能抖动。

服务二:Kibana(ES 的图形界面)

  # 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

Kibana 是 ES 的可视化控制台——可以理解成"ES 的 Navicat"。浏览器打开 5601 端口,就能可视化地建索引、写查询、看数据。

两个关键点:

1. ELASTICSEARCH_HOSTS=http://es:9200——用服务名通信

注意这里写的是 es:9200,不是 localhost:9200

为什么?因为 Kibana 跑在自己的容器里,对它来说 localhost它自己,不是 ES。而 es 是 compose 里定义的服务名——Docker 的网络会自动把服务名解析成对应容器的 IP。

这就是容器编排的便利:服务之间用"名字"互相找,不用关心 IP。 而且 ES 容器重启后 IP 变了也没关系,服务名始终有效。

2. depends_on: es——启动顺序

Kibana 依赖 ES,所以要先等 ES 起来。这里有个微妙之处(前面讲 Milvus 时也遇到过):depends_on 只保证"容器启动顺序",不保证"服务就绪"。 ES 容器起来了,但它可能还在初始化(加载插件、建集群状态)。Kibana 这时候连上去可能会报错。

生产环境通常要配合 healthcheck 用(下面 minio 和 milvus 的配置里就有)。

3. 版本号必须一致

注释特意强调了两次:

"Kibana 最新稳定版:8.17.0(必须与 ES 版本完全一致)"

这不是建议,是硬要求。Kibana 和 ES 通过内部协议通信,版本不匹配会直接拒绝连接。所以:

es:     build: ./elasticsearch    # 8.17.0(注释标明)
kibana: image: kibana:8.17.0      # 8.17.0 ✅ 对上了

装 ES 前先想好 Kibana 用哪个版本——升级必须一起升。

服务三到五:Milvus 三件套

  # Milvus 最新稳定版:2.5.0(必须与 ES 版本完全一致)# Milvus
  etcd:
    container_name: etcd-dev
    image: quay.io/coreos/etcd:v3.5.18
    environment:
      - ETCD_AUTO_COMPACTION_MODE=revision
      - ETCD_AUTO_COMPACTION_RETENTION=1000
      - ETCD_QUOTA_BACKEND_BYTES=4294967296
      - ETCD_SNAPSHOT_COUNT=50000
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/etcd:/etcd
    command: etcd -advertise-client-urls=http://etcd:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd
    healthcheck:
      test: ["CMD", "etcdctl", "endpoint", "health"]
      interval: 30s
      timeout: 20s
      retries: 3

  minio:
    container_name: minio-dev
    image: minio/minio:RELEASE.2024-05-28T17-19-04Z
    environment:
      MINIO_ACCESS_KEY: minioadmin
      MINIO_SECRET_KEY: minioadmin
    ports:
      - "9001:9001"
      - "9000:9000"
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/minio:/minio_data
    command: minio server /minio_data --console-address ":9001"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 20s
      retries: 3

  standalone:
    container_name: standalone-dev
    image: milvusdb/milvus:v2.5.25
    command: ["milvus", "run", "standalone"]
    security_opt:
      - seccomp:unconfined
    environment:
      MINIO_REGION: us-east-1
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
    volumes:
      - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/milvus:/var/lib/milvus
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9091/healthz"]
      interval: 30s
      start_period: 90s
      timeout: 20s
      retries: 3
    ports:
      - "19530:19530"
      - "9091:9091"
    depends_on:
      - "etcd"
      - "minio"

networks:
  default:
    name: common-network

等等,为什么 ES 的文章里会出现 Milvus?

这正是这份文件最有意思的地方——它不是"ES 的配置",而是"整个 AI 检索设施的总图"。

Milvus 是向量数据库(前面专门讲过——管"语义检索"),ES 是全文检索(管"关键词检索")。两个一起上,就是 RAG 的完整检索层:

引擎检索方式适合什么
ES倒排索引 + 关键词匹配精确术语、专有名词、代码符号
Milvus向量相似度语义相近、换个说法也能找到

一个负责"准",一个负责"懂"。 这就是为什么它们出现在同一份 compose 里——现代 RAG 系统常见的"混合检索"配置。

三个 Milvus 服务的分工(前面讲过,这里简要回顾):

服务版本职责
etcdv3.5.18存元数据(档案室)
minioRELEASE.2024-05-28T17-19-04Z存向量数据(地下仓库)
standalonev2.5.25Milvus 主服务(主楼),端口 19530

网络:五个服务在同一个局域网

networks:
  default:
    name: common-network

最后这三行,把五个服务(es、kibana、etcd、minio、standalone)放进了同一个叫 common-network 的网络。

这是一个常被忽略但很重要的细节——正因为它们在同一网络里:

  • Kibana 能用 http://es:9200 找到 ES
  • Milvus 能用 etcd:2379 找到 etcd、minio:9000 找到 minio
  • etcdminio 完全不需要暴露端口到宿主机(配置里确实没写 ports)——只有同一网络内的服务能访问,外网碰不到

网络隔离本身就是一层安全防护。 数据库、对象存储这些"内部服务"没必要对外开放——这份配置里只有 es(9200)、kibana(5601)、minio(9000/9001)、milvus(19530)暴露了端口。

一个反复出现的语法

volumes:
  - ${DOCKER_VOLUME_DIRECTORY:-.}/volumes/es/data:/usr/share/elasticsearch/data

${DOCKER_VOLUME_DIRECTORY:-.} 是 shell 的默认值语法

${变量:-默认值}

含义:如果环境变量 DOCKER_VOLUME_DIRECTORY 有值就用它,没有就用 .(当前目录)。

这样写的灵活性在于——开发时数据放当前目录(./volumes/es/data),服务器上可以设成 /data/docker-volumes一份配置到处能用。

左侧挂载点(宿主机)与右侧(容器内)用冒号分隔,这是 Docker 数据卷的标准写法。


五、一条命令,五个服务

现在回头看那条命令:

docker compose up -d

它做了什么?按依赖关系依次拉起五个服务:

docker compose up -d
      ↓
  [es](先构建镜像,自带 IK 中文分词)
      ↓ healthcheck 通过
  [kibana] 依赖 es,后启动,端口 5601[etcd] + [minio] 负责存储
      ↓ healthcheck 通过
  [standalone](Milvus)依赖 etcd + minio,端口 19530
      ↓
全部就绪,同在 common-network 网络

手动部署这一套要多久? 装 ES、装 IK 插件、配 JVM 参数、装 Kibana 并对齐版本、装 etcd、装 MinIO、装 Milvus……顺利的话半天,不顺利的话两天。

用 compose 呢? 一条命令,几十秒。

这就是 readme 说的"将多个容器编排到一起"的价值——把"环境搭建"从一次性劳动变成了可复现的配置文件。


六、完整的检索方案长什么样

最后把上篇和下篇串起来,看看这节课整个技术栈是怎么配合的:

┌─────────────────────────────────────────────────┐
│         FastAPI 后端服务(上篇)                 │
│   用 Pydantic 校验入参、Swagger 自动出文档       │
│                      ↓                          │
│              检索请求分发                       │
│         ┌────────────┴────────────┐             │
│         ▼                         ▼             │
│  ┌──────────────┐         ┌──────────────┐      │
│  │ Elasticsearch│         │   Milvus     │      │
│  │ 倒排索引      │         │  向量检索    │      │
│  │ 关键词精确匹配│         │ 语义相似匹配 │      │
│  │ (IK 中文分词)│         │ (etcd+minio) │      │
│  └──────────────┘         └──────────────┘      │
│        └────────────┬────────────┘              │
│                     ▼                           │
│         结果合并 → 喂给 LLM → 生成回答           │
│                                                 │
│      全部跑在一个 docker-compose 网络里          │
└─────────────────────────────────────────────────┘
层次技术这节课教的
接口层FastAPI类型注解校验 + 自动文档(上篇)
检索层-关键词Elasticsearch倒排索引 + IK 中文分词
检索层-语义Milvus向量检索(前面课程)
部署层Docker Compose五个服务一键编排

从"接口怎么写"到"数据怎么搜"再到"服务怎么部署"——一条完整的链路。


七、三个可以立刻用上的结论

第一,什么时候该上 ES?

不是所有搜索都需要 ES。判断标准很简单:

场景用什么
WHERE id = 123 精确查MySQL 够了
JOIN 关联查询MySQL 够了
在长文本里找关键词考虑 ES
要按相关性排序、要高亮、要分词必须 ES

readme 的判断"select like 建议不要用的"——如果你的 LIKE'%关键词%' 这种前置通配符,而且数据量大、还要求排序,那确实该换 ES。

第二,倒排索引的思想比 ES 本身更值钱。

"把方向倒过来索引"这个思路,不只用在 ES:

技术倒排思想的体现
搜索引擎词 → 网页
数据库索引值 → 行位置
向量数据库向量 → 文档(ANN 索引)
Git对象哈希 → 引用

理解了"为了查询方便,反过来建索引",你就理解了大半个数据系统。

第三,开发配置和生产配置是两回事。

这份 compose 里那些"图省事"的配置——关掉安全认证、单节点、minio 默认账号密码 minioadmin、数据库端口全开放——每一条都是给开发环境踩的油门,生产环境都得踩回来:

开发配置生产该怎么做
xpack.security.enabled=false开启认证 + HTTPS
minioadmin / minioadmin强密码 + 密钥管理
single-node集群 + 副本
端口全部暴露只开必须的,其余走内网

"能跑起来"和"能扛住"之间,隔着这一张表。


PS:这节课的两个判断我觉得都挺实在——FastAPI 用注解替代了手写校验(代码即文档),ES 用倒排索引替代了全表扫描(方向决定速度)。而下篇里那份 docker-compose.yml 更像一张"现代 AI 检索设施的总图":ES 管准、Milvus 管懂、Kibana 管看、五个服务一个网络。下次再有人问"全文搜索为什么不用 LIKE",你可以反问他一句——你知道你刚才那句话让索引失效了吗。