Agent 工程学习站 做棵大树 出品 · beatree.cn 免费 · 无需登录 · 进度存本机
IV 版 · 知识引擎 第 04 章 Hybrid Retrieval and Trust
IV · 知识引擎 04 / 22 深入

混合检索、Rerank 与知识可信

Hybrid Retrieval and Trust
预计阅读 34 分钟
难度 深入
关键词 混合检索 · Rerank
本机状态 未读

召回要宽、排序要准、引用要能被点开验证——这是检索系统的三件事。这一章把关键词检索与向量检索拼成一条混合链路,用 Rerank 做精排,最后解决最容易被跳过的问题:凭什么相信这条知识。

编辑部注 · 本章是知识引擎部分的收尾,也是三章内容的汇总:实体(第 1 章)→ 检索链路(第 2 章)→ 向量边界(第 3 章)→ 混合与可信(本章)。

前三章分别解决了一个环节的问题。这一章把它们接起来,处理两个更高层的问题:怎么让两路检索的结果合理融合,以及怎么让用户敢相信系统给的答案。后者看起来是产品问题,实际上是工程问题——它取决于你能不能给出可验证的引用。

一、混合检索:两路互补

第 3 章讲清了向量检索的三类失效场景,也讲清了关键词检索的短板(无法处理同义表达)。两者恰好互补。

两种检索的互补关系
查询类型关键词/BM25向量检索混合后
精确标识符(ERR_45004):精确命中弱:被语义泛化淹没关键词主导
同义表达(提高复购的方式)弱:词不匹配就找不到:语义等价向量主导
否定/极性(不支持批量):词序与否定词可精确匹配弱:极性几乎无位移关键词补位
长问题/多概念(如何降低新客流失)弱:需要词全部出现:整句语义理解向量主导
短查询(转化率)中:词命中但排序粗中:语义泛但噪音多两者接近,靠重排补救

融合方式有两种,各有取舍:

方式做法优点缺点
加权分数和两路分数各自归一化后按权重相加可解释,权重可调两路分数的分布差异大,归一化本身会引入偏差;需要调参
RRF(倒数排名融合)只看排名不看分数:Σ 1/(k + rank_i)无需归一化,鲁棒性好,几乎不用调参丢失了分数幅度的信息
为什么推荐 RRF

两路检索的分数尺度往往不同:向量相似度可能落在 0.2-0.9,BM25 分数可能是 3.7、12.4、45.1 这种没有固定上界的值。强行归一化再加权,等于用一个不稳定的映射去调和两个不可比的量。

RRF 绕开了这个问题:它只关心「在这一路里排第几」,不关心分数是多少。经验上,RRF 在多数场景下接近调好权重的加权和,而且几乎不需要调参。这是一个工程上非常实用的取舍。

混合检索链路:召回宽 → 融合稳 → 精排准 查询 + 实体归一 + 条件抽取 关键词 / BM25 精确串匹配、否定词、编号 元数据过滤下推 取 top-50,记录排名 向量检索 语义等价、跨语言、长问题 同一套元数据过滤 取 top-50,记录排名 RRF 融合 Σ 1/(60 + rank) 只看排名,不看分数 → 无需归一化 Rerank 交叉编码器精排 查询与候选一起过模型 → 截断到 top-6 带来源 / 时间 / 层级的证据块 → 组装进上下文,并保留可点开的引用
图 1 混合检索的三段式:两路并行召回(各取 50)→ RRF 融合(只看排名)→ Rerank 精排(截断到 6)。注意两路必须用同一套元数据过滤条件,否则融合时会混入不该出现的内容。

二、Rerank:为什么必须有

初排(无论是 BM25 还是向量)都有一个结构性缺陷:查询和文档是分别编码的,没有任何交互。 向量检索把查询编码成一个点、文档编码成一个点,然后比距离——这个过程里查询和文档从未「见过」对方。

交叉编码器(Cross-Encoder)改变了这一点:它把「查询 + 候选文档」拼成一段输入,一起过模型,输出一个相关性分数。这样模型能捕捉两者之间的细粒度交互(同一个词在查询里和在文档里是否指向同一件事)。

初排与精排的取舍
初排(向量 / BM25)精排(Cross-Encoder)
查询-文档交互无(各自编码后比距离)(拼在一起过模型)
速度极快(可预计算、可 ANN)慢(每条候选都要跑一次模型)
可扩展性百万级文档可用只适合几十到几百条候选
适用位置召回阶段精排阶段(在召回之后)
一个常见的过度优化

