Agent 怎么从一堆 Bug 报告里找到相关的那几条?
检索:先用便宜的方法从库里挑出最相关的 k 条,只把这 k 条放进上下文。BM25 擅长精确的词(错误码、接口名),向量擅长换了说法的同一件事,RRF 只看名次把两路结果合起来。
测试 Agent 每发现一个疑似 Bug,都要先查"以前报过没有、需求怎么规定的"。查不到会重复报、误报;查到了却排在第 30 名,也等于没查到。
1为什么不把所有资料都塞进上下文
BugHunt-Bench 的场景:测试 Agent 在 Conduit(RealWorld 博客应用)上发现"收藏后计数没变"。提交之前它要回答两个问题:历史 Bug 库里有没有同一个问题?需求文档对收藏计数是怎么规定的?
- 放不下:Bug 库和需求文档会一直涨。一个跑了几个月的项目,几万条报告加上全部需求,远超上下文窗口。第 3 周「上下文快满了,Codex 怎么办?」讲过窗口满了只能压缩,压缩是有损的。
- 放得下也会被忽略:Liu et al. 2023 的 Lost in the Middle(arXiv 2307.03172,后发表于 TACL)在多文档问答和键值检索两个任务上发现,相关信息在输入开头或结尾时效果最好,放在长上下文中间时显著下降,标榜长上下文的模型也一样。
- 贵、慢:每轮都重发全部资料,token 和延迟跟着涨(第 2 周「Agent 到底是什么?一个 while 循环」算过,累计 token 随轮数平方增长)。
所以常见做法是 RAG(Retrieval-Augmented Generation,Lewis et al. 2020,NeurIPS)的思路:先检索,再把检索到的少量片段放进 prompt。
2词法检索:从 TF-IDF 到 BM25
词法检索只看"查询里的词在文档里出现了没有、出现几次、这个词稀不稀有"。最朴素的是 TF-IDF:词频 × 逆文档频率,常见词("文章"在 46 条里出现在 19 条中)权重低,稀有词(ERR_SLUG_CONFLICT 只在 2 条里出现)权重高。
BM25 在 TF-IDF 上加了两个修正,下面是经典形式(\(f(t,d)\) 是词 \(t\) 在文档 \(d\) 里的次数,\(|d|\) 是文档长度,avgdl 是平均长度):
$$\text{score}(q,d)=\sum_{t\in q}\text{IDF}(t)\cdot\frac{f(t,d)\,(k_1+1)}{f(t,d)+k_1\left(1-b+b\,\dfrac{|d|}{\text{avgdl}}\right)}$$ $$\text{IDF}(t)=\ln\!\left(1+\frac{N-n(t)+0.5}{n(t)+0.5}\right)$$\(N\) 是文档总数,\(n(t)\) 是包含 \(t\) 的文档数。这个 IDF 是 Lucene BM25Similarity 的写法,永远为正;Robertson 原始写法没有"1 +",词出现在一半以上文档时 IDF 会变负。
| 参数 | 管什么 | 取值的效果 |
|---|---|---|
| \(k_1\) | 词频饱和:同一个词出现很多次,分数涨得越来越慢 | \(k_1=0\):只看出现没出现(二值);\(k_1\) 越大越接近原始词频。在文档长度等于平均长度时,\(k_1=1.2\) 下 tf = 1、2、5、10 的系数是 1、1.375、1.774、1.964,上限是 \(k_1+1=2.2\) |
| \(b\) | 长度归一化:长文档天然包含更多词,要打折 | \(b=0\):不管长度;\(b=1\):完全按长度比例。tf = 1、长度是平均 2 倍时,系数在 \(b=0 / 0.75 / 1\) 下是 1 / 0.710 / 0.647 |
Lucene 和 Elasticsearch 的默认值是 \(k_1=1.2,\ b=0.75\)。Manning 等人的 Introduction to Information Retrieval(§11.4.3)说,没有调参数据时,\(k_1\) 取 1.2 到 2、\(b\) 取 0.75 是实验上合理的值;有标注数据就在开发集上网格搜索。
中文怎么切词
BM25 的"词"由分词器决定。本章的实验不用词典分词,英文和数字按连续字母数字切,中文按相邻两字切(bigram):无法登入 → 无法 法登 登入。这和 Lucene CJKAnalyzer 里 CJKBigramFilter 的做法同类,好处是不需要词典,代价是会产生"法登"这种无意义的片段,也会让"一篇文章最多"这种字面重合得到高分(下面互动演示里查"一篇文章最多能加几个标签",第 2 名是收藏规则 R05,就是被这几个字骗的)。
err_slug_conflict 出现在 B07 和 R04 两条里,\(\text{IDF}=\ln(1+44.5/2.5)=\ln 18.8\approx 2.934\)。B07 里它出现 2 次、B07 长 35 个词元、平均长度 33.96,贡献 \(2.934\times\frac{2\times2.2}{2+1.2\times(0.25+0.75\times35/33.96)}\approx 4.00\)。"文章"出现在 19 条里,IDF 只有 0.880。3向量检索:嵌入与余弦相似度
嵌入模型把一段文本映射成一个固定长度的向量,训练目标让意思相近的文本向量方向相近。检索时把查询也变成向量,按余弦相似度排序:
$$\cos(\mathbf{u},\mathbf{v})=\frac{\mathbf{u}\cdot\mathbf{v}}{\lVert\mathbf{u}\rVert\,\lVert\mathbf{v}\rVert}$$向量预先归一化成长度 1 时,余弦就是点积。这样"点赞数量不对"和"收藏后 favoritesCount 没有加一"一个字都不重合,向量也可以很接近,这是 BM25 做不到的。
TS-999 这类字符串,只是不保证精确命中排在前面。另外,概念的归并是我按这个应用定的:Conduit 的 favorite 是带计数的爱心按钮,用户常叫"点赞",所以本语料把点赞和收藏放进同一个概念;在一般 App 里两者是不同功能。
Anthropic 那篇文章举的例子正是这个:用户查 "Error code TS-999",嵌入模型可能找到一堆讲错误码的内容,却漏掉精确包含 TS-999 的那条;BM25 按字面找,能找到。BEIR 基准(Thakur et al. 2021,NeurIPS Datasets and Benchmarks)在 18 个数据集上做零样本评测,结论之一是 BM25 是一个稳健的基线,稠密检索模型往往不如其他方法,泛化还有很大提升空间。
4各自擅长什么、栽在哪里
在 46 条语料(36 条 Bug 报告 + 10 条需求片段)、8 个人工标注了相关文档的查询上,跑 code/retrieval_lab/retrieval.py 的结果(RR 是第一个相关文档名次的倒数):
| 查询 | 类型 | BM25 | 向量(玩具) | RRF |
|---|---|---|---|---|
| 新建帖子报 ERR_SLUG_CONFLICT | 精确标识符 | B07、R04 排第 1、2 | B07 排第 5,第 1 是无关的 B11 | R04、B07 第 1、2 |
| 无法登入,提示凭证无效 | 同义改写 | 第 1 是 B06(只因为都有"登入"),B01 第 2(只靠"提示"两个字) | 第 1 是 B03(同样是登录 + 密码),B01 第 2 | B01 第 1 |
| 点赞数量不对 | 同义改写 | 只找到 B13(靠"数量"),recall@5 = 0.33 | 3 条相关全在前 3 | 3 条全在前 3 |
| 注销以后 JWT 还能用 | 混合 | B06 第 1,需求 R02(写的是"退出登录""令牌")找不到 | B06、R02 第 1、2 | B06 第 1,R02 第 3 |
| 一篇文章最多能加几个标签 | 两者都行 | R03 第 1,B21 第 5 | R03 第 1,B21 不在前 5 | R03 第 1,B21 第 5 |
8 个查询的平均:BM25 recall@5 = 0.854、MRR = 0.938;玩具向量 recall@5 = 0.938、MRR = 0.875;RRF 两项都是 1.000。
5混合检索与 RRF
两路各有盲区,自然想到合起来。难点是分数没法直接加:BM25 分数没有上限(上面的例子从 3 到 13),余弦在 −1 到 1 之间,量纲完全不同。Reciprocal Rank Fusion 干脆只用名次:
$$\text{RRF}(d)=\sum_{r\in R}\frac{1}{k+r(d)}$$\(r(d)\) 是文档 \(d\) 在第 \(r\) 路结果里的名次(从 1 开始),某一路没返回它,就不加这一项。出处是 Cormack、Clarke、Büttcher 的 SIGIR 2009 短文 Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods。
- k = 60 怎么来的:原文说 k = 60 是在一次试点研究里定下的,之后验证时没有再改。试点实验(30 个系统在 TREC topics 351–400 上融合)的表 1 里,k = 0 时 MAP 是 .2072,k = 60 是 .2145,k = 500 是 .2098;作者的结论是 60 接近最优,但取值不关键。所以 60 是经验值,不是推导出来的常数。
- k 管什么:k 越大,名次之间的差距越小。k = 60 时第 1 名得 1/61 ≈ 0.0164,第 10 名得 1/70 ≈ 0.0143,只差 1.15 倍;k = 0 时差 10 倍。原文的说法是 k 用来削弱"个别离群系统把某个文档排得很高"的影响。
- RRF 奖励共识:k = 60 时,只在一路排第 1 的文档得 0.0164;在两路都排第 3 的文档得 2/63 ≈ 0.0317,几乎翻倍。上面"注销以后 JWT 还能用"里,无关的 B33 在 BM25 排第 2、向量排第 3,RRF 把它抬到第 2,压过了只在向量里排第 2 的需求 R02。融合不是免费的。
Elasticsearch 的 rrf retriever 把这个常数叫 rank_constant,默认值也是 60;另一个参数 rank_window_size 决定每一路取前多少名参与融合。本章的实验每路取前 10 名、分数为 0 的文档不算返回。
6分块:切多大、要不要重叠
长文档要先切成块再建索引,检索返回的是块。Anthropic 那篇文章提到块"通常不超过几百个 token",也提醒块大小、块边界和重叠都会影响检索效果。用一份 238 字的编辑器需求全文做实验,查询"标题长度上限是多少",答案是"120 个字符",用 BM25 在"46 条语料 + 切出来的块"里检索:
| 切法 | 块数 | 第 1 名 | 第 1 名里有答案吗 |
|---|---|---|---|
| 整篇不切 | 1 | 整篇(238 字) | 有,但把无关的 5 条规则一起塞进了上下文 |
| 每块 100 字,无重叠 | 3 | 块 1(100 字) | 没有。边界正好切在"标题长 | 度上限为 120 个字符"中间,块 1 有"标题""题长",答案却在块 2 |
| 每块 100 字,重叠 20 字 | 3 | 块 2(100 字) | 有,整句落在同一块里 |
| 每块 20 字,无重叠 | 12 | 块 5(20 字) | 没有。块太碎,问题和答案被拆开 |
结论要带条件:重叠能缓解"句子被切断",但不能保证;更稳的做法是按语义边界切(句子、条目、标题),每块再带上所属文档的标题。块太大会稀释(向量被无关内容平均掉、BM25 被长度惩罚、上下文里塞进无关内容),太小会丢上下文。没有普适的最佳值,要在自己的数据上量。
▶互动演示:同一个查询,三种检索
语料是 46 条自己构造的 BugHunt 风格文档(B 开头是 Bug 报告,R 开头是需求片段),和 code/retrieval_lab/corpus.json 相同;分数在浏览器里实时算,算法和 Python 版逐行对应。向量一栏是手写的 30 维概念词典嵌入,只能演示"同义词落到同一方向",词典外的词它看不见。绿色编号 = 这个预设查询人工标注的相关文档。点任一行看分数分解。
BM25 分数 · 色块 = 各词贡献
向量(玩具) 余弦相似度
RRF 融合 Σ 1/(k + 名次)
✎练习
favoritescount 出现在其中 2 条里。按 Lucene 的写法 \(\ln(1+\frac{N-n+0.5}{n+0.5})\),它的 IDF 是多少?ERR_SLUG_CONFLICT,要查历史 Bug 库里有没有报过。只能选一路,哪一路最可靠?7面试要点与代码
一句话讲清楚
上下文放不下全部资料,放进去中间的也容易被忽略,所以先检索 top-k。BM25 按词算分:IDF 让稀有词权重高,k1 让词频饱和,b 按文档长度打折,擅长错误码、接口名这类精确词;向量检索按嵌入的余弦相似度排,擅长同义改写,但不保证精确匹配。混合检索用 RRF 按名次融合,\(\sum 1/(k+r)\),k = 60 是 Cormack 2009 试点实验定的经验值,取值不关键。分块的大小和边界决定答案会不会被切断,都要在自己的标注集上用 recall@k 量出来。
追问准备
- 为什么不直接把两路分数加权相加?BM25 分数没有上界、随语料变化,余弦在 −1 到 1,量纲不同;要加权得先归一化,归一化方式本身又引入超参数。RRF 只用名次,不需要校准分数。
- RRF 的缺点?丢掉了分数里的"差多少"信息:第 1 名领先一大截和险胜,贡献一样;而且奖励两路共识,两路都"有点像"的无关文档可能被抬上来。
- 中文 BM25 怎么分词?词典分词(如 jieba)或字 bigram。bigram 不需要词典、召回高,但会引入"法登"这种噪声片段;查询和索引必须用同一个分词器。
- 检索效果怎么测?先标注"查询 → 相关文档",算 recall@k(前 k 名覆盖了多少相关文档)和 MRR(第一个相关文档名次倒数的平均)。改分词、改块大小、加混合检索,每一次都在同一个标注集上对比。指标和重排的细节见下一章「检索变好了吗?recall@k、MRR 与 nDCG」。
常见错误说法
❌ "上下文窗口够大,就把所有文档都塞进去":资料少(Anthropic 给的参考线是 20 万 token 以内)时确实可以;资料多时放不下,放得下也有 Lost in the Middle。
❌ "RRF 的 k = 60 是理论推导出来的最优值":是试点实验里定的经验值,原文说接近最优但取值不关键。
❌ "BM25 的 b 越大越好":b 只决定长度打折的力度,最佳值取决于语料,要在标注集上调。
❌ "块切得越小越精确":太小会把问题和答案拆开;实验里每块 20 字时第 1 名就不含答案。
最小实现(Python,只用标准库)
import math
import re
TOKEN_RE = re.compile(r"[a-z0-9_]+|[一-鿿]+")
def tokenize(text):
"""英文 / 数字按连续字符切,中文按相邻两字切(bigram)。"""
out = []
for run in TOKEN_RE.findall(text.lower()):
if run[0] < "一" or len(run) == 1:
out.append(run)
else:
out.extend(run[i:i + 2] for i in range(len(run) - 1))
return out
class BM25:
def __init__(self, texts, k1=1.2, b=0.75):
self.docs = [tokenize(t) for t in texts]
self.k1, self.b = k1, b
self.n = len(self.docs)
self.avgdl = sum(map(len, self.docs)) / self.n
self.df = {}
for d in self.docs:
for t in set(d):
self.df[t] = self.df.get(t, 0) + 1
def score(self, query, i):
doc = self.docs[i]
norm = self.k1 * (1 - self.b + self.b * len(doc) / self.avgdl)
total = 0.0
for t in dict.fromkeys(tokenize(query)): # 查询里重复的词只算一次
tf = doc.count(t)
if tf:
n = self.df[t]
idf = math.log(1 + (self.n - n + 0.5) / (n + 0.5)) # Lucene 的 IDF
total += idf * tf * (self.k1 + 1) / (tf + norm)
return total
def rrf(rankings, k=60):
"""rankings:每一路是按名次排好的文档 id 列表。"""
fused = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking, start=1):
fused[doc_id] = fused.get(doc_id, 0.0) + 1 / (k + rank)
return sorted(fused, key=fused.get, reverse=True)
完整实验在 week04_上下文与记忆/code/retrieval_lab/:corpus.json 是语料、概念词典和 8 个标注查询,python3 retrieval.py 输出每个查询三种检索的前 5 名、recall@5 / MRR、BM25 分数分解和分块实验,结果存在 results.txt。只用标准库。
公式显示依赖 KaTeX(CDN),断网时公式会显示成原始 LaTeX 源码,交互部分不受影响。