"搜不到"和"搜不准"是两种故障:前者几乎总是写入链路或分词不一致,后者几乎总是字段类型与打分。倒排索引只回答一件事——给定一个 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. 自测
- posting list 里为什么存 doc id 而不是文档内容?这对
_source: false意味着什么? - FST 常驻堆内存,它存的是完整 term 还是"前缀到 block 的路径"?为什么这样能省内存?
refresh_interval: -1期间写入的文档,在什么条件下才会变成可搜?- 索引期
ik_max_word、查询期ik_smart,为什么这是安全方向而不是不一致? ignore_above: 256对超长值的行为是截断还是不写入?对聚合有什么后果?- 为什么
match_phrase依赖 positions,而普通match不依赖?