有人会想:「既然精排更准,那是不是可以不用初排,直接对所有文档精排?」

不行。精排需要对每一条候选跑一次模型前向,成本与文档量成线性关系。一万篇文档每次查询都跑一遍交叉编码器,延迟会到秒级甚至更高。正确的分工是:初排把候选从百万级降到几十级(哪怕排得不够准),精排在这几十条里排序(准且成本可接受)。「召回宁可宽、排序必须准」是这套架构的核心。

三、可信:让引用能被点开验证

这是本章最有价值的部分,也是多数系统做得最差的部分。

「有引用」和「引用可信」是两件不同的事。 很多系统会让模型生成类似「根据相关资料,转化率为 1.1%」的表述——这看起来有依据,但用户无法验证,因为「相关资料」是模糊的。

引用可信度的四个等级
等级形态用户能不能验证实现成本
L0 无引用直接给结论不能
L1 模糊引用「根据相关资料」「参考了相关文档」不能(等于没有)
L2 可定位引用「来源:2025Q3 经营分析报告 第 3.2 节」能(能找到原文)低:块里已有来源字段
L3 可点开验证引用是链接,点开直达原文位置,且能看到更新时间与口径版本能,且成本极低中:需要 URL 与锚点

目标至少要做到 L2,推荐 L3。 实现要点有三个:

第一,引用标识必须在上下文里就给到模型。 每个证据块带上一个短 ID 和来源,并要求模型在结论后用这个 ID 标注依据。这样引用关系是模型「抄」的,不是它「编」的。

第二,引用要能落到具体位置,而不只是文档级。 「出自某文档」不够,要能到「第 3 节第 2 段」。

第三,矛盾的证据要同时呈现。 如果两路检索召回了互相冲突的内容,不要静默选一个——把两个都列出来并标注来源与时间,让用户判断。这一点与第 1 章、第 5 章的原则完全一致:不假装自己知道答案。

hybrid_trust.pypython
from dataclasses import dataclass, field
@dataclass
class Evidence:
    """带可验证信息的证据块"""
    eid: str                       # 短 ID,供模型在回答里引用
    text: str
    source: str                    # 来源标识
    url: str = ""                  # 可点开的位置(含锚点)
    anchor: str = ""               # 定位信息:章节 / 段落号
    updated_at: str = ""
    tier: str = "fact"             # fact | inference
    caliber: str = ""              # 口径版本(数值类必须有)
    vec_rank: int | None = None
    kw_rank: int | None = None
    def cite(self) -> str:
        return f"[{self.eid}]"
    def render(self) -> str:
        """上下文里的形态:短 ID + 正文 + 来源信息,三者齐全"""
        loc = self.anchor or self.source
        extra = f"|口径 {self.caliber}" if self.caliber else ""
        return (f"[{self.eid}] {self.text}\n"
                f"     来源:{self.source} 位置:{loc} 更新:{self.updated_at}"
                f"{extra} 层级:{self.tier}")
