摘要:本文从 SQL LIKE 的检索痛点切入,拆解基于 Lucene 倒排索引的全文检索原理,覆盖 Analyzer 文本分析、NoSQL 文档 mapping 设计、match 查询与 BM25 相关性评分,并给出可运行的索引创建、批量写入与查询示例,以及生产环境常见的分词与调优坑。
导语
做后端时,商品搜索、站内文章检索、日志关键词排查几乎每个系统都绕不开。很多人第一反应是 SELECT * FROM t WHERE content LIKE '%关键词%',但数据量一上来就用不了:前缀通配无法走索引、中文还得分词、相关性排序更无从谈起。
Elasticsearch(下文简称 ES)是一个基于 Lucene 的分布式 NoSQL 文档检索引擎:它存的是 JSON 文档,提供近实时的全文检索能力,横向扩展也比较自然。本文聚焦"全文检索怎么落地",把原理和可直接抄的代码讲清楚。
声明:本文基于个人使用体验,非商业推广。
一、为什么需要全文检索
传统关系型数据库的 LIKE '%x%' 至少有三个硬伤。
第一,性能 :前置通配 %x% 无法利用 B+Tree 索引,只能全表扫描,百万级数据就可能拖垮库。第二,分词 :英文按空格切词勉强够用,中文、I/O、C++ 这类词需要按语义切分,数据库并不知道怎么切。第三,相关性 :用户要的是"最相关的结果排前面",LIKE 只能做是否包含,给不出打分。
ES 把文档以 JSON 形式存入索引(index),底层由 Lucene 维护倒排索引,查询时在毫秒级返回并按相关性排序。典型场景包括:
- 站内搜索:博客、商品、知识库的标题与正文检索;
- 日志检索:把应用日志灌进 ES,做关键词与聚合分析;
- 商品/内容召回:先以全文检索做候选召回,再做精排。
二、Lucene 倒排索引原理
ES 的全文检索能力全部来自底层的 Lucene。理解倒排索引,就理解了检索快的根本原因。
正排与倒排
正排索引 是按文档存字段,比如"文档 1 的内容是......",想找"哪些文档包含某词"就得逐篇扫描。倒排索引(Inverted Index)反过来:以"词项(Term)"为键,记录它出现在哪些文档、位置与频率。
text
文档集合:
doc1: "Elasticsearch 全文检索"
doc2: "Lucene 倒排索引"
doc3: "Elasticsearch 倒排索引实战"
倒排索引(简化):
Elasticsearch -> [doc1, doc3]
Lucene -> [doc2]
全文检索 -> [doc1]
倒排索引 -> [doc2, doc3]
实战 -> [doc3]
查询"Elasticsearch 倒排索引"时,先分词成两个 Term,再取倒排链求交集 [doc1,doc3] ∩ [doc2,doc3] = [doc3],文档 3 命中。

Term Dictionary 与 Posting List
倒排索引由两部分组成:Term Dictionary (词项字典,所有 Term 的有序集合)和 Posting List(词项表,每个 Term 对应的文档列表,含 docId、词频 TF、位置等)。
Lucene 用 FST(Finite State Transducer,有限状态转换器) 压缩 Term Dictionary,让词典可以常驻内存,查找 Term 时走前缀共享,内存占用远低于存原始字符串。

不可变 Segment 与近实时刷新
Lucene 的索引由多个 Segment(段) 组成,Segment 一旦写盘就不可变(immutable) :新增文档是写新 Segment,删除只是打 .del 标记,更新等价于"删除旧 + 写入新"。
好处是并发安全、缓存友好;代价是 Segment 会越来越碎,Lucene 在后台通过 merge(合并) 把小段合成大段并真正清除已删除文档。文档写入后默认约 1 秒(refresh_interval=1s) 即可被搜索到,这就是 ES 所说的"近实时(NRT,Near Real-Time)"。

