Skip to content

倒排索引详解

要理解倒排索引(Inverted Index),我们需要先从「正排索引」的痛点入手——倒排索引的诞生,本质是为了解决 「从关键词快速找文档」 的效率问题。

一、正排索引:文档到关键词的映射(Why 倒排?)

假设我们有3篇文档(Doc1~Doc3),正排索引的逻辑是 「以文档为中心,记录每个文档包含的所有关键词」

  • Doc1:人工智能、正在、改变、世界
  • Doc2:机器学习、是、人工智能、的、分支
  • Doc3:人工智能、和、机器学习、都、重要

如果现在要查询「包含‘人工智能’的文档」,正排索引的做法是遍历所有文档,逐个检查文档是否包含该关键词——当文档数量达到百万、千万级时,这种方式的效率低到无法接受。

倒排索引的核心革新反转映射关系——从「文档→关键词」变成「关键词→文档」。这样一来,查询「人工智能」时,直接定位到该关键词对应的「文档列表」,无需遍历所有文档。

二、倒排索引的核心结构:词典(Term Dictionary)+ postings 列表(Postings List)

倒排索引的结构可以拆解为两个关键部分,我们用上面的例子来具象化讲解:

1. 词典(Term Dictionary):所有关键词的「目录」

词典是去重后的所有关键词(Term)的集合,并且会对Term进行有序存储(比如按字母/拼音排序),目的是快速定位某个Term(类似字典的目录)。
以上面的例子为例,词典中的Term包括:
人工智能、正在、改变、世界、机器学习、是、的、分支、和、都、重要

为什么要有序?
有序结构(比如B+树、FST有限状态机)能让「查找Term」的时间复杂度从O(n)降到O(log n)——比如查「人工智能」,可以用二分法快速定位到这个Term在词典中的位置。

2. Postings List:关键词对应的「文档详情列表」

每个Term都会对应一个Postings List,里面存储了所有包含该Term的文档的详细信息。这些信息通常包括:

  • 文档ID(Doc ID):文档的唯一标识(比如Doc1=1、Doc2=2、Doc3=3);
  • 词频(TF, Term Frequency):该Term在文档中出现的次数(比如「人工智能」在Doc1中出现1次,TF=1);
  • 位置(Position):该Term在文档中的「词序位置」(比如「人工智能」在Doc1中是第1个词,Position=0;在Doc2中是第3个词,Position=2);
  • 偏移量(Offset):该Term在文档中的「字符位置」(比如「人工智能」在Doc1中的起始字符是0,结束字符是4,Offset=0-4)。

用例子中的「人工智能」和「机器学习」来看Postings List的结构:

  • Term=人工智能的Postings List:
    [(Doc1, TF=1, Position=0), (Doc2, TF=1, Position=2), (Doc3, TF=1, Position=0)]
  • Term=机器学习的Postings List:
    [(Doc2, TF=1, Position=0), (Doc3, TF=1, Position=2)]

三、倒排索引的查询过程:从关键词到文档的「秒级匹配」

当用户查询「人工智能 机器学习」时,倒排索引的工作流程是:

  1. 查词典:分别找到「人工智能」和「机器学习」对应的Postings List;
  2. 求交集:两个Postings List的Doc ID交集是Doc2、Doc3(这两个文档同时包含两个关键词);
  3. 相关性排序:根据Postings List中的「词频(TF)」「位置(Position)」等信息计算相关性(比如Doc3中两个词的位置更接近,可能排更前);
  4. 返回结果:将排序后的文档返回给用户。

这个过程的效率为什么高?

  • 词典的有序结构让「找Term」很快;
  • Postings List的「文档ID有序性」让「求交集/并集」可以用「双指针法」快速完成(比如两个有序列表[1,2,3]和[2,3,4],双指针一次遍历就能找到交集[2,3])。

四、倒排索引的构建流程:从文档到索引的「生产线」

倒排索引不是天生的,需要通过离线/在线构建生成,核心步骤如下:

  1. 文档收集:获取要索引的原始文档(比如网页、文章、商品描述);
  2. 预处理:将原始文本转化为「干净的Term」,包括:
    • 分词(中文用jieba、HanLP等工具拆分,比如「人工智能正在改变世界」拆成「人工智能/正在/改变/世界」;英文按空格拆分);
    • 去除停用词(过滤「的、是、和、都」等无意义的词);
    • 词干提取/小写转换(英文将「Running」转成「run」,「AI」转成「ai」,统一形态);
  3. 构建词典:将预处理后的Term去重、排序,存入有序结构(比如B+树、FST);
  4. 构建Postings List:遍历所有文档,对每个Term记录其出现的Doc ID、TF、Position等信息,形成Postings List;
  5. 优化存储:对Postings List进行压缩(比如「差值编码」:将有序Doc ID的差值存储,比如Doc1=1、Doc3=3、Doc5=5,存储为1、2、2,节省空间),或用FST优化词典的存储效率。

五、倒排索引的工业级优化:从“能用”到“好用”

倒排索引的基础结构(词典+Postings List)并不复杂,但要支撑TB级数据、毫秒级查询,必须解决两个核心问题:存储成本查询效率。以下是最关键的优化技术:

