从MySQL LIKE到ES倒排索引:全文检索与混合检索实战

发布时间:2026/11/2 20:49:47
从MySQL LIKE到ES倒排索引:全文检索与混合检索实战

MySQL 的 LIKE 查询大概是每个后端开发都写过的东西,WHERE title LIKE '%关键词%'一敲,功能就跑通了。可当数据量从几万涨到几百万,这条语句的响应时间从 50ms 变成 5s 的时候,你就知道事情没那么简单了。这篇文章想聊的,就是怎么从 MySQL 的模糊匹配一步步走到 ES 的倒排索引,再把全文检索和混合检索这两件事讲清楚。不管你是刚接触搜索功能的新手,还是正在做搜索架构升级的老手,这里面的选型逻辑、原理拆解和实操细节,应该都能让你少走一些弯路。

1. 为什么 LIKE '%xxx%' 注定撑不起搜索场景

1.1 从一次线上慢查询说起

我印象特别深的一次经历,是帮一个朋友看他的内容管理系统。文章表大概 80 万行,标题和正文加起来平均每行 2KB 左右。他做了一个站内搜索,SQL 大概长这样:

SELECT id, title, summary, created_at FROM articles WHERE title LIKE '%机器学习%' OR content LIKE '%机器学习%' ORDER BY created_at DESC LIMIT 20;

单看这条语句,逻辑没问题,结果也对。问题是每次执行要 4 到 6 秒,赶上高峰期数据库 CPU 直接飙到 90% 以上。他一开始以为是索引没建好,给 title 加了索引,结果发现根本没用——因为%关键词%这种前后都带通配符的写法,B+ 树索引完全用不上。

这里就引出了第一个核心问题:MySQL 的索引结构决定了它不擅长做"包含式"匹配。B+ 树索引的本质是一棵排好序的树,它能快速定位"等于某个值""大于某个值""以某个前缀开头"这类查询,因为这些都是有序区间。但%关键词%要求的是"任意位置包含",这等于让索引放弃它最擅长的有序性,退化成全表扫描。

1.2 全表扫描到底慢在哪

很多人知道全表扫描慢,但说不清慢在哪。我拆一下这个过程你就明白了。

假设 articles 表 80 万行,每行平均 2KB,整张表大约 1.6GB。执行LIKE '%机器学习%'时,MySQL 需要:

  1. 从磁盘(或缓冲池)把每一行的数据读出来;
  2. 对每一行的 title 和 content 字段做字符串匹配;
  3. 匹配成功的行再参与排序和分页。

这里面最要命的是第 1 步和第 2 步。数据量大到内存装不下时,就要反复读磁盘;字符串匹配本身又是逐字符比较,中文还要考虑编码,CPU 开销也不小。80 万行扫一遍,几秒钟太正常了。

更麻烦的是,这种查询没法有效利用缓存。因为每次搜索的关键词不同,扫描的路径虽然一样,但匹配计算每次都要重来。你没法像等值查询那样把结果缓存起来。

1.3 前缀匹配能救一部分,但救不了全部

有人会说,那我把%关键词%改成关键词%不就行了?确实,前缀匹配能用上索引:

-- 能用索引,因为是有序区间的起点 SELECT * FROM articles WHERE title LIKE '机器学习%';

但问题是,用户搜索的时候,关键词往往出现在标题中间或结尾。比如"深入理解机器学习算法",用户搜"机器学习",前缀匹配就匹配不到。而且正文内容的搜索,几乎不可能用前缀匹配解决。

所以结论很明确:只要你的搜索需求涉及"任意位置包含"或者"多字段联合搜索",MySQL 的 LIKE 就不是一个可扩展的方案。数据量小的时候能用,是因为全表扫描的成本还能接受;一旦上了规模,必须换思路。

2. 倒排索引:把"找文档"变成"查词典"

2.1 正排索引和倒排索引的本质区别

要理解 ES 为什么快,得先搞清楚倒排索引和正排索引的区别。这两个词听起来玄乎,其实用一本书的目录就能解释清楚。

正排索引就像书的正文顺序:第 1 页讲了什么,第 2 页讲了什么。你知道页码,去翻就能找到内容。MySQL 的 B+ 树索引本质上也是正排思路——通过主键或索引键定位到行。

