主线第 18 篇把整篇文档压缩成一个 512 维向量。这个"池化"操作不可避免地丢失信息:一篇同时讨论"线程池配置"和"垃圾回收调优"的长文档,其单向量是两个主题的模糊平均——搜任何一个主题都不会得到高分,搜两个主题的交集反而可能命中。

ColBERT(Khattab & Zaharia, 2020)提出了不同的方案:不池化,保留每个 token 的独立向量,在检索时做 token 级的细粒度匹配。

单向量的信息瓶颈

把一篇 200 个 token 的文档压缩成 1 个向量,信息压缩比是 200:1。对短文档(标题、摘要)这个损失可以接受;对长文档(技术文章、API 文档),关键细节可能被淹没。

具体场景:

1
2
3
4
5
6
7
8
9
文档: "ConcurrentHashMap 使用分段锁实现线程安全。
初始容量默认 16,负载因子 0.75。
Java 8 之后改用 CAS + synchronized 替代分段锁。"

单向量: [0.12, -0.34, 0.56, ...] ← 三个事实混合成一个点

Query A: "ConcurrentHashMap 初始容量" → 余弦相似度 0.71
Query B: "ConcurrentHashMap 锁实现变化" → 余弦相似度 0.73
Query C: "HashMap 负载因子" → 余弦相似度 0.68

三个查询的相似度差距很小——单向量无法区分文档中哪个部分与哪个查询最相关。

ColBERT 的 MaxSim 机制

ColBERT 的做法:保留文档中每个 token 的独立向量,检索时计算 query token 与 document token 之间的细粒度匹配。

编码

1
2
3
4
5
Query:    "ConcurrentHashMap 初始容量"
→ q1=[...], q2=[...], q3=[...] (3 个 128 维向量)

Document: "ConcurrentHashMap 使用分段锁..."
→ d1=[...], d2=[...], ..., d20=[...] (20 个 128 维向量)

每个 token 向量的维度通常是 128(比单向量的 512 低,但数量多)。

MaxSim 计算

对 query 中的每个 token,找 document 中与它最相似的 token,取最大相似度:

1
2
3
MaxSim(q_i, D) = max_j cos(q_i, d_j)

score(Q, D) = Σ_i MaxSim(q_i, D)

手算示例:

1
2
3
4
5
6
7
8
            d1("Concurrent") d2("HashMap") d3("分段") d4("锁")
q1("Concurrent") 0.95 0.12 0.03 0.01
q2("HashMap") 0.15 0.93 0.05 0.02
q3("初始") 0.02 0.08 0.11 0.04 → max=0.11
q4("容量") 0.01 0.05 0.03 0.07 → max=0.07
↑max=0.95 ↑max=0.93

score = 0.95 + 0.93 + 0.11 + 0.07 = 2.06

关键特性:每个 query token 独立找到文档中最匹配的位置。“Concurrent"匹配到了"Concurrent”,“HashMap"匹配到了"HashMap”——即使它们在文档中不相邻。

存储成本

多向量的代价是存储量大幅增加:

方案 每文档向量数 维度 字节/文档 100 文档 10K 文档
单向量 float32 1 512 2,048 200 KB 20 MB
单向量 int8 1 512 512 50 KB 5 MB
ColBERT float32 ~200 128 102,400 10 MB 1 GB
ColBERT int8 ~200 128 25,600 2.5 MB 250 MB

200 个 token 的文档,ColBERT float32 的存储是单向量的 50 倍。

ColBERTv2 的压缩方案

ColBERTv2(Santhanam et al., 2022)用残差压缩大幅降低存储:

  1. 对所有 token 向量做 k-means 聚类(如 k=65536)
  2. 每个 token 向量只存:聚类 ID(2 字节)+ 残差的 2-bit 量化(128d × 2bit = 32 字节)
  3. 每个 token 向量从 512 字节压缩到 ~34 字节

压缩后 200 token 文档的存储:200 × 34 = 6,800 字节 ≈ 单向量 float32 的 3.3 倍。代价从 50 倍降到 3 倍,可以接受。

检索策略

多向量检索有两条路线:

路线 A:作为 Reranker

最实际的方案——用 ColBERT 做 reranker,只在候选集上计算 MaxSim:

1
2
3
4
5
BM25 + dense → Top-100 候选

ColBERT MaxSim 重排 Top-100

返回 Top-10

优势:

  • 只存储候选文档的 token 向量,或在线计算
  • 不需要全量 token 向量索引
  • 与现有流水线兼容

