RAG 进阶:重排序 + 证据过滤闭环,从根源解决大模型幻觉

前言:为什么你的混合 RAG 依然产生幻觉

上一章我们实现了向量检索 + BM25 关键词检索 + RRF 结果融合的混合检索。 很多同学做到这里以为 RAG 已经完成,但实际业务跑起来依旧幻觉严重。

混合检索输出的只是候选证据,不是可用证据。 召回只求 “不要漏掉有用文档”,并不保证排在前面的文档一定适合回答用户问题。

如果把一堆低相关、无关的候选直接喂给大模型,模型在缺少有效依据时,就会脑补编造内容,这就是 RAG 幻觉一大来源。

本章要解决两件事:

  1. 重排序 (Rerank):使用 Cross‑Encoder 重新计算问题和每条候选证据的真实相关性,把真正有用的排到最前面
  2. 证据过滤 + 拒答:设置分数阈值过滤低质量证据;没有合格证据就直接拒答,绝不强行回答

最终形成链路: 多路召回 → RRF融合 → Cross‑Encoder重排序 → 分数归一化 → 阈值过滤 → 证据不足拒答

涉及文件清单

  • backend/app/config.py 重排序相关配置
  • backend/app/services/pipeline_utils.py 归一化、证据过滤工具
  • backend/app/services/retrieval.py 重排序、检索主逻辑、query 改写
  • backend/scripts/check_rerank_filter.py 多模式对比测试脚本

一、召回 vs 重排序:两者目标完全不一样

1. 召回阶段(向量 / BM25)

目标:尽量不要漏掉可能有用的内容 允许把一些相关性一般的文档捞回来,追求高召回率。

2. 重排序阶段(Cross‑Encoder)

目标:从候选里面挑选真正适合回答问题的内容,追求精准。

举个业务例子:

用户问题:数字化补贴申报材料

混合检索召回多条结果:

  1. 第二条 支持范围
  2. 第三条 补贴标准
  3. 第四条 申报材料 ✅(真正需要)
  4. 第五条 申报时间

向量 / BM25 可能把 “补贴标准” 排到第一; 重排序会识别出第四条申报材料和问题匹配度最高,把它置顶

召回负责 “大海捞候选”;重排序负责 “候选里面挑精品”。

二、Cross‑Encoder 与 Embedding 的区别

表格

模型使用方式特点
Embedding(向量模型)问题、文档分别编码向量,再算相似度速度快,适合全库检索;精度一般
Cross‑Encoder 重排序模型输入(query, document)问答对,直接输出相关性分数精度更高;速度慢,只能对少量候选打分,不能直接全库检索

工程范式:Embedding/BM25 做召回,Cross‑Encoder 做小集合重排序

三、四种检索模式整体架构

  • vector:仅向量检索;无 BM25,无重排序,无过滤
  • hybrid:向量 + BM25 + RRF 融合;不重排序、不过滤
  • hybrid_rerank:混合召回 + RRF+Cross‑Encoder 重排序;不做阈值过滤
  • full:完整链路,混合召回 + RRF + 重排序 + 分数归一化 + 阈值过滤 + 证据不足拒答(生产推荐)

四、工程实现完整落地

4.1 配置文件 config.py

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    # 重排序模型,可使用BAAI/bge‑reranker-base / qwen3‑rerank
    reranker_model: str = "BAAI/bge-reranker-base"
    # 是否开启重排序
    enable_reranker: bool = True
    # 重排序之后保留多少条证据
    rerank_top_k: int = 4
    # 证据最低分数阈值,低于该分数会被过滤
    min_evidence_score: float = 0.25

    class Config:
        env_file = ".env"

.env环境变量示例

RERANKER_MODEL=qwen3‑rerank
ENABLE_RERANKER=true
RERANK_TOP_K=4
MIN_EVIDENCE_SCORE=0.25