倒排索引则像书后面的索引页:你想找"机器学习"这个词,索引页告诉你它出现在第 15、28、103 页。你不需要翻遍整本书,直接跳到那几页就行。

用技术语言描述:倒排索引建立的是"词项(term)到文档列表(posting list)"的映射。每个词项记录它出现在哪些文档里,以及出现的位置、频率等信息。

对比维度正排索引倒排索引
映射方向文档 → 内容词项 → 文档列表
典型代表MySQL B+ 树Elasticsearch、Lucene
擅长查询等值、范围、前缀全文检索、多词组合
写入成本较低较高(需分词、建表)
存储开销较小较大(需存词典和倒排表)

2.2 分词:倒排索引的第一道工序

倒排索引要建立"词项到文档"的映射,第一步就是把文档拆成词项,这个过程叫分词(tokenization)。

英文分词相对简单,按空格和标点切就行。中文就麻烦多了,因为中文没有天然的词边界。"机器学习"这四个字,可以切成"机器/学习",也可以切成"机/器/学/习"。切法不同,检索效果天差地别。

ES 默认的中文分词器是 standard,它会把中文按单字切分。也就是说"机器学习"会被切成"机""器""学""习"四个词项。这样搜"机器学习"能搜到,但搜"学习"也能搜到,精度很差,而且倒排表会膨胀得很大。

实际项目中,中文场景一般会换成 IK 分词器。IK 有两种模式:

  • ik_smart:粗粒度,尽量切出有意义的词,"机器学习"切成"机器学习";
  • ik_max_word:细粒度,穷举所有可能的词,"机器学习"切成"机器""学习""机器学习"等。

选哪个取决于你的场景。搜索精度要求高、词典覆盖全的,用 ik_smart;召回率优先、不怕多切一点的,用 ik_max_word。我个人的经验是,标题字段用 ik_max_word 提高召回,正文字段用 ik_smart 控制索引大小。

2.3 倒排表的物理结构长什么样

光知道"词项到文档列表"还不够,真正让 ES 快的是倒排表的物理组织方式。Lucene(ES 的底层引擎)的倒排表大致包含这几部分:

  • 词典(Term Dictionary):所有词项的有序集合,通常用 FST(有限状态转换器)存储,既省空间又支持快速查找;
  • 倒排列表(Posting List):每个词项对应的文档 ID 列表,按文档 ID 升序排列;
  • 频率信息(Term Frequency):词项在文档中出现的次数,用于相关性打分;
  • 位置信息(Positions):词项在文档中的位置,用于短语查询和高亮。

这里有个关键设计:倒排列表里的文档 ID 是有序的。有序意味着可以用跳表(skip list)加速求交集。比如搜"机器 学习",需要找出同时包含这两个词的文档,就是对两个有序列表求交集。有序列表求交集可以边扫边跳,比无序列表快得多。

还有一个优化叫Frame of Reference(FOR)编码,把文档 ID 的差值而不是绝对值存下来,差值通常很小,可以用更少的位存储。这些底层优化叠加起来,才让 ES 在亿级文档上还能做到毫秒级响应。

2.4 相关性打分:为什么搜出来的顺序不一样

用 LIKE 搜索,结果是按你指定的字段排序的,比如按时间倒序。但 ES 默认会按相关性排序,这就是为什么同样的关键词,ES 搜出来的第一条往往比 MySQL 更"对味"。

ES 默认用 BM25 算法打分,核心思想是:

  • 词项在文档中出现次数越多,越相关(但增长会饱和,不是线性);
  • 词项在越少的文档中出现,区分度越高,权重越大(IDF 思想);
  • 文档越长,单个词项的贡献会被稀释。

举个直观的例子:搜"苹果",如果一篇文章里"苹果"出现了 10 次,另一篇只出现 1 次,前者通常更相关。但如果"苹果"这个词在 90% 的文档里都出现,那它的区分度就很低,权重会被压低。BM25 就是把这些因素综合起来算一个分数。

理解这一点很重要,因为它决定了你后面做混合检索时,怎么把 ES 的分数和别的信号融合。

