Lucene 底层原理

Lucene 底层原理 #

Easysearch 的搜索能力建立在 Apache Lucene 之上。理解 Lucene 的核心概念,有助于更好地使用 Easysearch 和进行性能调优。

Lucene 与 Easysearch 的关系 #

用户请求 → Easysearch (分布式协调、REST API、集群管理)
               ↓
         每个分片 = 一个 Lucene Index
               ↓
         Lucene (倒排索引、评分、段合并)
  • Easysearch 负责:分布式路由、副本复制、集群管理、REST API、安全
  • Lucene 负责:文本分析、倒排索引构建、查询执行、评分计算

每个 Easysearch 分片(shard) 对应一个完整的 Lucene Index。

术语对齐:Lucene 的"索引"在 Easysearch 里更接近"分片";而 Easysearch 的"索引"是多个分片的集合。

倒排索引:全文检索的核心 #

要理解 Easysearch 的搜索行为,必须先回答一个基础问题:文本是怎么变成"可搜索"的?

关系型数据库通常把一个字段当作一个整体来索引;而全文检索需要把一个字段里的每个"词"都变成可检索的索引项。这就需要倒排索引(Inverted Index)——一个天然支持"一个字段对应很多词项"的数据结构。

正排 vs 倒排 #

正排索引(文档 → 词项):

文档 ID内容
1“搜索引擎 技术”
2“分布式 搜索引擎”

倒排索引(词项 → 文档):

词项文档列表
搜索引擎[1, 2]
技术[1]
分布式[2]

搜索 “搜索引擎” 时,直接查倒排索引,O(1) 定位到文档 1 和 2,无需逐条扫描。

倒排索引的组成 #

一个真实的倒排索引不仅包含"出现在哪些文档",还会包含丰富的统计信息:

