主题
11.11 检索的下一档功率:迟交互、稀疏学习与多库调度
痛点场景:你已经用上了 11.5 的“BM25 + 向量 + Cross-Encoder 重排”,但总有些场景卡在瓶颈上——长文档里一句话的细微差别被“一整段压成一个向量”抹平了;扫描件里的关键截图文本层压根没有;公司有产品库、制度库、工单库好几个知识库,该问谁全靠 if-else 硬编码。本节把检索层的“下一档功率”一次讲透:迟交互(ColBERT/ColPali)、稀疏学习(SPLADE)、多知识库调度。
痛点:一句话一向量,抹平了词级细节
11.3 讲的向量嵌入是“双塔”模型:把整段文字压成一个向量再比对。就像把一整部电影压成一张海报——大体风格能对上,但“主角在第 47 分钟说的那句话”早被压缩没了:
| 阵型 | 怎么比 | 强项 | 短板 |
|---|---|---|---|
| 双塔(Bi-Encoder) | 段落→1 个向量,问题→1 个向量,算一次相似度 | 快,可离线建库,ANN 友好(11.4) | 词级细节被压缩抹平 |
| 交叉(Cross-Encoder) | 把问题和段落一起输入模型,逐 Token 交互打分 | 精度通常较高(11.5 重排) | 每一对都要运行一次模型,无法预先建库 |
| 迟交互(Late Interaction) | 段落每个 token 一个向量离线存好;查询时问题 token 与段落 token 逐对“晚点再交互” | 词级精度 + 离线建库两者兼得 | 向量数量暴涨几十倍,存储成本高 |
第三种“迟交互”就是 ColBERT 的核心思想:把“压缩成一句话”变成“每人带一小队士兵入场”——段落里的每个词都是一个携带完整上下文的向量,查询词和文档词之间可以两两配对找最强呼应(MaxSim),细粒度信息不再丢失。
思路:三类检索方法与一张调度表
| 方法 | 解决什么 | 基本原理 |
|---|---|---|
| SPLADE 稀疏学习 | BM25 不会扩词,“笔记本电脑”搜不到“手提电脑” | 用模型学会“往查询/文档里智能加同义词”,输出仍是稀疏向量,兼容倒排索引 |
| ColBERT 迟交互 | 双塔压缩丢细节 | Token 级向量 + MaxSim,在细粒度匹配与可扩展检索之间折中 |
| ColPali 视觉检索 | 扫描件/图表 PDF 没有(或很烂的)文本层 | 把 PDF 页面当图片直接嵌入,跳过 OCR,版面/表格/图都保留 |
| 多库调度 | 几个知识库各管一摊,路由全靠 if-else | 用轻量语义分类器给问题“分诊”,按置信度路由 + 兜底 |
代码实现一:手写 MaxSim,看穿迟交互的本质
ColBERT 的打分公式其实只有一行:查询的每个 token,去文档所有 token 向量里找一个“最像的”(MaxSim),再对查询所有 token 求和。我们用 numpy 手搓一遍:
python
# 11.11 迟交互核心:手写 MaxSim
import numpy as np
def maxsim_score(query_vecs: np.ndarray, doc_vecs: np.ndarray) -> float:
"""query_vecs: (m, d) 查询 m 个 token 的向量
doc_vecs: (n, d) 文档 n 个 token 的向量
每个查询 token 在文档里找最相似的文档 token,再求和。"""
sim = query_vecs @ doc_vecs.T # (m, n) 两两相似度
return float(sim.max(axis=1).sum()) # 每行取最大 → 对查询求和
# 假设已经用 ColBERT 模型拿到了 token 级向量(实际用 pylate/RAGatouille)
q = np.random.RandomState(0).randn(3, 8) # 查询 3 个 token
doc_a = np.random.RandomState(1).randn(12, 8) # 文档 A:12 个 token
doc_b = doc_a.copy()
doc_b[5] = q[0] # 文档 B 在某个 token 上与查询“遥相呼应”
q /= np.linalg.norm(q, axis=1, keepdims=True)
doc_a /= np.linalg.norm(doc_a, axis=1, keepdims=True)
doc_b /= np.linalg.norm(doc_b, axis=1, keepdims=True)
print(maxsim_score(q, doc_a)) # ≈ 0.10 泛泛之交
print(maxsim_score(q, doc_b)) # 更高 一个词的“神呼应”就能被捕捉到这就是迟交互的全部灵魂:文档 B 只有一个 token 与查询呼应,双塔会把这点呼应稀释在平均里,而 MaxSim 让它“一票突出”。
生产中不必手搓,直接用封装库。PyLate 把 ColBERT 的“每个词一个向量”收成建库 + 检索两步。现行 1.6 往 PLAID 索引里塞文档时,只收 id 和已经算好的 token 向量——原文文本不进这个函数。多传一个 documents_texts=,调用会直接报错。文本你自己留着做展示和引用即可,索引只负责“词和词怎么对齐”。
python
# pip install pylate
# 说明:迟交互/稀疏检索目前没有成熟的国产 API 端点,这里用本地小模型演示
# (GTE-ModernColBERT 约 0.6GB / SPLADE 约 0.8GB,下载一次即可离线复用);
# 生产可选 Vespa、Qdrant 稀疏向量支持或托管服务。
from pylate import indexes, models, retrieve
colbert = models.ColBERT(model_name_or_path="lightonai/GTE-ModernColBERT-v1")
docs = ["员工差旅住宿标准:一线城市每晚 500 元。",
"公司年会每年 12 月举办,全员参加。"]
# 离线:每个 token 一个向量,写入 PLAID 索引
index = indexes.PLAID(index_folder="indexes", index_name="demo_kb", override=True)
index.add_documents(
documents_ids=["d1", "d2"],
documents_embeddings=list(colbert.encode(docs, is_query=False)),
)
# 在线:查询 token 与文档 token 逐一 MaxSim
query_embeddings = colbert.encode("住一线城市一晚补贴多少?", is_query=True)
retriever = retrieve.ColBERT(index=index)
rankings = retriever.retrieve(query_embeddings, k=2)
print(rankings[0][0]) # 命中差旅制度那条代码实现二:SPLADE —— 会自己“扩词”的稀疏检索
BM25 是“抠字眼”的老实人:你搜“手提电脑”,它绝不会想到“笔记本电脑”。SPLADE 的思路很聪明:仍然输出稀疏向量(兼容倒排索引,11.4 的基础设施照用),但稀疏向量的每个维度权重由模型学会“智能扩词”——相当于一个既懂倒排索引又懂语义的混血儿。
python
# pip install sentence-transformers
from sentence_transformers import SparseEncoder
splade = SparseEncoder("naver/splade-cocondenser-ensembledistil")
docs = ["员工差旅住宿标准:一线城市每晚 500 元。",
"公司年会每年 12 月举办,全员参加。"]
doc_embeds = splade.encode(docs)
# 稀疏向量:绝大多数维度是 0,只有少数“词”维度有权重
query_embed = splade.encode_query("住一线城市一晚补贴多少?")
scores = splade.similarity(query_embed, doc_embeds)
print(scores) # “补贴”命中了文档里的“标准”类似语义——BM25 做不到| 对比项 | BM25 | SPLADE | Dense 双塔 |
|---|---|---|---|
| 会扩词/近义 | ❌ | ✅ 模型学的 | ✅ |
| 术语精确匹配(型号/编号) | ✅ 强 | ✅ 强 | ⚠️ 常糊 |
| 索引基础设施 | 倒排索引 | 倒排索引 | 向量索引 |
| 存储成本 | 低 | 中 | 中 |
💡 选型直觉:企业检索里满是“型号、条款号、错误码”这类必须抠字眼的词时,可以评估 SPLADE。它仍使用稀疏向量与倒排检索思路,但模型推理、扩词后的索引体积、部署兼容性和领域训练都会增加成本,不能把它当作“无痛升级”。
代码实现三:多知识库调度 —— 给问题装一个“分诊台”
11.6 的意图路由解决“把问题翻译清楚”,本节解决“翻译清楚之后该进哪个库”。核心是:轻量分类器先分诊,低置信度就走全库兜底,宁可慢一点也不能答错门:
python
# 11.11 多知识库调度:语义分诊 + 置信度兜底
from pydantic import BaseModel, Field
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
class Triage(BaseModel):
"""分诊结果:去哪个知识库、置信度多少"""
kb: str = Field(description="目标知识库:product / policy / ticket / unknown")
confidence: float = Field(description="0~1 的置信度")
reason: str = Field(description="一句话理由")
triage_llm = ChatOpenAI(model="gpt-4o-mini", temperature=0).with_structured_output(Triage)
ROUTER_PROMPT = ChatPromptTemplate.from_template(
"你是知识库分诊台。可用知识库:\n"
"- product:产品功能、参数、价格\n"
"- policy:公司制度、报销、考勤\n"
"- ticket:历史工单、故障案例\n"
"用户问题:{question}\n输出目标库与置信度。拿不准就输出 unknown。"
)
def route(question: str, retrievers: dict, threshold: float = 0.7) -> list:
r = triage_llm.invoke(ROUTER_PROMPT.format(question=question))
if r.kb != "unknown" and r.confidence >= threshold:
return retrievers[r.kb].invoke(question) # 高置信:直奔对应库
# 低置信兜底:并发查所有库,交给 11.5 的 RRF/重排统一裁决
all_hits = []
for kb, ret in retrievers.items():
all_hits.extend(ret.invoke(question))
return all_hits调度层的三个工程要点:
- 置信度阈值要靠 11.9 的评测集调,不是拍脑袋;误路由一次,用户收到的是驴唇不对马嘴的答案;
- 保留兜底路径:分类器仍可能误判,必要时可以使用全库并发检索与重排作为回退方案;
- 路由结果要写进 Trace(11.9):上线后看“unknown 率”和“路由命中率”,unknown 率飙升往往意味着新话题出现了,该建新库或补训练样本了。
拓展:迟交互很香,但账要先算清
| 维度 | 双塔 | 迟交互(ColBERT) | 什么时候上 |
|---|---|---|---|
| 存储 | 1 文档 = 1 向量 | 1 文档 = 多个 token 向量;实际膨胀比例取决于长度、维度、量化与压缩 | 先按真实语料测索引大小 |
| 检索延迟 | ANN 一次 | 需要专门索引(PLAID),工程复杂度更高 | 有专门基础设施再上 |
| 精度收益 | 基线 | 对长文档、细粒度匹配提升明显 | 先用 11.9 评测集验证“值得”再迁 |
💡 务实的升级路径:先用 11.5 的混合检索打地基 → 拿 11.9 的评测集证明瓶颈在“词级匹配” → 再把重排层换成迟交互(成本可控)→ 最后考虑把召回层也换成 SPLADE/ColBERT。别为了新而新,先证明旧刀不够快。
MaxSim 分数不能直接当“相关概率”
ColBERT 分数会受查询 Token 数量、模型、语料和索引配置影响。因为标准公式按查询 Token 求和,查询越长,分数尺度往往也会变化。不能拿一个查询的 12 分和另一个查询的 8 分直接断言前者更可信,更不能随手设一个跨数据集通用阈值。
若要做拒答或路由,应在自己的标注集上使用排名特征、Top1 与 Top2 差距、候选分布或专门的相关性分类器校准,并按题型检查误拒和误答。
SPLADE 的“会扩词”也会让索引变胖
SPLADE 训练通常通过稀疏正则约束非零维度,否则每篇文档扩出太多词,倒排表会迅速膨胀。需要观察:
- 每篇文档和每条查询的平均非零项数量;
- 索引体积、建库吞吐与查询 P95;
- 扩出来的词是否引入领域歧义;
- 对错误码、型号、中文分词和跨语言问题的真实收益;
- 与 BM25 + Dense 混合检索相比是否值得额外模型推理。
多库调度和 11.6 的路由怎样分工?
11.6 先把用户问题恢复清楚、拆成子问题;本节的调度再决定每个子问题去哪个已授权知识库。若一个问题同时涉及差旅政策和财务付款周期,应允许多库并发,而不是强迫分类器只能选一个标签。最终候选统一融合、重排,并保留来源库供审计。
s11_colbert_sparse.py 已按标准公式改为 MaxSim 求和,并对 Token 向量归一化;测试会明确断言两个完全匹配的查询 Token 得分为 2,而不是求平均后的 1。PyLate 的 PLAID 索引和 SPLADE 检索也都建在 testdata/ 真实制度页上——问「住一线城市一晚补贴多少」,Top 榜会被现行版与废止版的住宿标准页占满,正好演示稀疏/迟交互检索的扩词能力与版本冲突并存的麻烦。
把存储增加和召回改善放在一起看
迟交互保留词元级表示,通常会显著增加索引体积和计算量;稀疏扩展也可能生成许多额外词项。评估时应同时记录索引大小、查询延迟和 Recall@k,不能只展示某个问题的更好结果。
可以先把它们用在难例重排或特定知识库,而不是立即替换全量检索。型号、法规编号等场景若 BM25 已经很好,增加复杂模型未必有足够收益。
权威官方参考
- ColBERT 官方仓库(Stanford FutureData)
- ColBERT 论文(Khattab & Zaharia, 2020, arXiv:2004.12832)
- ColBERTv2 论文(Santhanam et al., 2022, arXiv:2112.01488)
- PyLate:迟交互检索的现代化封装(LightOn)
- RAGatouille:RAG 里最易用的 ColBERT 封装(Answer.AI)
- SPLADE 官方仓库(Naver)
- SPLADE 论文(Formal et al., 2021, arXiv:2107.05720)
- ColPali:视觉文档检索(Illuin Technology)
- ColPali 论文(Faysse et al., 2024, arXiv:2407.01449)