倒排索引基础:倒排列表结构、词典构建、压缩方式

面试问到搜索引擎或密文索引时,倒排索引几乎是必考题。我第一次接触倒排索引是在做日志检索系统的时候,脑子里全是正排索引的思维,结果查一个词要遍历全文档,耗时跟蜗牛一样。后来改成倒排,速度直接起飞。说白了,倒排索引就是把“文档→词”的关系倒过来,变成“词→文档列表”。

倒排列表结构

每个词项对应的倒排列表,本质上是一个有序的文档ID序列。我习惯把结构拆成三部分:文档ID、词频、位置列表。文档ID按升序排列,这是后续压缩和合并的前提。

举个例子,假设有三篇文档:

  • Doc1: "我 爱 倒排"
  • Doc2: "倒排 索引 很 快"
  • Doc3: "倒排 压缩"

词项“倒排”的倒排列表大概长这样:

倒排 -> [(1, 1, [3]), (2, 1, [1]), (3, 1, [1])]

每个元组里依次是:文档ID、词频、出现位置。实际工程中位置信息常用于短语查询,但如果只做布尔检索,可以省略位置,只存docID。

面试关键点:面试官常问“为什么docID要排序?”答案很简单:排序后可以用差值存储(即d-gap),减少存储开销,而且合并多个倒排列表时可以用类似归并排序的算法,效率高。

词典构建

词典是倒排索引的“入口”,每个词项要能快速定位到对应的倒排列表。构建词典的方法有好几种,我整理了一个对比表:

方法 查询时间复杂度 内存占用 适用场景
哈希表 O(1) 平均 较高(冲突桶) 小规模词典,不要求前缀查询
排序数组 + 二分查找 O(log n) 低(连续内存) 静态词典,构建后不更新
Trie(前缀树) O(len) 词长 较高(节点指针) 支持前缀匹配,自动补全
B树/B+树 O(log n) 中等(树结构) 动态更新,磁盘友好

我在项目中选过排序数组 + 二分。因为当时词典是离线生成的,不频繁更新,内存连续对缓存特别友好。有一次我图省事用了哈希表,结果测试时发现哈希冲突导致内存爆炸,查了十分钟才定位——这就是个坑。

避坑:我曾经在构建中文词典时,直接把unicode字符串当键去哈希,结果建出来的哈希表巨大。后来改成先转成字节数组再哈希,既省内存又加速。你想想看,字符串是不可变的,每次查找都拷贝,性能能好吗?

压缩方式:Variable Byte 与 Simple9

倒排列表的原始数据一般很大,尤其docID和词频。压缩的目的就是在不影响解压速度的情况下减体积。面试时经常让你手撕编码或者对比两种压缩。我个人面试时被问过三次,每次都绕不开Variable Byte和Simple9。

Variable Byte(变长字节编码)

Variable Byte的思路很简单:每个整数用1-5个字节表示,每个字节的最高位是延续位(1表示后面还有字节,0表示结束),剩下的7位存数据。压缩率不错,解压速度也快。

我手写一个Python示例:

def vb_encode(numbers):
    res = []
    for n in numbers:
        while n > 127:
            res.append((n & 0x7f) | 0x80)
            n >>= 7
        res.append(n)
    return bytes(res)

def vb_decode(data):
    result = []
    n = 0
    for b in data:
        if b & 0x80:
            n = (n << 7) | (b & 0x7f)
        else:
            n = (n << 7) | b
            result.append(n)
            n = 0
    return result

核心就是每次取低7位,高位补1,直到最后一个字节高位为0。解压时拼回去。

注意:Variable Byte对小数字很友好,比方说docID差值如果是1,只需1个字节。但如果差值很大(比如10万),就需要3个字节。我在测试中发现,如果文档ID不连续、跨度大,VB的压缩率会下降,这时候考虑Simple9可能更好。

Simple9(简单九编码)

Simple9的思想是把多个小整数打包到一个32位字里。它预设了9种打包模式,每个模式规定了字内分成几个比特段、每个段占几位。例如模式0表示每个段占1位,可以塞28个整数(因为需要4位存储模式标识);模式1每个段占2位,能塞14个整数……模式8每个段占28位,只能塞1个整数。

编码时,遍历剩余整数,找到能打包最多的模式,然后一次压入一个32位整数。解码时根据前4位判断模式,然后按位解析。

我当初实现Simple9时犯了个错:没考虑剩余整数超过28个时,模式递归逻辑写错了,导致打包结果不正确。后来加了个极端情况处理才搞定。

两种方式的对比:

维度 Variable Byte Simple9
压缩粒度 逐字节 逐字(32位)
适用整数范围 小数值更优 数值较均匀时较好
解压速度 较快(逐字节解析) 更快(SIMD可优化)
最差空间 5字节/整数 4字节/整数(1个整数装不满)

面试时,你如果能说出“Simple9利用字长对齐,现代CPU对字操作更快”这类细节,就能加分。

我的建议:若倒排列表的差值集中在1-100之间,用Variable Byte就挺好。如果差值分布比较“胖”,数值有大有小,可以试Simple9。我在实际项目里两者都用过,最后根据线上统计的差值分布动态选择编码方式,代码复杂一点但压缩率提升了30%多。

倒排索引核心结构 文档集合 Doc1, Doc2, ... 分词与统计 term + 频率 词典 排序 / 哈希 倒排列表(原始) docID, freq, positions 压缩编码 VByte / Simple9 压缩后列表 字节流 / 32位字 说明 压缩环节可放在内存或磁盘,面试常问变长编码与定长打包的区别