组件作用对应文件
Term Dictionary词项的有序字典.tim
Term Index词典的前缀索引(FST,加速查找).tip
Postings List每个词项的文档列表、位置、频率.doc, .pos, .pay
Stored Fields原始文档内容(_source.fdt, .fdx
Doc Values列式存储,用于排序和聚合.dvd, .dvm
Norms字段长度归一化因子(评分用).nvd, .nvm

在 Easysearch 中,每个被索引的字段,都有自己的倒排索引——这是一个关键点。

示例:倒排索引匹配 #

假设有两个文档:

  1. The quick brown fox jumped over the lazy dog
  2. Quick brown foxes leap over lazy dogs in summer

构建倒排索引后(简化版):

Term      Doc_1  Doc_2
-------------------------
Quick   |       |  X
The     |   X   |
brown   |   X   |  X
dog     |   X   |
dogs    |       |  X
fox     |   X   |
foxes   |       |  X
jumped  |   X   |
lazy    |   X   |  X
leap    |       |  X
over    |   X   |  X
quick   |   X   |
summer  |       |  X

搜索 quick brown,两个文档都匹配,Doc_1 命中 2 个词条更相关。

但这个原始索引存在问题:

  • Quickquick 是不同的词条——用户可能认为它们相同
  • foxfoxesdogdogs 有相同词根但被视为不同词条
  • jumpedleap 是同义词但无法关联

这就是为什么在索引之前需要对文本进行分析(Analysis):小写化、词干提取、同义词扩展等,使词条被规范为标准形式。分析后的索引会变成:

Term      Doc_1  Doc_2
-------------------------
brown   |   X   |  X
dog     |   X   |  X
fox     |   X   |  X
jump    |   X   |  X
lazy    |   X   |  X
over    |   X   |  X
quick   |   X   |  X
summer  |       |  X

现在搜索 Quick fox 就能匹配到两个文档了。这个过程由分析器自动处理,详见 映射与分析入门

段(Segment)与不可变性 #

不可变性:为什么倒排索引"写入后不改" #

倒排索引一旦写入磁盘,就被设计为不可变(immutable):不会原地修改。这带来了几个重要好处:

  • 无需锁:不存在"就地更新",并发读写更容易
  • 更友好的缓存:文件进入操作系统缓存后可以长期有效
  • 缓存更耐用:过滤等缓存不会因为"索引被修改"而频繁失效
  • 更好的压缩:一次写入较大的不可变结构,压缩比更高、IO 更少

不可变性的代价也很直观:你无法直接把一个新文档"插入到旧索引里"让它立刻可搜

动态更新:用"段"追加,而不是改写 #

Lucene 引入了"按段搜索"的概念来解决这个问题:

  • 一个 段(segment) 本身就是一个完整的倒排索引
  • 一个 Lucene 索引是多个段的集合,并通过 提交点(commit point) 记录"当前有哪些段是已知且可用的"

写入的概念流程:

  1. 新文档先进入内存缓冲(in-memory buffer)
  2. 缓冲不时被 refresh:写入一个新段到文件系统缓存
  3. 新段被打开,从而使新文档"可见/可搜"
  4. 定期 flush:将段 fsync 到磁盘,写入新的提交点

查询时会按顺序查询所有已知段,并把段级结果合并成全局结果。这样就实现了"动态更新",而不需要重建整个大索引。

段的生命周期 #

写入文档 → 内存缓冲区
           ↓ refresh (默认 1 秒)
         新段(不可变)→ 可被搜索
           ↓ flush / translog
         持久化到磁盘
           ↓ merge
         多个小段合并为大段

关键特性:

  • 不可变性:段一旦写入就不会被修改。更新 = 标记旧文档删除 + 写入新文档
  • 近实时:refresh 操作创建新段,新文档变为可搜索(默认 1 秒延迟)
  • 段合并:后台自动将小段合并为大段,减少段数量,回收已删除文档的空间

详见 写入与存储机制

段数量与性能 #

段数量影响
太多每次搜索需遍历所有段,查询变慢
太少合并时消耗大量 IO 和 CPU
适中由 Easysearch merge policy 自动控制

Easysearch 默认使用 TieredMergePolicy(分层合并策略),其核心参数:

参数默认值说明
floor_segment2 MB小于此大小的段会被视为等大,鼓励合并小段
max_merge_at_once10单次合并操作最多合并多少个段
max_merged_segment5 GB合并后的段最大不超过此值(太大的段不参与常规合并)
segments_per_tier10.0每层允许的段数量,控制合并的激进程度
deletes_pct_allowed33%段中已删除文档的比例超过此值时,会优先被选中合并

可通过以下 API 查看段信息:

GET /my-index/_segments

删除与更新:不是原地修改 #

段不可变,因此:

  • 删除并不会从旧段里抹掉文档,而是在 .del(live docs)文件里标记删除
  • 被标记删除的文档仍可能被查询匹配到,但会在最终结果返回前被过滤掉
  • 更新也是同样语义:旧版本文档被标记删除,新版本作为新文档写入新段

段文件格式 #

每个段包含一组不可变的文件。以段名 _0 为例:

文件扩展名用途说明
.siSegment Info段的元信息(版本、文档数等)
.fnmField Names字段名称与属性
.fdx / .fdtStored Fields_source 的索引和数据
.tim / .tipTerm Dictionary & Index词项字典与 FST 前缀索引
.doc / .pos / .payPostings文档频率、位置、附加数据
.dvd / .dvmDoc Values列式存储(排序/聚合用)
.nvd / .nvmNorms字段长度归一化因子
.livLive Docs删除标记位图
.cfs / .cfeCompound File合并小文件减少文件句柄

索引级别的文件:

文件用途
segments_NCommit Point,记录当前活跃的段列表
write.lock写入锁,防止并发写入

倒排索引文件的查找路径 #

搜索 "Easysearch" 的查找路径:

  .tip (FST前缀索引)
    ↓ 快速定位到 "E" 开头的块
  .tim (Term Dictionary)
    ↓ 在块内二分查找 "Easysearch"
  .doc (Postings - 频率)
    ↓ 获取包含该词的文档列表 [doc1, doc5, doc12]
  .pos (Postings - 位置)
    ↓ 获取词在每个文档中的位置(短语查询需要)

**FST(Finite State Transducer)**是 .tip 文件的核心数据结构,它是一种高度压缩的前缀树,将词项映射到 .tim 文件中的偏移量。FST 常驻内存,是快速查找的关键。

存储字段与 Doc Values #

  • Stored Fields.fdt / .fdx):存储文档的原始 JSON(即 _source),使用 LZ4 或 DEFLATE 压缩
  • Doc Values.dvd / .dvm):列式存储,用于排序和聚合