劣势:

  • 召回上界受限于第一阶段——如果 BM25 + dense 漏掉了文档,ColBERT 也救不回来
  • 在线计算 MaxSim 的延迟取决于候选数量

路线 B:作为 Retriever

把所有 token 向量建 HNSW 索引,检索时每个 query token 独立搜索最近邻:

1
2
3
4
5
6
7
8
query token q1 → HNSW 搜索 → 命中 doc3/token5, doc7/token12, ...
query token q2 → HNSW 搜索 → 命中 doc3/token8, doc1/token3, ...

候选文档去重: {doc1, doc3, doc7, ...}

对每个候选做完整 MaxSim

Top-K 结果

优势:

  • 召回不受 BM25/dense 限制,理论上更完整
  • 对 token 级匹配的查询效果好

劣势:

  • 索引规模巨大(N 文档 × T token/文档 个向量)
  • 每个 query token 都要做一次 HNSW 搜索,延迟是 query token 数的倍数

对于本系列的 100 篇小语料,路线 A 更合适。路线 B 在大规模生产环境中才有意义。

Lucene 的 Late Interaction 支持

Lucene 10.3 引入了 late-interaction reranking 的基础能力,但没有原生的 ColBERT MaxSim 评分器。实现路径:

存储多向量

1
2
3
4
5
6
7
8
// 每个文档存储多个 token 向量
Document doc = new Document();
float[][] tokenVectors = colbertModel.encode(text);
for (int i = 0; i < tokenVectors.length; i++) {
doc.add(new KnnFloatVectorField(
"colbert_" + i, tokenVectors[i], VectorSimilarityFunction.COSINE));
}
writer.addDocument(doc);

这种方式每个 token 一个字段——字段数量不固定,管理复杂。

更实际的做法:自定义 Collector

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
// 在候选集上做 MaxSim 重排
public class MaxSimCollector extends SimpleCollector {
private final float[][] queryVectors;
private final StoredFields storedFields;

@Override
public void collect(int docId) throws IOException {
// 从存储中加载文档的 token 向量
byte[] packed = storedFields.document(docId)
.getBinaryValue("colbert_vectors").bytes;
float[][] docVectors = unpack(packed);

// 计算 MaxSim
float score = 0;
for (float[] qv : queryVectors) {
float maxSim = Float.NEGATIVE_INFINITY;
for (float[] dv : docVectors) {
float sim = cosineSimilarity(qv, dv);
if (sim > maxSim) maxSim = sim;
}
score += maxSim;
}
// 收集结果...
}
}

把 token 向量序列化为一个二进制字段存储,检索时反序列化后计算 MaxSim。这种方案绕过了 Lucene 向量搜索的限制,代价是无法利用 HNSW 加速。

成本效益对比

把 ColBERT 与主线已有的 cross-encoder reranker(第 21 篇)放在一起比较:

维度 Cross-Encoder ColBERT MaxSim
计算方式 query-doc 拼接过完整 Transformer query/doc 分别编码,点积
延迟(20 候选) ~200ms(串行) ~5ms(向量运算)
延迟(100 候选) ~1000ms ~25ms
离线编码 不可能(依赖 query) doc 端可离线
质量 最高(全交互) 略低(只有 MaxSim 交互)
存储 无额外存储 每文档额外 ~7 KB(压缩后)

ColBERT 的优势在延迟:100 候选的 MaxSim 计算只需 25ms,而 cross-encoder 需要 1 秒。在延迟预算紧张的场景,ColBERT 是 cross-encoder 的高效替代。

在质量上,cross-encoder 因为看到了 query 和 doc 的完整交互,通常优于 ColBERT 1-3 个 nDCG 点。如果延迟预算允许,cross-encoder 仍是更好的选择。

练习

  1. 手算一个 3-token query 和 5-token document 之间的 MaxSim 分数
  2. 计算 ColBERT 在 100 文档、1000 文档、10000 文档规模下的存储量(float32 和 int8)
  3. 设计实验:在 Top-50 候选上分别用 cross-encoder 和 ColBERT MaxSim 重排,对比 nDCG@10 和延迟

延伸阅读

  • Khattab & Zaharia, “ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT”, SIGIR 2020
  • Santhanam et al., “ColBERTv2: Effective and Efficient Retrieval via Lightweight Late Interaction”, NAACL 2022
  • Lucene 10.3 Release Notes(late-interaction reranking 支持)