Appearance
倒排索引详解
要理解倒排索引(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)]
三、倒排索引的查询过程:从关键词到文档的「秒级匹配」
当用户查询「人工智能 机器学习」时,倒排索引的工作流程是:
- 查词典:分别找到「人工智能」和「机器学习」对应的Postings List;
- 求交集:两个Postings List的Doc ID交集是Doc2、Doc3(这两个文档同时包含两个关键词);
- 相关性排序:根据Postings List中的「词频(TF)」「位置(Position)」等信息计算相关性(比如Doc3中两个词的位置更接近,可能排更前);
- 返回结果:将排序后的文档返回给用户。
这个过程的效率为什么高?
- 词典的有序结构让「找Term」很快;
- Postings List的「文档ID有序性」让「求交集/并集」可以用「双指针法」快速完成(比如两个有序列表[1,2,3]和[2,3,4],双指针一次遍历就能找到交集[2,3])。
四、倒排索引的构建流程:从文档到索引的「生产线」
倒排索引不是天生的,需要通过离线/在线构建生成,核心步骤如下:
- 文档收集:获取要索引的原始文档(比如网页、文章、商品描述);
- 预处理:将原始文本转化为「干净的Term」,包括:
- 分词(中文用jieba、HanLP等工具拆分,比如「人工智能正在改变世界」拆成「人工智能/正在/改变/世界」;英文按空格拆分);
- 去除停用词(过滤「的、是、和、都」等无意义的词);
- 词干提取/小写转换(英文将「Running」转成「run」,「AI」转成「ai」,统一形态);
- 构建词典:将预处理后的Term去重、排序,存入有序结构(比如B+树、FST);
- 构建Postings List:遍历所有文档,对每个Term记录其出现的Doc ID、TF、Position等信息,形成Postings List;
- 优化存储:对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的处理流程:
- 分词:
title字段分词为“人工智能”“的”“未来”(停用词“的”被过滤);content字段分词为“人工智能”“正在”“改变”“世界”“机器学习”“是”“其”“核心”“分支”(停用词“是”“其”被过滤);author_id字段不分词,Term为“123”。 - 生成段:将分词后的Term写入内存缓冲区(In-Memory Buffer);
- Refresh:每秒将内存缓冲区的内容生成一个“内存段”(Segment),此时文档可被查询(近实时);
- Flush:每隔30分钟(默认)将内存段刷盘,生成持久化的段;
- Merge:后台线程将小段合并成大段(比如将5个10MB的段合并成1个50MB的段),删除旧段,释放空间。
3. ES的查询流程:从关键词到文档
当查询“人工智能 机器学习”:
json
GET /my_blog/_search
{
"query": {
"match": {
"content": "人工智能 机器学习"
}
}
}ES的处理流程:
- 路由:根据查询的关键词,计算路由到对应的分片(比如
content字段的Term哈希到分片1); - 查词典:在分片1的Lucene索引中,用FST找到“人工智能”和“机器学习”对应的Term;
- 查Postings List:获取两个Term的Postings List(包含Doc ID、词频、位置);
- 求交集:用跳表快速找到两个Postings List的Doc ID交集(比如Doc1、Doc3);
- 相关性排序:根据TF-IDF(词频-逆文档频率)、位置 proximity(两个词的位置距离)等计算相关性得分,排序;
- 返回结果:将排序后的文档返回给用户。
八、总结:倒排索引的“前世今生”
倒排索引的本质是 “反转映射关系” ——从“文档→关键词”到“关键词→文档”,解决了全文检索的效率问题。它的发展历程是:
- 理论模型:词典+Postings List;
- 工业优化:FST压缩词典、差值+FOR压缩Postings List、跳表加速查询;
- 工程实现:ES用分片+段解决分布式和实时性问题,用Doc Values补充精确查询的不足。