3. 从 MySQL 到 ES:数据同步的几种打法

3.1 同步方案选型:先想清楚一致性要求

把数据从 MySQL 同步到 ES,是绕不开的一步。但同步方案不是越实时越好,得先想清楚你的业务能接受多大的延迟。

方案实时性实现复杂度适用场景
双写准实时低写入量小、能接受偶发不一致
定时任务全量分钟级低数据量小、实时性要求低
定时任务增量分钟级中有更新时间字段、能接受延迟
Binlog 订阅秒级高实时性要求高、写入量大

双写是最容易想到的方案:业务代码里写完 MySQL 紧接着写 ES。但它有个致命问题——两个写操作不在一个事务里,MySQL 写成功、ES 写失败怎么办?网络抖动、ES 集群短暂不可用,都会导致数据不一致。而且双写会让业务代码耦合搜索逻辑,后期维护很痛苦。

Binlog 订阅是目前主流方案。MySQL 的 binlog 记录了所有数据变更,用一个中间件(比如 Canal)伪装成从库订阅 binlog,解析出变更事件后推送到 ES。这个方案的好处是业务代码零侵入,实时性也好。代价是要维护额外的中间件,而且要注意 binlog 格式必须是 ROW 模式。

我自己的项目里,中小规模用定时增量同步就够了,简单可靠;上了千万级数据、实时性要求高的,才上 Binlog 方案。

3.2 增量同步的一个坑:删除和更新怎么处理

增量同步最容易踩的坑,是删除操作。很多人的增量逻辑是"查更新时间大于上次同步时间的记录",但物理删除的记录,你根本查不到,ES 里就会残留脏数据。

解决办法有两个:

  • 软删除:表里加is_deleted字段,删除时标记而不是物理删除,增量查询带上这个字段;
  • Binlog 订阅:binlog 里能捕获 DELETE 事件,直接同步删除操作到 ES。

我强烈建议用软删除,不光是为了同步,数据恢复、审计都用得上。物理删除在生产环境里能少用就少用。

更新操作也有讲究。ES 的更新本质是"删旧文档 + 写新文档",不是原地修改。所以频繁更新同一个文档,会产生大量段(segment),触发频繁的段合并,影响性能。如果某个字段更新特别频繁(比如浏览量),建议不要放进 ES,或者单独存一个字段用 ES 的 update API 局部更新。

3.3 全量同步的限流与断点续传

首次全量同步,或者数据修复时的全量重建,一定要做限流和断点续传。

限流是因为:全量同步会瞬间给 ES 写入大量数据,如果不控制速率,ES 的写入队列会堆积,严重时触发写入拒绝(EsRejectedExecutionException),甚至影响线上查询。

断点续传是因为:全量同步动辄几小时,中途失败很常见。如果没有断点记录,每次失败都要从头再来,效率极低。

一个实用的做法是:按主键 ID 分片,每批处理比如 5000 条,记录最后成功处理的 ID。失败重启时从记录的 ID 继续。同时用令牌桶或信号量控制每秒写入的文档数,观察 ES 的写入延迟动态调整。

// 伪代码示意:分批 + 断点 + 限流 long lastId = loadCheckpoint(); int batchSize = 5000; while (true) { List<Article> batch = queryFromMySQL(lastId, batchSize); if (batch.isEmpty()) break; for (Article a : batch) { rateLimiter.acquire(); // 限流 bulkIndex(a); } lastId = batch.get(batch.size() - 1).getId(); saveCheckpoint(lastId); }

4. 全文检索的实战配置与调优

4.1 索引映射设计:字段类型决定检索能力

ES 的索引映射(mapping)一旦建好,大部分字段类型就不能改了。所以建索引之前一定要想清楚每个字段怎么用。

以文章搜索为例,一个典型的 mapping 大概是这样:

{ "mappings": { "properties": { "id": { "type": "long" }, "title": { "type": "text", "analyzer": "ik_max_word", "fields": { "keyword": { "type": "keyword" } } }, "content": { "type": "text", "analyzer": "ik_smart" }, "author": { "type": "keyword" }, "category": { "type": "keyword" }, "created_at": { "type": "date" }, "view_count": { "type": "integer" } } } }

