3、倒排索引:倒排索引原理、构建过程、压缩技术、更新策略

好,咱们今天聊聊搜索引擎的“心脏”——倒排索引。

我经常跟团队里的新人说,你搞懂了倒排索引,搜索引擎的大门就算推开了一半。这东西说白了,就是把“文档→词”的关系,反过来变成“词→文档”。你想想看,用户搜一个词,你总不能把几亿个文档都翻一遍吧?那得等到猴年马月。倒排索引就是提前告诉你:这个词出现在哪些文档里,位置在哪。

3.1 倒排索引的原理

先看个简单的例子。假设我们有三个文档:

  • Doc1: “我喜欢搜索引擎”
  • Doc2: “搜索引擎优化很重要”
  • Doc3: “我喜欢优化代码”

分词之后,我们建立一个“正排索引”,就是文档ID到词的映射。但这玩意儿对搜索没啥用。我们需要的是“倒排”:

词项倒排列表(文档ID:位置)
Doc1:1, Doc3:1
喜欢Doc1:2, Doc3:2
搜索引擎Doc1:3, Doc2:1
优化Doc2:2, Doc3:3
Doc2:3
重要Doc2:4
代码Doc3:4

看到没?每个词项后面挂着一个链表,记录了它出现在哪些文档里,以及具体位置。用户搜“搜索引擎”,我直接查这个词的倒排列表,啪一下就把Doc1和Doc2拎出来了。速度快得飞起。

核心思想:以词为键,以文档列表为值。空间换时间,查询复杂度从O(N)降到O(1)。

3.2 构建过程

构建倒排索引,我一般分三步走。嗯,这里要注意顺序,搞反了会出大问题。

  1. 文档预处理:分词、去停用词、词干提取。比如“running”变成“run”,“搜索引擎”不能拆成“搜索”和“引擎”。
  2. 生成词项-文档ID对:遍历每个文档,对每个词输出一个二元组(词项,文档ID)。这一步会产生大量中间数据。
  3. 排序与合并:按词项排序,相同词项的文档ID合并成一个倒排列表。同时记录词频和位置信息。

我在项目中遇到过一个问题:当文档量达到亿级时,中间数据根本放不进内存。这时候就得用外部排序,或者用MapReduce的思想,分而治之。我记得有一次,我们团队用Hadoop跑了一个200TB的文档集,光排序阶段就跑了6个小时。但没办法,这一步省不了。

避坑指南:我曾经在合并阶段忘记去重,结果同一个文档ID在倒排列表里出现了好几次。用户搜一个词,同一个文档在结果里重复出现,那体验简直灾难。所以合并时一定要做去重和排序。

3.3 压缩技术

倒排索引建好了,但问题来了——它太占空间了。一个词项的倒排列表可能包含几百万个文档ID,每个ID都是32位整数,存下来就是几MB。整个索引动辄几十GB甚至TB。不压缩?服务器硬盘会哭的。

我常用的压缩技术有两种:可变字节编码和Gamma编码。说白了,就是用更少的比特位来表示数字。

3.3.1 可变字节编码

这个思路很朴素:小数字用少字节,大数字用多字节。每个字节的最高位作为“延续位”,1表示后面还有字节,0表示这是最后一个字节。剩下的7位存数据。

举个例子,数字 127 的二进制是 1111111,一个字节就能搞定:01111111(最高位0表示结束)。数字 128 呢?二进制是 10000000,需要两个字节:10000001 00000000。第一个字节最高位1表示后面还有,第二个字节最高位0表示结束。

// 可变字节编码示例
// 输入:文档ID列表 [1, 2, 127, 128, 300]
// 输出:字节序列
1    -> 00000001
2    -> 00000010
127  -> 01111111
128  -> 10000001 00000000
300  -> 10000010 01011100  // 300 = 0x12C,拆成7位一组

你想想看,大部分文档ID的差值(后面会讲)都很小,用可变字节编码能省下不少空间。我实测过,对于普通网页索引,压缩比能达到3:1到4:1。

3.3.2 Gamma编码

Gamma编码更狠,它把数字拆成两部分:长度和偏移。长度用一元编码,偏移用二进制编码。

具体做法:对于数字N,先找到它的二进制表示,去掉最高位的1,剩下的就是偏移。长度就是二进制位数减1,用N-1个1后面跟一个0表示。

举个例子,数字 13 的二进制是 1101。去掉最高位1,偏移是 101。长度是 3(因为二进制有4位,4-1=3),用一元编码就是 1110。所以13的Gamma编码是:1110 101

数字二进制长度(一元)偏移Gamma编码
110(空)0
21010010 0
31110110 1
13110111101011110 101

注意:Gamma编码适合小数字,大数字会变得很长。比如数字1000,长度部分就要写9个1加1个0,占10位,加上偏移9位,总共19位。而原始二进制才10位。所以实际应用中,我一般对差值小于128的用Gamma,大于128的用可变字节。

3.4 更新策略

索引建好了,但文档是动态的。今天新增100万篇,明天删除50万篇,后天修改30万篇。怎么更新?

我见过最蠢的做法是每次更新都重建整个索引。对于小网站还行,对于百度这种量级的,重建一次得几天,黄花菜都凉了。

常用的策略有三种:

  • 完全重建:定期(比如每周)重建一次主索引。简单粗暴,但更新延迟高。
  • 增量更新:维护一个小的增量索引,新文档只更新到增量索引里。查询时合并主索引和增量索引的结果。增量索引满了就合并到主索引里。
  • 原地更新:直接修改倒排列表。但倒排列表是连续存储的,插入或删除会导致大量数据移动。我一般不推荐。

我个人习惯用“主索引+增量索引”的方案。主索引每周重建一次,增量索引每5分钟刷一次磁盘。查询时,先查增量索引,再查主索引,合并结果。这样既保证了实时性,又避免了频繁重建。

避坑指南:我曾经在增量索引合并时没处理好删除操作。用户删了一篇文章,但增量索引里还有它的记录,导致搜索结果里出现404页面。后来我加了一个“删除列表”,专门记录被删除的文档ID,查询时过滤掉。嗯,这个问题折腾了我两天才搞定。

好了,倒排索引的核心内容就这些。原理、构建、压缩、更新,环环相扣。你想想看,没有倒排索引,搜索引擎就是个瘸子。有了它,用户才能在一秒内从几十亿网页里找到想要的内容。下一章咱们聊聊查询处理,看看倒排索引是怎么被“用”起来的。