2861 字
约 9 分钟
16
S13-B04 · 搜索入门:倒排索引、分词与相关性从哪来

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. 自测

  1. 为什么 LIKE '%索引%' 用不上 B+ 树索引?
  2. 倒排表里存位置信息,多支持了哪两类查询?
  3. 用 standard 搜中文,为什么"能搜到但排序很怪"?
  4. BM25 相比 TF-IDF 改了两件事来修同一个毛病,是哪两件?
  5. 写完一条文档立刻查不到,先怀疑什么?
  6. "该排第一的文档排到第五",先看召回还是先调 boost?
S13-B04 · 搜索入门:倒排索引、分词与相关性从哪来
https://www.clxhxhhr.top/posts/6053/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。