注意:第一次启动会自动下载模型,或者把模型下载到本地,使用本地路径加载,避免线上环境无法访问 huggingface。

4.2 工具函数 pipeline_utils.py 归一化 + 证据过滤

Cross‑Encoder 输出的是 logit 原始分数,范围不是 0‑1;我们需要归一化统一打分口径。

import math
from typing import Any

def normalize_logit(value: float) -> float:
    """
    sigmoid归一化,把任意实数映射到 [0,1]
    max(-30, min(30, value)):限制区间防止math.exp数值溢出
    """
    return 1 / (1 + math.exp(-max(-30, min(30, value))))


def filter_evidence(evidence: list[dict[str, Any]], min_score: float) -> list[dict]:
    """
    根据阈值过滤证据,只保留分数>=min_score的证据
    设计思想:宁可拒答,也不要低质量证据进入大模型
    """
    return [
        item for item in evidence if float(item.get("evidence_score", 0)) >= min_score
    ]

4.3 retrieval.py:重排序模型加载、rerank 函数、查询改写

模型加载 @lru_cache 全局单例

重排序模型体积大,不能每次请求重复加载,使用缓存只加载一次。 local_files_only=True强制只用本地模型文件,不在线拉取。

from functools import lru_cache
from sentence_transformers import CrossEncoder
from app.config import get_settings

@lru_cache
def get_reranker():
    """获取重排序模型单例,全局只加载一次"""
    settings = get_settings()
    return CrossEncoder(settings.reranker_model, local_files_only=True)

# 如果需要自定义本地绝对路径
# def get_reranker():
#     model_path = r"D:\models--BAAI--bge-reranker-base\snapshots\2cfc18c9415c912f9d8155881c133215df768a70"
#     return CrossEncoder(model_path, local_files_only=True)
rerank 重排序主函数,带异常降级

模型 OOM、文件损坏、加载失败不能让整个接口崩溃;使用 RRF 分数做降级兜底。

import asyncio
from app.services.pipeline_utils import normalize_logit

async def rerank(query: str, candidates: list[dict], top_k: int) -> list[dict]:
    """
    Cross‑Encoder重排序
    :param query: 用户改写后的查询
    :param candidates: RRF融合后的候选证据列表
    :param top_k: 重排序后保留条数
    :return: 重排序完成的证据列表
    """
    if not candidates:
        return []
    settings = get_settings()
    if not settings.enable_reranker:
        # 开关关闭,直接截取前N条返回
        return candidates[:top_k]

    try:
        reranker_model = get_reranker()
        # 组装问答对 [(query,doc_content), ...]
        pairs = [(query, item["content"]) for item in candidates]
        # 使用to_thread把同步predict放到线程运行,不阻塞asyncio事件循环
        scores = await asyncio.to_thread(reranker_model.predict, pairs)

        for item, score in zip(candidates, scores):
            raw_score = float(score)
            item["rerank_raw_score"] = raw_score
            item["rerank_score"] = normalize_logit(raw_score)

        # 按归一化分数倒序排序,取top_k
        return sorted(candidates, key=lambda x: x["rerank_score"], reverse=True)[:top_k]

    except Exception as e:
        # 异常降级:模型出错时复用RRF分数放大充当伪重排分数,保证链路可用
        print(f"[rerank warn] 重排序异常,触发降级: {e}")
        for item in candidates:
            item["rerank_score"] = min(1.0, item.get("rrf_score", 0) * 30)
        return candidates[:top_k]
Query 改写函数:口语化问题转为检索查询

用户输入口语,LLM 改写为适合知识库检索的简短 query;失败直接返回原问题。

from app.services.model_factory import create_chat_model, invoke_text