行式 (_source):  doc1={name:"A", age:25}  doc2={name:"B", age:30}
列式 (doc values): name: ["A", "B"]   age: [25, 30]

Doc Values 的关键信息:

  • keyword、数值、日期、布尔字段默认启用 Doc Values
  • text 字段不支持 Doc Values(使用 fielddata,不推荐)
  • 不需要排序/聚合的字段可关闭以节省空间:"doc_values": false

压缩控制 #

PUT /my-index
{
  "settings": {
    "index.codec": "best_compression"
  }
}

使用 DEFLATE 代替默认 LZ4,压缩率提升约 20%,但写入和读取速度会略慢。

磁盘空间估算 #

各文件类型的典型占比(仅供参考):

文件类型占比说明
存储字段 (.fdt)40~60%_source 通常是最大的部分
倒排索引 (.tim/.doc/.pos)15~30%取决于字段数量和文本长度
Doc Values (.dvd)5~15%取决于可排序/聚合字段数
其他 (norms/meta等)5~10%

评分模型 #

Lucene 使用 BM25 作为默认评分算法(从 Lucene 6 开始,之前是 TF-IDF)。

BM25 核心公式 #

$$score(q,d) = \sum_{t \in q} IDF(t) \cdot \frac{tf \cdot (k_1 + 1)}{tf + k_1 \cdot (1 - b + b \cdot \frac{dl}{avgdl})}$$

默认参数:$k_1 = 1.2$,$b = 0.75$,discount_overlaps = true

BM25 核心因子 #

因子含义
TF (Term Frequency)词项在文档中出现的频率,越高越相关
IDF (Inverse Document Frequency)词项在所有文档中的稀有程度,越稀有越重要
Field Length字段越短,单个词项的权重越高

直觉理解:

  • 一个词在文档中出现越多 → 越相关(TF)
  • 一个词在整个索引中越罕见 → 越有区分度(IDF)
  • 文档越短 → 单个匹配词的密度越高(Field Length)

查看评分解释 #

GET /my-index/_search
{
  "explain": true,
  "query": {
    "match": { "title": "搜索引擎" }
  }
}

详见 相关性与排序

调优要点 #

优化点说明
refresh_interval增大刷新间隔减少段创建频率
merge policy调整合并策略平衡写入与查询
doc values不需要排序/聚合的字段可关闭 "doc_values": false
_source大字段考虑 _source.excludes 减少存储
norms不需要评分的字段可关闭 "norms": false
位置信息不需要短语查询时设置 "index_options": "docs"
压缩使用 best_compression 提升压缩率

小结 #

  • Lucene 是 Easysearch 的底层搜索引擎,每个分片对应一个 Lucene Index
  • 倒排索引把"词项 → 文档集合"作为核心结构,每个字段各自维护倒排索引
  • 不可变性带来并发、缓存与压缩的优势,但意味着不能原地更新
  • 动态更新依赖段(segment):追加新段 + 更新提交点;查询合并段级结果
  • FST 是 Lucene 快速查找词项的关键数据结构
  • BM25 评分基于词频、逆文档频率和字段长度三个因子

下一步可以继续阅读: