云计算百科
云计算领域专业知识百科平台

SA-K-05 知识与检索工程 混合检索与重排

混合检索与重排值不值那 200ms:三路合流的漏召诊断与融合实现

系列导航:00 系列导航 · 01 四类知识 · 02A 解析五环 · 02B 断裂修复 · 03 切分标定 · 04 查询理解 · 本篇(05) · 06 引用验证 · 07 知识新鲜度 · 08 多源一致性 · 09 管道门禁 · 10 全簇总结 · 11 展望

小提一句: 由于平台每日发布笔记数量限制,目前系列笔记还没全部上传。后续内容会持续更新,大家可以先点个关注、收藏,以免错过。 谢谢!


引子:两处改动,一种说法,买回来的不是同一种东西

一次典型调参:值班人员抱怨「答案老拉不到对的 runbook」,第一反应是接一个 cross-encoder 重排。上线后 P95 从 1.2s 抬到 1.4s,下游准确率涨了 1.3pp [算]。两周后另一拨人加了一行不起眼的逻辑:把查询里的 runbook 编号抽出来做精确匹配。P95 几乎没动,同批评测里「应命中的片段压根没进候选集」的比例从 11% 掉到 4%。

两次改动都挂在「优化检索」名下,买的却不是同一种东西:一次买的是排得更准,一次买的是不再丢。丢掉的(top-50 里根本没有 gold)和排错的(top-50 里有 gold 但排在第 12)位置不同,能修的手段也不同,代价差一个量级。混为一谈,很容易把重排当成万能补丁,为一个自己都没有的问题付三倍延迟。

本文只回答一个问题:同样叫「优化检索」,为什么加一行精确匹配几乎不花延迟、上重排却贵三倍,到底该先做哪个? 答案是先给纯向量漏召的归因方法、三路合流的可运行内核,再给「要不要上重排」的判据。不讲 ANN 索引原理(HNSW / IVF 只给名词级解释并指向资料),不做 embedding 选型,不写框架 API 用法。

#mermaid-svg-IgWB7pZdSPthfSrO{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-IgWB7pZdSPthfSrO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-IgWB7pZdSPthfSrO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-IgWB7pZdSPthfSrO .error-icon{fill:#552222;}#mermaid-svg-IgWB7pZdSPthfSrO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-IgWB7pZdSPthfSrO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-IgWB7pZdSPthfSrO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-IgWB7pZdSPthfSrO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-IgWB7pZdSPthfSrO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-IgWB7pZdSPthfSrO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-IgWB7pZdSPthfSrO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-IgWB7pZdSPthfSrO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-IgWB7pZdSPthfSrO .marker.cross{stroke:#333333;}#mermaid-svg-IgWB7pZdSPthfSrO svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-IgWB7pZdSPthfSrO p{margin:0;}#mermaid-svg-IgWB7pZdSPthfSrO .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-IgWB7pZdSPthfSrO .cluster-label text{fill:#333;}#mermaid-svg-IgWB7pZdSPthfSrO .cluster-label span{color:#333;}#mermaid-svg-IgWB7pZdSPthfSrO .cluster-label span p{background-color:transparent;}#mermaid-svg-IgWB7pZdSPthfSrO .label text,#mermaid-svg-IgWB7pZdSPthfSrO span{fill:#333;color:#333;}#mermaid-svg-IgWB7pZdSPthfSrO .node rect,#mermaid-svg-IgWB7pZdSPthfSrO .node circle,#mermaid-svg-IgWB7pZdSPthfSrO .node ellipse,#mermaid-svg-IgWB7pZdSPthfSrO .node polygon,#mermaid-svg-IgWB7pZdSPthfSrO .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-IgWB7pZdSPthfSrO .rough-node .label text,#mermaid-svg-IgWB7pZdSPthfSrO .node .label text,#mermaid-svg-IgWB7pZdSPthfSrO .image-shape .label,#mermaid-svg-IgWB7pZdSPthfSrO .icon-shape .label{text-anchor:middle;}#mermaid-svg-IgWB7pZdSPthfSrO .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-IgWB7pZdSPthfSrO .rough-node .label,#mermaid-svg-IgWB7pZdSPthfSrO .node .label,#mermaid-svg-IgWB7pZdSPthfSrO .image-shape .label,#mermaid-svg-IgWB7pZdSPthfSrO .icon-shape .label{text-align:center;}#mermaid-svg-IgWB7pZdSPthfSrO .node.clickable{cursor:pointer;}#mermaid-svg-IgWB7pZdSPthfSrO .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-IgWB7pZdSPthfSrO .arrowheadPath{fill:#333333;}#mermaid-svg-IgWB7pZdSPthfSrO .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-IgWB7pZdSPthfSrO .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-IgWB7pZdSPthfSrO .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IgWB7pZdSPthfSrO .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-IgWB7pZdSPthfSrO .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IgWB7pZdSPthfSrO .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-IgWB7pZdSPthfSrO .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-IgWB7pZdSPthfSrO .cluster text{fill:#333;}#mermaid-svg-IgWB7pZdSPthfSrO .cluster span{color:#333;}#mermaid-svg-IgWB7pZdSPthfSrO div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-IgWB7pZdSPthfSrO .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-IgWB7pZdSPthfSrO rect.text{fill:none;stroke-width:0;}#mermaid-svg-IgWB7pZdSPthfSrO .icon-shape,#mermaid-svg-IgWB7pZdSPthfSrO .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-IgWB7pZdSPthfSrO .icon-shape p,#mermaid-svg-IgWB7pZdSPthfSrO .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-IgWB7pZdSPthfSrO .icon-shape .label rect,#mermaid-svg-IgWB7pZdSPthfSrO .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-IgWB7pZdSPthfSrO .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-IgWB7pZdSPthfSrO .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-IgWB7pZdSPthfSrO :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

否

是

