召回要宽、排序要准、引用要能被点开验证——这是检索系统的三件事。这一章把关键词检索与向量检索拼成一条混合链路,用 Rerank 做精排,最后解决最容易被跳过的问题:凭什么相信这条知识。
编辑部注 · 本章是知识引擎部分的收尾,也是三章内容的汇总:实体(第 1 章)→ 检索链路(第 2 章)→ 向量边界(第 3 章)→ 混合与可信(本章)。
前三章分别解决了一个环节的问题。这一章把它们接起来,处理两个更高层的问题:怎么让两路检索的结果合理融合,以及怎么让用户敢相信系统给的答案。后者看起来是产品问题,实际上是工程问题——它取决于你能不能给出可验证的引用。
一、混合检索:两路互补
第 3 章讲清了向量检索的三类失效场景,也讲清了关键词检索的短板(无法处理同义表达)。两者恰好互补。
| 查询类型 | 关键词/BM25 | 向量检索 | 混合后 |
|---|---|---|---|
| 精确标识符(ERR_45004) | 强:精确命中 | 弱:被语义泛化淹没 | 关键词主导 |
| 同义表达(提高复购的方式) | 弱:词不匹配就找不到 | 强:语义等价 | 向量主导 |
| 否定/极性(不支持批量) | 强:词序与否定词可精确匹配 | 弱:极性几乎无位移 | 关键词补位 |
| 长问题/多概念(如何降低新客流失) | 弱:需要词全部出现 | 强:整句语义理解 | 向量主导 |
| 短查询(转化率) | 中:词命中但排序粗 | 中:语义泛但噪音多 | 两者接近,靠重排补救 |
融合方式有两种,各有取舍——混合检索(Hybrid Retrieval)把关键词(BM25)与向量两路结果融合,常用倒数排名融合(Reciprocal Rank Fusion)或加权分数和(Weighted Score Sum)。
| 方式 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 加权分数和 | 两路分数各自归一化后按权重相加 | 可解释,权重可调 | 两路分数的分布差异大,归一化本身会引入偏差;需要调参 |
| RRF(倒数排名融合) | 只看排名不看分数:Σ 1/(k + rank_i) | 无需归一化,鲁棒性好,几乎不用调参 | 丢失了分数幅度的信息 |
两路检索的分数尺度往往不同:向量相似度可能落在 0.2-0.9,BM25 分数可能是 3.7、12.4、45.1 这种没有固定上界的值。强行归一化再加权,等于用一个不稳定的映射去调和两个不可比的量。
RRF 绕开了这个问题:它只关心「在这一路里排第几」,不关心分数是多少。经验上,RRF 在多数场景下接近调好权重的加权和,而且几乎不需要调参。这是一个工程上非常实用的取舍。
二、Rerank:为什么必须有
初排(无论是 BM25 还是向量)都有一个结构性缺陷:查询和文档是分别编码的,没有任何交互。 向量检索把查询编码成一个点、文档编码成一个点,然后比距离——这个过程里查询和文档从未「见过」对方。交叉编码器(Cross-Encoder)改变了这一点。
交叉编码器改变了这一点:它把「查询 + 候选文档」拼成一段输入,一起过模型,输出一个相关性分数。这样模型能捕捉两者之间的细粒度交互(同一个词在查询里和在文档里是否指向同一件事)。
| 初排(向量 / BM25) | 精排(Cross-Encoder) | |
|---|---|---|
| 查询-文档交互 | 无(各自编码后比距离) | 有(拼在一起过模型) |
| 速度 | 极快(可预计算、可 ANN) | 慢(每条候选都要跑一次模型) |
| 可扩展性 | 百万级文档可用 | 只适合几十到几百条候选 |
| 适用位置 | 召回阶段 | 精排阶段(在召回之后) |
有人会想:「既然精排更准,那是不是可以不用初排,直接对所有文档精排?」
不行。精排需要对每一条候选跑一次模型前向,成本与文档量成线性关系。一万篇文档每次查询都跑一遍交叉编码器,延迟会到秒级甚至更高。正确的分工是:初排把候选从百万级降到几十级(哪怕排得不够准),精排在这几十条里排序(准且成本可接受)。「召回宁可宽、排序必须准」是这套架构的核心。
三、可信:让引用能被点开验证
这是本章最有价值的部分,也是多数系统做得最差的部分。
「有引用」和「引用可信」是两件不同的事。 很多系统会让模型生成类似「根据相关资料,转化率为 1.1%」的表述——这看起来有依据,但用户无法验证,因为「相关资料」是模糊的。
| 等级 | 形态 | 用户能不能验证 | 实现成本 |
|---|---|---|---|
| L0 无引用 | 直接给结论 | 不能 | 无 |
| L1 模糊引用 | 「根据相关资料」「参考了相关文档」 | 不能(等于没有) | 无 |
| L2 可定位引用 | 「来源:2025Q3 经营分析报告 第 3.2 节」 | 能(能找到原文) | 低:块里已有来源字段 |
| L3 可点开验证 | 引用是链接,点开直达原文位置,且能看到更新时间与口径版本 | 能,且成本极低 | 中:需要 URL 与锚点 |
引用可信等级(Citation Tier)至少要做到 L2,推荐 L3。 实现要点有三个:
第一,引用标识必须在上下文里就给到模型。 每个证据块带上一个短 ID 和来源,并要求模型在结论后用这个 ID 标注依据。这样引用关系是模型「抄」的,不是它「编」的。
第二,引用要能落到具体位置,而不只是文档级。 「出自某文档」不够,要能到「第 3 节第 2 段」。
第三,矛盾的证据要同时呈现。 如果两路检索召回了互相冲突的内容,不要静默选一个——把两个都列出来并标注来源与时间,让用户判断。这一点与第 1 章、第 5 章的原则完全一致:不假装自己知道答案。
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 秒内在系统里点开它的原始依据,并看到该依据的更新时间与口径版本。
在收尾之前,把「引用可信」这件事画成四级台阶——四级之间的差别不是措辞,而是用户能不能自己核对。
四、常见误区与追问
4.1 误区:融合两路,把分数加权平均就行
错在哪:把两路分数当成同一把尺子。为什么自然:加权平均是最熟悉的做法,而且看上去可解释、可调参。但两路的量纲本来就不同——BM25 分数没有固定上界,向量相似度被限制在固定区间(见 图 2)。 判据:先看两路分数的量级差。差在一个数量级以内,加权和加固定口径的归一化可以用;差到一个数量级以上,优先换 RRF。如果坚持加权和,归一化口径必须钉死成历史分位数,而不是本批候选的 min / max——否则换一批候选,同一篇文档的名次就会变,而这种漂移在评测里往往表现为「指标时好时坏」,极难归因。
4.2 误区:召回取 50 条,就都送进上下文
错在哪:把「召回要宽」直接搬到了生成阶段。为什么自然:材料给得越多,模型越不容易漏掉答案。但召回宽度是为排序服务的,不是为生成服务的:进上下文的每一条都在消耗预算、并稀释真正有用的那几条。 判据:召回取 20~50 条,精排后截断到 4~8 条进上下文;每条必须带来源与位置。另一条可操作的检查:如果某个查询在「精排截断」之后一条都没留下,那要查的是召回与精排,而不是「再塞几条进去」——把低分材料硬塞进上下文,只会让模型跟着低分材料走。
4.3 误区:引用幻觉靠提示词写「不要编引用」就能避免
错在哪:把一件可校验的工程问题交给一句自然语言。为什么自然:加一句约束成本最低,而且模型大多数时候确实听话。但只要它偶尔编出一个不存在的编号,这条结论就变成「看起来有据可查」的错误。 判据:在上下文里给每个证据块一个短 ID(E1、E2…),生成之后跑一次 后置校验(Post-hoc Validation):回答里出现的引用编号是否都在证据清单里。这一步是二十行左右的代码,能把 引用幻觉(Citation Hallucination) 的检出变成确定性的判断;发现不存在的编号时,处理方式是重试、降级为「未找到依据」,或标记待人工核对——不是把编号换成一个最接近的。
4.4 误区:只要标了引用,就算「可验证」
错在哪:把「有引用」当成「引用可信」。为什么自然:看到「来源:某某报告」这种字样,人和模型都会觉得有依据。但引用要能落到具体位置、并且能被点开,才算把验证权交给用户。 判据:随手指一条结论,能否在 10 秒内点开它的原始依据,并看到该依据的更新时间与口径版本?做不到,就还停在 L1。工程上还有一个便宜的加分项:把用到的证据连同链接附在产物末尾,做成 证据清单(Provenance Block)——用户不必信你,他可以直接逐条核对。
4.5 误区:两路召回冲突时,选相似度高的那条
错在哪:用一个本身不可比、而且没被校准过的分数做裁决。为什么自然:分数高看起来就更相关,选它最省事。但冲突的正确处理是暴露,不是消解——这一点与第 1 章的层级裁决原则完全一致。 判据:同一指标、同一口径、同一期出现两个不同的值时,把两条来源、更新时间一起呈现,而不是静默选一个。可操作的检查:如果你的系统从来没输出过「两条证据并列」的形态,那说明冲突在某个环节被静默丢掉了——那比答错更隐蔽,因为用户看不到被隐藏的那一半。
五、自测
六、小结
| 环节 | 做法 | 要点 |
|---|---|---|
| 双路召回 | 关键词(BM25)+ 向量,各取 top-50 | 两路必须用同一套元数据过滤条件 |
| 融合 | RRF:Σ 1/(60 + rank) | 只看排名,无需归一化,免调参 |
| 精排 | Rerank 交叉编码器,截断到 6 条 | 查询与候选一起过模型,捕捉细粒度交互 |
| 引用 | 证据块带短 ID、来源、位置、更新时间、口径版本 | 目标至少 L2,推荐 L3 可点开 |
| 校验 | 后置检查引用 ID 是否真实存在 | 抓住引用幻觉,降级而非掩盖 |
| 冲突 | 同指标不同值的证据必须同时呈现 | 不静默选边 |
信任不是靠「我们的答案很准」建立的,而是靠「你随时可以自己去核对」建立的。后者是工程能提供的东西,前者只是承诺。本刊编辑部
至此四个知识版块全部读完。回到进度页,看看你的完成度,并把还没掌握的部分加入复习队列——这些内容的价值不在于读一遍,而在于面试前能脱口而出。
七、参考与延伸
本章的三段式(召回 → 融合 → 精排)和引用分级各有对应的一手材料,下面按「先看实现、再看代码、最后读论文」排好了。全站不做原文转载,这里只登记链接与「为什么值得读」。
先看实现(RRF 在工业系统里长什么样)
- Elasticsearch Reference · Reciprocal rank fusion —— 官方文档把 RRF 的公式、参数(
rank_constant默认 60、rank_window_size)和一个手算到底的完整算例都写出来了。想确认「只看名次」这句话在真实系统里怎么落地,看它那个 5 篇文档的例子最快——跟着算一遍比读十遍公式有用。
再看代码(精排为什么必须放在召回之后)
- Sentence Transformers · Cross-Encoders —— 官方对双塔与交叉编码器的对比,并给出一个很有说服力的数字:把 10000 句聚类,交叉编码器要算约 5000 万次句对(约 65 小时),双塔只要 5 秒。这一条就是「召回宽、排序准」分工的全部理由,也是回答「为什么不直接全量精排」时最好用的例子。
最后读论文(对齐一手定义)
- Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods(SIGIR 2009) —— RRF 的原始论文,k=60 这个经验值的出处。只需要看它的公式与那几张对比表;读完之后你会知道「k 越大,头部优势越平」这件事是可以解释的,而不是玄学。
- Dense Passage Retrieval for Open-Domain Question Answering(arXiv:2004.04906) —— 双塔检索取代 BM25 的关键论文。摘要里那句「top-20 检索准确率绝对高出 9%–19%」是「向量检索值得上」的最直接证据。但请注意它比的是纯 BM25:本章讲的是两路并用,两者的关系是互补,不是替代。
◇ 面试官会怎么问
共 7 条 · 其中 2 条高频 · 先自己答一遍,再展开对照
混合检索怎么设计?关键词和向量怎么融合? 高频
① 并行召回——一路 BM25/倒排做关键词检索(擅长精确命中、专有名词、数字),一路向量检索(擅长同义改写、语义近似),各取 Top-K(比如各 50 条)。
② 分数融合——两种分数不可直接比较(量纲不同),常见做法是RRF(倒数排名融合):只用排名不用分数,
score = Σ 1/(k + rank),简单、鲁棒、不需要调权重;或者做分数归一化后加权求和,但要为不同数据源分别标定。③ 元数据过滤——在召回阶段就加权限、时效、类型过滤,而不是事后筛。
④ 精排——把融合后的前 50–100 条交给 Rerank 模型(cross-encoder),选出最终进上下文的 5–10 条。
这套结构的分工是:召回要宽(宁可多召回)→ 排序要准(决定最终质量)→ 上下文要省(只放最相关的几条)。
追问链
- RRF 的 k 一般取多少?
- 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"),这既诚实又让问题能被修复。
④ 数据质量冲突(空值、格式错误)——在采集清洗阶段解决,不应进入检索层。
核心原则是:知识引擎的第一职责是"不制造虚假确定性",冲突要么按明确规则消解,要么显式暴露。
两路召回融合时,为什么常说 RRF 比加权分数和更稳?给出量化判据。 高频
加权和要先归一化,而归一化用的 min / max 取决于这一批候选:换一批数据,同一篇文档的名次就会变,即使权重一点没动。
RRF 只看名次,用 1/(60 + rank) 求和,与分数尺度无关,也几乎不用调参;代价是丢掉分数幅度信息——第一名和第十名的差距被压平,k 的取值决定压平程度。
判据:两路分数的量级差在一个数量级以内,可以用加权和(归一化口径要固定成历史分位数);超过一个数量级,优先 RRF。
追问链
- RRF 里的 k 取 60 有什么讲究?调大调小分别影响什么?
- 如果坚持用加权分数和,归一化怎么做才稳定?
模型在回答里标注了 [E7],但证据清单里只有 E1 到 E5。列出三种处理方式,并说明哪一种做法是错的。
三种处理:重试生成;把该结论降级改写为「未找到依据」;标记该条为待人工核对。
错误做法:把 [E7] 替换成最接近的 E5(这是伪造引用),或者直接忽略、照常返回(这是掩盖问题)。
工程前提是两步:在上下文里给每个证据块一个短 ID,并要求模型用这些 ID 标注依据;生成之后跑一次后置校验,检查回答里出现的引用编号是否都真实存在。这是确定性的代码判断,不该指望在提示词里写一句「不要编引用」。
追问链
- 为什么说 L1「模糊引用」在用户侧等于没有引用?
- 后置校验发现「未被使用的证据」时,应该做什么?
有人提议跳过初排,直接对所有文档做 Rerank,反正交叉编码器更准。这个方案会在什么量级上崩?
参考量级:把 10000 句做两两打分需要约 5000 万次句对推理(数十小时量级),而双塔只需要算 10000 次嵌入(秒级)。
所以崩溃点在「候选规模从几十涨到上万」这个量级上:单次查询会从毫秒变成秒级,成本随文档量线性上升。
正确分工:初排把候选从百万级降到几十级(哪怕排得不够准),精排在这几十条里做准排序,最后截断到 4 到 8 条进上下文。「召回宁可宽、排序必须准」说的就是这条分工。
追问链
- 召回取 50 条,是不是就该把 50 条都送进模型?
- 两路召回结果冲突时,为什么不能选相似度更高的那条?
结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。