混合检索与重排值不值那 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 两种融合式的适用与陷阱
| 是否需要标定 | 不需要,只吃名次 | 需要,且标定点会漂移 |
| 对量纲敏感 | 不敏感 | 归一化前敏感,之后取决于标尺怎么取 |
| 对候选集规模敏感 | 弱(名次截断在 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 再精排就被同源偏好挤了出去。正确顺序是「深候选 → 重排 → 浅截断」:
| 不做重排 | 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 取值表
| 单跳事实问答 | 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),拆完就知道钱花在哪:
| 查询结构化(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}。
| 基线:纯向量 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 篇的门禁里重跑,不能只测一次。
九、小结
这里的分水岭是:先补齐漏召,再谈排序。 混合检索的收益集中在符号与稀疏两路把三类漏召接住,融合权重是二阶变量;重排的收益随候选集质量上升而下降,前置条件是「候选质量中等及以下」而不是「想要更高准确率」。
四条排序规则:
抽 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 双塔结构的出处,支撑「同源该换粒度」)
网硕互联帮助中心


评论前必须登录!
注册