值班口语查询

查询结构化意图+约束+实体

向量路相似度

稀疏路BM25 倒排

符号路ID 与元数据

融合层RRF 或加权

符号保底必须进候选集

重排闸门看候选质量

直接取 top-k

cross-encoder或 LLM 重排

最终上下文


一、纯向量漏掉的是哪三类,各自靠什么兜住?

先把名词摆清楚,后面不再回头解释:

名词一句话原理知识结构定位本文耦合点
BM25 词频 × 逆文档频率 × 长度惩罚的稀疏排序函数,只认字面命中 上位:稀疏检索;下位:Okapi BM25 兜住罕见术语与精确串;k1=1.2、b=0.75 [公开]
cross-encoder 查询与文档拼成一个序列过 Transformer,直接输出相关分 上位:重排;下位:ColBERT 多向量 每对候选一次前向,延迟随深度线性增长
RRF 只取各路名次的倒数和作为融合分,不看原始分数 上位:结果融合;同族:CombMNZ k=60;对量纲免疫,但对「多路共识」盲信

不在表里的名词顺带交代:相似度被 L2 归一化压在 −1~1,与 BM25 的 0~30 不同量纲;元数据过滤用结构化字段缩小候选集,条件只能来自查询结构;bi-encoder 与召回同源同粒度时几乎没有增益(第五节反例);ANN 本篇只假设「向量路能给出 top-N」。

查询结构化产物就是 04 篇那一篇的输出,本篇把它当成已标定的输入:

{
"qid": "ops-20261017-0031",
"intent": "处置动作查询",
"constraints": {"service": "svc-order", "severity": "P1"},
"entities": [{"type": "runbook_id", "value": "RB-214", "hard": true},
{"type": "symptom", "value": "磁盘告警", "hard": false}],
"alias": {"磁盘告警": "DISK_FULL"}
}

hard 这个字段决定它是硬过滤还是软加分,第四节会展开它为何能决定整套过滤设计的成败。

纯向量的漏召,落到这三类:

漏召类型触发条件现场表现兜底手段
同义改写 口语与文档用词不共享词面,语义距离被同主题邻近文档拉平 top-k 里全是同题材片段,gold 落在 k+1 别名表(04 的产出)落到符号路
精确标识符 查询里出现 RB-214、服务名这类强特征串 讲原理的长段落排前面,带编号那条被挤到后面 正则抽出标识符,符号路精确匹配并提到队首
罕见术语 术语只出现一两次,embedding 学不出稳定表征 排上来的都是字面接近但语义不同的手册 文档频率 ≤2 判为罕见词,直接进符号路

这三类有一个共同点:都不靠语义相似度能解决。所以「加重排」之前先看漏召归属——重排只能在已有候选池里重排,对没进池子的 gold 毫无作用。06 篇会从「引用该怎么验」的角度再用这张分工表。

一句话判断:混合检索的收益,主要来自符号与稀疏两路把漏召补回来;融合权重怎么调,影响远小于「过滤条件选对没有」。


二、三路分数怎么合,为什么直接加权等于让 BM25 独裁?

先看一个三行的算术。vec 路给 gold 的相似度是 0.82,bm25 路给同一条的分数是 12.4,权重五五开:

0.5 * 12.4 + 0.5 * 0.82 = 6.2 + 0.41 = 6.61
BM25 贡献占比 = 6.2 / 6.61 = 93.8% [算]

名义上的五五开,实际是 BM25 独裁。原因不是权重写错,而是两路分数量纲不可比:相似度被 L2 归一化压在 −1~1,BM25 是无上界累加值,长文档上轻易超过 30。

2.1 两种融合式的适用与陷阱

维度RRF归一化后加权
是否需要标定 不需要,只吃名次 需要,且标定点会漂移
对量纲敏感 不敏感 归一化前敏感,之后取决于标尺怎么取
对候选集规模敏感 弱(名次截断在 top-N 内) 强,最大分值随 N 变化
能否表达「哪一路更可信」 只能靠 α 加权,粒度粗 可以直接给不同权重
什么时候失效 多路都不相关时仍给「多路共识」最高分 标尺取本次查询最大值,把第一名压成 1.0

我们的判断是:默认用 RRF,只有当你明确知道「某一路更可信」且有能力离线标定(第七节给流程)时才换成加权。 标定的性价比通常被高估——权重从 0.5/0.5 挪到 0.6/0.4,在 opsqa-200 上的差异落在 1pp 内 ,而条件选错一个,召回率能掉 5pp 以上。

两处最常见的写法对照:❌ 0.5*bm25 + 0.5*cos 直接相加 → ✅ 改 RRF 或先做分位数归一化再加权;❌ 用「本次查询路内最高分」当分母 → ✅ 用抽样分位数做标尺。另两条容易漏:符号路不赛马(布尔约束不是相似度),多路都拿不到证据时要触发「无证据」分支交给 06 篇——RRF 保证的是共识,不是相关。

Anthropic 在 Contextual Retrieval 里给了可对照数据:基线 top-20 检索失败率 5.7%,加 Contextual Embeddings 降到 3.7%,再叠 Contextual BM25 降到 2.9%,加重排降到 1.9%,评测域是代码库、小说、arXiv 与科学论文四类,发布日 2024-09-19 [公开]。加一路 BM25 贡献 0.8pp,加重排贡献 1.0pp——绝对值接近,但加倒排的延迟成本近乎为零,重排却要先把候选池拉到 150 条再逐对打分。绝对值接近不等于性价比接近,第五节那张表就是这么来的。


三、三路召回 + 融合的内核怎么跑通

3.1 输入是什么,输出是什么,跑歪了先看哪

