Elasticsearch 倒排索引中词条索引原理分析 --- 基于 Lucene FST
1. 概述
Elasticsearch 的倒排索引(Inverted Index)由三层结构组成:
Term Index(词条索引) → Term Dictionary(词条词典) → Posting List(倒排表)
.tip 文件 .tim 文件 .doc / .pos 文件
FST 结构 Block Tree 分块存储 DocID、词频、位置
全部加载到内存 按需从磁盘加载 按需从磁盘加载
三层职责严格分离,不要混淆:
| 层级 | 存储内容 | 底层结构 | 加载方式 |
|---|---|---|---|
| Term Index | 每个 Block 的入口词条(稀疏采样),指向 Term Dictionary 中对应 Block 的偏移量 | FST(Finite State Transducer) | 全部驻留内存 |
| Term Dictionary | 全部词条,按字典序排列,按固定大小分 Block | 排序 Block + 前缀压缩 | 按需从磁盘加载 Block |
| Posting List | 每个词条对应的 DocID 列表、词频、位置等 | 差分编码 + 压缩 | 按需从磁盘加载 |
本文聚焦的是 第一层 --- Term Index 的底层数据结构 FST ,由 org.apache.lucene.util.fst.FST 类实现。
FST vs Term Dictionary 的本质区别:
- FST(Term Index)只存每个 Block 的第一个词条,是"词典的索引",量级很小
- Term Dictionary 存所有词条,数据量大,按 Block 分块存储在磁盘上
- 查询时:先用 FST 定位到目标 Block 的磁盘偏移 → 再从 .tim 文件加载该 Block → 在 Block 内二分查找目标词条 → 最后获取 Posting List
FST 本质上是一个 带输出的有限状态自动机(FSA),它不仅能够判断某个输入字符串是否被接受(即是否存在),还能在匹配的同时输出与该词条关联的值(如指向 Term Dictionary Block 的磁盘偏移量)。
2. FST 核心概念
2.1 FSA vs FST
| 特性 | FSA(有限状态自动机) | FST(有限状态转换器) |
|---|---|---|
| 输入 | 字符串 | 字符串(字节序列) |
| 输出 | 仅判断是否接受(True/False) | 输出一个关联值(Output) |
| 用途 | 判断词条是否存在 | 查询词条并返回关联数据 |
关系:FSA 是 FST 的特例(输出为空时 FST 退化为 FSA)。
2.2 FST 结构要素 --- 理论与代码映射
FST 的理论定义:
FST = (States, Transitions, StartState, FinalStates)
每个要素的含义及其在 Lucene 源码中的对应:
| 理论要素 | 含义 | Lucene 源码中的体现 |
|---|---|---|
| States(状态集合) | 自动机中的所有节点。每个状态代表"已匹配了某个前缀" | 不是独立对象。每个状态是 byte[] 数组中的一个偏移位置 (long nodeOffset)。nodeCount 记录总节点数 |
| Transitions(转换/边) | 状态之间的有向边,每条边带有输入标签和输出值 | FST.Arc<T> 类。字段:label(输入字符)、output(输出值)、target(目标节点偏移)、nextArc(同节点的下一条边) |
| StartState(起始状态) | 所有匹配的起点,输入为空时的状态 | FST.startNode(long类型),指向 byte[] 中起始节点的偏移位置 |
| FinalStates(终态集合) | 匹配成功的终点。到达终态说明输入串是一个合法词条 | 没有独立集合。节点在序列化时通过 flag 位 标记是否为终态(BIT_FINAL_ARC),查询时通过 Arc.isFinal() 判断 |
关键设计理解:Lucene FST 中没有独立的 State 对象!
所有节点和边被紧凑地序列化到一个 byte[] 字节数组中:
- 一个"状态" =
byte[]中的一个偏移量(long整数) - 一个"边" = 从该偏移量开始的一段字节编码(通过
FST.readFirstArc()/FST.readNextArc()解码) - 这样做的好处是:零对象创建、GC 友好、可直接 mmap 磁盘文件
2.3 核心代码结构
java
// org.apache.lucene.util.fst.FST
public final class FST<T> extends Accountable {
// 输入类型:BYTE1(单字节,ASCII)或 BYTE4(四字节,Unicode 码点)
private final INPUT_TYPE inputType;
// === StartState ===
// 起始节点在 bytes 数组中的偏移位置
private final long startNode;
// === States 的存储载体 ===
// 所有节点和边紧凑序列化在此字节数组中
// 没有独立的 State 对象,"状态" = 此数组的一个偏移量
private final FSTBytes bytes; // (新版 Lucene 使用 FSTBytes 封装)
// === Transitions 的输出值类型 ===
// 决定 output 的数据类型,如 POS_INTS_OFFSETS 表示正整数
private final Outputs<T> outputs;
// === States / Transitions 的计数 ===
private int nodeCount; // 节点总数(States 的数量)
private int arcCount; // 边总数(Transitions 的数量)
}
// org.apache.lucene.util.fst.FST.Arc --- 代表一条 Transition(边)
public static final class Arc<T> {
// 输入标签:这条边匹配的字符(字节值)
public int label;
// 输出值:沿这条边的输出(查询时沿路径累加)
public T output;
// 目标节点:这条边指向的状态节点在 bytes 中的偏移
public long target;
// === FinalState 判断 ===
// 如果 target 指向的节点是终态,则 flags 中包含 BIT_FINAL_ARC
public byte flags;
public boolean isFinal() { return (flags & BIT_FINAL_ARC) != 0; }
// 同节点的下一条边(链式遍历同一状态的所有出边)
public long nextArc;
}
2.4 FST 的核心优势
- 前缀共享:具有相同前缀的词条共享路径节点,大幅压缩空间
- 后缀共享:具有相同后缀的词条在构建时可合并尾节点(minimization)
- 空间压缩:FST 只存 Block 入口词条(稀疏采样),将 Term Index 压缩到 MB 级,可完全放入内存
- 查询高效:时间复杂度为 O(len(term)),与词条数量无关
3. 举例:词条 "上海"、"上学"、"海上"、"上" 的 FST 构建
⚠️ 重要说明:本节示例 vs 生产环境的区别
本节(3.1 ~ 3.4)将 4 个词条全部放入 FST ,是为了讲解 FST 数据结构的内部机制(前缀共享、增量编码)。
但在 Lucene 生产环境 中(见第 6 节),FST(Term Index)只存储 每个 Block 的第一个词条 (稀疏采样),不是全部词条。因为 Block 内剩余的词条(如 "上学了" 与 "上学" 在同一块)会通过 FST 定位 Block → Block 内二分查找 的方式找到。
请把本节理解为"数据结构层面的教学模型",不是"生产架构的真实复现"。
3.1 词条排序
FST 构建的前提是词条 必须按字典序排序后依次插入。对上述词条按字典序(UTF-8 字节序)排列:
| 序号 | 词条 | Unicode 编码 |
|---|---|---|
| 1 | 上 | U+4E0A |
| 2 | 上海 | U+4E0A U+6D77 |
| 3 | 上学 | U+4E0A U+5B66 |
| 4 | 海上 | U+6D77 U+4E0A |
UTF-8 编码(十六进制):
- "上" =
E4 B8 8A- "海" =
E6 B5 B7- "学" =
E5 AD A6
3.2 为每个词条分配输出值
假设每个词条的输出值(Output)是其对应的 Term Dictionary Block 在 .tim 文件中的偏移量:
再次强调 :FST 的输出值指向的是 Term Dictionary 中的 Block 位置,不是直接指向 Posting List 。
完整查询链路是:
FST → 定位 Block 偏移 → 加载 .tim Block → 在 Block 中查找词条 → 获取 Posting List 偏移 → 加载 .doc/.pos
| 词条 | 输出值(示例) |
|---|---|
| 上 | 100 |
| 上海 | 200 |
| 上学 | 300 |
| 海上 | 400 |
3.3 逐步构建 FST
步骤 1:插入 "上" → output=100
┌─────────────┐
│ Start (0) │
└──────┬──────┘
│
│ input='上' (E4 B8 8A)
│ output=100
▼
┌─────────────┐
│ (1) ✓ │ ← ✓ 表示终态节点
└─────────────┘
此时 FST 只有一条路径:Start → ①,节点①是终态,输出累加为 100。
步骤 2:插入 "上海" → output=200
"上海" 与 "上" 共享前缀 "上",需要在原有基础上扩展:
前缀共享
┌──────────────┐
│ Start (0) │
└──────┬───────┘
│
│ input='上'
│ output=100 ← "上" 的输出仍保留在此边上
▼
┌──────────────┐
│ (1) ✓ │ ← "上" 的终态(output=100,到此为止)
└──────┬───────┘
│
│ input='海' (E6 B5 B7)
│ output=100 ← 新增边的输出 = 200 - 100 = 100(增量)
▼
┌──────────────┐
│ (2) ✓ │ ← "上海" 的终态
└──────────────┘
关键理解 --- 输出值的增量编码:
FST 沿路径累加输出值。对于共享前缀的路径:
- 边
(0)→(1)的 output = 100("上" 的完整输出) - 边
(1)→(2)的 output = 100(增量:200 - 100 = 100) - 到达节点 (2) 时,累加输出 = 100 + 100 = 200("上海" 的输出)
步骤 3:插入 "上学" → output=300
"上学" 同样共享前缀 "上",从节点 (1) 分叉:
┌──────────────────────┐
│ Start (0) │
└──────────┬───────────┘
│
│ input='上'
│ output=100
▼
┌───────────────┐
│ (1) ✓ │ ← "上" 的终态
└──────┬────────┘
│
┌──────┴──────┐
│ │
│ │ input='学' (E5 AD A6)
│ │ output=200 (增量: 300-100=200)
│ ▼
│ ┌───────────────┐
│ │ (3) ✓ │ ← "上学" 的终态
│ └───────────────┘
│
│ input='海'
│ output=100 (增量: 200-100=100)
▼
┌──────────────┐
│ (2) ✓ │ ← "上海" 的终态
└──────────────┘
验证输出值:
- "上" → 路径到 (1),输出 = 100 ✓
- "上海" → 路径到 (2),输出 = 100 + 100 = 200 ✓
- "上学" → 路径到 (3),输出 = 100 + 200 = 300 ✓
步骤 4:插入 "海上" → output=400
"海上" 与前三个词条无公共前缀,需要从 Start 节点新增一条独立路径:
┌─────────────────────────────────────┐
│ Start (0) │
└──────┬──────────────────┬───────────┘
│ │
input='上' │ │ input='海'
output=100 │ │ output=400
▼ ▼
┌───────────────┐ ┌───────────────┐
│ (1) ✓ │ │ (4) │
└──────┬────────┘ └──────┬────────┘
│ │
┌──────┴──────┐ │ input='上'
│ │ │ output=0
│ │ ▼
│ │ ┌───────────────┐
│ │ │ (5) ✓ │ ← "海上" 的终态
│ │ └───────────────┘
│ │
│ │ input='学'
│ │ output=200
│ ▼
│ ┌───────────────┐
│ │ (3) ✓ │ ← "上学" 的终态
│ └───────────────┘
│
│ input='海'
│ output=100
▼
┌──────────────┐
│ (2) ✓ │ ← "上海" 的终态
└──────────────┘
验证输出值:
- "上" → 100 ✓
- "上海" → 100 + 100 = 200 ✓
- "上学" → 100 + 200 = 300 ✓
- "海上" → 400 + 0 = 400 ✓
注意 :边
(4)→(5)的 output=0,因为 "海上" 的完整输出 400 已经全部编码在第一条边(0)→(4)上了。
3.4 完整 FST 结构总结
| 路径 | 词条 | 边序列 | 累加输出 |
|---|---|---|---|
(0)→(1) |
上 | 上/100 |
100 |
(0)→(1)→(2) |
上海 | 上/100 → 海/100 |
200 |
(0)→(1)→(3) |
上学 | 上/100 → 学/200 |
300 |
(0)→(4)→(5) |
海上 | 海/400 → 上/0 |
400 |
前缀压缩效果:
- "上"、"上海"、"上学" 共享了第一条边
(0)→(1),字符 "上" 只存储一次 - 节点 (1) 同时是终态("上")和中间节点("上海"、"上学" 的前缀节点)
4. FST 查询过程
4.1 FST 内部的词条匹配
以查询 "上学" 为例:
步骤 1: 从 Start(0) 出发,匹配第一个字节序列 '上'(E4 B8 8A)
→ 找到边 (0)→(1),output=100,累加输出=100
步骤 2: 从节点 (1) 出发,匹配 '学'(E5 AD A6)
→ 在节点(1)的所有出边中找到 input='学' 的边
→ 找到边 (1)→(3),output=200,累加输出=100+200=300
步骤 3: 到达节点 (3),检查是否为终态
→ (3) 标记为 ✓(终态)→ FST 匹配成功!
→ 返回输出值 300(Term Dictionary Block 在 .tim 文件中的偏移量)
以查询 "上学了"(不在 FST 中,但在 Block 内)为例:
步骤 1: 在 FST 中查找 "上学了"
→ FST 中没有精确匹配 "上学了"
→ 找到最后一个小于等于 "上学了" 的词条:"上学"
→ 输出值 = 300(Block 偏移量)
步骤 2: 加载 .tim 中偏移量 300 处的 Block
→ Block 内包含词条:["上学", "上学了", "上学前", ... "上学难"]
步骤 3: 在 Block 内二分查找 "上学了"
→ 找到 → 返回其 Posting List 偏移
以查询 "上人"(词条不存在)为例:
步骤 1: 在 FST 中查找 "上人"
→ FST 中没有精确匹配
→ 找到 floor 词条 "上",输出值 = 100(某个 Block 偏移)
步骤 2: 加载对应 Block
→ Block 内包含:["上", "上海", "上学", ... "上路"]
步骤 3: 在 Block 内二分查找 "上人"
→ 未找到 → 词条不存在
4.2 完整的查询链路
FST 匹配成功并不等于查询结束。FST 只完成了第一层定位,完整链路如下:
用户查询: "上学"
┌─ 第 1 层:Term Index(FST,内存中)────────────────────────┐
│ │
│ FST 匹配 "上学" → 成功,输出值 = 300 │
│ 输出值 300 = Term Dictionary 中目标 Block 的 .tim 偏移量 │
│ │
└────────────────────────────┬──────────────────────────────┘
│ 定位到 Block 偏移
▼
┌─ 第 2 层:Term Dictionary(.tim 文件,按需加载 Block)─────┐
│ │
│ 从 .tim 文件偏移量 300 处加载整个 Block(如 256 个词条) │
│ 在 Block 内对词条做二分查找 → 找到 "上学" │
│ 词条条目中记录了其 Posting List 在 .doc/.pos 的偏移量 │
│ │
└────────────────────────────┬──────────────────────────────┘
│ 获取 Posting List 偏移
▼
┌─ 第 3 层:Posting List(.doc / .pos 文件,按需加载)───────┐
│ │
│ 根据偏移量加载 DocID 列表、词频、位置信息 │
│ 返回匹配的文档集合 │
│ │
└───────────────────────────────────────────────────────────┘
关键点:
- FST 中存储的词条是稀疏采样(每个 Block 的第一个词条),不是所有词条
- FST 查询不是"精确匹配",而是 floor/ceiling 定位:找到不大于(或大于等于)目标词条的最大(或最小)Block 入口点
- FST 定位到 Block 后,还需在 Block 内二分查找做精确匹配
- 如果 FST 定位的 Block 内找不到目标词条,则词条不存在
5. Lucene FST 构建与查询 API
FST 的类结构已在 [2.3 节](#2.3 节) 中详细说明,本节聚焦构建和查询过程。
5.1 构建过程 --- Builder
java
// org.apache.lucene.util.fst.Builder
// 词条必须按字典序依次 add()
Builder<T> builder = new Builder<>(FST.INPUT_TYPE.BYTE4, outputs);
builder.add(Util.toIntsRef(new BytesRef("上"), scratch), output100);
builder.add(Util.toIntsRef(new BytesRef("上海"), scratch), output200);
builder.add(Util.toIntsRef(new BytesRef("上学"), scratch), output300);
builder.add(Util.toIntsRef(new BytesRef("海上"), scratch), output400);
FST<T> fst = builder.finish();
Builder 内部工作原理:
- 每插入一个新词条,与上一个词条比较,找出 最长公共前缀
- 公共前缀部分复用已有路径
- 非公共部分创建新的节点和边
- 在添加新词条时,冻结(freeze) 上一个词条中不可能再被共享的节点(因为词条有序,后续词条不会再有相同前缀到达这些节点)
- 冻结时对节点做 最小化(minimization):如果两个节点的出边完全相同(相同的输入标签、输出值、目标节点),则合并为一个节点(实现后缀共享)
5.2 查询过程 --- BytesRefFSTEnum / Util.get
java
// 快速查询:给定词条,获取输出值(Term Dictionary Block 偏移量)
T output = Util.get(fst, new BytesRef("上学"));
// output = 300 → 指向 .tim 文件中偏移量 300 处的 Block
// 遍历查询
BytesRefFSTEnum<T> fstEnum = new BytesRefFSTEnum<>(fst);
IntsRef result = fstEnum.seekCeil(new BytesRef("上"));
// 返回 >= "上" 的第一个词条及其输出
6. FST 在 ES 倒排索引中的位置 --- 三层架构详解
Lucene 倒排索引采用 三层结构,FST 只是第一层(Term Index):
┌─────────────────────────────────────────────────────────────┐
│ Segment (段) --- Lucene 基本索引单元 │
│ │
│ ┌────────────────┐ ┌─────────────────┐ ┌──────────┐ │
│ │ Term Index │ │ Term Dictionary│ │ Posting │ │
│ │ (.tip 文件) │────▶│ (.tim 文件) │───▶│ List │ │
│ │ │ │ │ │(.doc/.pos)│ │
│ │ 结构: FST │ │ 结构: Block │ │ │ │
│ │ 内容: 每个 │ │ Tree 分块存储 │ │ 结构: │ │
│ │ Block 的入口 │ │ 内容: 全部词条 │ │ 差分编码 │
│ │ 词条(稀疏) │ │ + Posting List │ │ + 压缩 │
│ │ 输出: .tim │ │ 偏移量 │ │ │ │
│ │ Block 偏移量 │ │ │ │ │ │
│ │ │ │ 按 Block 加载 │ │ 按需加载 │ │
│ │ 全部内存驻留 │ │ (Block 默认 │ │ │ │
│ │ │ │ 256 词条) │ │ │ │
│ └────────────────┘ └─────────────────┘ └──────────┘ │
│ │
│ 内存中,很小 磁盘上,很大 │
│ ~几十 MB ~数百 MB - 数 GB │
└─────────────────────────────────────────────────────────────┘
索引文件层级:
| 文件后缀 | 层级 | 数据结构 | 存储内容 | 加载方式 |
|---|---|---|---|---|
.tip |
Term Index | FST | 每个 Block 的入口词条 → .tim Block 偏移量 |
全部内存驻留 |
.tim |
Term Dictionary | 排序 Block + 前缀压缩 | 全部词条 + 每条词条的 Posting List 偏移量 | 按需加载 Block |
.doc |
Posting List | 差分编码 (FOR) | DocID 列表、词频 | 按需加载 |
.pos |
Posting List | 差分编码 + 压缩 | 词条在文档中的位置 | 按需加载 |
6.1 为什么需要三层?--- 空间与速度的平衡
假设一个 Segment 有 1 亿个不同词条:
| 方案 | 内存占用 | 查询速度 |
|---|---|---|
| 全部词条放内存(HashMap) | ~数 GB,JVM 压力大 | O(1) 最快 |
| 全部词条放磁盘(B+ Tree) | 内存占用小 | O(log N) 但每次可能触发磁盘 IO |
| FST (Term Index) + Block (Term Dict) ✅ | ~几十 MB | FST 内存定位 + 1 次磁盘 IO 加载 Block |
Lucene 的设计精髓:
- FST 只存 每个 Block 第一个词条(稀疏采样),如每 256 个词条取 1 个 → FST 只需存 1亿/256 ≈ 39万 个词条
- 39万个词条的 FST 只有几十 MB,可完全放入内存
- 查询时:FST 内存定位 → 1 次磁盘 IO 加载 Block → Block 内二分查找(256 个词条,最多 8 次比较)
FST 稀疏采样查询示例:
假设 FST 中只存了以下 Block 入口词条(每 4 个为一组,实际生产环境是 256):
FST 中存储的词条(Block 入口点):
"上" → 偏移 100(Block #0 的起始)
"上海" → 偏移 200(Block #1 的起始)
"上学" → 偏移 300(Block #2 的起始)
Term Dictionary(.tim 文件,分 3 个 Block):
Block #0(偏移 100): [上, 上午, 上面, 上学下]
Block #1(偏移 200): [上海, 上海滩, 上海话, 上海站]
Block #2(偏移 300): [上学, 上学了, 上学去, 上学路]
现在查询不同词条时:
| 查询词条 | FST 中查找结果 | 定位到的 Block | Block 内查找 |
|---|---|---|---|
| "上" | 精确命中 | Block #0 | 找到 "上" |
| "上面" | floor 命中 "上" | Block #0 | 找到 "上面" |
| "上海滩" | floor 命中 "上海" | Block #1 | 找到 "上海滩" |
| "上学了" | floor 命中 "上学" | Block #2 | 找到 "上学了" |
| "上天" | floor 命中 "上" | Block #0 | 找不到 → 词条不存在 |
最后一行 "上天":FST floor 命中了 "上",但 Block #0 内并没有 "上天",最终词条不存在。这说明 FST 只能缩小范围,不能判定词条一定存在,精确判定在 Block 内完成。
7. FST 空间压缩效果分析
回到我们的例子,对比传统存储与 FST 存储:
传统哈希表 / 排序数组
| 词条 | 存储内容 |
|---|---|
| 上 | "上" (3B) + 指针 (8B) + 输出 (4B) = 15B |
| 上海 | "上海" (6B) + 指针 (8B) + 输出 (4B) = 18B |
| 上学 | "上学" (6B) + 指针 (8B) + 输出 (4B) = 18B |
| 海上 | "海上" (6B) + 指针 (8B) + 输出 (4B) = 18B |
| 合计 | 69B(不含哈希表额外开销) |
FST 存储
| 组件 | 大小估算 |
|---|---|
| 节点 0(Start) | 2 条出边 |
| 节点 1("上"终态) | 2 条出边 |
| 节点 2("上海"终态) | 0 条出边 |
| 节点 3("上学"终态) | 0 条出边 |
| 节点 4(中间节点) | 1 条出边 |
| 节点 5("海上"终态) | 0 条出边 |
| 边总数 | 6 条 |
| 字符存储 | "上"×1 + "海"×1 + "学"×1 + "海"×1 + "上"×1 = 5个字符×3B = 15B |
| 关键 :Lucene FST 序列化后,通过紧凑编码(变长整数、位压缩),实际约 30-40B |
对于 百万级词条 的实际场景:
- 排序数组 / 哈希表:数百 MB ~ 数 GB
- FST:压缩到数十 MB,可完全放入内存
8. Lucene 索引构建顺序与分块规律
8.1 构建顺序:先 Term Dictionary,后 Term Index(FST)
Lucene 构建索引时的写入顺序是:
第 1 步:收集阶段(内存中)
└─ 遍历文档 → 提取词条 → 构建 Posting List(DocID、Freq、Pos 等)
第 2 步:Flush Posting List(.doc / .pos)
└─ 将内存中的 Posting List 按词条顺序写入磁盘
第 3 步:Flush Term Dictionary(.tim) ← 先写
└─ 按字典序遍历词条,分组写入 Block
└─ 每确定一个 Block,记录该 Block 第一个词条及文件偏移量
第 4 步:Flush Term Index(.tip / FST) ← 后写
└─ 用第 3 步记录的 Block 入口词条构建 FST
└─ FST 的输出值 = 对应 Block 在 .tim 中的偏移量
└─ 写入 .tip 文件
为什么 FST 必须最后写?
FST 的输出值 是每个 Block 在 .tim 文件中的磁盘偏移量。只有等所有 Block 都写完了,才知道每个 Block 的精确偏移位置,才能构建 FST。
java
// 伪代码示意
void flush() {
// 1. 先 flush Posting List
postingsWriter.flush();
// 2. 构建并写入 Term Dictionary(.tim)
for (Block block : blocks) {
long blockOffset = out.getFilePointer(); // 记录当前文件偏移
writeBlock(block); // 写入 Block
// 3. 收集 Block 入口词条,用于后续构建 FST
fstBuilder.add(block.firstTerm, blockOffset);
}
// 4. 最后构建并写入 FST(.tip)
FST fst = fstBuilder.finish();
writeFST(fst);
}
8.2 词条词典的分块规律
Lucene 的分块不是简单的前 N 个词条一组,而是基于 前缀树(Prefix Trie) 的自底向上合并:
分块流程
Step 1: 构建前缀树(Trie)
所有词条按字典序插入前缀树
示例词条:[上, 上海, 上海滩, 上海话, 上学, 上学了, 上学去, 海上]
(root)
/ \
上 海
/ | |
海 学 上
/ | |
滩 了 (end)
/
话
Step 2: 自底向上合并节点
每个叶节点是一个"候选 Block"
如果候选 Block 词条数 < MIN_BLOCK_SIZE → 与兄弟合并
如果候选 Block 词条数 > MAX_BLOCK_SIZE → 拆分为多个子 Block
Step 3: 确定最终 Block
每个 Block 内所有词条共享一个"公共前缀"
Block 内只存后缀差异部分(前缀压缩)
具体规则
| 规则 | 默认值 | 说明 |
|---|---|---|
| MIN_BLOCK_SIZE | 25 | 少于 25 个词条就和兄弟合并 |
| MAX_BLOCK_SIZE | 48 | 超过 48 个词条就拆分 |
| 前缀选择 | 最长公共前缀 | Block 内词条共享最长前缀,减少冗余 |
| 后缀存储 | 差异部分 | Block 内只存去掉公共前缀后的后缀 |
关键设计思想
传统做法:前 256 个词条为一组(固定大小)
Lucene 做法:按前缀树合并(前缀共享最大化压缩)
为什么按前缀分块更好?
- "上海" 和 "上海滩" 共享前缀 "上海"
- 如果它们被分到不同 Block,前缀 "上海" 要存两次
- 如果分到同一块,前缀只存一次,节省大量空间
代码层面
java
// org.apache.lucene.codecs.blocktree.BlockTreeTermsWriter
public class BlockTreeTermsWriter {
// Block 大小阈值
public static final int DEFAULT_MIN_BLOCK_SIZE = 25;
public static final int DEFAULT_MAX_BLOCK_SIZE = 48;
void flush() {
// 1. 先 flush Posting List
postingsWriter.flush();
// 2. 构建前缀树并分块
PendingBlock root = buildBlockTree();
// 3. 写入 .tim(Term Dictionary)
for (Block block : root.blocks) {
long blockOffset = out.getFilePointer();
writeBlock(block);
fstBuilder.add(block.firstTerm, blockOffset);
}
// 4. 构建并写入 .tip(FST)
FST fst = fstBuilder.finish();
writeFST(fst);
}
}
8.3 总结
| 问题 | 答案 |
|---|---|
| 先写哪个? | 先 .tim(Term Dictionary),后 .tip(FST)。FST 依赖 Block 的磁盘偏移量,必须等 Block 都写完才能构建 |
| 分块规律? | 基于前缀树自底向上合并,不是固定大小。词条共享相同前缀的尽量分到同一块,Block 大小在 25~48 之间动态调整 |
| 为什么这样设计? | 前缀共享最大化压缩比,同时控制 Block 大小在合理范围(内存加载 + 二分查找效率) |
9. FST 的优缺点总结
优点
| 优点 | 说明 |
|---|---|
| 空间极致压缩 | 前缀共享 + 后缀合并 + 增量编码 |
| 查询高效 | O(len(term)),与词典大小无关 |
| 内存友好 | 可直接操作磁盘映射字节,无需全量反序列化 |
| 支持范围查询 | 可枚举词条、前缀匹配、seekCeil/seekFloor |
| 关联输出 | 一次查询同时获得 Term Dictionary Block 偏移量 |
缺点
| 缺点 | 说明 |
|---|---|
| 构建成本高 | 必须有序插入,构建时需要大量节点比较和冻结 |
| 不可变 | 构建完成后不可修改(Lucene 每次写入 Segment 时重建) |
| 随机写入不支持 | 无法像哈希表一样 O(1) 插入 |
这些缺点在搜索引擎场景中完全可以接受:
- 索引写入是 批量追加(Segment 机制),不是单条随机写
- 查询是 高频操作,FST 的 O(len(term)) 查询性能极佳
- 空间压缩带来的内存收益远超构建成本
10. 总结
Lucene 倒排索引采用 三层架构,FST 是第一层(Term Index)的核心数据结构:
FST (Term Index) → Block Tree (Term Dictionary) → Posting List
.tip 内存 .tim 磁盘按需加载 .doc/.pos 磁盘
稀疏索引 全量词条 DocID 列表
FST 的关键特性:
- 它是 Term Dictionary 的"索引的索引",只存每个 Block 的入口词条(稀疏采样),不是所有词条
- 它是有输出的自动机 ,匹配词条的同时输出指向
.timBlock 的磁盘偏移量(不是直接指向 Posting List) - 增量编码 使得共享前缀路径上的输出值可以高效累加
- 最小化(Minimization) 进一步合并相同后缀节点,实现极致压缩
- 几十 MB 的 FST 可索引数亿词条的 Term Dictionary,完美平衡内存与磁盘 IO
理解 FST 以及它与 Term Dictionary 的分层关系,是深入理解 Elasticsearch 倒排索引底层原理的关键。
参考资料:
- Lucene 源码:
org.apache.lucene.util.fst.FST、org.apache.lucene.util.fst.Builder - Lucene FST 官方 Wiki:https://lucene.apache.org/core/9_0_0/core/org/apache/lucene/util/fst/package-summary.html
- 《Elasticsearch 权威指南》--- 倒排索引章节