周五下午的代码 review,老李盯着屏幕上的检索日志,眉头拧成了麻花。
“你看看,用户问‘Kubernetes 集群怎么扩缩容’,检索回来的 top-5 文档里,有两篇讲的是 K8s 基本概念,一篇讲的是 Docker Swarm 扩容,只有两篇真正在讲 K8s 扩缩容。”老李把保温杯往桌上重重一放,“标题看着都挺相关,怎么内容就是不对呢?”
小王把椅子滑过来,瞥了一眼日志:“老李你想啊,向量检索就像一个在相亲角翻资料卡的人。他看到一张资料卡上写着‘爱好运动、收入稳定、有房’,觉得条件不错,就收下了。但真见面一聊,发现‘爱好运动’指的是每天散步十分钟,‘有房’是老家县城的一套。资料卡和真人,差距大着呢。”
“那怎么办?总不能每篇文档都让大模型读一遍再决定吧?那延迟得上天。”
“不用全读,但可以让一个更仔细的人,把初筛出来的几份资料认真看一遍。这叫重排序。”
Bi-Encoder vs Cross-Encoder:外卖 App 和私厨
小王在玻璃上写了两个词。
“向量检索底层用的是 Bi-Encoder 架构。问题和文档各编码各的,分别压成一个向量,然后算余弦相似度。就像你在外卖 App 上点餐——看着菜单图片选,速度快,但图片跟实物总有差距。”
老李点点头:“这个我懂,向量库就是这么检索的。”
“重排序用的是 Cross-Encoder 架构。它不是各算各的,而是把问题和候选文档拼在一起,当成一个整体送进模型。模型逐字逐句地读,判断这两个文本到底有多相关。就像你把私厨请到家里,让他亲自尝你的口味再下厨——精准,但成本高。”
老李眼睛亮了:“那直接用 Cross-Encoder 做检索不就行了?”
“不行。你想想,你有十万篇文档,Cross-Encoder 要把每个问题跟十万篇文档逐对编码,每对都要跑一次完整的 Transformer 前向传播。十万次推理,用户得等到下辈子。Bi-Encoder 能提前把文档都编码好存起来,检索时只编码问题再算距离,一次推理就行。”
小王总结道:“所以实际的做法是两阶段检索——Bi-Encoder 粗排海选,从十万篇里筛出几十篇候选;Cross-Encoder 精排复面,对这几十篇逐对打分,按新分数重新排序。两个阶段各干各的,谁也别越界。”
图:两阶段检索——Bi-Encoder 负责速度,Cross-Encoder 负责精度
重排序到底重排了什么?
“那 Cross-Encoder 打分跟余弦相似度有什么本质区别?”老李追问。
“向量相似度看的是‘整体语义方向’,Cross-Encoder 看的是‘逐词交互’。我举个例子。”小王在屏幕上敲了一段对比:
# 演示:Bi-Encoder 和 Cross-Encoder 对同一个文档-问题对的判断差异
# 文档 A:讲 K8s 扩缩容,但开篇大段介绍 K8s 基本概念
doc_a = """
Kubernetes 是一个容器编排平台,支持自动部署、扩展和管理容器化应用。
K8s 集群由 Master 节点和 Worker 节点组成。在扩缩容方面,可以使用 HPA
根据 CPU 使用率自动调整 Pod 副本数,也可以手动调整 deployment 的 replicas。
"""
# 文档 B:直接讲扩缩容操作
doc_b = """
HPA 配置步骤:1. 确保 metrics-server 已部署;2. 设置 resource requests;
3. 创建 HPA 对象指定目标 CPU 利用率;4. 压测验证自动扩缩行为。
"""
question = "Kubernetes 集群怎么扩缩容?"
# Bi-Encoder:两个文档的向量可能都与问题相似
# doc_a 虽然开头跑题,但整体语义方向跟问题接近
# doc_b 精准命中但语义覆盖范围小
# Cross-Encoder:逐词交互后能发现 doc_a 前半段是"背景噪音"
# doc_b 的每一句都与扩缩容操作直接相关,分数会明显高于 doc_a“你看,Cross-Encoder 能区分‘文档里提到过相关概念’和‘文档围绕这个问题展开’。这就是为什么重排序能让真正对口的文档浮上来。”
实战:给 RAG 加一个 Reranker
“别光说不练,”老李催促道,“这玩意儿怎么用?”
小王打开终端,调出了 HuggingFace 上的 BGE-Reranker:
# 用 BGE-Reranker 对检索结果精排
from FlagEmbedding import FlagReranker
# 加载重排序模型(BGE-Reranker-large,专做精排)
reranker = FlagReranker("BAAI/bge-reranker-large", use_fp16=True)
# 候选文档:向量检索召回的 top-10
candidates = [
"Kubernetes 是一个容器编排平台,支持自动扩展...",
"HPA 配置需要先部署 metrics-server,设置 resource requests...",
"Docker Swarm 也支持服务扩容,使用 docker service scale...",
"K8s 集群节点管理包括添加和移除 Worker 节点...",
]
query = "Kubernetes 集群怎么扩缩容?"
# Cross-Encoder 逐对打分
pairs = [[query, doc] for doc in candidates]
scores = reranker.compute_score(pairs)
# 按新分数重排
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
for i, (doc, score) in enumerate(ranked[:3]):
print(f"重排 Top{i+1} (score={score:.4f}): {doc[:50]}...")
# 输出:真正讲 HPA 操作的文档会被排到第一
# Docker Swarm 那篇会被挤到后面,即便它的向量相似度不低“就这几行?”老李看着代码,保温杯在手里转了半圈。
“就这几行。FlagEmbedding 把重排序模型封装好了,compute_score 返回的就是 Cross-Encoder 的相关性分数。你把向量检索的 top-10 或 top-20 扔进去,它还你一个按精排分数重新排序的列表。”
性能代价:延迟与成本的权衡
“每次查询多跑一次模型推理,延迟加多少?”老李的运维本能立刻上线。
“取决于候选数量和硬件。用 BGE-Reranker-large 在 GPU 上,精排 20 篇文档大约需要 0.5 到 1 秒。如果用 CPU,大约是 2 到 4 秒。”小王如实回答。
“那用户岂不是要多等一秒?”
“分场景。如果是内部知识库问答,多等一秒完全可接受,换来的是答案准确率大幅提升。如果是实时对话,可以考虑缩小候选数量到 top-5,延迟控制在 200 毫秒以内。另外有些轻量级的 reranker,比如 BGE-Reranker-base 或者 Jina Reranker,速度更快但精度略逊。”
小王补充道:“当然还有更经济的方案——LLM 重排序。你把候选文档和问题一起扔给 GPT-4,让它挑最相关的。不需要单独部署模型,但每次重排都要消耗 token,候选一多成本就涨得很快。”
老李拿出小本本,在上面画了个对比表:
| 方案 | 延迟 | 精度 | 成本 |
|---|---|---|---|
| 不重排 | 最低 | 中 | 零 |
| BGE-Reranker-base | 低 | 中上 | 低(需 GPU) |
| BGE-Reranker-large | 中 | 高 | 中(需 GPU) |
| LLM 重排序 | 高 | 高 | 高(按 token 计) |
“所以又是一个权衡,”老李合上本子,“没有银弹。”
“对。但至少比‘召回即用’靠谱多了。你那个 K8s 扩缩容的问题,加一层重排序,Docker Swarm 那篇直接掉出前五。”
老李沉默了一会儿,把保温杯端起来喝了一口。枸杞已经凉了。
“行。下周一把重排序加到 pipeline 里,先用 BGE-Reranker-large,候选数量设 15。延迟控制在 1 秒内,优先级低于检索准确率。”他站起来,走了两步又回头,“那什么,Cross-Encoder 的内部结构跟 Bi-Encoder 到底差在哪?为什么前者能捕捉细微差异而后者不行?你给我画一下注意力矩阵的区别。”
小王笑了:“老李,你这是从架构师转行搞 NLP 啊。行,我给你画——你看,Bi-Encoder 里,问题和文档的 token 永远不照面,各自做 self-attention。Cross-Encoder 里,问题 token 和文档 token 放在一起做 cross-attention,每个问题词都能跟每个文档词‘对视’一眼。这就是差距的根源。”
窗外夕阳西沉,白板上的注意力矩阵示意图还没擦。老李的保温杯重新续了热水,枸杞在杯底沉浮,像是那些在 Cross-Encoder 里互相照面的 token 对,一对一地确认着彼此的关联。