输入:条目列表 (id, text, meta)(meta 至少含 tag)、一个查询串、一条已知应命中的 gold id。处理:BM25 建倒排、Vec 建向量、symbolic 抽符号证据,三路召回后经 symbol_gate 保底、融合、rerank 排序。输出:每路是否命中 gold 及其名次(漏召诊断)+ 融合后 gold 名次 + 最终 top-k。验证:symbol 命中而 vec 未命中,说明漏召属于第一节三类之一,该补证据;三行全 False 则问题在 03 篇的切分或 02A 篇的解析。

# hybrid_search.py —— 三路召回融合与重排闸门内核(零依赖,仅标准库)
import math, re, zlib
from collections import Counter

TOKEN = re.compile(r"[a-z0-9]+|[\\u4e00-\\u9fff]")
IDPAT = re.compile(r"\\b[A-Z]{2,}[-_]?\\d{2,}\\b")
K1, B, RRF_K = 1.2, 0.75, 60

def tokenize(s):
return TOKEN.findall(s.lower())

# ── 适配口 1:稠密向量。接真实 embedding 时替换这一个函数 ──
def embed(text):
"""占位实现:邻近二字散列到 64 维后 L2 归一化,只求脚本能独立跑通。"""
v = [0.0] * 64
for i in range(len(text) – 1):
v[zlib.crc32(text[i:i + 2].encode("utf-8")) % 64] += 1.0
n = math.sqrt(sum(x * x for x in v)) or 1.0
return [x / n for x in v]

def cos(a, b):
return sum(x * y for x, y in zip(a, b))

# 别名表:口语 → 文档写法。这是 04 篇的产出,本篇当已标定输入
ALIAS = {"磁盘告警": "DISK_FULL", "扩容": "SCALE_OUT", "回滚": "ROLLBACK"}

def _top(sc, k):
return sorted(sc.items(), key=lambda x: –x[1])[:k]

# ── 路一:稀疏倒排(Okapi BM25) ──
class BM25:
def __init__(self, docs):
self.docs, self.N = docs, len(docs)
self.tf, self.ln, self.df = [], [], Counter()
for _, text, _ in docs:
c = Counter(tokenize(text))
self.tf.append(c); self.ln.append(sum(c.values()))
self.df.update(c.keys())
self.avg = (sum(self.ln) / self.N) or 1.0
self.rare = {w for w, n in self.df.items() if n <= 2 and len(w) > 1}

def search(self, q, topk=50):
out = {}
for i, tf in enumerate(self.tf):
s = 0.0
for w in tokenize(q):
f = tf.get(w, 0)
if not f: continue
idf = math.log(1 + (self.N – self.df[w] + 0.5) / (self.df[w] + 0.5))
s += idf * f * (K1 + 1) / (f + K1 * (1 – B + B * self.ln[i] / self.avg))
if s > 0: out[self.docs[i][0]] = s
return _top(out, topk)

# ── 路二:稠密向量(相似度) ──
class Vec:
def __init__(self, docs):
self.v = {d[0]: embed(d[1]) for d in docs}
def search(self, q, topk=50):
qv = embed(q)
return _top({i: cos(qv, v) for i, v in self.v.items()}, topk)

# ── 路三:符号召回(精确标识符 / 改写别名 / 罕见术语) ──
def symbolic(q, docs, bm):
keys = set(IDPAT.findall(q.upper()))
keys |= {ALIAS[k] for k in ALIAS if k in q}
keys |= {w for w in tokenize(q) if w in bm.rare}
out = {}
for did, text, meta in docs:
hay = (text + " " + " ".join(meta.values())).upper()
hit = sum(hay.count(k) for k in keys)
if hit: out[did] = float(hit)
return out

# ── 融合两式 ──
def rrf(runs, topk=50, k=RRF_K):
"""Reciprocal Rank Fusion:只用名次不看分数,天然免疫量纲差异。"""
out = Counter()
for _, res in runs:
for rank, (did, _) in enumerate(res, 1):
out[did] += 1.0 / (k + rank)
return sorted(out.items(), key=lambda x: –x[1])[:topk]

def weighted(runs, w, topk=50):
"""先按路内最高分归一化到 0~1 再加权;不归一化的话 BM25 会独裁。"""
out = Counter()
for src, res in runs:
mx = max((s for _, s in res), default=1.0) or 1.0
for did, s in res:
out[did] += w.get(src, 0.0) * (s / mx)
return sorted(out.items(), key=lambda x: –x[1])[:topk]

def symbol_gate(fused, sym):
"""符号命中项提到队首:它们是必须进场的候选,不参与分数赛马。"""
if not sym: return fused
sc = dict(fused)
head = sorted((d for d in sym if d in sc), key=lambda d: –sc[d])
return [(d, sc[d]) for d in head] + [(d, s) for d, s in fused if d not in sym]

# ── 重排闸门:三档,后两档要先进适配口 ──
CROSS = None # 适配口 2:cross_score(q, doc) -> float,如 BGE-reranker
LLM_RANK = None # 适配口 3:llm_rank(q, ids) -> list[id],滑动窗口式

def rerank(q, fused, texts, mode="none", depth=50, topk=8):
"""depth = 参与重排的候选深度,topk = 最终进上下文的条数。"""
cand = fused[:depth]
if mode == "none": return cand[:topk]
if mode == "bi": return sorted(cand, key=lambda x: –cos(embed(q), embed(texts[x[0]])))[:topk]
if mode == "cross":
if CROSS is None: raise RuntimeError("先接通 CROSS 适配口")
return sorted(cand, key=lambda x: –CROSS(q, texts[x[0]]))[:topk]
if mode == "llm":
if LLM_RANK is None: raise RuntimeError("先接通 LLM_RANK 适配口")
pos = {d: i for i, d in enumerate(LLM_RANK(q, [d for d, _ in cand]))}
return sorted(cand, key=lambda x: pos.get(x[0], len(pos)))[:topk]
raise ValueError("未知重排档位: " + mode)

