芥末
发布于 2026-04-06 / 0 阅读
0
0

RAG 中 Rerank 重排序的原理、实现与工程优化

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[相似度分数]

它的工作方式是:

  1. 文档提前切成 chunk;
  2. 每个 chunk 通过编码模型变成向量;
  3. 向量写入向量数据库;
  4. 用户提问时,只需要把 query 编码成向量;
  5. 在向量库中做近邻搜索,返回距离最近的 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-EncoderCross-Encoder
中文名双编码器交叉编码器
输入方式query 和 document 分开编码query 和 document 拼接后一起编码
是否能预计算文档向量可以不可以
检索速度
适合处理的数据规模全量文档库少量候选文档
擅长能力话题相似度召回答案相关性判断
常见位置粗召回精排 / 重排序
主要问题可能召回语义相近但无答案的噪声推理成本高,无法全库逐条计算

工业级 RAG 一般不会二选一,而是组合使用:

Bi-Encoder 负责从全库中快速缩小范围,Cross-Encoder 负责在候选集中精细排序。


5. 级联检索架构:Top-20 召回,再 Top-5 精排

一个常见配置是:

  1. 向量检索召回 Top-20;
  2. Reranker 对 20 条候选文档重新打分;
  3. 取分数最高的 Top-5;
  4. 低于阈值的文档丢弃;
  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-5Bi-Encoder Top-20 + Reranker Top-5
送给 LLM 的 chunk 噪声比例42%11%
LLM 回答幻觉率18.3%6.4%
平均检索延迟12ms58ms
Precision@50.610.84
Recall@50.580.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

在一个保险问答评测集中,阈值扫描可能得到类似结果:

阈值PrecisionRecallF1
0.300.690.910.79
0.400.760.860.81
0.500.820.800.81
0.520.840.790.81
0.600.880.680.77
0.700.930.510.66

阈值越高,Precision 往往越高,但 Recall 会下降。问答系统需要根据业务选择平衡点:

场景阈值策略
金融、医疗、法律等高风险场景阈值偏高,减少错误回答
通用客服、开放问答阈值适中,避免频繁拒答
搜索推荐类场景可以降低阈值,保留更多候选

9. 领域微调:让 Reranker 学会专业术语和答案形式

通用 Reranker 在通用问答中通常表现不错,但到了垂直领域会遇到两个问题:

  1. 专业术语和日常表达之间的映射不足;
  2. 答案形式带有行业特征,通用模型未必敏感。

保险场景中有一个典型例子:

用户问:轻症赔付比例是多少?
文档写:轻度恶性肿瘤按基本保额的 20% 给付。

对保险知识熟悉的人知道,“轻症”“轻度恶性肿瘤”“给付比例”“20%”之间存在业务关联。但通用 Reranker 未必能稳定给出高分,因为这些对应关系在通用语料中不一定常见。

这种情况可以通过领域微调解决。

Reranker 微调常用三元组数据:

(query, positive_doc, negative_doc)

含义是:

字段说明
query用户问题
positive_doc真正相关、能支撑答案的文档
negative_doc看起来相关但实际不能回答问题的文档

其中最关键的是 negative_doc,尤其是 Hard Negative(难负例)。

难负例不是完全无关文档,而是:

语义很像、关键词也接近,但没有真正答案的文档。

例如:

Query正例难负例
重疾险等待期是多少天?本产品等待期为 180 天。等待期内发生的疾病不在保障范围之内。
轻症赔付比例是多少?轻度恶性肿瘤按基本保额的 20% 给付。轻症疾病需符合合同约定定义。

如果负例太简单,比如 query 问保险,负例是“手机电池保养方法”,模型很容易区分,但学不到真正有用的判别能力。


10. 构造 Reranker 微调数据

一种实用的数据构造方式是:

  1. 准备人工标注的问答对;
  2. 为每个问题标注正例文档;
  3. 用向量检索召回 Top-50;
  4. 从召回结果中排除正例;
  5. 找出语义接近但不包含答案的文档作为难负例;
  6. 保存为 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@5Top-5 中相关文档占比
Recall@5相关文档被 Top-5 覆盖的比例
NDCG@5NDCG(Normalized Discounted Cumulative Gain,归一化折损累计增益),衡量相关文档是否排在更靠前位置
幻觉率LLM 基于检索结果生成错误或无依据回答的比例

一个垂直保险知识库的微调评测示例:

评估指标通用 BGE-Reranker领域微调后
Precision@50.710.86
Recall@50.680.82
NDCG@50.740.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 的回答质量通常会比单纯调向量检索参数稳定得多。


评论