几个关键点:

  • text vs keyword:text 会分词,用于全文检索;keyword 不分词,用于精确匹配、聚合、排序。title 同时建了两种,因为既要全文搜,又要按标题精确匹配或聚合。
  • analyzer 选择:标题用 ik_max_word 提高召回,正文用 ik_smart 控制索引体积。
  • 数值和日期字段:view_count 用 integer 而不是 text,这样能范围查询和排序;created_at 用 date,支持时间范围过滤。

注意:mapping 里字段类型定错了,后期改起来非常麻烦,往往要重建索引再 reindex。所以设计阶段多花点时间,比后期返工划算得多。

4.2 查询 DSL:match、term、bool 到底怎么选

ES 的查询 DSL 很丰富,新手最容易搞混的是 match 和 term。

  • match:会分词,把查询词也走一遍分词器,然后去倒排索引里匹配。适合全文检索。
  • term:不分词,精确匹配词项。适合 keyword 字段、数值、日期。

举个例子,搜"机器学习":

// match:会分词,能匹配到"机器学习""机器""学习" { "query": { "match": { "title": "机器学习" } } } // term:不分词,只有当 title 分词后恰好有"机器学习"这个词项才匹配 { "query": { "term": { "title": "机器学习" } } }

实际查询往往是组合的,用 bool 把多个条件拼起来:

{ "query": { "bool": { "must": [ { "match": { "title": "机器学习" } } ], "filter": [ { "term": { "category": "技术" } }, { "range": { "created_at": { "gte": "2024-01-01" } } } ], "should": [ { "match": { "content": "算法" } } ], "minimum_should_match": 1 } } }

这里有个重要的性能技巧:能用 filter 就别用 must。filter 不参与打分,结果可以被缓存;must 要算相关性分数,开销更大。像分类过滤、时间范围这种不影响相关性的条件,一律放 filter。

4.3 分页的深坑:from + size 为什么不能太大

ES 的分页用 from 和 size,但from + size超过 10000 会直接报错。这不是 bug,是设计使然。

原因在于 ES 是分布式的,一个查询要发到所有分片,每个分片都要返回from + size条结果,协调节点再合并排序。如果 from 是 10000,size 是 10,每个分片都要返回 10010 条,5 个分片就是 50050 条,内存和网络开销巨大。

解决办法有两个:

  • search_after:用上一页最后一条的排序值作为游标,适合深度翻页,但不支持随机跳页;
  • scroll:适合导出全量数据,会生成快照,不适合实时查询。
// search_after 示例 { "size": 10, "query": { "match": { "title": "机器学习" } }, "sort": [ { "created_at": "desc" }, { "id": "desc" } ], "search_after": [1704067200000, 12345] }

提示:search_after 的排序字段必须唯一,否则可能漏数据或重复。通常用"时间 + ID"组合排序。

4.4 分词器调优:自定义词典解决业务词

IK 分词器自带词典,但业务里的专有名词它不一定认识。比如"云原生""大模型""向量数据库"这些词,早期版本的 IK 可能切得乱七八糟。

解决办法是配置自定义词典。IK 支持扩展词典和停用词典:

  • 扩展词典:把业务专有名词加进去,让 IK 认识它们;
  • 停用词典:把"的""了""是"这类无意义词去掉,减小索引体积。

配置方式是在 IK 的 config 目录下建custom.dic,每行一个词,然后在IKAnalyzer.cfg.xml里引用。改完要重启 ES 或者热更新(IK 支持远程词典热更新)。

我踩过的一个坑是:自定义词典里的词如果包含特殊字符或者编码不对,会导致整个词典加载失败,分词退化成单字切分。所以加完词一定要用_analyzeAPI 验证一下:

POST /_analyze { "analyzer": "ik_max_word", "text": "云原生向量数据库" }

看返回的分词结果对不对,不对就检查词典文件。

5. 混合检索:当全文检索不够用时

5.1 什么是混合检索,为什么需要它

纯全文检索有个天然短板:它只懂"字面匹配",不懂"语义"。用户搜"怎么提升搜索准确率",如果文档里写的是"如何提高检索精度",字面不匹配,全文检索就搜不到,但语义上它们是同一个意思。