def rrf_fuse(vec_ranked: list[str], kw_ranked: list[str], k: int = 60) -> list[tuple[str, float]]:
    """
    RRF:只依赖排名,无需归一化。
    k 一般取 60(原论文经验值),作用是压低头部排名的相对优势,让两路更均衡。
    """
    scores: dict[str, float] = {}
    for ranked in (vec_ranked, kw_ranked):
        for rank, doc_id in enumerate(ranked, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
    return sorted(scores.items(), key=lambda x: -x[1])
def build_citations(evidences: list[Evidence], answer: str) -> dict:
    """
    后置校验:检查回答里引用的 ID 是否真实存在。
    模型可能引用一个不存在的编号 —— 这类幻觉必须被抓住。
    """
    valid = {e.eid for e in evidences}
    used = set()
    for e in evidences:
        if e.cite() in answer:
            used.add(e.eid)
    unknown = [x for x in extract_cite_ids(answer) if x not in valid]
    return {
        "used": sorted(used),
        "unused": sorted(valid - used),
        "hallucinated": unknown,        # 引用了不存在的证据 → 需要重试或降级
        "credible": len(unknown) == 0 and len(used) > 0,
        "checklist": [{"eid": e.eid, "url": e.url, "source": e.source}
                      for e in evidences if e.eid in used],
    }
def conflict_pairs(evidences: list[Evidence]) -> list[tuple[Evidence, Evidence]]:
    """
    同指标不同值的证据 → 必须同时呈现,不能静默选一个。
    判定条件:同一 source 主题、同期、同为 fact、值不同。
    """
    out = []
    for i in range(len(evidences)):
        for j in range(i + 1, len(evidences)):
            a, b = evidences[i], evidences[j]
            if a.tier == b.tier == "fact" and a.caliber == b.caliber and same_metric(a, b):
                if extract_value(a.text) != extract_value(b.text):
                    out.append((a, b))
    return out
CITE_RE = r"\[(E\d{1,3})\]"
def extract_cite_ids(text: str) -> list[str]:
    import re
    return re.findall(CITE_RE, text)
def same_metric(a: Evidence, b: Evidence) -> bool:
    return False        # 占位:真实实现比对指标名
def extract_value(text: str) -> str:
    return ""           # 占位:真实实现抽取数值
def provenance_block(evidences: list[Evidence]) -> str:
    """
    产物的「证据清单」区块:放在报告末尾,
    让用户能逐条点开核对。这是信任的最后一公里。
    """
    lines = ["本产物的依据来源:"]
    for e in evidences:
        loc = e.anchor or "—"
        link = e.url or "(无链接)"
        lines.append(f"  {e.eid} {e.source} {loc} 更新 {e.updated_at} {link}")
    return "\n".join(lines)
实操任务
  • 给你的检索结果加上短 ID 与来源,并要求模型在结论后标注依据 ID;用 build_citations() 检查有没有「引用了不存在的 ID」;
  • 实现「证据清单」区块:把用到的证据连同可点开的链接附在产物末尾,用人工方式核对 10 条;
  • 构造一组「同指标不同值」的证据,验证 conflict_pairs() 能检出,并设计一段话术同时呈现两者。

验收标准:随手指一条结论,你都能在 10 秒内在系统里点开它的原始依据,并看到该依据的更新时间与口径版本。

四、自测

本章自测第 1、3 题为高频考点
Q1为什么推荐 RRF 而不是加权分数和来融合两路检索?
B。C 说反了——RRF 恰好丢掉了分数幅度信息,这正是它的取舍所在。核心问题在于:把 0.2-0.9 的向量相似度和没有上界的 BM25 分数放在一起加权,需要一个不稳定的映射。RRF 用「只看名次」绕开了整个问题,工程上非常划算。
Q2为什么不直接对所有文档做 Rerank,跳过初排?
D。这是「召回宽、排序准」这套分工的根本原因:初排的作用不是排得准,而是把候选从百万级降到几十级,让精排的成本变得可接受。想跳过初排,就像想「跳过筛选直接面试所有候选人」——逻辑上更准,实际上不可行。
Q3模型在回答里标注了 [E7],但证据列表里只有 E1-E5。应该怎么处理?
C。引用幻觉是「有引用」但「引用不可信」的典型形态,它比无引用更危险——因为它看起来有据可查。A 和 B 都是在掩盖问题,D 是伪造证据。正确做法是在后置校验里抓住它,并明确降级处理:要么重试,要么把这条结论改写为「未找到依据」,要么标记待核。

五、小结

环节做法要点
双路召回关键词(BM25)+ 向量,各取 top-50两路必须用同一套元数据过滤条件
融合RRF:Σ 1/(60 + rank)只看排名,无需归一化,免调参
精排Rerank 交叉编码器,截断到 6 条查询与候选一起过模型,捕捉细粒度交互
引用证据块带短 ID、来源、位置、更新时间、口径版本目标至少 L2,推荐 L3 可点开
校验后置检查引用 ID 是否真实存在抓住引用幻觉,降级而非掩盖
冲突同指标不同值的证据必须同时呈现不静默选边

信任不是靠「我们的答案很准」建立的,而是靠「你随时可以自己去核对」建立的。后者是工程能提供的东西,前者只是承诺。本刊编辑部

至此四个知识版块全部读完。回到进度页,看看你的完成度,并把还没掌握的部分加入复习队列——这些内容的价值不在于读一遍,而在于面试前能脱口而出。

面试官会怎么问

共 4 条 · 其中 1 条高频 · 先自己答一遍,再展开对照

混合检索怎么设计?关键词和向量怎么融合? 高频
目标是用关键词补精确性、用向量补语义泛化,两者召回后再融合排序。
① 并行召回——一路 BM25/倒排做关键词检索(擅长精确命中、专有名词、数字),一路向量检索(擅长同义改写、语义近似),各取 Top-K(比如各 50 条)。
② 分数融合——两种分数不可直接比较(量纲不同),常见做法是RRF(倒数排名融合):只用排名不用分数,score = Σ 1/(k + rank),简单、鲁棒、不需要调权重;或者做分数归一化后加权求和,但要为不同数据源分别标定。
③ 元数据过滤——在召回阶段就加权限、时效、类型过滤,而不是事后筛。
④ 精排——把融合后的前 50–100 条交给 Rerank 模型(cross-encoder),选出最终进上下文的 5–10 条。
这套结构的分工是:召回要宽(宁可多召回)→ 排序要准(决定最终质量)→ 上下文要省(只放最相关的几条)

追问链

  1. RRF 的 k 一般取多少?
  2. Rerank 模型和 embedding 模型的区别是什么?
Rerank 为什么比向量检索准?代价是什么?
区别在于是否让查询和文档相互作用
向量检索(bi-encoder)先把查询和文档各自独立编码成向量,再算相似度。好处是可以离线建索引、检索快(毫秒级),代价是查询和文档之间没有交互,细粒度的匹配关系表达不出来。
Rerank(cross-encoder)把查询和文档拼接后一起送进模型,让每个词之间都能相互作用,因此精度显著更高。代价是无法预计算——每个(查询,文档)对都要跑一次前向,成本随候选数线性增长,所以只能用在精排阶段、候选只有几十到上百条的时候。
这就是"粗排用双塔求快,精排用交叉编码器求准"的两阶段架构的根本原因。
补充一句工程考量:Rerank 的延迟通常几十到几百毫秒,如果对首字延迟极敏感,可以考虑异步 rerank + 先返回粗排结果再更新,或者只对 Top-N 做 rerank。
加分点:能说出"两阶段架构的根本原因是可预计算性",说明你理解的是原理而不是套件。
怎么让回答"有据可查"?
关键是把引用做成可验证的数据,而不是装饰性的角标
① 要求结构化引用——模型输出时对每个结论都要给出来源标识(chunk ID、文档 ID),而不是在正文里写一句"[1]"。
② 引用必须真实存在——后处理阶段校验:引用的 ID 是否真的在本次上下文中、引用的段落是否真的支持该结论。这一步能拦掉相当比例的"编造引用"。
③ 溯源到原文位置——chunk 要保留原文定位信息(文档链接 + 页码/段落),用户点开能看到原始上下文,而不是只看到片段。
④ 无据要能拒答——明确允许并鼓励"根据现有资料无法回答",并对这种情况监控比例。一个从不拒答的系统,其引用可信度必然很低。
⑤ 评测引用质量——把"引用支持率"(引用是否真的支撑结论)作为独立指标,而不是只看答案看起来对不对。
加分点:把"引用支持率"作为独立指标、并强调"要允许拒答",是这一步最核心的两个设计。
知识冲突(同一事实有多个版本的记录)怎么处理?
先判断冲突类型,再决定策略:
① 时效性冲突(旧版制度 vs 新版制度)——按生效时间处理:检索时优先返回当前生效版本,但保留历史版本可查(因为"当时适用什么"也是一个合法问题)。落地手段是元数据里带 effective_from / effective_to。
② 来源权威性冲突(正式文件 vs 内部聊天记录)——引入来源权重,正式发布的主数据优先;权重必须在检索与排序阶段生效,而不是最后让模型自己判断。
③ 真实矛盾(两处记录确实不一致,无法判断谁对)——不要静默选择其中一个。正确做法是把冲突显式暴露出来("系统中存在两种记录:A 系统显示 X,B 系统显示 Y"),这既诚实又让问题能被修复。
④ 数据质量冲突(空值、格式错误)——在采集清洗阶段解决,不应进入检索层。
核心原则是:知识引擎的第一职责是"不制造虚假确定性",冲突要么按明确规则消解,要么显式暴露。
加分点:最后那句"不制造虚假确定性",是知识治理层面最有价值的表达。
答这类题的通用结构

结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。

本章收尾 · Wrap up
掌握度自评
点一下给自己打分;低于 3 分建议加入复习队列
个人笔记 · Notes