async def rewrite_query(question: str) -> str:
    """把口语化问题改写成更适合知识库检索的简洁查询,异常退回原问题"""
    prompt = """
将下面的政务咨询改写为一个适合知识库检索的简洁中文查询。
不得添加问题中没有的地区、政策或条件,只输出改写结果。
问题:{question}
"""
    try:
        llm = create_chat_model(get_settings(), temperature=0)
        content, _ = await invoke_text(llm, prompt.format(question=question))
        return content.strip()
    except Exception:
        return question

4.4 retrieve 主流程整合重排序、分数统一、证据过滤、拒答

关键流程片段(完整代码参考原文)

async def retrieve(
    session: Session,
    question: str,
    filters: RetrievalFilters,
    top_k: int = 8,
    evidence_top_k: int = 4,
    min_score: float = 0.25,
    mode: str = "full"
) -> RetrievalResult:

    # 1.查询改写
    new_question = await rewrite_query(question)

    # 2.向量检索
    vector_results = await VectorStoreService().search(
        new_question, top_k * 2, document_ids=filters.document_ids or None
    )
    vector_results = await apply_metadata_filters(vector_results, filters)
    for item in vector_results:
        item["source"] = "vector"

    keyword_list = []
    if mode != "vector":
        # 3.BM25关键词检索
        keyword_list = await keyword_search(session, new_question, top_k*2, filters)

    # 4.RRF融合两路召回结果
    fused = [vector_results[:top_k], keyword_list]
    rrf_list = reciprocal_rank_fusion(fused)
    candidates = rrf_list[:top_k]

    # 5.父块上下文扩展(子块补齐父文档内容)
    evidence = []
    for item in candidates:
        score = max(0.0, min(1.0, float(item.get("score", 0))))
        if score >= min_score:
            item["evidence_score"] = score
            evidence.append(item)
    evidence = await expand_parent_context(session, evidence)

    # 保证送入重排序的候选数量足够,不能太少
    evidence = evidence[:max(evidence_top_k, top_k)]

    # 6.分模式是否执行重排序
    if mode in ["hybrid_rerank", "full"]:
        evidence = await rerank(new_question, evidence, evidence_top_k)
    else:
        evidence = evidence[:evidence_top_k]

    # 7.统一 evidence_score 字段,多模式兼容
    # 优先级:rerank_score > score > rrf_score*30
    for item in evidence:
        normalized = item.get("rerank_score")
        if normalized is None:
            normalized = item.get("score", item.get("rrf_score", 0) * 30)
        item["evidence_score"] = max(0.0, min(1.0, float(normalized)))

    # 8.full模式:执行证据阈值过滤
    if mode == "full":
        evidence = filter_evidence(evidence, min_score)

    # 9.证据为空则标记拒答
    refused = len(evidence) == 0
    refusal_reason = "没有查询到合适的数据" if refused else None

    trace = {
        "original_query": new_question,
        "vector_results": vector_results,
        "keyword_results": keyword_list,
        "fused_results": rrf_list,
        "final_evidence": evidence
    }

    return RetrievalResult(
        query=new_question,
        candidates=candidates,
        evidence=evidence,
        refused=refused,
        refusal_reason=refusal_reason,
        trace=trace
    )

关键点说明:

  1. max(evidence_top_k, top_k):给重排序提供足够候选,候选太少重排序没有意义。
  2. evidence_score统一字段:不管是向量分数、RRF 分数、重排序分数,全部收敛到同一个字段,上层服务不需要关心底层模式。
  3. 只有full模式才开启过滤,hybrid_rerank 只调顺序不丢数据,方便调试对比。
  4. refused=True交给上层接口,当为 True 时直接返回拒答文案,不要送入 LLM 生成。

五、测试脚本:对比 hybrid /hybrid_rerank/full /vector

backend/scripts/check_rerank_filter.py

import asyncio
import sys
from pathlib import Path

BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(BACKEND_ROOT) not in sys.path:
    sys.path.insert(0, str(BACKEND_ROOT))

from app.database import SessionLocal
from app.schemas import RetrievalFilters
from app.services.retrieval import retrieve