三、ES 文本分析 Analysis
写入文本字段和查询文本时,ES 都要先经过 Analysis(文本分析):把原始字符串切成一个个 Token(词项),才能进倒排索引或去匹配。
Analyzer 的三段式组成
一个 Analyzer(分析器) 由三部分按顺序拼接:
| 组成部分 | 作用 | 是否必需 |
|---|---|---|
| Character Filters(字符过滤器) | 预处理,如去掉 HTML 标签、替换字符 | 可选 |
| Tokenizer(分词器) | 按规则切词,如按空格、按标准 Unicode 切分 | 必需(唯一) |
| Token Filters(词项过滤器) | 转小写、去停用词、同义词、提取词干 | 可选 |
对称性 是关键点:索引阶段用 Analyzer 把文档切成 Token 写入倒排索引;查询阶段默认用同一 Analyzer 把查询串切成 Token 再去匹配。如果两边分词规则不一致,就会出现"明明有却搜不到"的诡异现象。

standard analyzer 与自定义 analyzer
ES 默认对 text 字段用 standard analyzer :它用 standard tokenizer(按 Unicode 词边界切分,能正确切英文单词、CJK 按字切分),并接 lowercase 与 stop(默认停用词)两个 token filter。
中文场景往往需要自定义,例如引入 IK 或 jieba 分词插件做按词切分。下面是一个自定义 analyzer 的 mapping 片段(节选):
json
{
"settings": {
"analysis": {
"analyzer": {
"my_cn_analyzer": {
"tokenizer": "ik_max_word",
"filter": ["lowercase"]
}
}
}
}
}
想确认某段文本会被切成哪些 Token,直接调 _analyze API,无需建索引:
json
POST /my-index/_analyze
{
"analyzer": "standard",
"text": "Elasticsearch 全文检索 BM25"
}
返回里 tokens 数组的每个元素含 token(切出的词)、start_offset、end_offset 与 position,一眼看清分词结果。

四、NoSQL 文档检索落地
ES 是文档型 NoSQL:一行数据就是一个 JSON 文档,结构灵活,先写后用动态映射也能跑。但生产上建议显式定义 mapping,避免类型推断踩坑。
索引与 mapping 设计
下面创建一个商品索引,把 title、description 设为 text(可全文检索),并额外挂一个 .keyword 多字段用于精确匹配、聚合与排序:
http
PUT /products
{
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "standard",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"description": {
"type": "text",
"analyzer": "standard",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"price": { "type": "scaled_float", "scaling_factor": 100 },
"created_at": { "type": "date" }
}
}
}
text 与 keyword 多字段(multi-fields)
同一份字符串,ES 常用 multi-fields 同时建两种索引:text 用于全文检索(走 Analyzer),keyword 用于精确匹配(term 查询)、terms 聚合和排序(不分词)。
注意 ignore_above: 256:当 ES 用动态映射自动为 text 字段生成 .keyword 子字段时,该子字段默认就带 ignore_above: 256------超过 256 个字符的值不会被索引进 .keyword,但仍保留在 _source 里,只是不能用于精确匹配和聚合。手动定义时建议显式写上这个保护,避免长文本触发 Lucene 单 Term 的 32766 字节上限导致整条文档被拒。

bulk 批量写入与近实时可见
单条 _doc 写入适合演示,生产请用 _bulk 批量接口,显著降低网络往返开销:
http
POST /products/_bulk
{"index":{"_id":1}}
{"title":"ES 实战指南","description":"讲解倒排索引与全文检索原理","price":5900,"created_at":"2026-01-12"}
{"index":{"_id":2}}
{"title":"Lucene 原理解析","description":"深入 FST 与 Segment 合并机制","price":3900,"created_at":"2026-02-03"}
{"index":{"_id":3}}
{"title":"BM25 相关性评分入门","description":"理解 TF IDF 与 Okapi BM25","price":2900,"created_at":"2026-03-21"}
_bulk 要求"行为(action)"与"数据(source)"两行一组、整体必须是换行分隔的 NDJSON。写入后默认约 1 秒即可被搜到;若写入后立刻要查,可显式 GET /products/_refresh 强制刷新(仅调试用,生产别频繁调)。