# ── 漏召诊断:gold 到底是哪一路捞回来的 ──
def diagnose(gold, runs, fused):
rows = []
for src, res in runs:
ids = [d for d, _ in res]
rows.append((src, bool(gold in ids), ids.index(gold) + 1 if gold in ids else "-"))
fids = [d for d, _ in fused]
return rows, (fids.index(gold) + 1 if gold in fids else "-")

上面是内核。下面把六条 runbook 灌进去,跑一次漏召诊断:

# demo.py —— 6 条 runbook,跑一次漏召诊断
from hybrid_search import BM25, Vec, symbolic, rrf, weighted, symbol_gate, rerank, diagnose

DOCS = [
("rb-214", "单据序号 RB-214:切换写路径,清理归档日志。", {"tag": "DISK_FULL"}),
("rb-301", "扩容预案:先摘流量再扩副本,避免缓存击穿。", {"tag": "SCALE_OUT"}),
("rb-118", "磁盘 IO 打满时先抓火焰图,再决定是否限流。", {"tag": "IO"}),
("rb-455", "回滚:保留上一个镜像号,回滚后补一次冒烟。", {"tag": "ROLLBACK"}),
("topo-7", "svc-order 依赖 Redis 与 DB,拓扑见附件一。", {"tag": "TOPO"}),
("rb-902", "证书轮换后重启网关,注意灰度顺序。", {"tag": "CERT"}),
]
TEXTS = {d[0]: d[1] for d in DOCS}
BM, VEC = BM25(DOCS), Vec(DOCS)

def ask(q, gold, mode="none", fuse="rrf"):
sym = symbolic(q, DOCS, BM)
runs = [("vec", VEC.search(q)), ("bm25", BM.search(q)),
("symbol", sorted(sym.items(), key=lambda x: –x[1]))]
raw = rrf(runs) if fuse == "rrf" else weighted(runs, {"vec": .5, "bm25": .3, "symbol": .2})
fused = symbol_gate(raw, sym)
rows, fin = diagnose(gold, runs, fused)
top3 = [d for d, _ in rerank(q, fused, TEXTS, mode=mode, topk=3)]
print("Q: %-22s gold=%s" % (q, gold))
for src, hit, rank in rows:
print(" %-6s 命中=%-5s 名次=%s" % (src, hit, rank))
print(" 融合(%s)后名次=%-3s top3=%s\\n" % (fuse, fin, top3))

ask("磁盘告警咋整,要不要扩容", "rb-214")
ask("磁盘告警咋整,要不要扩容", "rb-214", fuse="weighted")
ask("RB-214 这个文件讲啥", "rb-214")
ask("磁盘告警咋整,要不要扩容", "rb-214", mode="bi")

3.2 逐段读这份脚本

  • embed / cos:唯一适配口。占位实现用相邻二字散列,没有语义,输出里的向量名次只代表字面重叠程度。
  • symbolic:三类证据合成一张 key 集合——正则标识符、ALIAS 别名、rare 罕见词。hay 把 text 与 meta 拼起来比对,元数据里有没有 DISK_FULL 决定这条路能否命中。
  • rrf / weighted / rerank:weighted 先除以路内最高分,这是那个 93.8% 的唯一解药;rrf 的 k=60 让第 1、2 名差约 1.6% [算];rerank 的 depth / topk 分离是刻意的,cross / llm 留成 None 避免静默退化成「没重排」。

已知偏差两条:① 占位 embedding 无语义能力,真实场景里 vec 路更强,「同义改写」类漏召会少见一些但不消失;② bi 档与召回共用 embed,会把候选集重排回召回顺序——最后一行运行结果演示了这个退化。

运行结果:

Q: 磁盘告警咋整,要不要扩容 gold=rb-214
vec 命中=True 名次=5
bm25 命中=False 名次=-
symbol 命中=True 名次=1
融合(rrf)后名次=2 top3=['rb-301', 'rb-214', 'rb-118']

Q: 磁盘告警咋整,要不要扩容 gold=rb-214
vec 命中=True 名次=5
bm25 命中=False 名次=-
symbol 命中=True 名次=1
融合(weighted)后名次=2 top3=['rb-301', 'rb-214', 'rb-118']

Q: RB-214 这个文件讲啥 gold=rb-214
vec 命中=True 名次=1
bm25 命中=True 名次=1
symbol 命中=True 名次=1
融合(rrf)后名次=1 top3=['rb-214', 'rb-455', 'rb-118']

Q: 磁盘告警咋整,要不要扩容 gold=rb-214
vec 命中=True 名次=5
bm25 命中=False 名次=-
symbol 命中=True 名次=1
融合(rrf)后名次=2 top3=['rb-118', 'rb-301', 'topo-7']

第一条最值得看:vec 把 gold 排到第 5、bm25 完全没捞到,只有 symbol 靠别名兜住,融合后回到第 2。第二条说明:RRF 与归一化加权给出完全相同的 top-3,权重之争远不如「元数据里有没有这个别名」重要。最后一条是反例:打开 bi 重排,gold 掉出 top-3——它与召回同源,等于把 symbol_gate 扶上去的条目又按回去。