1. 词典优化:用FST(有限状态转移机)压缩存储

词典的核心需求是“快速查Term”+“节省空间”。传统的有序数组或B+树虽然查询快,但存储大量Term时空间开销很大(比如存储100万条Term,每条平均5个字符,纯字符存储需要5MB,但实际会更大)。

FST(Finite State Transducer,有限状态转移机)是解决这个问题的“神器”——它能共享Term的前缀和后缀,将多个Term压缩成一个状态机。

举个例子,存储“app”“apple”“apply”“banana”四个Term:

  • 传统存储:每个Term独立存储,总长度是3+5+5+6=19字符;
  • FST存储:共享“app”前缀,“apple”和“apply”共享“appl”,最后分支到“e”和“y”;“banana”单独成链。最终存储的是“a→p→p→(l→e, l→y)”和“b→a→n→a→n→a”,总状态数远小于字符总数。

FST的优势:

  • 空间效率:比传统存储节省50%~90%的空间;
  • 查询效率:查询一个Term的时间复杂度是O(len(Term))(按字符遍历状态机),比B+树的O(log n)更快;
  • 支持前缀查询:比如查“app*”,可以直接走到“app”状态,遍历所有后续分支,轻松实现前缀匹配。

ES中的应用:Lucene(ES的底层引擎)从4.0版本开始,用FST存储Term Dictionary,这是ES能处理千万级Term的关键。

2. Postings List优化:压缩+跳表,兼顾空间与速度

Postings List存储的是“Term出现的所有文档信息”,其大小可能是原始文档的2~3倍(尤其是存位置信息时)。优化的核心是 “压缩存储”+“快速查询”

(1)压缩技术:差值编码+FOR编码

Postings List中的Doc ID是有序的(因为文档是按写入顺序分配ID的),这是压缩的关键前提。

  • 差值编码(Delta Encoding):存储相邻Doc ID的差值,而不是原始ID。比如Doc ID序列是[1,3,5,7,9],差值是[1,2,2,2,2](第一个数存原始ID,后面存差值)。这样可以把大数转换成小数,减少存储位数。
  • FOR编码(Frame Of Reference):进一步优化差值编码——将差值分成固定大小的“块”(比如128个差值为一块),计算块内差值的最大位数,用该位数存储所有差值。比如块内差值最大是3(二进制11,2位),则块内所有差值都用2位存储,比用固定32位存储节省大量空间。

例子:Doc ID序列[100, 102, 105, 107],差值是[100,2,3,2]。用FOR编码:

  • 块大小设为4,块内差值最大是100(二进制7位),所以整个块用7位/每个差值存储,总位数是4×7=28位,而原始存储需要4×32=128位,压缩率约78%。

(2)跳表(Skip List):加速交集/并集查询

当查询“Term1 AND Term2”时,需要求两个Postings List的交集。如果Postings List很长(比如百万级Doc ID),双指针遍历的效率依然不够——跳表可以解决这个问题。

跳表的原理是在Postings List中每隔固定间隔插入“跳跃点”,形成多层索引。比如Postings List是[1,3,5,7,9,11,13,15],每隔2个元素插入跳跃点:

  • 第一层(原始列表):1→3→5→7→9→11→13→15
  • 第二层(跳跃层):1→5→9→13
  • 第三层(顶层):1→9

查询时,从顶层开始快速跳跃:比如找Term1的Postings List中的9,先从第三层跳到9,再下到第二层找后续元素,比逐一遍历快得多。

ES中的应用:Lucene的Postings List默认使用跳表,当查询多个Term的交集/并集时,跳表能将时间复杂度从O(n)降到O(log n)。

3. 位置信息优化:倒排索引的“短语查询”支撑

短语查询(比如“人工智能 改变世界”)需要判断两个Term在文档中的位置是否相邻(比如“人工智能”在位置0,“改变世界”在位置1)。这要求Postings List中存储Term的位置信息(Position)

位置信息的优化同样基于“有序性”:

  • 差值编码:存储相邻位置的差值(比如位置序列[0,2,5],差值是[0,2,3]);
  • 块压缩:类似Doc ID的FOR编码,将位置差值分成块,用最小位数存储。

例子:Term“人工智能”在Doc1中的位置是[0,3,6],差值是[0,3,3]。用FOR编码后,存储的是块内最大差值(3,2位)和差值序列,总位数远小于原始位置。

六、倒排索引的局限性:不是“银弹”

倒排索引是全文检索的基石,但它也有天生的短板:

1. 实时性挑战:“不可变性”导致的延迟

倒排索引的核心设计是“不可变(Immutable)”——一旦生成,就不能修改。这带来两个好处:

  • 查询时无需加锁,并发性能高;
  • 可以安全地缓存Postings List,提升查询速度。

但“不可变”也导致实时更新困难:当有新文档写入时,不能修改已有的倒排索引,只能生成新的小分段(Segment)。比如:

  • 第1分钟写入1000条文档,生成Segment A;
  • 第2分钟写入2000条文档,生成Segment B;
  • 查询时需要合并Segment A和B的结果,再返回。