五、实战全文检索
检索才是重头戏。ES 提供一整套全文查询,核心都基于 BM25 打分。
match / match_phrase / multi_match
match:对查询串先做 Analyzer 再匹配,是最常用的全文查询;match_phrase:要求 Token 顺序与位置连续,适合"整句匹配";multi_match:在多个text字段上同时match,适合"标题+正文"联合检索。
http
GET /products/_search
{
"query": {
"match": {
"title": "ES 实战"
}
}
}
返回的每条 hit 都带 _score :这是 ES 给出的相关性评分 ,是一个正浮点数,分数越高越相关。下面用 Python 客户端同样查一次,并打印评分:
python
from elasticsearch import Elasticsearch
client = Elasticsearch("localhost:9200", basic_auth=("elastic", "密码"))
# 1) 用 analyze 看标准分词器把查询切成哪些 token
tokens = client.indices.analyze(
index="products",
body={"analyzer": "standard", "text": "ES 实战"}
)
print([t["token"] for t in tokens["tokens"]])
# 2) 执行 match 查询,观察 BM25 打出的 _score
resp = client.search(
index="products",
body={"query": {"match": {"title": "ES 实战"}}}
)
for hit in resp["hits"]["hits"]:
print(hit["_id"], hit["_score"], hit["_source"]["title"])
BM25 相关性评分与 _score
ES 全文查询的相关性默认由 BM25(Best Matching 25,也称 Okapi BM25) 算法计算,对应 mapping 里 similarity 参数的默认值 BM25。它继承自 TF-IDF 思想,但做了两点改进:
- 词频饱和:词出现次数越多,边际贡献递减,避免一个词刷屏的文档霸榜;
- 字段长度归一:短字段(如标题)里命中比长字段(如正文)里命中更"值钱"。
所以 _score 不是简单的"出现次数",而是综合了词频、逆文档频率(IDF)与字段长度的归一化结果,最终是一个正浮点数。

ES|QL 全文检索函数(8.17+)
从 ES 8.17 起,ES|QL 以技术预览形式引入全文检索函数(如 MATCH、QSTR),后续版本逐步补齐 MATCH_PHRASE、QUERY 等。可用 ES|QL 直接写检索:
http
POST /_query?format=txt
{
"query": "FROM products | WHERE MATCH(title, \"ES 实战\") | SORT _score DESC"
}
ES|QL 把查询、过滤、聚合串成管道,对既想检索又想顺手做统计的场景很顺手;低版本没有这些函数时,仍用上面的 DSL match 查询即可。
六、性能优化与常见坑
落地后最容易踩的坑,按出现频率列几个。
term 字节长度限制与 ignore_above
Lucene 单个 Term 有 32766 UTF-8 字节 上限,超过会让整条文档写入失败。对 keyword 字段设 ignore_above(如 256)能提前把超长值排除出索引,保护写入不被拒。注意 ignore_above 统计的是字符数 ,但 Lucene 按字节 计;含大量非 ASCII(如中文、emoji)时,UTF-8 一个字符最多占 4 字节,想绝对保险可设到 8191 或更低。
分词器直接影响召回
召回率(能不能搜到)和分词器强相关。standard analyzer 对中文是按字切分 ,查"检索"未必能命中"全文检索"。做中文检索务必换 IK/jieba 等按词切分的分析器,并保证索引与查询用同一 analyzer(对称性)。
写入与查询调优
写入侧:用 _bulk 批量写、调大 refresh_interval(如 30s)减少段刷新、靠后台 merge 回收碎片。查询侧:只返回需要的字段(_source 过滤)、对精确匹配用 keyword 而非 text、热点查询加 filter 上下文走缓存(filter 不计 _score,可被节点缓存复用)。
小提醒:
match走 query 上下文会算分,filter/term走 filter 上下文不算分但可缓存,区分清楚能省不少 CPU。
总结
全文检索的本质,是用倒排索引把"文档找词"反转为"词找文档"。本文从 Lucene 的 Term Dictionary + Posting List、不可变 Segment 出发,讲到 ES 的 Analyzer 三段式与索引/查询对称性,再用可运行的 mapping、bulk、match 示例落地,最后落到 BM25 评分与常见坑。
实操建议:显式定义 mapping 并用 text+keyword 多字段、中文务必上专用分词器、批量写入配合合理 refresh_interval、精确匹配走 keyword。把这些点吃透,站内搜索、日志检索、商品召回都能稳稳落地。
© 2026 | 转载请注明出处
参考资料