#mermaid-svg-rM3SpIi3rKYJ9dnI{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-rM3SpIi3rKYJ9dnI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-rM3SpIi3rKYJ9dnI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-rM3SpIi3rKYJ9dnI .error-icon{fill:#552222;}#mermaid-svg-rM3SpIi3rKYJ9dnI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-rM3SpIi3rKYJ9dnI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-rM3SpIi3rKYJ9dnI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-rM3SpIi3rKYJ9dnI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-rM3SpIi3rKYJ9dnI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-rM3SpIi3rKYJ9dnI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-rM3SpIi3rKYJ9dnI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-rM3SpIi3rKYJ9dnI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-rM3SpIi3rKYJ9dnI .marker.cross{stroke:#333333;}#mermaid-svg-rM3SpIi3rKYJ9dnI svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-rM3SpIi3rKYJ9dnI p{margin:0;}#mermaid-svg-rM3SpIi3rKYJ9dnI .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-rM3SpIi3rKYJ9dnI .cluster-label text{fill:#333;}#mermaid-svg-rM3SpIi3rKYJ9dnI .cluster-label span{color:#333;}#mermaid-svg-rM3SpIi3rKYJ9dnI .cluster-label span p{background-color:transparent;}#mermaid-svg-rM3SpIi3rKYJ9dnI .label text,#mermaid-svg-rM3SpIi3rKYJ9dnI span{fill:#333;color:#333;}#mermaid-svg-rM3SpIi3rKYJ9dnI .node rect,#mermaid-svg-rM3SpIi3rKYJ9dnI .node circle,#mermaid-svg-rM3SpIi3rKYJ9dnI .node ellipse,#mermaid-svg-rM3SpIi3rKYJ9dnI .node polygon,#mermaid-svg-rM3SpIi3rKYJ9dnI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-rM3SpIi3rKYJ9dnI .rough-node .label text,#mermaid-svg-rM3SpIi3rKYJ9dnI .node .label text,#mermaid-svg-rM3SpIi3rKYJ9dnI .image-shape .label,#mermaid-svg-rM3SpIi3rKYJ9dnI .icon-shape .label{text-anchor:middle;}#mermaid-svg-rM3SpIi3rKYJ9dnI .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-rM3SpIi3rKYJ9dnI .rough-node .label,#mermaid-svg-rM3SpIi3rKYJ9dnI .node .label,#mermaid-svg-rM3SpIi3rKYJ9dnI .image-shape .label,#mermaid-svg-rM3SpIi3rKYJ9dnI .icon-shape .label{text-align:center;}#mermaid-svg-rM3SpIi3rKYJ9dnI .node.clickable{cursor:pointer;}#mermaid-svg-rM3SpIi3rKYJ9dnI .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-rM3SpIi3rKYJ9dnI .arrowheadPath{fill:#333333;}#mermaid-svg-rM3SpIi3rKYJ9dnI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-rM3SpIi3rKYJ9dnI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-rM3SpIi3rKYJ9dnI .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rM3SpIi3rKYJ9dnI .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-rM3SpIi3rKYJ9dnI .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rM3SpIi3rKYJ9dnI .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-rM3SpIi3rKYJ9dnI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-rM3SpIi3rKYJ9dnI .cluster text{fill:#333;}#mermaid-svg-rM3SpIi3rKYJ9dnI .cluster span{color:#333;}#mermaid-svg-rM3SpIi3rKYJ9dnI div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-rM3SpIi3rKYJ9dnI .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-rM3SpIi3rKYJ9dnI rect.text{fill:none;stroke-width:0;}#mermaid-svg-rM3SpIi3rKYJ9dnI .icon-shape,#mermaid-svg-rM3SpIi3rKYJ9dnI .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-rM3SpIi3rKYJ9dnI .icon-shape p,#mermaid-svg-rM3SpIi3rKYJ9dnI .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-rM3SpIi3rKYJ9dnI .icon-shape .label rect,#mermaid-svg-rM3SpIi3rKYJ9dnI .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-rM3SpIi3rKYJ9dnI .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-rM3SpIi3rKYJ9dnI .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-rM3SpIi3rKYJ9dnI :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

查询磁盘告警咋整

向量候选集top50 相似度 0.14

BM25 候选集top50 无 gold

符号候选集别名命中 rb-214

融合分RRF 名次倒数和

符号保底rb-214 进前 8

重排分可跳过

最终上下文top8 带来源


四、元数据过滤的条件从哪来,为什么不许从 embedding 来

一句话规则:过滤条件从查询结构里抽,不从 embedding 距离里砍。 前者的输出是布尔约束(服务等于 svc-order),后者只是距离阈值——距离近绝不意味着条件成立。

