RAG(Retrieval-Augmented Generation,检索增强生成)的质量,很大程度上取决于送进 LLM(Large Language Model,大语言模型)的上下文质量。
很多 RAG 系统会用向量检索召回 Top-K 文档,例如先召回 20 条,再取前 5 条交给 LLM。这个流程看起来没问题,但有一个常见误区:向量相似度高,不代表文档真的能回答用户问题。
用户问:
重疾险的等待期是多少天?
向量检索可能召回这些内容:
| 召回内容 | 是否真正回答问题 | 原因 |
|---|---|---|
| 等待期内发生的疾病不在保障范围之内 | 否 | 只讨论等待期内的责任限制,没有给出天数 |
| 等待期是保险合同的重要组成部分 | 否 | 话题相关,但没有答案 |
| 等待期的计算方式从合同生效日起计算 | 否 | 解释计算起点,没有回答“多少天” |
| 本产品等待期为 180 天 | 是 | 直接包含用户需要的答案 |
这些文档都和“等待期”有关,向量距离也可能很近。问题在于,RAG 不是只需要“话题相似”,而是需要“答案相关”。
Rerank(重排序)就是为了解决这个问题:先用向量检索快速找出一批候选文档,再用更精细的相关性模型重新打分,把真正能回答问题的文档排到前面,并过滤掉低质量上下文。
1. Rerank 解决的核心问题:召回多,不等于召回准
典型 RAG 检索链路可以分成三步:
flowchart LR
Q[用户问题 Query] --> R[向量召回<br/>Bi-Encoder]
R --> C[候选文档 Top-N]
C --> K[Rerank 精排<br/>Cross-Encoder]
K --> F[高相关文档 Top-K]
F --> L[LLM 生成回答]
向量召回负责“快”,Rerank 负责“准”。
如果只依赖向量召回,系统很容易把“语义相近但答案无关”的文档交给 LLM。LLM 一旦拿到噪声上下文,就可能把上下文里的无关信息拼接成看似合理的回答,这就是 RAG 幻觉的重要来源之一。
更准确地说,RAG 的检索目标不是:
找到和问题语义最像的文本。
而是:
找到最能支撑答案生成的文本。
这两个目标并不等价。
2. Bi-Encoder:为什么向量召回很快,但不够准
Bi-Encoder(双编码器)是向量检索中最常见的结构。它会把 query 和 document 分开编码:
flowchart TB
Q[Query] --> QE[Query Encoder]
D[Document] --> DE[Document Encoder]
QE --> QV[Query 向量]
DE --> DV[Document 向量]
QV --> S[余弦相似度 / 点积]
DV --> S
S --> Score[相似度分数]
它的工作方式是:
- 文档提前切成 chunk;
- 每个 chunk 通过编码模型变成向量;
- 向量写入向量数据库;
- 用户提问时,只需要把 query 编码成向量;
- 在向量库中做近邻搜索,返回距离最近的 Top-N 文档。
这种架构的优势非常明显:文档向量可以离线计算,在线检索只做一次 query 编码和向量近邻搜索。百万级文档库也可以做到毫秒级到几十毫秒级检索。
但 Bi-Encoder 有一个天然限制:query 和 document 在编码阶段没有交互。
也就是说,模型分别看 query、看 document,然后再比较两个向量距离。它比较擅长判断:
- 两段文本是不是同一个话题;
- 两段文本是不是使用了相近概念;
- 两段文本整体语义是否接近。
它不擅长判断:
- document 是否直接回答了 query;
- document 中的数字、条件、限定词是否和 query 对上;
- query 问的是“天数”“比例”“流程”“原因”中的哪一种;
- document 是否只是提到了关键词,但没有提供答案。
以前面的保险问题为例:
重疾险的等待期是多少天?
Bi-Encoder 很可能把所有包含“重疾险”“等待期”的条款都召回,因为这些文本在向量空间里距离很近。但只有“等待期为 180 天”这种 chunk 才是答案相关文档。
这就是 Rerank 必要的原因:Bi-Encoder 解决候选集召回,Cross-Encoder 解决候选集排序。
3. Cross-Encoder:让问题和文档在 token 级别交互
Cross-Encoder(交叉编码器)和 Bi-Encoder 的思路不同。它不会把 query 和 document 分别编码成两个向量,而是把二者拼接成一对输入,让 Transformer 在每一层注意力中同时看到问题和文档。
flowchart TB
Pair["[Query] + [Document]"] --> T[Transformer Encoder]
T --> CLS[相关性表示]
CLS --> Score[相关性分数 0~1]
Cross-Encoder 输入的是一组文本对:
(query, document_1)
(query, document_2)
(query, document_3)
...
对于每一对输入,模型都会输出一个相关性分数。这个分数表示:
这段 document 对回答 query 有多大帮助。
由于 query 和 document 在模型内部充分交互,Cross-Encoder 可以捕捉更细的关系。
例如 query 问:
轻症赔付比例是多少?
document 写:
轻度恶性肿瘤按基本保额的 20% 给付。
通用向量模型可能只看到“轻症”和“轻度恶性肿瘤”的语义距离不一定很近,但 Cross-Encoder 有机会根据上下文判断“给付比例”“20%”“轻度恶性肿瘤”之间的对应关系。
不过 Cross-Encoder 的代价也很明显:它不能提前为所有文档建索引。
因为每次用户问题都不同,Cross-Encoder 必须针对每个 (query, document) 对做一次前向推理。如果文档库有 100 万个 chunk,就不可能对 100 万个文本对逐个打分。
所以 Cross-Encoder 不适合作为全量检索工具,它适合放在精排阶段:
flowchart LR
A[全量文档库<br/>10万~千万 chunk] --> B[Bi-Encoder 向量检索]
B --> C[候选文档<br/>Top-20 / Top-50]
C --> D[Cross-Encoder Rerank]
D --> E[高质量上下文<br/>Top-3 / Top-5]
4. Bi-Encoder 和 Cross-Encoder 的区别
| 维度 | Bi-Encoder | Cross-Encoder |
|---|---|---|
| 中文名 | 双编码器 | 交叉编码器 |
| 输入方式 | query 和 document 分开编码 | query 和 document 拼接后一起编码 |
| 是否能预计算文档向量 | 可以 | 不可以 |
| 检索速度 | 快 | 慢 |
| 适合处理的数据规模 | 全量文档库 | 少量候选文档 |
| 擅长能力 | 话题相似度召回 | 答案相关性判断 |
| 常见位置 | 粗召回 | 精排 / 重排序 |
| 主要问题 | 可能召回语义相近但无答案的噪声 | 推理成本高,无法全库逐条计算 |
工业级 RAG 一般不会二选一,而是组合使用:
Bi-Encoder 负责从全库中快速缩小范围,Cross-Encoder 负责在候选集中精细排序。
5. 级联检索架构:Top-20 召回,再 Top-5 精排
一个常见配置是:
- 向量检索召回 Top-20;
- Reranker 对 20 条候选文档重新打分;
- 取分数最高的 Top-5;
- 低于阈值的文档丢弃;
- 剩余文档作为上下文交给 LLM。
完整流程如下:
flowchart TD
Q[用户问题] --> V[向量检索<br/>召回 Top-20]
V --> R[Reranker<br/>逐条计算相关性分数]
R --> S[按相关性分数降序排序]
S --> T[取 Top-5]
T --> F{是否超过阈值?}
F -- 是 --> C[进入上下文]
F -- 否 --> D[丢弃]
C --> L[LLM 生成]
D --> N[必要时返回未找到相关内容]
为什么不直接用 Cross-Encoder 检索全库?
假设有 10 万个 chunk,每个 (query, document) 对推理耗时 1ms,全量计算就是:
100000 × 1ms = 100s
100 秒的检索延迟无法用于在线问答。
如果先用向量检索把候选集缩小到 20 条,再让 Cross-Encoder 打分:
20 × 1ms = 20ms
加上向量检索、网络和排序开销,整体延迟可能从十几毫秒增加到几十毫秒。这个成本通常是可接受的,因为它换来的是更低的上下文噪声和更少的幻觉回答。
一个保险合同知识库的离线评测示例可以说明这种差异。假设知识库包含 5000 份合同,人工标注 200 个典型问题的相关文档,比较“仅向量检索”和“向量召回 + Rerank”的效果:
| 指标 | 仅 Bi-Encoder 召回 Top-5 | Bi-Encoder Top-20 + Reranker Top-5 |
|---|---|---|
| 送给 LLM 的 chunk 噪声比例 | 42% | 11% |
| LLM 回答幻觉率 | 18.3% | 6.4% |
| 平均检索延迟 | 12ms | 58ms |
| Precision@5 | 0.61 | 0.84 |
| Recall@5 | 0.58 | 0.79 |
Precision@5 表示 Top-5 结果中有多少比例是相关文档,Recall@5 表示所有相关文档中有多少被 Top-5 覆盖。可以看到,引入 Rerank 后,延迟增加了几十毫秒,但噪声比例和幻觉率下降明显。
6. 使用 BGE-Reranker 实现 Cross-Encoder 重排序
常见 Reranker 模型包括:
| 模型 / 服务 | 类型 | 特点 |
|---|---|---|
| BAAI/bge-reranker-v2-m3 | 开源模型 | 多语言能力较好,中文场景常用 |
| Cohere Rerank API | 闭源 API | 使用方便,效果强,依赖外部服务 |
| JinaAI Reranker | 模型 / API | 多语言检索场景常见 |
用 Hugging Face Transformers 可以直接加载 BGE-Reranker:
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer
@dataclass
class ScoredDocument:
text: str
score: float
class CrossEncoderReranker:
def __init__(
self,
model_name: str = "BAAI/bge-reranker-v2-m3",
device: str | None = None,
max_length: int = 512,
) -> None:
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
self.max_length = max_length
self.tokenizer = AutoTokenizer.from_pretrained(model_name)
self.model = AutoModelForSequenceClassification.from_pretrained(model_name)
self.model.to(self.device)
self.model.eval()
@torch.no_grad()
def rerank(
self,
query: str,
documents: list[str],
top_k: int = 5,
batch_size: int = 16,
normalize_with_sigmoid: bool = True,
) -> list[ScoredDocument]:
"""
对候选文档做 Cross-Encoder 重排序。
参数:
query: 用户问题
documents: 向量检索召回的候选文档
top_k: 重排序后保留的文档数量
batch_size: 批量推理大小
normalize_with_sigmoid: 是否把 logits 映射到 0~1
返回:
按相关性分数降序排列的文档列表
"""
if not documents:
return []
all_scores: list[float] = []
for batch_docs in self._batched(documents, batch_size):
pairs = [[query, doc] for doc in batch_docs]
inputs = self.tokenizer(
pairs,
padding=True,
truncation=True,
max_length=self.max_length,
return_tensors="pt",
).to(self.device)
logits = self.model(**inputs).logits.squeeze(-1)
if normalize_with_sigmoid:
scores = torch.sigmoid(logits)
else:
scores = logits
all_scores.extend(scores.detach().cpu().tolist())
scored_docs = [
ScoredDocument(text=doc, score=float(score))
for doc, score in zip(documents, all_scores)
]
scored_docs.sort(key=lambda item: item.score, reverse=True)
return scored_docs[:top_k]
@staticmethod
def _batched(items: list[str], batch_size: int) -> Iterable[list[str]]:
for start in range(0, len(items), batch_size):
yield items[start : start + batch_size]
调用方式:
query = "重疾险的等待期是多少天?"
candidate_docs = [
"等待期内发生的疾病不在保障范围之内。",
"等待期是保险合同的重要组成部分。",
"等待期的计算方式从合同生效日起计算。",
"本产品等待期为 180 天。",
]
reranker = CrossEncoderReranker()
results = reranker.rerank(query, candidate_docs, top_k=3)
for item in results:
print(round(item.score, 4), item.text)
实际使用时要注意一点:不同 Reranker 的分数分布不一样。有些模型输出的是 logits,有些服务直接输出归一化后的相关性分数。阈值不能跨模型照搬,必须在自己的数据集上标定。
7. 阈值过滤:Top-K 不够,还要判断“够不够相关”
Rerank 后直接取 Top-5 仍然有风险。
如果用户问了一个知识库里根本没有的问题,Reranker 仍然会把候选文档排出第 1、第 2、第 3。排名只表示相对顺序,不表示这些文档一定相关。
例如所有候选文档分数都很低:
| 排名 | 文档 | Rerank 分数 |
|---|---|---|
| 1 | 医疗险免赔额说明 | 0.31 |
| 2 | 重疾险责任免除说明 | 0.28 |
| 3 | 投保年龄限制说明 | 0.21 |
如果仍然强行取 Top-3 交给 LLM,模型很可能基于无关上下文编答案。
更稳妥的做法是:Rerank 后增加绝对阈值过滤。
def filter_by_threshold(
scored_docs: list[ScoredDocument],
threshold: float = 0.5,
) -> list[ScoredDocument]:
"""
丢弃低于相关性阈值的文档。
"""
return [doc for doc in scored_docs if doc.score >= threshold]
完整检索流程可以写成:
def rag_retrieve(
query: str,
vector_store,
reranker: CrossEncoderReranker,
top_k_recall: int = 20,
top_k_rerank: int = 5,
threshold: float = 0.5,
) -> list[ScoredDocument]:
"""
RAG 检索链路:
向量召回 -> Cross-Encoder 重排序 -> 阈值过滤。
"""
recall_results = vector_store.similarity_search(query, k=top_k_recall)
candidate_texts = [doc.page_content for doc in recall_results]
reranked_docs = reranker.rerank(
query=query,
documents=candidate_texts,
top_k=top_k_rerank,
)
final_docs = filter_by_threshold(
scored_docs=reranked_docs,
threshold=threshold,
)
return final_docs
生成阶段也要配合处理空结果:
def build_answer(query: str, final_docs: list[ScoredDocument], llm) -> str:
if not final_docs:
return "知识库中没有找到足够相关的内容,无法基于现有资料回答该问题。"
context = "\n\n".join(doc.text for doc in final_docs)
prompt = f"""
请只基于给定资料回答问题。
如果资料不足以回答,请说明资料中没有相关信息。
资料:
{context}
问题:
{query}
"""
return llm.invoke(prompt)
这个设计遵循一个原则:宁愿少给上下文,也不要为了凑满 Top-K 塞入噪声文档。
8. 阈值怎么定:用标注集找 F1 最优点
阈值不应该随手写成 0.5。正确做法是准备一批标注数据,然后在离线环境里扫描阈值。
需要准备的数据包括:
{
"query": "重疾险的等待期是多少天?",
"doc": "本产品等待期为 180 天。",
"label": 1
}
label = 1 表示文档相关,label = 0 表示文档不相关。
阈值选择过程如下:
flowchart TD
A[准备标注数据<br/>query-doc-label] --> B[Reranker 打分]
B --> C[遍历阈值<br/>0.30~0.80]
C --> D[计算 Precision]
C --> E[计算 Recall]
D --> F[计算 F1]
E --> F
F --> G[选择 F1 最高的阈值]
可以用 Python 简单实现:
from sklearn.metrics import precision_score, recall_score, f1_score
def search_best_threshold(
labels: list[int],
scores: list[float],
start: float = 0.3,
end: float = 0.8,
step: float = 0.05,
) -> dict:
"""
遍历多个阈值,选择 F1 分数最高的阈值。
"""
best = {
"threshold": None,
"precision": 0.0,
"recall": 0.0,
"f1": 0.0,
}
threshold = start
while threshold <= end + 1e-9:
preds = [1 if score >= threshold else 0 for score in scores]
precision = precision_score(labels, preds, zero_division=0)
recall = recall_score(labels, preds, zero_division=0)
f1 = f1_score(labels, preds, zero_division=0)
if f1 > best["f1"]:
best = {
"threshold": round(threshold, 4),
"precision": round(precision, 4),
"recall": round(recall, 4),
"f1": round(f1, 4),
}
threshold += step
return best
在一个保险问答评测集中,阈值扫描可能得到类似结果:
| 阈值 | Precision | Recall | F1 |
|---|---|---|---|
| 0.30 | 0.69 | 0.91 | 0.79 |
| 0.40 | 0.76 | 0.86 | 0.81 |
| 0.50 | 0.82 | 0.80 | 0.81 |
| 0.52 | 0.84 | 0.79 | 0.81 |
| 0.60 | 0.88 | 0.68 | 0.77 |
| 0.70 | 0.93 | 0.51 | 0.66 |
阈值越高,Precision 往往越高,但 Recall 会下降。问答系统需要根据业务选择平衡点:
| 场景 | 阈值策略 |
|---|---|
| 金融、医疗、法律等高风险场景 | 阈值偏高,减少错误回答 |
| 通用客服、开放问答 | 阈值适中,避免频繁拒答 |
| 搜索推荐类场景 | 可以降低阈值,保留更多候选 |
9. 领域微调:让 Reranker 学会专业术语和答案形式
通用 Reranker 在通用问答中通常表现不错,但到了垂直领域会遇到两个问题:
- 专业术语和日常表达之间的映射不足;
- 答案形式带有行业特征,通用模型未必敏感。
保险场景中有一个典型例子:
用户问:轻症赔付比例是多少?
文档写:轻度恶性肿瘤按基本保额的 20% 给付。
对保险知识熟悉的人知道,“轻症”“轻度恶性肿瘤”“给付比例”“20%”之间存在业务关联。但通用 Reranker 未必能稳定给出高分,因为这些对应关系在通用语料中不一定常见。
这种情况可以通过领域微调解决。
Reranker 微调常用三元组数据:
(query, positive_doc, negative_doc)
含义是:
| 字段 | 说明 |
|---|---|
| query | 用户问题 |
| positive_doc | 真正相关、能支撑答案的文档 |
| negative_doc | 看起来相关但实际不能回答问题的文档 |
其中最关键的是 negative_doc,尤其是 Hard Negative(难负例)。
难负例不是完全无关文档,而是:
语义很像、关键词也接近,但没有真正答案的文档。
例如:
| Query | 正例 | 难负例 |
|---|---|---|
| 重疾险等待期是多少天? | 本产品等待期为 180 天。 | 等待期内发生的疾病不在保障范围之内。 |
| 轻症赔付比例是多少? | 轻度恶性肿瘤按基本保额的 20% 给付。 | 轻症疾病需符合合同约定定义。 |
如果负例太简单,比如 query 问保险,负例是“手机电池保养方法”,模型很容易区分,但学不到真正有用的判别能力。
10. 构造 Reranker 微调数据
一种实用的数据构造方式是:
- 准备人工标注的问答对;
- 为每个问题标注正例文档;
- 用向量检索召回 Top-50;
- 从召回结果中排除正例;
- 找出语义接近但不包含答案的文档作为难负例;
- 保存为 jsonl 格式。
示例代码:
import json
from pathlib import Path
def contains_answer(doc_text: str, answer: str) -> bool:
"""
简单启发式判断:文档中是否包含答案片段。
生产环境可以替换成规则匹配、人工复核或语义判断。
"""
normalized_doc = doc_text.replace(" ", "")
normalized_answer = answer.replace(" ", "")
return normalized_answer in normalized_doc
def prepare_reranker_finetune_data(
qa_pairs: list[dict],
vector_store,
output_path: str,
recall_k: int = 50,
) -> None:
"""
生成 Reranker 微调所需的三元组数据。
qa_pairs 中每条数据格式示例:
{
"query": "重疾险等待期是多少天?",
"answer": "180 天",
"positive_doc": "本产品等待期为 180 天。"
}
"""
examples: list[dict] = []
for qa in qa_pairs:
query = qa["query"]
answer = qa["answer"]
positive_doc = qa["positive_doc"]
candidates = vector_store.similarity_search(query, k=recall_k)
hard_negatives: list[str] = []
for candidate in candidates:
candidate_text = candidate.page_content
if candidate_text == positive_doc:
continue
if not contains_answer(candidate_text, answer):
hard_negatives.append(candidate_text)
if not hard_negatives:
continue
hardest_negative = hard_negatives[0]
examples.append(
{
"query": query,
"pos": [positive_doc],
"neg": [hardest_negative],
}
)
output_file = Path(output_path)
output_file.parent.mkdir(parents=True, exist_ok=True)
with output_file.open("w", encoding="utf-8") as f:
for example in examples:
f.write(json.dumps(example, ensure_ascii=False) + "\n")
print(f"生成 {len(examples)} 条微调样本:{output_file}")
输出 jsonl 类似这样:
{"query": "重疾险等待期是多少天?", "pos": ["本产品等待期为 180 天。"], "neg": ["等待期内发生的疾病不在保障范围之内。"]}
{"query": "轻症赔付比例是多少?", "pos": ["轻度恶性肿瘤按基本保额的 20% 给付。"], "neg": ["轻症疾病需符合合同约定定义。"]}
微调后可以关注这些指标:
| 指标 | 含义 |
|---|---|
| Precision@5 | Top-5 中相关文档占比 |
| Recall@5 | 相关文档被 Top-5 覆盖的比例 |
| NDCG@5 | NDCG(Normalized Discounted Cumulative Gain,归一化折损累计增益),衡量相关文档是否排在更靠前位置 |
| 幻觉率 | LLM 基于检索结果生成错误或无依据回答的比例 |
一个垂直保险知识库的微调评测示例:
| 评估指标 | 通用 BGE-Reranker | 领域微调后 |
|---|---|---|
| Precision@5 | 0.71 | 0.86 |
| Recall@5 | 0.68 | 0.82 |
| NDCG@5 | 0.74 | 0.88 |
| 幻觉率 | 9.1% | 4.8% |
几百到一千条高质量领域三元组,就可能让 Reranker 学会行业里的术语对应关系和答案判断方式。数据量不一定要特别大,但难负例质量必须高。
11. Rerank 的工程注意事项
11.1 不要把 Reranker 当成万能补丁
Rerank 只能在候选集中重新排序。如果向量召回阶段没有召回正确文档,Reranker 也无法凭空生成正确上下文。
所以召回阶段仍然要做好:
- chunk 切分;
- embedding 模型选择;
- top_k_recall 设置;
- metadata 过滤;
- 混合检索,例如 BM25 + 向量检索;
- 同义词、缩写、专业术语归一化。
Rerank 提升的是候选集排序质量,不是替代召回。
11.2 Top-N 召回数量要给 Reranker 留空间
如果向量检索只召回 Top-5,然后 Reranker 再从 5 条里选 5 条,重排序空间太小。
常见配置是:
| 阶段 | 常见数量 |
|---|---|
| 向量召回 | Top-20 / Top-50 |
| Rerank 后保留 | Top-3 / Top-5 / Top-8 |
| 阈值过滤后 | 可能少于 Top-K |
候选越多,Reranker 越有机会把真正相关文档排上来,但延迟也会增加。线上系统需要根据模型推理速度和响应时间要求选择折中点。
11.3 批量推理能显著降低延迟
Cross-Encoder 对每个 (query, document) 对都要推理,但可以批处理:
results = reranker.rerank(
query=query,
documents=candidate_docs,
top_k=5,
batch_size=16,
)
GPU 推理时,batch_size 太小会浪费吞吐,太大可能显存不足。CPU 推理时,batch_size 也要结合并发量压测。
11.4 阈值要按业务和模型分别标定
不要直接复用别人的阈值。影响阈值的因素包括:
- Reranker 模型;
- 是否对 logits 做 sigmoid;
- 文档领域;
- chunk 长度;
- query 类型;
- 正负样本比例;
- 是否做过领域微调。
同一个 0.5,在不同模型和不同数据集上意义可能完全不同。
11.5 上下文过长仍然会伤害生成质量
Rerank 后也不是文档越多越好。过多上下文会带来几个问题:
- LLM 注意力被稀释;
- 无关信息增加;
- prompt 成本升高;
- 关键证据可能被挤到靠后位置。
可以按分数和长度做二次控制:
def pack_context(
docs: list[ScoredDocument],
max_chars: int = 4000,
) -> str:
chunks: list[str] = []
total = 0
for doc in docs:
text = doc.text.strip()
if total + len(text) > max_chars:
break
chunks.append(text)
total += len(text)
return "\n\n".join(chunks)
12. 面试中如何回答 RAG Rerank
如果被问到“RAG 召回了 20 条,其中大部分是噪声,Rerank 怎么做”,可以按四个层次回答。
12.1 说明 Bi-Encoder 的限制
Bi-Encoder 把 query 和 document 分开编码,文档向量可以预计算,所以检索速度快。但它主要衡量话题相似度,无法充分判断 document 是否真正回答 query。
“语义相似度高”不等于“答案相关度高”。比如用户问等待期天数,很多文档都包含“等待期”,但只有写明具体天数的文档才是真正相关。
12.2 说明 Cross-Encoder 的作用
Cross-Encoder 把 query 和 document 拼接在一起输入模型,让二者在 token 级别交互,然后输出相关性分数。它能更精细地判断文档是否能支撑答案。
但 Cross-Encoder 不能对全库逐条计算,成本太高,所以它适合放在向量召回之后做精排。
12.3 说明级联架构
工程上可以采用:
Bi-Encoder 向量召回 Top-20
↓
Cross-Encoder Rerank
↓
取 Top-5
↓
阈值过滤
↓
送入 LLM
这样既保留向量检索的速度,又用 Reranker 降低噪声上下文比例。
12.4 补充阈值过滤和领域微调
Rerank 后不能只看 Top-K,还要设置绝对阈值。低于阈值的文档丢弃,如果没有足够相关的文档,就返回“知识库未找到相关内容”,不要让 LLM 基于噪声编答案。
在专业领域,可以用 (query, positive_doc, hard_negative_doc) 三元组微调 Reranker。难负例要选择语义接近但没有答案的文档,因为这正是向量召回容易误判的噪声类型。
13. Rerank 检查清单
搭建 RAG 系统时,可以用这份清单检查 Rerank 环节是否完整:
| 检查项 | 是否需要 |
|---|---|
| 使用 Bi-Encoder 做全库快速召回 | 是 |
| 使用 Cross-Encoder 对候选文档重新排序 | 是 |
| 向量召回数量大于最终上下文数量 | 是 |
| Rerank 后设置绝对阈值 | 是 |
| 阈值通过标注集离线标定 | 是 |
| 没有相关文档时允许拒答 | 是 |
| 记录 Rerank 分数和最终上下文 | 是 |
| 对垂直领域准备三元组微调数据 | 推荐 |
| 使用难负例提升判别能力 | 推荐 |
| 定期评估 Precision@K、Recall@K、NDCG@K 和幻觉率 | 是 |
RAG 的核心不是把更多文本塞给 LLM,而是把更可靠的证据交给 LLM。Bi-Encoder 负责把候选范围缩小,Cross-Encoder 负责把真正有答案的文档排到前面,阈值过滤负责挡住低质量上下文,领域微调负责让模型理解专业语义。做好这几步,RAG 的回答质量通常会比单纯调向量检索参数稳定得多。