如果分段太多(比如每秒生成一个分段),查询时需要遍历所有分段,性能会急剧下降。

ES的解决办法

  • Refresh机制:默认每秒将内存中的文档生成一个“内存分段”(可查询,但未持久化),实现“近实时(NRT)”查询;
  • Merge机制:后台定期将小分段合并成大分段(比如将10个小分段合并成1个大分段),减少分段数量,提升查询效率。

2. 空间开销:“冗余存储”的代价

倒排索引需要存储词典+Postings List+位置信息,空间开销通常是原始文档的2~5倍。比如:

  • 原始文档是1GB文本,倒排索引可能需要2~5GB存储空间;
  • 如果启用位置信息(支持短语查询),空间开销会再增加50%~100%。

ES的解决办法

  • 对不需要短语查询的字段,关闭位置存储(比如"index_options": "docs",只存Doc ID和词频);
  • 对低频Term,合并存储(比如将出现次数少于5次的Term的Postings List合并,减少元数据开销)。

3. 精确查询的低效:对“等值查询”不友好

倒排索引擅长模糊查询/全文检索(比如“人工智能”),但对精确等值查询(比如“用户ID=12345”)效率不如B树——因为倒排索引的词典是按Term排序的,而B树的索引是按键(Key)排序的,等值查询的时间复杂度更低。

ES的解决办法

  • 对精确查询的字段(比如用户ID、订单号),使用keyword类型(不分词,直接作为Term存储);
  • 对高频精确查询的字段,使用Doc Values(正排索引,以文档为中心存储字段值),补充倒排索引的不足。

七、Elasticsearch中的倒排索引:从理论到实践

ES是倒排索引的“工业级实现典范”,它基于Lucene引擎,将倒排索引与分布式、高可用、实时性结合,支撑了亿级数据的检索需求。以下是ES中倒排索引的关键细节:

1. ES的索引结构:分片(Shard)+ 段(Segment)

ES的“索引(Index)”不是单一个倒排索引,而是多个分片(Shard)的集合——每个分片是一个独立的Lucene索引(包含多个不可变的段)。

  • 分片:解决分布式存储问题(比如一个100GB的索引分成5个分片,每个分片20GB);
  • :解决实时更新问题(每个段是一个不可变的倒排索引,新文档写入生成新段,后台合并小段)。

例子:创建一个名为my_blog的索引,设置5个分片:

json
PUT /my_blog
{
  "settings": {
    "number_of_shards": 5,  // 分片数
    "number_of_replicas": 1 // 副本数
  },
  "mappings": {
    "properties": {
      "title": { "type": "text" },  // 分词,生成倒排索引
      "content": { "type": "text" },
      "author_id": { "type": "keyword" } // 不分词,精确查询
    }
  }
}

2. ES的写入流程:从文档到倒排索引

当写入一篇博客(Doc ID=1):

json
PUT /my_blog/_doc/1
{
  "title": "人工智能的未来",
  "content": "人工智能正在改变世界,机器学习是其核心分支",
  "author_id": "123"
}

ES的处理流程:

  1. 分词title字段分词为“人工智能”“的”“未来”(停用词“的”被过滤);content字段分词为“人工智能”“正在”“改变”“世界”“机器学习”“是”“其”“核心”“分支”(停用词“是”“其”被过滤);author_id字段不分词,Term为“123”。
  2. 生成段:将分词后的Term写入内存缓冲区(In-Memory Buffer);
  3. Refresh:每秒将内存缓冲区的内容生成一个“内存段”(Segment),此时文档可被查询(近实时);
  4. Flush:每隔30分钟(默认)将内存段刷盘,生成持久化的段;
  5. Merge:后台线程将小段合并成大段(比如将5个10MB的段合并成1个50MB的段),删除旧段,释放空间。

3. ES的查询流程:从关键词到文档

当查询“人工智能 机器学习”:

json
GET /my_blog/_search
{
  "query": {
    "match": {
      "content": "人工智能 机器学习"
    }
  }
}

ES的处理流程:

  1. 路由:根据查询的关键词,计算路由到对应的分片(比如content字段的Term哈希到分片1);
  2. 查词典:在分片1的Lucene索引中,用FST找到“人工智能”和“机器学习”对应的Term;
  3. 查Postings List:获取两个Term的Postings List(包含Doc ID、词频、位置);
  4. 求交集:用跳表快速找到两个Postings List的Doc ID交集(比如Doc1、Doc3);
  5. 相关性排序:根据TF-IDF(词频-逆文档频率)、位置 proximity(两个词的位置距离)等计算相关性得分,排序;
  6. 返回结果:将排序后的文档返回给用户。

八、总结:倒排索引的“前世今生”

倒排索引的本质是 “反转映射关系” ——从“文档→关键词”到“关键词→文档”,解决了全文检索的效率问题。它的发展历程是:

  • 理论模型:词典+Postings List;
  • 工业优化:FST压缩词典、差值+FOR压缩Postings List、跳表加速查询;
  • 工程实现:ES用分片+段解决分布式和实时性问题,用Doc Values补充精确查询的不足。