#mermaid-svg-hYVO8ief8ACl3kJc{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-hYVO8ief8ACl3kJc .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-hYVO8ief8ACl3kJc .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-hYVO8ief8ACl3kJc .error-icon{fill:#552222;}#mermaid-svg-hYVO8ief8ACl3kJc .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-hYVO8ief8ACl3kJc .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-hYVO8ief8ACl3kJc .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-hYVO8ief8ACl3kJc .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-hYVO8ief8ACl3kJc .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-hYVO8ief8ACl3kJc .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-hYVO8ief8ACl3kJc .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-hYVO8ief8ACl3kJc .marker{fill:#333333;stroke:#333333;}#mermaid-svg-hYVO8ief8ACl3kJc .marker.cross{stroke:#333333;}#mermaid-svg-hYVO8ief8ACl3kJc svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-hYVO8ief8ACl3kJc p{margin:0;}#mermaid-svg-hYVO8ief8ACl3kJc .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-hYVO8ief8ACl3kJc .cluster-label text{fill:#333;}#mermaid-svg-hYVO8ief8ACl3kJc .cluster-label span{color:#333;}#mermaid-svg-hYVO8ief8ACl3kJc .cluster-label span p{background-color:transparent;}#mermaid-svg-hYVO8ief8ACl3kJc .label text,#mermaid-svg-hYVO8ief8ACl3kJc span{fill:#333;color:#333;}#mermaid-svg-hYVO8ief8ACl3kJc .node rect,#mermaid-svg-hYVO8ief8ACl3kJc .node circle,#mermaid-svg-hYVO8ief8ACl3kJc .node ellipse,#mermaid-svg-hYVO8ief8ACl3kJc .node polygon,#mermaid-svg-hYVO8ief8ACl3kJc .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-hYVO8ief8ACl3kJc .rough-node .label text,#mermaid-svg-hYVO8ief8ACl3kJc .node .label text,#mermaid-svg-hYVO8ief8ACl3kJc .image-shape .label,#mermaid-svg-hYVO8ief8ACl3kJc .icon-shape .label{text-anchor:middle;}#mermaid-svg-hYVO8ief8ACl3kJc .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-hYVO8ief8ACl3kJc .rough-node .label,#mermaid-svg-hYVO8ief8ACl3kJc .node .label,#mermaid-svg-hYVO8ief8ACl3kJc .image-shape .label,#mermaid-svg-hYVO8ief8ACl3kJc .icon-shape .label{text-align:center;}#mermaid-svg-hYVO8ief8ACl3kJc .node.clickable{cursor:pointer;}#mermaid-svg-hYVO8ief8ACl3kJc .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-hYVO8ief8ACl3kJc .arrowheadPath{fill:#333333;}#mermaid-svg-hYVO8ief8ACl3kJc .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-hYVO8ief8ACl3kJc .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-hYVO8ief8ACl3kJc .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hYVO8ief8ACl3kJc .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-hYVO8ief8ACl3kJc .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hYVO8ief8ACl3kJc .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-hYVO8ief8ACl3kJc .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-hYVO8ief8ACl3kJc .cluster text{fill:#333;}#mermaid-svg-hYVO8ief8ACl3kJc .cluster span{color:#333;}#mermaid-svg-hYVO8ief8ACl3kJc div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-hYVO8ief8ACl3kJc .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-hYVO8ief8ACl3kJc rect.text{fill:none;stroke-width:0;}#mermaid-svg-hYVO8ief8ACl3kJc .icon-shape,#mermaid-svg-hYVO8ief8ACl3kJc .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-hYVO8ief8ACl3kJc .icon-shape p,#mermaid-svg-hYVO8ief8ACl3kJc .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-hYVO8ief8ACl3kJc .icon-shape .label rect,#mermaid-svg-hYVO8ief8ACl3kJc .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-hYVO8ief8ACl3kJc .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-hYVO8ief8ACl3kJc .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-hYVO8ief8ACl3kJc :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

重排层 预算 200ms

cross-encoder逐对打分

LLM 滑动窗

融合层 预算 5ms

RRF 或归一化加权

符号保底与硬过滤

召回层 预算 60ms

向量路HNSW 索引

BM25 路倒排索引

符号路元数据索引

top-k 进上下文

预算这样切的理由很简单:召回层三路可并行,融合层是纯算术,只有重排层是串行逐对前向。把预算压在重排层最容易失控——候选深度每加一倍,耗时就加一倍。这套数字是 OpsAgent 口径,换硬件要重算。

五条规则:硬证据(ID、服务名、时间窗)走前置过滤,软证据只加分不剔除;元数据缺失按「通过并降权」处理;别名表要有维护者,按频次排序、按周补;重排之后再验一次硬过滤;先过滤后截断。

这五条对应的失败模式如下:

陷阱触发条件现象根因检测规避
硬过滤写成软加分 ID 类实体也丢进加权 带错编号的片段稳定排进 top-5 布尔约束被当成相似度赛马 抽样看 ID 错误占比 hard=true 只做过滤
缺失字段被当成不满足 过滤写成严格等于 老版本 runbook 整体消失 缺失值语义被默认为 False 统计命中集元数据完整率 缺失一律通过并降权
用相似度阈值当过滤器 写成 sim > 0.7 才留下 阈值一调,召回率跳几个百分点 距离不等于条件 画阈值-召回曲线看陡峭程度 阈值只做粗排裁剪
别名表无人维护 上线后未再更新 新术语的漏召逐季上升 别名是对齐产物,需要持续对齐 按月统计符号路零命中占比 按查询频次排序,周更
先截断再过滤 过滤写在 top-k 之后 候选池里没有符合服务的条目 截断发生在条件之前 比「截断前 / 后」命中率 过滤层必须在 ANN 之前

第二节那句「融合权重的影响小于过滤条件」就是对着这张表说的:前三条任何一条踩中,掉的都是 5pp 起步,调权重只在一两个百分点里来回。


五、重排什么时候是净收益,top-k 该截在哪?

5.1 top-k 截断在重排前还是重排后,差别有多大

差别是决定性的。先截断再重排,等于让重排在一个已经被筛坏的池子里选。 脚本里 bi 那次就是这样:gold 排在融合后第 2,截断到 3 再精排就被同源偏好挤了出去。正确顺序是「深候选 → 重排 → 浅截断」:

档位每次额外 P95 延迟质量成本适用
不做重排 0 基线 0 候选质量分位 ≥0.90
bi-encoder 精排 5–20ms 与召回同源则几乎无增益,换更大模型才有 低 预算极紧、且能把精排换成不同模型
cross-encoder 每 10 条候选 20–40ms 高,对「语义相近但答非所问」最有效 中,每次调用都要算 主力选项,depth=50 起
LLM 重排 300ms–3s 最高,能处理多跳与隐含意图 高,token 与 wall clock 双份 低频高价值查询,depth ≤20

上表的延迟依赖模型尺寸与批处理,是本机单卡口径。重排三档的下游收益,第八节那张对照表给出实测位。

5.2 取值表

场景候选深度 depth最终 top-k说明
单跳事实问答 50 8 多余会被 06 篇的引用验证当噪声
多跳、跨块(见 03) 100 12–16 答案分散时宁可多选,别让 evidence 断链
cross-encoder 重排 50 8 深度再高,延迟线性涨而收益收敛
LLM 滑动窗重排 20 8 窗长受限,深于此值需多轮窗口
低延迟接口型服务 30 5 优先保 P95,牺牲尾部召回

5.3 什么时候不该上重排

不该上重排的四种情况:候选质量分位 ≥0.90(抽样里 gold 落在 top-50 达九成),此时重排在 top-8 的提升预期 1pp 上下,而重排层预算是召回层的三倍;延迟预算余量 <100ms;重排模型与召回同源同粒度;top-50 里根本没有 gold——这时要做的是第一节的三类证据。