混合检索(Hybrid Search)就是把多种检索信号融合起来,通常包括:

  • 全文检索(关键词匹配):基于倒排索引,精确、可解释,但对同义词、语义变体无能为力;
  • 向量检索(语义匹配):把文本转成向量,用向量相似度找语义相近的文档,能处理同义表达,但可能召回不精确的结果;
  • 结构化过滤:分类、时间、标签等硬条件。

把这几路结果融合,既能保证精确性,又能提升召回和语义理解能力。这就是混合检索的价值。

5.2 向量检索的基本原理

向量检索的核心是文本向量化(embedding)。用一个模型把文本编码成一个高维向量(比如 768 维),语义相近的文本,向量在空间里的距离也近。

检索时,把查询也编码成向量,然后在向量库里找距离最近的 Top-K 个文档。距离度量常用余弦相似度或欧氏距离。

ES 从 7.x 开始支持dense_vector字段类型,8.x 之后原生支持 kNN 检索:

{ "mappings": { "properties": { "title_vector": { "type": "dense_vector", "dims": 768, "index": true, "similarity": "cosine" } } } }

查询时用 kNN:

{ "knn": { "field": "title_vector", "query_vector": [0.1, 0.2, ...], "k": 10, "num_candidates": 100 } }

num_candidates是每个分片预选的候选数,越大越准但越慢。一般设成 k 的 10 倍左右起步,再根据效果调。

5.3 分数融合:RRF 和加权求和怎么选

全文检索和向量检索的分数不在一个量纲上,不能直接相加。常见的融合方式有两种:

加权求和:把两路分数归一化后按权重相加。

final_score = α * normalize(bm25_score) + (1 - α) * normalize(vector_score)

优点是直观可控,缺点是归一化方式对结果影响大,而且权重需要反复调。

RRF(Reciprocal Rank Fusion,倒数排名融合):不看分数,只看排名。

RRF_score(d) = Σ 1 / (k + rank_i(d))

其中 k 是常数(通常取 60),rank_i(d) 是文档 d 在第 i 路结果里的排名。RRF 的好处是不用归一化,对分数尺度不敏感,鲁棒性强。缺点是丢失了分数信息,只用了排名。

我自己的经验是:两路检索质量差不多时用 RRF,简单省心;如果某一路明显更可靠,用加权求和,给可靠的那路更高权重。

5.4 一个可落地的混合检索流程

把上面的东西串起来,一个完整的混合检索流程大概是这样:

  1. 查询预处理:对用户输入做分词、同义词扩展、拼写纠错;
  2. 并行发起两路检索:全文检索走 match 查询,向量检索走 kNN;
  3. 结构化过滤:两路都带上 filter 条件(分类、时间等);
  4. 分数融合:用 RRF 或加权求和合并结果;
  5. 重排序(可选):用更精细的模型对 Top-N 结果重排;
  6. 返回结果:带上高亮、分页信息。
// 8.x 支持在 search 里同时写 query 和 knn,ES 会自动做融合 { "query": { "match": { "title": "搜索准确率" } }, "knn": { "field": "title_vector", "query_vector": [...], "k": 10, "num_candidates": 100 }, "size": 10 }

ES 8.x 之后,query 和 knn 可以写在同一个请求里,底层用 RRF 融合。这大大简化了混合检索的实现,不用自己在应用层做融合了。

6. 性能与踩坑:那些文档里不会写的事

6.1 段合并:ES 写入性能的隐形杀手

ES 的索引是不可变的,每次写入其实是生成一个新的段(segment),查询时要扫所有段。段太多,查询就慢。所以 ES 后台会不断做段合并(merge),把多个小段合并成大段。

段合并本身很耗 IO 和 CPU。如果写入量很大,段合并跟不上,就会出现写入延迟飙升。几个应对经验:

  • 控制写入速率:用 bulk 批量写,每批 5-15MB 比较合适,太大反而容易触发拒绝;
  • 调整 refresh_interval:默认 1 秒刷新一次,写入密集时可以调大到 30 秒,减少段生成;
  • 用 bulk 而不是单条写:单条写的开销是批量的几十倍。
