2749 字
约 9 分钟
14
S13-X05 · 倒排索引与分词:为什么搜得到又能搜得准

"搜不到"和"搜不准"是两种故障:前者几乎总是写入链路或分词不一致,后者几乎总是字段类型与打分。倒排索引只回答一件事——给定一个 term,哪些文档含它、含几次、在什么位置;高亮、短语、相关度、聚合都从这三个信息派生。

下面按数据流向下拆:结构 → 压缩 → 写入 → 分词 → 类型 → 打分 → 边界。行为描述对齐 Elasticsearch 8.x + Lucene 9,默认值请按你锁定的 8.x 小版本核对。


1. term → posting list:倒排索引的最小结构

term "elasticsearch"
  ├─ doc 3  : tf=2, positions=[7, 41], offsets=[(112,125),(690,703)]
  ├─ doc 9  : tf=1, positions=[2],     offsets=[(30,43)]
  └─ doc 17 : tf=1, positions=[5],     offsets=[(88,101)]
存的东西 形态 谁在用
doc id 序列 递增整数,delta 编码 布尔求交/求并,按 skip 块跳读
term freq(tf) 与 doc id 同块压缩 BM25 的词频项
positions 仅 index_options: positions 时才有 match_phrase、邻近度
offsets 仅 offsets 时才有 高亮直接切原文,不必再分词
docFreq / totalTermFreq 词典条目旁的计数 IDF 与统计接口

posting list 存的是 doc id,不是文档内容:原文在 _source 与列存里,所以 _source: false 依然能搜、拿不回原文。index_options 一次定死(docs < freqs < positions < offsets),选了 docs 想补 positions 只能重建索引。


2. 词典与压缩:FST 为什么能省内存

Lucene 把"词"分两层放:

结构 位置 规模特征 作用
term index(FST) 堆内(on-heap) 远小于词典本体 定位到某个前缀所在的 block
term dictionary(block) 磁盘,mmap 读 全量 term 字节 每 1024 个 term 一组,组内共享前缀
posting list 磁盘 与文档×term 相关 doc id 序列 + skip 数据

FST 把"前缀 → 后继"压成一张有向图,公共前缀只存一次、后缀也能合并:它不是"每个 term 一个字符串",而是"一组路径"。百万级 term 的 FST 常见在几十 MB 量级(按词典规模核对),这就是"内存放不下全词典、却能秒定 term"的原因。

压缩走三步:delta(doc id 递增,存差值)→ 分块(每 128 篇一块,块内统一编码宽度)→ 变长编码(小数 1 字节)。代价是不能随机定位单篇,只能顺序读或按 skip 块跳——这正是第 8 节"term 精确查询快、子串查询慢"的根因。


3. 写入链路:buffer → segment → refresh → merge

PUT /article/_settings
{ "index": { "refresh_interval": "30s" } }
阶段 触发 文档是否可搜 产物
index buffer 每次写入 否 堆内缓冲,按分片独立
refresh 默认每 1s,或手动 是 新 segment + 打开新搜索器
flush translog 到阈值、或手动 不变 Lucene commit point,translog 截断
merge 后台策略 不变 小 segment 合并,清理已删文档

排查顺序按命中率排:refresh_interval 被设成 -1(→ _refresh)→ 写进 article-v2 而查的是别名指向的 article-v1 → routing 打到的分片没被查询覆盖 → 分词器不一致(下一节,最高频)→ _bulk 返回 200 但 errors: true。

批量导入的常规组合:refresh_interval: -1 + translog.durability: async,导完 _refresh 再改回 1s;这期间新文档对外不可见,别在服务中的索引上关。


4. 分词:中文的三类做法与内置分词器的边界

做法 代表 原理 强项 边界
词典驱动 ik_smart / ik_max_word 词典 + 最大匹配,长词优先 可控、可加词、切分稳定 未登录词、新词、歧义全靠词典维护
统计驱动 n-gram、HMM/CRF 用概率选切分,未登录词靠模型 新词、口语好 需要语料,短文本不稳,词典外实体仍弱
混合 HanLP、jieba、ik 的双模式组合 词典保主词,统计兜未登录 兼顾召回与精度 词典与模型两套都要维护

内置 standard 按 UAX#29 切,CJK 汉字基本被逐字切成单字 token:倒排索引 变成 倒、排、索、引 四个 term,match 几乎总能命中,召回虚高、精度差、短语高亮全错位。

GET /_analyze
{ "analyzer": "standard", "text": "倒排索引" }

换成词典分词,同样的文本只出一个 token:

GET /_analyze
{ "analyzer": "ik_smart", "text": "倒排索引" }

ik_max_word 切全部子词(倒排、索引、倒排索引)用于索引期提召回;ik_smart 切最粗粒度用于查询期降噪。


5. analyzer 一致性:索引期与查询期不同 = 搜不到

PUT /article
{
  "mappings": {
    "properties": {
      "title": { "type": "text", "analyzer": "ik_max_word", "search_analyzer": "ik_smart" },
      "tag":   { "type": "keyword", "normalizer": "lowercase_normalizer" }
    }
  },
  "settings": {
    "analysis": {
      "normalizer": { "lowercase_normalizer": { "type": "custom", "filter": ["lowercase"] } }
    }
  }
}