判定顺序如图:

先测候选集质量分位 = 抽样集里 gold 落在 top-50 的比例
≥ 0.90 → 不上重排;把预算花在元数据与别名上
0.75 ~ 0.90 → cross-encoder,depth=50,top-k=8
< 0.75 → 先修召回(03 的块边界 / 04 的改写),重排救不了漏召

再看延迟预算余量(P95)
≥ 300ms 且 QPS 低 → 可以试 LLM 重排(depth ≤20)
100 ~ 300ms → cross-encoder
< 100ms → 只留融合层,不动重排


六、1405ms 的 P95 花在哪一级,超预算先降哪一级?

P95 是端到端的,但决策要落在某一级上。把 C 档的 1405ms 拆开(depth=50、单卡 T4、批 8),拆完就知道钱花在哪:

阶段P50 msP95 ms长尾主因
查询结构化(04 产出,缓存命中 62%) 8 35 未命中要走一次改写
三路并行召回 + 融合保底 42 107 HNSW 长查询退化成近全扫描
cross-encoder depth=50 162 200 候选超 512 token 时截断重算
下游生成 785 1005 输出长度方差

合计 997 / 1347ms,端到端再加 58ms 排队与网络。往下推两个档位也对得上:关掉 cross 那档就是 A 档的 1205ms;纯向量一路召回是 37 / 82ms,比三路并行少 25ms,于是基线 1180ms——第八节那三个 P95 就是这么来的。

真正能省的是第二、三行,降价链按「先降贵的、再动结构」排(成本见下):

  • L0 常态:RRF + cross depth=50,top-k=8 → P95 1405ms,precision@10 0.650。
  • L1(P95 连续 5 min >1400ms):depth 50→30,cross 档 200→118ms → 1323ms,precision@10 0.630。
  • L2(>1600ms):关 cross-encoder,退回 A 档融合 top-8 → 1205ms,precision@10 0.560。
  • L3(>2000ms):开 5 分钟查询缓存(命中率按 45% 折算)+ 退回纯向量 top-5 → 620ms,precision@10 0.510。

L3 的 precision 掉 5pp 不是截断造成的,是符号路被关掉后 recall@50 回到 0.890。成本按规范单价推:50 对候选 ×180 token × 0.02 USD/M = 0.18 USD/千次 [算],关掉后只剩查询侧 embedding 的 0.0002 USD/千次;LLM 重排是 2,400 输入 token × 0.02/M + 60 输出序号 token × 15/M = 0.95 USD/千次 [算],比 cross 贵 5.3 倍。


七、融合权重与归一化标尺,怎么离线标定?

第二节说「标尺取自离线分位数」却没给流程,这节补上。标定产出两样东西:每路一个分母(标尺)和一组权重。标尺错了,加权就是那个 93.8%。

7.1 标定流程与重标线

步骤做什么取什么数采纳判据
1 抽样 抽 300 条真实查询,按频次加权 覆盖 80% 查询频次 抽样集 recall@50 与全量差 <1pp
2 打分 三路各跑 top-50,落盘路内分数 每路 15000 个分数 分布不退化成单点
3 定标尺 取每路 P95 作分母,超出截断到 1.0 scale_vec=0.86、scale_bm25=18.4 被截断的比例 ≤5%
4 定权重 vec / bm25 权重做 grid,步长 0.1 11 组候选取最优 比 0.5/0.5 高 ≥1pp 才采纳

重标触发线四条:语料块数 +20% 只重算标尺,约 12 min;换 embedding 模型必须标尺与权重全重标,相似度分布整体平移会让旧标尺失效,代价是全量重算向量约 40 min;改 03 篇的切分参数只重算 bm25 标尺,约 12 min;只加别名表条目不重标(符号路不进加权)。

7.2 标定脚本

输入:runs.json({路名: {qid: [[did, score], …]}},来自第八节那批抽样打分)与 gold.json({qid: gold_id})。怎么执行:python calibrate.py runs.json gold.json。输出什么:每路标尺、grid 最优权重与它的 recall@50、0.5/0.5 基线的 recall@50,外加 adopt 建议。结果不对查哪:标尺出现 0.0 说明该路全零命中,先查召回;11 组 recall@50 若相同,说明两路召回名单几乎重合。

# calibrate.py —— 归一化标尺与融合权重的离线标定(零依赖,仅标准库)
import json, sys

def pct(xs, p):
"""取最近秩分位数;xs 需已排序,空列表返回 0.0。"""
if not xs:
return 0.0
return xs[min(len(xs) – 1, int(round((len(xs) – 1) * p / 100.0)))]

def fit_scale(runs, p=95):
"""输入 {路名: {qid: [[did, score], …]}};输出每路一个分母。"""
out = {}
for src, qmap in runs.items():
pool = sorted(s for res in qmap.values() for _, s in res)
out[src] = pct(pool, p) or 1.0 # 分位数为 0 时退化为 1,避免除零
return out

def fuse(qres, scale, w, clip=1.0, topk=50):
"""按标尺归一化后加权求和;超过 1.0 的部分截断。"""
acc = {}
for src, res in qres.items():
for did, s in res:
acc[did] = acc.get(did, 0.0) + w.get(src, 0.0) * min(s / scale[src], clip)
return sorted(acc.items(), key=lambda x: –x[1])[:topk]

def recall_at(runs, scale, gold, wv, topk=50):
"""给定 vec 路权重(bm25 取 1-wv),算抽样集上的 recall@topk。"""
w = {"vec": wv, "bm25": round(1.0 – wv, 2)}
hit = 0
for q, g in gold.items():
qres = {s: m[q] for s, m in runs.items() if q in m}
hit += int(g in [d for d, _ in fuse(qres, scale, w, topk=topk)])
return hit / len(gold)

