WHERE title LIKE '%索引%' 在开发库上很快,上线后页面转圈;改成 索引% 快了一点,但排序还是乱的——按时间倒序会把最不相关的推到第一。问题不在 SQL 写得丑:关系库只回答"命中没命中",它没有"多相关"这个概念,也没法按相关度排序。
要把搜索做对,需要三样关系库不提供的东西:能按词查的结构、把句子切成词的能力、给命中打分的公式。
1. LIKE '%x%' 到底贵在哪
| 需求 | LIKE 的做法 |
代价 |
|---|---|---|
| 找包含某词的文档 | 逐行取字段值、逐字符比对 | 走不了索引,全表扫描 |
| 按相关度排序 | 没有相关度,只能按别的字段排 | 排序和"想要什么"无关 |
| 一词多形(索引/建索引) | 每个变体 OR 一遍 |
组合爆炸,SQL 难维护 |
| 多字段加权(标题重于正文) | CASE WHEN 拼权重 |
纯 CPU 计算,不参与索引选择 |
EXPLAIN SELECT id, title FROM article WHERE title LIKE '%索引%';
-- type=ALL, rows≈全表, Extra=Using where
B+ 树索引的前提是前缀有序,% 开头破坏了前缀,索引无从下手。十万行级别、单机 SSD 的典型量级是单次查询几百毫秒(按 MySQL 8.x 核对)。
2. 倒排索引:把"文档→词"翻过来
正排是"文档里有哪些词",倒排是"词出现在哪些文档里"。三篇文档:
| doc_id | title |
|---|---|
| 1 | 倒排索引入门 |
| 2 | 索引与分片 |
| 3 | 分片与副本 |
按词切分后的倒排表:
| 词项 term | 文档频率 df | 倒排表 postings(doc:位置) |
|---|---|---|
| 倒排 | 1 | 1:0 |
| 索引 | 2 | 1:1, 2:0 |
| 入门 | 1 | 1:2 |
| 分片 | 2 | 2:1, 3:0 |
四个部件各管一件事:
| 部件 | 存什么 | 用来干什么 |
|---|---|---|
| 词项词典 | 全部 term,有序存放 | 判断词在不在索引里,定位倒排表 |
| 倒排表 | 每个 term 的 doc_id 列表 | 求交/并集,得到候选文档 |
| 文档频率 df | term 出现在多少文档 | 算 IDF:越常见的词权重越低 |
| 位置 positions | term 在文档内的偏移 | 短语查询、邻近查询、高亮 |
{ "索引": { "df": 2, "postings": [ {"doc": 1, "pos": [1]}, {"doc": 2, "pos": [0]} ] } }
没有位置信息也能搜,只是搜不了要求相邻的短语,也没法精确高亮。所以 positions 可配:不要它,索引能小一截。
3. 分词:中文的特殊性就在这一步
英文按空格切,中文没有空格。三种常见处理:
| 方式 | "倒排索引入门"切出 | 优点 | 代价 |
|---|---|---|---|
| 词典分词(ik 类) | 倒排/索引/入门 | 词义完整,打分准 | 新词专名靠词典补,切错就搜不到 |
| 字粒度 | 倒/排/索/引/入/门 | 召回高,不易漏 | 索引大,"的"也进索引 |
| ngram(2-gram) | 倒排/排索/索引/引入/入门 | 无需词典 | 索引膨胀,误召回多 |
POST /books/_analyze
{ "analyzer": "standard", "text": "倒排索引入门" }
内置 standard 跑中文,输出是单字——它按 Unicode 文本边界切,CJK 没有词边界。这解释了"能搜到但排序很怪":打分单位是单字,词频统计全乱了。要词粒度得装 analysis-ik 这类插件(版本须与 ES 主版本对齐,按 8.x 核对)。
附加两个动作:停用词(去掉"的、了、是",减小索引与噪音)、同义词(ES 与 Elasticsearch 归一)。索引期改同义词要重建,查询期改完立即生效但有开销。
4. 打分:TF-IDF 与 BM25 的直觉差别
一句话:一个词在本篇出现得越多、在所有文档里出现得越少,这篇就越相关。
TF-IDF: score = tf × idf, idf = log(N / df)
BM25: score = idf × (tf × (k1+1)) / (tf + k1 × (1 - b + b × dl / avgdl))
| 维度 | TF-IDF | BM25 |
|---|---|---|
| 词频增长 | 线性,出现 10 次就是 10 倍 | 饱和,有上限 |
| 文档长度 | 不处理,长文天然占优 | 按 dl/avgdl 用 b 归一 |
| 可调参数 | 基本没有 | k1 饱和速度、b 长度惩罚 |
BM25 的两处改动修的是同一个毛病:长文档什么词都容易多出现几次,不该因此排前面;饱和项则防止关键词堆砌刷分。
打分是逐字段、逐词累加的:查询词在 title 算一次、在 body 算一次,各乘权重后相加。"总分 7.3"背后是一串小分,用 _explain 才看得出哪个字段贡献的。
5. 字段权重与多字段匹配
| 位置 | 写法 | 特点 |
|---|---|---|
| 查询期 | fields: ["title^3", "body"] |
改完立即生效,随时可调 |
| 索引期 | copy_to 合并到 all 字段 |
稳定,但改权重可能要重建索引 |
GET /books/_search
{ "query": { "multi_match": {
"query": "倒排索引", "fields": ["title^3", "body^1"], "type": "best_fields" } } }
type 决定多字段怎么合分:best_fields 取最高分字段(默认),most_fields 累加,cross_fields 把多字段当一个整体看。选错 type 的症状是"本该第一的排到后面",不报错。
6. 查询类型与执行代价
| 查询 | 语义 | 打分 | 相对代价 |
|---|---|---|---|
term |
精确匹配词项,不分词 | 通常配 filter |
低 |
match |
先分词再按词项匹配 | 算分 | 中 |
match_phrase |
分词后要求位置相邻 | 算分(读 positions) | 中高 |
must/should |
与/或,参与打分 | 算分 | 逐条累积 |
filter |
与,不算分可缓存 | 不算 | 低,优先用 |
range |
区间过滤 | 不算 | 低 |
GET /books/_search
{ "query": { "bool": {
"must": [ { "match": { "body": "分片" } } ],
"filter": [ { "term": { "tags": "search" } },
{ "range": { "publish_at": { "gte": "2026-01-01" } } } ] } } }
规则:只影响"要不要"的放 filter,影响"多相关"的放 must/should。把标签、时间塞进 must,等于为每个候选多算一遍分,还丢掉 filter cache。
7. 写入与近实时:refresh 的存在感
| 动作 | 做了什么 | 默认行为 |
|---|---|---|
index |
写内存 buffer + 追加 translog | 立即返回,不可搜 |
| refresh | buffer → 新 segment,打开供搜索 | 每 1s 一次(可调) |
| flush | segment 落盘、清 translog | 按大小/时间触发 |
| merge | 合并小 segment,清删除标记 | 后台自动 |
PUT /books/_doc/4?refresh=wait_for
{ "title": "近实时与 refresh", "body": "写完不一定马上能搜到" }
refresh=true 立刻刷新,但每次生成小 segment,写多时别用;refresh=wait_for 等下一次自动 refresh 再返回,适合"写完要能马上读到"。
8. 分片与副本:一个管写,一个管读
| 概念 | 解决什么 | 对搜索的影响 | 对写入的影响 |
|---|---|---|---|
| 主分片 | 水平切分,突破单机容量 | 查询要打到所有分片再归并 | 按 _routing 落到唯一主分片 |
| 副本 | 读吞吐 + 故障接管 | 同一分片可打到任一副本 | 主分片写完同步副本才算成功 |
| 分片数 | 建索引时定,改要 reindex | 分片多→协调开销上升 | 分片多→每批写入变小 |
两个结论:分片不是越多越好(10 个分片会把一次查询放大成 10 次子查询);副本加的是读能力,不是写能力。
9. 相关性调不过来,常是这三个原因
| 原因 | 症状 | 怎么确认 |
|---|---|---|
| 分词不对 | 该搜到的搜不到;排序很怪 | _analyze 看实际 token |
| 字段没设对 | 精确匹配被分词;模糊搜用了 keyword |
查 _mapping;term 查 text 必然 0 条 |
| boost 拍脑袋 | 调了没变化,改 A 又坏 B | 先看 _explain 子项再动权重 |
排查顺序:先看召回(文档在不在候选集),再看排序(候选集里的分对不对)。召回不对查分词与字段类型,召回对了才看权重。
10. 最小可跑示例
PUT /books
{ "settings": { "number_of_shards": 1, "number_of_replicas": 0 },
"mappings": { "properties": {
"title": { "type": "text" }, "body": { "type": "text" },
"tags": { "type": "keyword" }, "publish_at": { "type": "date" } } } }
POST /books/_bulk
{"index":{"_id":"1"}}
{"title":"倒排索引入门","body":"倒排索引把词项映射到文档","tags":["search"],"publish_at":"2026-11-01"}
{"index":{"_id":"2"}}
{"title":"索引与分片","body":"分片决定数据怎么切","tags":["search"],"publish_at":"2026-11-02"}
NDJSON 两个细节:每行都要以换行结束(最后一行也要),请求头 Content-Type: application/x-ndjson。少换行会报 illegal_argument_exception。
GET /books/_search
{ "query": { "multi_match": { "query": "倒排索引", "fields": ["title^3", "body"] } } }
GET /books/_explain/1
{ "query": { "match": { "title": "倒排索引" } } }
跑通这四步就有了调相关性的闭环:改 mapping/分词 → 重建 → 看 _score → 看 _explain。
11. 排错表
| 现象 | 常见原因 | 处理 |
|---|---|---|
| 刚写入查不到 | 还没 refresh | 等 1s,或写入带 refresh=wait_for |
term 查 text 永远 0 条 |
text 被分词,词项不是原句 |
改 match,或加 keyword 子字段 |
| 中文有命中但排序怪 | standard 按单字切 |
装 ik 指定 analyzer,重建 |
should 没影响排序 |
bool 里有 must 时 should 不强制 |
设 minimum_should_match: 1 |
| 写入变慢、segment 多 | 频繁 refresh=true |
去掉强制 refresh,改 bulk |
12. 边界:什么时候本就该用数据库
| 场景 | 更合适的选择 | 理由 |
|---|---|---|
| 万级数据、只要精确匹配 | 关系库 + 索引 | 少一套系统,少一处一致性维护 |
| 只要前缀匹配或标签过滤 | 关系库或 Redis | 不需要分词与打分 |
| 强事务、写完必须立即可读 | 关系库 | 搜索近实时,无事务 |
| 复杂报表与大规模聚合 | 数仓 / OLAP | 搜索聚合适合展示,不当报表引擎 |
搜索引擎不是"更好的数据库",而是"另一个擅长找文本的工具"。判断标准一句话:要不要"按相关度排序"? 要,上倒排索引;不要,关系库更省事。
13. 自测
- 为什么
LIKE '%索引%'用不上 B+ 树索引? - 倒排表里存位置信息,多支持了哪两类查询?
- 用
standard搜中文,为什么"能搜到但排序很怪"? - BM25 相比 TF-IDF 改了两件事来修同一个毛病,是哪两件?
- 写完一条文档立刻查不到,先怀疑什么?
- "该排第一的文档排到第五",先看召回还是先调 boost?