召回要宽、排序要准、引用要能被点开验证——这是检索系统的三件事。这一章把关键词检索与向量检索拼成一条混合链路,用 Rerank 做精排,最后解决最容易被跳过的问题:凭什么相信这条知识。
编辑部注 · 本章是知识引擎部分的收尾,也是三章内容的汇总:实体(第 1 章)→ 检索链路(第 2 章)→ 向量边界(第 3 章)→ 混合与可信(本章)。
前三章分别解决了一个环节的问题。这一章把它们接起来,处理两个更高层的问题:怎么让两路检索的结果合理融合,以及怎么让用户敢相信系统给的答案。后者看起来是产品问题,实际上是工程问题——它取决于你能不能给出可验证的引用。
一、混合检索:两路互补
第 3 章讲清了向量检索的三类失效场景,也讲清了关键词检索的短板(无法处理同义表达)。两者恰好互补。
| 查询类型 | 关键词/BM25 | 向量检索 | 混合后 |
|---|---|---|---|
| 精确标识符(ERR_45004) | 强:精确命中 | 弱:被语义泛化淹没 | 关键词主导 |
| 同义表达(提高复购的方式) | 弱:词不匹配就找不到 | 强:语义等价 | 向量主导 |
| 否定/极性(不支持批量) | 强:词序与否定词可精确匹配 | 弱:极性几乎无位移 | 关键词补位 |
| 长问题/多概念(如何降低新客流失) | 弱:需要词全部出现 | 强:整句语义理解 | 向量主导 |
| 短查询(转化率) | 中:词命中但排序粗 | 中:语义泛但噪音多 | 两者接近,靠重排补救 |
融合方式有两种,各有取舍:
| 方式 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 加权分数和 | 两路分数各自归一化后按权重相加 | 可解释,权重可调 | 两路分数的分布差异大,归一化本身会引入偏差;需要调参 |
| 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 与锚点 |
目标至少要做到 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 秒内在系统里点开它的原始依据,并看到该依据的更新时间与口径版本。
四、自测
五、小结
| 环节 | 做法 | 要点 |
|---|---|---|
| 双路召回 | 关键词(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 条。
这套结构的分工是:召回要宽(宁可多召回)→ 排序要准(决定最终质量)→ 上下文要省(只放最相关的几条)。
追问链
- 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"),这既诚实又让问题能被修复。
④ 数据质量冲突(空值、格式错误)——在采集清洗阶段解决,不应进入检索层。
核心原则是:知识引擎的第一职责是"不制造虚假确定性",冲突要么按明确规则消解,要么显式暴露。
结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。