PUT /articles/_settings { "index": { "refresh_interval": "30s", "number_of_replicas": 1 } }

注意:refresh_interval 调大后,新写入的数据要等更久才能被搜到。如果业务对实时性要求高,就别调太大。

6.2 分片设计:不是越多越好

新手容易犯的错是分片设太多,觉得分片多就快。实际上分片过多会带来:

  • 每个分片都是一个 Lucene 索引,有固定开销;
  • 查询要发到所有分片,分片越多,协调节点合并结果的负担越重;
  • 集群元数据膨胀,主节点压力大。

经验法则:单个分片大小控制在 10GB 到 50GB 之间。假设你的数据总量 100GB,那 3 到 5 个主分片就够了。分片数在索引创建时定好,后期改要 reindex,所以一开始就要估算好数据增长。

副本数则看查询压力和可用性要求。副本能分担查询,但会占用额外存储,而且写入时要同步到副本,影响写入性能。一般 1 到 2 个副本。

6.3 中文搜索的精度问题

中文搜索经常遇到"搜不准"的问题,原因往往在分词。几个典型场景:

  • 专有名词被切碎:比如"鸿蒙系统"被切成"鸿蒙""系统",搜"鸿蒙系统"能搜到,但搜"鸿蒙"会召回一堆不相关的;
  • 同义词搜不到:搜"手机"搜不到只写了"移动电话"的文档;
  • 停用词处理不当:把"的"去掉后,"我的世界"变成"我世界",语义变了。

解决办法:

  • 专有名词加自定义词典;
  • 同义词用 synonym 过滤器配置;
  • 停用词要谨慎,中文里很多单字停用词去掉后会影响语义。

同义词配置示例:

{ "settings": { "analysis": { "filter": { "my_synonym": { "type": "synonym", "synonyms": [ "手机,移动电话", "电脑,计算机,PC" ] } }, "analyzer": { "my_analyzer": { "tokenizer": "ik_max_word", "filter": ["my_synonym"] } } } } }

6.4 监控指标:哪些数字必须盯

ES 上线后,有几个指标必须持续监控,出问题能第一时间发现:

指标含义异常信号
查询延迟 P9999% 查询的耗时持续上升说明查询变重或资源不足
写入拒绝数被拒绝的写入请求大于 0 说明写入队列满了
段数量索引的段总数持续增长说明合并跟不上
JVM 堆使用率堆内存占用超过 75% 要警惕 GC 问题
磁盘使用率数据盘占用超过 85% 要扩容
线程池队列各线程池排队数队列满说明处理不过来

其中 JVM 堆和段数量是最容易被忽视的。堆用满会触发长时间 GC,查询直接卡死;段数量失控会让查询越来越慢。建议给这两个指标设告警。

7. 写在最后的一点个人体会

从 MySQL 的 LIKE 走到 ES 的倒排索引,再走到混合检索,这条路我走了好几年,踩过的坑比上面写的多得多。如果让我给正在做搜索功能的朋友几句实在话:

第一,别一上来就上 ES。数据量不到百万、搜索需求简单的,MySQL 的 LIKE 加个缓存可能就够了。ES 的运维成本不低,集群、分片、段合并、JVM 调优,每一项都要投入精力。技术选型要看投入产出比,不是越先进越好。

第二,同步方案先想清楚一致性要求。很多团队一上来就追求实时同步,结果维护成本高得吓人。其实大部分业务能接受分钟级延迟,定时增量同步简单又可靠。真需要实时的,再上 Binlog 方案。

第三,混合检索不是银弹。向量检索能提升语义召回,但也会引入噪声,而且 embedding 模型的选型、向量维度的确定、融合权重的调整,都需要反复实验。先把纯全文检索做好,再考虑加向量。

第四,监控和压测要提前做。ES 的问题往往在数据量涨上来之后才暴露,等线上出问题再排查就晚了。上线前用真实数据量压测,把查询延迟、写入拒绝、段数量这些指标摸清楚,心里才有底。

搜索这件事,没有一劳永逸的方案,只有不断根据业务调整的平衡。希望这篇内容能帮你少踩几个坑,把搜索做得更稳一点。