这是《Agent Harness 从用到会造》第 8 讲。
先给判断:纯向量检索会丢精确串,混合检索(向量 + 关键词 + 图)是工业默认,不是偏好。 但这句话的后半段经常被误读。混合检索的首要价值是「兜底」------让任何一族查询都不被单路的短板拖穿,而不是「涨分」。本讲的台架实测里,同义改写这一族混合检索并没有赢过关键词。如果你把混合检索当成「向量检索的加强版」,你会对它的收益预期错位,然后在调参上白费力气。
这一讲我们把这件事拆开:为什么向量会丢精确串,关键词会丢什么,两者拼起来到底补上了哪一块,以及怎么用一份可复现的台架把「哪路漏哪些」变成数出来的差集,而不是感觉。
原理:两路检索的短板是互补的,不是谁替代谁
向量检索擅长什么、弱在哪
向量检索把查询和文档块都编码成稠密向量,用余弦相似度或内积排序。它的强项是语义匹配:你问「怎么把 PDF 转成 markdown」,文档里写的是「PDF 解析为 Markdown 格式的流程」,字面几乎不重叠,但向量能把它们拉到相近的位置。同义改写、跨语言、口语化提问,这些场景向量有天然优势。
它的弱项同样明确:精确串 。编号、术语、文件名、符号、专有名词、代码标识符------这些 token 在语义空间里没有稳定的「邻居」。md_convert.py 和 md_parser.py 在语义上几乎一样(都是「markdown 转换脚本」),向量分不出你要的是哪一个。docProps 这种 XML 命名空间里的字段名,在训练语料里出现频率低,编码出来的向量方向不稳定。全角冒号 这种中文标点术语,向量模型很可能把它和「半角冒号」「标点符号」混在一起。
这不是模型不够大就能解决的问题。语义空间的设计目标就是「把意思相近的东西放近」,而精确串要的是「把字面相同的东西放近」。这两个目标在同一个向量空间里天然冲突。你换更大的模型,语义能力变强,精确串的区分度未必跟着涨------因为区分精确串靠的是字面,不是语义。
关键词检索擅长什么、弱在哪
关键词检索(BM25 是工业默认)走的是另一条路:统计词频、逆文档频率、文档长度归一化,算一个词对文档的相关性分数。它的强项是精确匹配 :查询里出现 md_convert.py,包含这个字符串的文档块分数就会显著抬高,不包含的几乎拿不到分。编号、术语、文件名、符号,这些在 BM25 眼里就是普通 token,匹配上了就是匹配上了,没有「语义模糊」这回事。
它的弱项是换个说法问同一件事。你问「怎么把 PDF 转成 markdown」,文档里写的是「PDF 解析为 Markdown 格式的流程」------BM25 需要查询词和文档词有重叠。如果用户用了同义词、缩写、或者完全不同的表述,BM25 的召回会掉。中文还需要分词,分词错误会直接传导成检索错误。
为什么是「混」,而不是「选一个更好的」
把两路的短板摆在一起看:
- 向量:语义强,精确串弱
- 关键词:精确强,同义改写弱
这两组短板不重叠 。向量丢的那部分,关键词能接住;关键词丢的那部分,向量能接住。所以工业默认是混合检索------不是因为它「更强」,而是因为它更不容易在某一个族上崩掉。
这里有个关键判断,本讲的台架数据会反复印证:混合检索的收益主要体现在「不掉分」。在精确串族,向量掉到 36%,关键词 100%,混合 100%------混合拿回的那 64 个百分点,全部来自关键词那一路,向量没有贡献。在同义改写族,向量 60%,关键词 70%,混合 70%------混合和关键词打平,向量也没贡献。换句话说,混合检索在这个语料上的价值,是「让精确串族不被向量拖穿」,而不是「让每一族都涨」。
这个判断很重要,因为它决定了你该怎么调混合检索:先保证每一族都不掉到及格线以下,再谈排序优化。如果你一上来就调 RRF 的 k 值、加 rerank、做查询改写,但精确串族还在被向量拖穿,那你调的是排序,不是召回------召回没接住的东西,排序再准也排不出来。
融合方式上,本讲台架用的是 RRF(Reciprocal Rank Fusion),k=60 写死,没调参。RRF 的好处是不依赖两路分数的量纲------BM25 的分数和余弦相似度的分数不可比,直接加权求和需要归一化,RRF 只看排名,天然规避了这个问题。公式是 score(d) = Σ 1/(k + rank_i(d)),对每一路检索结果里的文档按排名累加。k=60 是文献里的常用默认值,本讲没有做消融,所以「k=60 最优」这个结论不成立,只能说「k=60 在这个台架上能跑」。
一个常被忽略的细节:切块策略会放大或缩小这个短板
原理讲到这里,很多人会直接跳到「那我上混合检索就完了」。但有一个前置变量会显著改变两路的相对表现:切块策略。
本讲台架把每个 md 文件切成 ≤600 字的块,每块带上最近的标题作为上下文。这个策略对精确串检索是有利的------一个文件名、一个字段名,只要它出现在某个块里,BM25 就能在那个块上给出高分。如果切块切得太碎(比如按行切),精确串可能被切散,BM25 的命中率会掉;如果切得太粗(比如整篇文档一块),精确串的分数会被大量无关文本稀释,向量和关键词的排序都会变钝。
换句话说,「向量丢精确串」这个结论的强度,和切块粒度是耦合的。本讲固定了 ≤600 字 + 带标题这个配置,所以 36% 这个数是这个配置下的数。你换切块策略,这个数会动。这不是说结论不可靠,而是说:当你复现这个台架时,切块策略要跟着一起复现,否则你比的是两个不同的东西。
动手做:同一批查询跑三路,用差集看谁漏了谁
台架结构
本讲的产物是 tools/harness_lab/rag_compare.py(本讲新建,已可跑)。它做三件事:
- 把仓库里的 markdown 切成块(每块 ≤600 字,带最近的标题作为上下文)
- 对同一批查询分别跑关键词(BM25)、向量(本地 embedding)、混合(RRF 融合)三路,算 recall@5
- 输出每一路的漏检差集------哪路丢了哪些查询,丢的是精确串还是同义改写
语料是本仓库的 329 个 md 文件,切成 3818 块。向量模型用本地 Qwen3-Embedding-0.6B-Q8_0(1024 维,CPU 推理)。关键词用 rank_bm25 的 BM25Okapi,参数 k1=1.5, b=0.75(本机安装版签名实测,台架显式写死)。融合用 RRF,k=60 写死。
查询集分两族:11 个精确串查询(查询词是文件名、字段名、术语这类精确串),10 个同义改写查询(查询词和文档表述不同但语义相同)。金标由 marker 字符串机械定位------每个查询预先指定一个「答案块必须包含的字符串」,检索结果里包含这个字符串的块就算命中。人工不打分,避免主观偏差。
跑起来
bash
# 进入仓库根目录
cd /path/to/your/repo
# 跑三路对比,输出 recall@5 和漏检差集
python3 tools/harness_lab/rag_compare.py
# 阳性对照:验证 BM25 在精确串族能拿到 11/11
python3 tools/harness_lab/rag_compare.py --selftest
--selftest 是阳性对照。它的作用是:如果 BM25 在精确串族拿不到 11/11,说明台架本身有问题(分词错了、语料没切对、金标定位错了),这时候三路对比的数不能信。先过阳性对照,再看对比数。
台架输出
跑完会得到 out/harness/L08/rag_compare.json(2026-10-17 冻结)。核心数字:
| 查询族 | 关键词 | 向量 | 混合 |
|---|---|---|---|
| 精确串(n=11) | 100% | 36% | 100% |
| 同义改写(n=10) | 70% | 60% | 70% |
| 合计(n=21) | 86% | 48% | 86% |
漏检差集:
- 向量独漏 11 个,其中 7 个是精确串(含
md_convert.py、docProps、全角冒号等) - 关键词只漏 3 个,全是同义改写
- 混合的漏检集合与关键词完全相同
最后一行是这一讲最该记住的数:混合的漏检集合和关键词一模一样。也就是说,在这个台架上,混合检索没有比关键词多救回任何一个查询。它做的事,是把向量在精确串族丢掉的那 7 个,用关键词那一路补回来------补回来的方式不是「融合出了更好的排序」,而是「关键词那一路本来就没丢,融合时没把它压下去」。
怎么读这份差集
差集比 recall 数字更有用。recall 告诉你「掉了多少」,差集告诉你「掉的是哪一类」。看差集的时候问三个问题:
- 向量独漏的 11 个里,有几个是精确串? 7 个。这印证了「向量丢精确串」不是感觉,是可数的。
- 关键词漏的 3 个是什么? 全是同义改写。这印证了「关键词丢换个说法」。
- 混合漏的和关键词一样吗? 一样。这说明混合在这个语料上没有引入新的漏检,也没有额外救回------它的作用是「不放大单路的短板」。
如果你在自己的知识库上跑这个台架,差集的结构可能不同。但读法一样:先看每一路独漏的查询属于哪一族,再看混合有没有把两路的漏检并集缩小。如果混合的漏检集合等于两路漏检的交集,说明融合有效;如果等于并集,说明融合没起作用,得查 RRF 实现或者两路结果的对齐。
一个可复用的检查脚本
台架跑完之后,你可能想快速看某个查询在三路里的具体排名。下面这个脚本读 rag_compare.json,打印指定查询的三路排名和金标位置:
python
import json
# 读台架输出
with open("out/harness/L08/rag_compare.json", encoding="utf-8") as f:
data = json.load(f)
def show(query_id):
"""打印某个查询在三路里的排名和金标命中情况。"""
for route in ("keyword", "vector", "hybrid"):
result = data["results"][route][query_id]
ranks = result["ranks"] # 命中的块在 top-k 里的排名,未命中为 None
hit = result["hit"]
print(f"[{route:8s}] hit={hit} ranks={ranks}")
# 金标块 id,用来对照
gold = data["queries"][query_id]["gold_block_ids"]
print(f"gold blocks: {gold}")
# 看一个精确串查询
show("q_md_convert")
# 看一个同义改写查询
show("q_pdf_to_md")
这个脚本的价值在于:recall 是聚合数,排名是单查询数。当你想知道「向量到底是完全没召回,还是召回了但排在 5 名之外」,看 ranks 比看 recall 直接。如果向量把金标块排在第 6、第 7 名,那加个 rerank 或者把 top-k 从 5 放到 10 可能就救回来了;如果排在第 200 名,那是召回问题,rerank 救不了。
把差集落成一张可执行的清单
光看差集还不够,你要把它变成下一步的动作。下面这段脚本把差集按「族」和「路」聚合,输出一张表:每一族里,哪一路漏了几个,漏的是哪些查询 id。这张表就是你调优的工单。
python
import json
from collections import defaultdict
with open("out/harness/L08/rag_compare.json", encoding="utf-8") as f:
data = json.load(f)
# 按 (族, 路) 聚合漏检
miss = defaultdict(list)
for qid, q in data["queries"].items():
family = q["family"] # "exact" 或 "paraphrase"
for route in ("keyword", "vector", "hybrid"):
if not data["results"][route][qid]["hit"]:
miss[(family, route)].append(qid)
# 打印工单
for (family, route), qids in sorted(miss.items()):
print(f"{family:10s} {route:8s} 漏 {len(qids):2d} 个: {qids}")
跑出来的结构会直接告诉你:精确串族里向量漏了哪些、同义改写族里关键词漏了哪些。先修漏得最多、且属于「本不该漏」那一族的------比如精确串族里向量漏的那 7 个,它们本该被关键词接住,如果混合之后还在漏,说明融合环节有问题,而不是检索本身有问题。这个优先级排序,比「哪路 recall 低就调哪路」要准得多。
会推翻它的现象
这一讲的结论建立在一个固定配置上:0.6B 嵌入模型、RRF k=60、BM25 默认参数、≤600 字切块、marker 字符串判金标。下面这些变量本台架没测,任何一个变了,结论都可能被推翻。把它们列清楚,是为了让你知道这份数据的边界在哪。
第一,rerank 没测。 本台架的三路都是「检索完直接取 top-5」,没有加交叉编码器重排。rerank 的作用是把召回的候选重新排序,它救的是「召回了但排在 5 名之外」的情况。如果向量把某个精确串块排在第 6、第 7 名,加 rerank 有可能把它拉进 top-5,那向量的 36% 会往上走。但 rerank 救不了「完全没召回」的情况------如果向量压根没把金标块放进候选集,rerank 无从下手。所以 rerank 会改变的是「召回但排名靠后」那部分查询的归属,不会改变「向量丢精确串」这个结构性判断。
第二,查询改写没测。 查询改写(比如把用户的口语问法改写成更接近文档表述的形式)主要帮的是同义改写族。如果加了改写,关键词那一路的 70% 可能往上走,因为改写后的查询和文档词的重叠变多了。但改写本身有风险:改错了会把精确串改没,反而伤到精确串族。本台架没测改写,所以「同义改写族关键词 70%」这个数是在「不改写」前提下的数。
第三,更大的嵌入模型没测。 本台架用的是 0.6B、1024 维的本地模型。换更大的模型,同义改写族的语义匹配会变强,向量在这一族的 60% 可能超过关键词的 70%。但精确串族的短板不会因为模型变大而消失------那是语义空间的设计目标决定的,不是容量问题。所以换大模型最可能改变的是同义改写族的排序,最不可能改变的是精确串族的结构。
第四,k 的消融没做。 RRF 的 k=60 是写死的,BM25 的 k1=1.5、b=0.75 也是写死的。k 值影响的是融合时两路排名的相对权重:k 小的时候,排名靠前的结果权重更集中;k 大的时候,排名分布更平缓。本台架没做消融,所以「k=60 最优」不成立,只能说「k=60 在这个台架上能跑」。如果你要调 k,得重新跑一遍三路对比,看漏检集合有没有变化。
第五,判重口径没测。 本台架的金标是 marker 字符串机械定位------块里包含指定字符串就算命中。这个口径简单、可复现,但它不判「块的内容是否真的回答了问题」。一个块可能包含 md_convert.py 这个字符串,但只是顺带提了一句,并不是答案所在。如果换成人工判分或者更严的判重口径,recall 数字会变,差集结构也可能变。本台架选机械定位,是为了让「哪路漏哪些」这件事可复现,代价是牺牲了一部分判分精度。
把这五条摆在一起看,你会发现一个规律:没测的变量,大多影响的是「排序」和「同义改写族」,很少影响「精确串族的结构性短板」。 这不是巧合------精确串的短板来自语义空间的设计目标,不是来自某个参数没调好。所以你可以放心地把「纯向量会丢精确串」当成一个稳健的判断,但要把具体的 recall 数字当成「这个配置下的数」,别外推。
课后思考题
第一题:什么情况下纯向量反而更好?
给个思路。纯向量在两种情况下可能优于混合:
一是查询族高度同质且全是语义改写。如果你的用户从来不问精确串(不查文件名、不查编号、不查术语),查询全是「怎么做一个 X」这种语义问法,那关键词那一路的贡献接近零,混合只是多一路开销。但这种情况在真实知识库里很少见------只要有一个用户查了一次文件名,精确串族就存在了。
二是语料本身没有精确串可匹配。如果文档全是自然语言散文,没有代码、没有编号、没有专有名词,那关键词能接住的东西本来就少。但这时候要问的是:用户会怎么查?如果用户也只能用自然语言查,那向量确实够用。
反过来想:纯向量更好的场景,往往是「关键词那一路本来就没东西可接」的场景,而不是「向量比关键词强」的场景。这个区分很重要。
第二题:你的知识库里,哪一类查询最容易被纯向量漏掉?
给个思路。先列你知识库里出现过的精确串类型:文件名、函数名、配置项、错误码、版本号、专有名词、缩写、符号。然后问:这些串在向量空间里有没有稳定的邻居?文件名和函数名通常没有------config_loader.py 和 config_parser.py 在语义上几乎一样。错误码和版本号也没有------E4021 和 E4022 在语义上没有区别。专有名词看情况------高频的(比如「Kubernetes」)有稳定表示,低频的(比如内部项目代号)没有。
列完之后,拿你知识库里真实出现过的查询跑一遍本讲的台架,看差集。差集里属于精确串族的那些,就是纯向量会漏的。这个清单比任何理论推导都准,因为它是你自己的语料上数出来的。
下一讲我们把这个台架往前推一步:混合检索接住了精确串,但接住之后排序怎么调?RRF 的 k 值、rerank 的引入时机、查询改写的边界------这些是「不掉分」之后才该谈的事。