规则只有一条:search_analyzer 切出的 token 必须能在索引期词典里找到条目。索引期细、查询期粗是安全方向;反过来"查询比索引更细",只有部分 token 命中。

形态 表象 定位手段
索引期 standard、查询期 ik 整词查不到,单字能查到 _analyze?field=title 看索引期切法
装 ik 前建索引、装完只改查询 新文档能查,老文档查不到 对比 _mapping 与 _termvectors
keyword 字段用了 match 查询 只有完全相等才命中 改 term,或加 text 子字段
normalizer 只配一半 大小写混写查不到 确认索引期也过同一 normalizer
GET /article/_analyze
{ "field": "title", "text": "倒排索引原理" }

GET /article/1/_termvectors?fields=title&term_statistics=true

第一条用字段自己的索引期 analyzer 切,第二条看真实落进倒排的 token 与 tf。


6. text 与 keyword:类型差异与 field.keyword

维度 text keyword
是否分词 是,产出多个 term 否,整串一个 term
默认查询 match、match_phrase term、terms、prefix
排序/聚合 不支持(fielddata 吃堆) 默认支持,走 doc_values
磁盘 倒排 + 位置,较大 倒排 + 列存,较小
典型误用 用它做 term 用它做全文检索
"author": {
  "type": "text",
  "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } }
}

ignore_above: 256 按字节判断,超长值不写入该子字段(不是截断),聚合时掉进缺失桶、静默少数据。中文一字 3 字节,256 字节约 85 个汉字。


7. 相关度从哪来:TF-IDF 到 BM25

BM25 拆三块:TF 饱和(词频越高分越高但增速递减)、IDF(term 越罕见权重越高)、长度归一化(文档越长,命中同一词的含金量越低)。

参数 默认 调大 调小 典型场景
k1 1.2 更奖励词频,长文重复词占优 更快饱和,接近"出现即满分" 标题类短字段可调小
b 0.75 更重罚长文档 长文档不吃亏 长短文档混排时调小

8.x 默认 similarity: BM25,classic(TF-IDF 的 Lucene 实现)仍可配但新索引不必再用。注意 _score 的绝对值没有意义:它依赖当次查询的 term 集合、命中文档集与 boost 乘子,只能在同一查询内比较,不能拿来当阈值跨查询判断。


8. 倒排索引做不了什么

需求 倒排的表现 替代
任意子串 *abc* 前导通配要扫全词典 ngram 分词、wildcard 字段类型
前缀 abc* 可走 FST 前缀遍历,但要读大量 posting prefix 查询(只对 term 前缀)、edge_ngram
正则 regexp 自动锚定后状态机扫词典 尽量给出确定前缀,大范围用 ngram
短语相邻 依赖 positions match_phrase;没存 positions 只能靠 shingle
跨字段同句 字段各自倒排,无法跨字段判相邻 copy_to 汇总到一个字段
数值范围 倒排不适合 数值类型自带的 BKD 树,range 查询

edge_ngram 管"输入即搜"的前缀补全,ngram 管子串,两者都让索引成倍膨胀(min_gram 越小越明显,典型量级是 2-gram 的 term 数达到原词数的数倍),而且只该配索引期:查询期若也切碎,召回会被噪声淹没。


9. 排错

现象 原因 处理
写完立刻查不到 refresh_interval 为 -1 或很大 显式 _refresh,或临时调回 1s
只有部分文档搜不到 _bulk 部分失败、routing 漏分片 看 errors 与 _cat/shards
中文整词搜不到、单字能搜到 索引期用了 standard 改 ik 后 reindex
大小写、全半角不一致 keyword 没配 normalizer 加 normalizer 并 reindex
排序报 fielddata 错误 在 text 上排序 改用 field.keyword
聚合少数据 ignore_above 卡掉长值 调大阈值或换字段
_score 全 0 查询在 filter 上下文 别用 constant_score 包查询

10. 边界

场景 该用什么 理由
精确等值、枚举过滤 keyword + term 不分词、可缓存、可聚合
中文全文检索 text + ik 或 HanLP 词典/统计切分才有多词概念
日志类任意串搜索 wildcard 字段、或外挂对象存储 倒排不为子串而生
向量相似、语义召回 dense_vector + kNN 倒排按 term 命中,不懂语义
超大结果集导出 PIT + search_after、composite 聚合 深分页与全量遍历不属于倒排强项
只需过滤不需打分的场景 filter 上下文 跳过打分,还能进缓存

一句话:倒排索引是"词到文档"的映射,能力与限制都来自"按词"这两个字。


11. 自测

  1. posting list 里为什么存 doc id 而不是文档内容?这对 _source: false 意味着什么?
  2. FST 常驻堆内存,它存的是完整 term 还是"前缀到 block 的路径"?为什么这样能省内存?
  3. refresh_interval: -1 期间写入的文档,在什么条件下才会变成可搜?
  4. 索引期 ik_max_word、查询期 ik_smart,为什么这是安全方向而不是不一致?
  5. ignore_above: 256 对超长值的行为是截断还是不写入?对聚合有什么后果?
  6. 为什么 match_phrase 依赖 positions,而普通 match 不依赖?
S13-X05 · 倒排索引与分词:为什么搜得到又能搜得准
https://www.clxhxhhr.top/posts/5806/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。