if __name__ == "__main__":
runs = json.load(open(sys.argv[1], encoding="utf-8")) # 抽样打分
gold = json.load(open(sys.argv[2], encoding="utf-8")) # {qid: gold_id}
scale = fit_scale(runs)
grid = [(round(i * 0.1, 2), recall_at(runs, scale, gold, round(i * 0.1, 2))) for i in range(11)]
best = max(grid, key=lambda x: x[1])
base = min(grid, key=lambda x: abs(x[0] – 0.5))[1]
print("scale :", {k: round(v, 3) for k, v in scale.items()})
print("best : w_vec=%.1f recall@50=%.3f" % best)
print("base : w_vec=0.5 recall@50=%.3f" % base)
print("adopt :", "yes" if best[1] – base >= 0.01 else "no, keep 0.5/0.5")

7.3 逐段读这份脚本

  • pct / fit_scale:分位数取最近秩而非插值,样本少时会跳档。p=95 是刻意的——取 max 把标尺抬到极端值,取 P50 又让太多分数被截断到 1.0。
  • fuse:clip=1.0 把超出标尺的部分抹平,正是这一步让 BM25 的长尾不再独裁。w 里没有 symbol 键,符号路永远走 symbol_gate。
  • recall_at / __main__:只在 recall@50 上比,因为标尺的目标是把 gold 抬进候选池,排序交给重排层;grid 只搜 vec 一路共 11 组,不足 1pp 就建议别动权重。

已知偏差两条:① 分位数用最近秩,抽样少于 1000 条时 P95 会跳档、标尺随之抖动,所以第 2 步要 15000 个分数;② grid 只优化召回不看精确率,最优权重可能对「多塞进前 50」有利而对 top-8 无益,采纳前要回第八节复核。


八、怎么测,边际延迟才不会骗你

实验纪律沿用 00 篇的三层口径:检索层看召回率与精确率,下游层看 opsqa-200 的准确率,成本层看 P95 延迟与 token。三者必须同时出,只有中间指标变好不算数——召回率涨了而答案变差,是融合把更全但更杂的候选灌给模型时的常见结果。

对照设计:基线=纯向量 top-8;对照组=混合 × {RRF, 加权} × 重排 {无, cross, LLM}。

配置recall@50precision@10P95 延迟 ms每 +1pp 下游准确率 ms下游 accuracy
基线:纯向量 top-8 0.890 0.520 1180 — 0.740
A:混合 RRF × 无重排 0.960 0.560 1205 31 0.748
B:混合 加权 × 无重排 0.955 0.555 1208 47 0.746
C:A + cross-encoder(depth=50) 0.960 0.650 1405 154 0.761
D:A + LLM 重排(depth=20) 0.960 0.680 2600 775 0.766

数据跑出来填 results/k05.csv,表头如下:

arm,recall@50,precision@10,fusion_mode,rerank,p95_latency_ms,marginal_ms_per_pp,accuracy

最后一列最容易被省掉。最有用的读数:拿本行与「更便宜那一档」的 P95 之差,除以下游准确率之差(百分点),就是「每买 1pp 要付多少毫秒」。引子那次调参正是这个算法:200ms ÷ 1.3pp ≈ 154ms/pp [算]。三行 recall 都是 0.960 也是机制使然:重排不召回,只重排。

四类误测:

误测怎么被测错正确测法
只看 top-8 精确率 漏召这类错误在 top-8 里完全看不见 同时报 top-50 召回率,两者一起看趋势
延迟用均值 长尾被均值抹平,决策时看到的是「才加了 40ms」 一律取 P95 / P99,并按 QPS 折算批处理效应
单机跑一次就下结论 候选集规模不同,ANN 的近似误差也不同 固定索引版本与机器,跑 bootstrap 看置信区间
只用一个 embedding 评自己的管道 结论不可迁移 至少两个不同源的 embedding 各跑一遍(见 MTEB / BEIR)

第三条岔出去一句:索引重建会改变候选集分布,这套数要在 09 篇的门禁里重跑,不能只测一次。


九、小结

这里的分水岭是:先补齐漏召,再谈排序。 混合检索的收益集中在符号与稀疏两路把三类漏召接住,融合权重是二阶变量;重排的收益随候选集质量上升而下降,前置条件是「候选质量中等及以下」而不是「想要更高准确率」。

四条排序规则:

  • 漏召必先归因:标识符与罕见术语走符号路、同义改写走别名——归因错了,后面调参全在原地打转。
  • 融合默认 RRF;要加权就先归一化,标尺取自离线分位数而不是本次查询最大值。
  • 元数据过滤是前置硬约束,不是后置修剪;缺失字段通过并降权。
  • 上重排的判据是候选质量分位 + 延迟预算余量,都不满足时,200ms 买不回 1pp。
  • 抽 100 条日志,只看两个数字:三路全漏的占比,和只有符号路命中的占比。前者指向 03 篇的切分与 02A 篇的解析,后者指向元数据——两个数的比值,直接决定本篇这套东西在你那儿值多少。

    下一步,把 symbolic 接进现有检索,先只开符号保底、不开重排,重测一次 recall@50:这一档不花延迟预算,却是本篇唯一能同时抬召回与准确率的改动。

    下一篇:《06未经验证的引用比没引用更危险:断言-证据对齐回路与三档降级》


    参考

    • Okapi BM25(BM25 原理与 k1=1.2 / b=0.75 默认参数)
    • Reciprocal Rank Fusion (Cormack et al., 2009)(支撑 rrf 与「只吃名次」这一性质)
    • ColBERT · SPLADE(稀疏/多向量两条中间路线,不选它们的理由在第三节)
    • Sentence-BERT(bi-encoder 双塔结构的出处,支撑「同源该换粒度」)
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » SA-K-05 知识与检索工程 混合检索与重排
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!