前面九篇的倒排索引和 BM25 有一个隐含假设:所有文档在内存里放得下。50 篇文档确实没问题。但把数据集扩展到 10,000 篇、每篇平均 2,000 词时,仅 (词项, docID) 对就可能有几千万条,单次排序的内存需求轻松超过 JVM 默认堆。
这不是"用更大的机器"就能解决的问题。即使加到 32 GB 内存,真实语料还是可能更大。需要一种策略:把内存装不下的数据分批处理,每一批写成磁盘上的段文件(segment),最后把所有段合并成完整索引。
分批建立索引
朴素方案的瓶颈
第 07 篇的索引构建把所有文档一次性塞进 HashMap:
1 2 3 4 5 6
| Map<String, List<Integer>> index = new HashMap<>(); for (Document doc : allDocs) { for (String term : analyze(doc.content())) { index.computeIfAbsent(term, k -> new ArrayList<>()).add(doc.id()); } }
|
假设 10,000 篇文档平均 2,000 词,分析后约 1,500 个词项/篇,posting 总条目约 1,500 万。每个 Integer 对象 16 字节,加上 ArrayList 的数组、HashMap 的 Entry——实际内存远超数据本身大小。Java 对象头的开销在小对象上尤其明显。
SPIMI:Single-Pass In-Memory Indexing
SPIMI 的做法是:内存装不下就不装。每次只处理一批文档,在内存中构建一个小的倒排索引,写入磁盘成为一个段文件,然后清空内存处理下一批。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| int memoryBudget = 64 * 1024 * 1024; Map<String, List<Integer>> currentBlock = new HashMap<>(); int estimatedSize = 0; int segmentCount = 0;
for (Document doc : documentStream) { for (String term : analyze(doc.content())) { currentBlock.computeIfAbsent(term, k -> new ArrayList<>()).add(doc.id()); estimatedSize += 20; }
if (estimatedSize >= memoryBudget) { writeSegment(currentBlock, segmentCount++); currentBlock.clear(); estimatedSize = 0; } }
if (!currentBlock.isEmpty()) { writeSegment(currentBlock, segmentCount++); }
|
SPIMI 不需要预先建立词项到 ID 的映射——直接用字符串作 key,每个段文件独立包含自己的词典。这使得它可以处理任意规模的语料,只要磁盘够大。
另一种更早的做法是 BSBI(Blocked Sort-Based Indexing),它先把 (termID, docID) 对全部收集再排序。BSBI 需要全局 termID 映射表,而 SPIMI 省掉了这一步,在内存利用率上更好。
段文件格式
段文件需要一个结构使得后续合并可以顺序读取。最简单的设计:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| [Header] magic: "SIDX" (4 bytes) version: 1 (4 bytes) termCount (4 bytes) checksum (4 bytes, CRC32)
[Term Dictionary] — 按词项字典序排列 termLength (2 bytes) termBytes (variable) docFreq (4 bytes) postingOffset (8 bytes, 指向 Posting 区域的偏移)
[Posting Lists] 每个词项的 docID 列表,用 delta + VByte 编码
|
词典按字典序排列是关键——合并时可以像归并排序一样同时扫描多个段。
为什么不用 JSON 或纯文本
段文件用二进制格式而非 JSON/文本,原因有三:
- 空间效率:10 万个 docID 用 JSON 数组约 800 KB,VByte 编码后约 120 KB
- 解析速度:二进制格式直接读字节,JSON 需要词法分析和数值转换
- 定位能力:postingOffset 允许跳过不需要的词项,直接定位目标词项的 posting list
Delta 编码与 VByte
Delta 编码
Posting list 中的 docID 是递增排列的。存储绝对值浪费空间:
1 2
| 原始 docID: [3, 5, 20, 21, 23, 76] delta (gap): [3, 2, 15, 1, 2, 53]
|
第一个值保持原样,后续每个值只存与前一个的差(gap)。高频词的 posting list 中 docID 分布密集,gap 通常很小——大量的 1、2、3——可以用更少的字节表达。
VByte 编码
Variable Byte 编码用变长字节表示整数。每个字节的最高位是 continuation bit:1 表示这是最后一个字节,0 表示后续还有。低 7 位是数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| void encodeVByte(int value, OutputStream out) throws IOException { byte[] buf = new byte[5]; int pos = 4; buf[pos] = (byte) ((value & 0x7F) | 0x80); value >>>= 7; while (value > 0) { buf[--pos] = (byte) (value & 0x7F); value >>>= 7; } out.write(buf, pos, 5 - pos); }
int decodeVByte(InputStream in) throws IOException { int result = 0; int b; do { b = in.read(); result = (result << 7) | (b & 0x7F); } while ((b & 0x80) == 0); return result; }
|
编码示例:
| gap |
二进制 |
VByte 字节 |
字节数 |
| 1 |
1 |
10000001 |
1 |
| 5 |
101 |
10000101 |
1 |
| 127 |
1111111 |
11111111 |
1 |
| 128 |
10000000 |
00000001 10000000 |
2 |
| 214577 |
… |
00001101 00001100 10110001 |
3 |
gap < 128 只需 1 字节。对高频词(gap 通常很小),压缩率很高。
压缩效果对比
假设 posting list 有 10,000 个 docID,总文档数 100,000:
| 编码方式 |
每个 docID 字节数 |
总字节 |
| 固定 4 字节 int |
4 |
40,000 |
| Delta + VByte |
约 1.5(取决于分布) |
约 15,000 |
压缩到原来的 37%。对低频词(gap 大)效果没这么好,但低频词的 posting list 本身就短,绝对空间不大。
K-way 合并
合并流程
10 个段文件合并成一个完整索引,用优先队列实现 k-way merge:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| void mergeSegments(List<SegmentReader> segments, SegmentWriter output) { PriorityQueue<SegmentReader> pq = new PriorityQueue<>( Comparator.comparing(SegmentReader::currentTerm));
for (SegmentReader seg : segments) { if (seg.hasNext()) { seg.advance(); pq.offer(seg); } }
while (!pq.isEmpty()) { String term = pq.peek().currentTerm();
List<int[]> postingsToMerge = new ArrayList<>(); while (!pq.isEmpty() && pq.peek().currentTerm().equals(term)) { SegmentReader seg = pq.poll(); postingsToMerge.add(seg.currentPostings()); if (seg.hasNext()) { seg.advance(); pq.offer(seg); } }
int[] merged = mergePostingLists(postingsToMerge); output.writeTerm(term, merged); } }
|
每个段的词典已按字典序排列,合并时优先队列确保全局字典序。同一词项在多个段中出现时,各段的 posting list 合并(docID 已排序,做归并即可)。
合并的 I/O 模式
合并是顺序读、顺序写——对磁盘最友好的访问模式。不需要随机寻址。这就是为什么段文件内部按字典序排列:它把合并变成了一次线性扫描。
正确性验证
合并后的索引与"假设内存无限、一次性建立的索引"必须完全一致。验证方法:
- 用小数据集(100 篇文档),分别用一次性方式和分 5 段合并方式建索引
- 对 10 个查询分别执行,比较返回的 docID 集合和 BM25 分数
- 任何不一致都是 bug
损坏检测
段文件写入过程可能因程序崩溃、磁盘故障而中断。不检测损坏的后果是读到错误数据,返回错误的搜索结果——而且可能不报错,只是结果静默出错。
CRC32 校验:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| void writeSegment(SegmentData data, Path path) throws IOException { byte[] payload = serializePayload(data); CRC32 crc = new CRC32(); crc.update(payload);
try (var out = new DataOutputStream(Files.newOutputStream(path))) { out.writeInt(0x53494458); out.writeInt(1); out.writeInt(data.termCount()); out.writeInt((int) crc.getValue()); out.write(payload); } }
SegmentData readSegment(Path path) throws IOException { try (var in = new DataInputStream(Files.newInputStream(path))) { int magic = in.readInt(); if (magic != 0x53494458) throw new IOException("不是段文件"); int version = in.readInt(); int termCount = in.readInt(); int storedChecksum = in.readInt();
byte[] payload = in.readAllBytes(); CRC32 crc = new CRC32(); crc.update(payload); if ((int) crc.getValue() != storedChecksum) { throw new IOException("段文件损坏:checksum 不匹配"); } return deserializePayload(payload, termCount); } }
|
这不是完整的持久性保证——写入中断时可能产生半截文件。完整的 crash recovery(预写日志、原子重命名)留给第 12 篇处理。本篇只保证:能识别损坏的段文件并拒绝使用。
内存受限下的端到端流程
完整流程:
1 2 3 4 5 6
| 文档流 ──→ SPIMI ──→ 段文件 0 ──→ 段文件 1 ──→ ... ──→ 段文件 k
段文件 0..k ──→ K-way merge ──→ 最终索引文件
|
配置项只有一个:内存预算(memoryBudget)。预算越小,段文件越多,合并 I/O 越大。预算越大,段文件越少,合并越快。两者之间有平衡点,但对教学规模来说无需调参——64 MB 足够。
当前局限
- 段文件格式是教学级的,不兼容 Lucene 的段格式——第 15 篇迁移
- 没有实现增量索引(新文档到来时追加)——本篇只处理静态语料的一次性构建
- 合并策略是全量合并,没有分层(Tiered Merge Policy)——第 12 篇讨论
- VByte 不是最快的解码方式,PForDelta 在 SIMD 下更快——但教学场景不需要
- crash recovery 只有检测,没有恢复——第 12 篇补充
练习
- 将第 07 篇的索引构建改为 SPIMI:设定 memoryBudget = 1 MB,用 50 篇文档验证分段后合并的结果与一次性构建一致
- 实现 VByte 编码和解码,用 [1, 2, 127, 128, 100000] 作为测试用例
- 故意在段文件中翻转一个字节,验证 CRC32 检测到损坏
- 对比 delta + VByte 和不压缩的段文件大小,记录压缩率
- 用 3 个段文件和 2 个共有词项,手工跟踪 k-way merge 的优先队列变化
延伸阅读
- Introduction to Information Retrieval, Chapter 4: Index Construction
- Introduction to Information Retrieval, Chapter 5: Index Compression
- Heinz, S. & Zobel, J. (2003). Efficient Single-Pass Index Construction for Text Databases. JASIST, 54(8).
- Lucene 源码:
org.apache.lucene.index.TieredMergePolicy