def print_evidence(mode: str, result) -> None:
    print(f"===== 模式:{mode} =====")
    print(f"是否拒答:{result.refused}")
    print(f"拒答原因:{result.refusal_reason}")
    print(f"候选数量:{len(result.candidates)}")
    print(f"证据数量:{len(result.evidence)}")
    for item in result.evidence:
        metadata = item.get("metadata", {})
        info = {
            "chunk_id": item.get("chunk_id"),
            "source": item.get("source"),
            "rrf_score": round(float(item.get("rrf_score", 0)), 4),
            "rerank_score": round(float(item.get("rerank_score", 0)), 4),
            "evidence_score": round(float(item.get("evidence_score", 0)), 4),
            "filename": metadata.get("filename"),
            "section": metadata.get("section"),
            "content_preview": item.get("content", "")[:80]
        }
        print(info)
    print()


async def main() -> None:
    question = "数字化补贴申报材料"
    filters = RetrievalFilters()

    with SessionLocal() as session:
        hybrid = await retrieve(session, question, filters, mode="hybrid", top_k=6, min_score=0.2)
        hybrid_rerank = await retrieve(session, question, filters, mode="hybrid_rerank", top_k=6, min_score=0.2)
        full = await retrieve(session, question, filters, mode="full", top_k=6, min_score=0.6)
        vector = await retrieve(session, question, filters, mode="vector", top_k=6, min_score=0.6)

    print(f"用户问题:{question}\n")
    print_evidence("hybrid", hybrid)
    print_evidence("hybrid_rerank", hybrid_rerank)
    print_evidence("full", full)
    print_evidence("vector", vector)


if __name__ == "__main__":
    asyncio.run(main())

六、运行测试 & 结果解读

执行命令

.\.venv\Scripts\python.exe scripts\check_rerank_filter.py

示例输出参考:

用户问题:数字化补贴申报材料

===== 模式:hybrid =====
是否拒答: False
候选数量: 6
证据数量: 3
{'chunk_id':'xxx','rrf_score':0.0164,'rerank_score':0.0,'evidence_score':0.4918,...}

===== 模式:hybrid_rerank =====
是否拒答: False
证据数量:3
{'chunk_id':'xxx','rerank_score':0.8742,'evidence_score':0.8742,'section':'第四条 申报材料',...}

===== 模式:full =====
是否拒答: False
证据数量:2
{'rerank_score':0.8742,'evidence_score':0.8742,'section':'第四条 申报材料'}

观测要点:

  1. hybrid_rerank 的输出出现rerank_score,并且真正相关文档分数显著更高,顺序发生变化;
  2. full模式会按照min_score过滤掉低分证据;
  3. 如果过滤完证据为空,refused=True,拒答原因填充。

首次运行加载重排序模型会慢,属于正常现象。

七、工程踩坑与细节说明

  1. Cross‑Encoder 不能全库打分:速度很慢,只能作用于召回后的小批量候选集合。
  2. sigmoid 截断‑30~30:原始 logit 分数绝对值过大,直接传给math.exp会数值溢出报错。
  3. 降级逻辑必不可少:生产环境模型 OOM、文件损坏、GPU 内存不足,不能让检索接口直接 500。
  4. 多 mode 模式的意义:开发调试用 hybrid/hybrid_rerank 看中间结果;线上生产打开 full 模式。
  5. 拒答不是能力弱:RAG 系统质量标准:不知道就说不知道,不要编造。
  6. evidence_score 统一字段:上层服务、评估脚本不需要区分底层是向量 / BM25 / 重排序,只看这一个字段。

八、总结

本章完成 RAG 检索层质量闭环: 多路召回 → RRF融合 → Cross‑Encoder重排序 → sigmoid分数归一化 → 阈值过滤 → 证据不足拒答

RAG 系统从 “能够找到文档” 升级为 “只使用高质量文档回答”,从源头降低幻觉风险。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值