Agent 怎么从一堆 Bug 报告里找到相关的那几条?

检索:先用便宜的方法从库里挑出最相关的 k 条,只把这 k 条放进上下文。BM25 擅长精确的词(错误码、接口名),向量擅长换了说法的同一件事,RRF 只看名次把两路结果合起来。

测试 Agent 每发现一个疑似 Bug,都要先查"以前报过没有、需求怎么规定的"。查不到会重复报、误报;查到了却排在第 30 名,也等于没查到。

1为什么不把所有资料都塞进上下文

BugHunt-Bench 的场景:测试 Agent 在 Conduit(RealWorld 博客应用)上发现"收藏后计数没变"。提交之前它要回答两个问题:历史 Bug 库里有没有同一个问题?需求文档对收藏计数是怎么规定的?

  1. 放不下:Bug 库和需求文档会一直涨。一个跑了几个月的项目,几万条报告加上全部需求,远超上下文窗口。第 3 周「上下文快满了,Codex 怎么办?」讲过窗口满了只能压缩,压缩是有损的。
  2. 放得下也会被忽略:Liu et al. 2023 的 Lost in the Middle(arXiv 2307.03172,后发表于 TACL)在多文档问答和键值检索两个任务上发现,相关信息在输入开头或结尾时效果最好,放在长上下文中间时显著下降,标榜长上下文的模型也一样。
  3. 贵、慢:每轮都重发全部资料,token 和延迟跟着涨(第 2 周「Agent 到底是什么?一个 while 循环」算过,累计 token 随轮数平方增长)。

所以常见做法是 RAG(Retrieval-Augmented Generation,Lewis et al. 2020,NeurIPS)的思路:先检索,再把检索到的少量片段放进 prompt。

反过来也成立:资料少的时候不必检索。Anthropic 在 Introducing Contextual Retrieval(2024-09-19)里写:知识库小于 20 万 token(约 500 页)时,可以直接整个放进 prompt,配合 prompt caching 降低成本和延迟。本章的 46 条小语料只是为了把机制看清楚,真到这个规模直接放进去就行。

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,就是被这几个字骗的)。

算一个:语料 \(N=46\),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 做不到的。

本章用的是玩具嵌入。本机没有装 sentence-transformers,实验也不调付费 API,所以我手写了一个 30 维的"概念词典嵌入":每一维是一个概念(登录、收藏、评论……),文本里出现这个概念的任一说法("登录 / 登入 / login"都算"登录")就在那一维加 1,再归一化。它保留了真实嵌入最关键的性质(同义说法落到同一方向),但有两个真实模型没有的极端缺陷:词典外的词完全看不见(错误码、接口路径对它是空气),词序和否定也完全不管。真实嵌入模型能部分编码 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、2B07 排第 5,第 1 是无关的 B11R04、B07 第 1、2
无法登入,提示凭证无效同义改写第 1 是 B06(只因为都有"登入"),B01 第 2(只靠"提示"两个字)第 1 是 B03(同样是登录 + 密码),B01 第 2B01 第 1
点赞数量不对同义改写只找到 B13(靠"数量"),recall@5 = 0.333 条相关全在前 33 条全在前 3
注销以后 JWT 还能用混合B06 第 1,需求 R02(写的是"退出登录""令牌")找不到B06、R02 第 1、2B06 第 1,R02 第 3
一篇文章最多能加几个标签两者都行R03 第 1,B21 第 5R03 第 1,B21 不在前 5R03 第 1,B21 第 5

8 个查询的平均:BM25 recall@5 = 0.854、MRR = 0.938;玩具向量 recall@5 = 0.938、MRR = 0.875;RRF 两项都是 1.000。

这组数字只说明机制,不说明"RRF 一定满分"。语料和查询都是我构造的,只有 8 个查询,向量是玩具。真实项目要在自己的标注集上量,第 1 周讲的置信区间同样适用:8 个查询的平均数,误差条很宽。

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。

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 recall@5 / RR
向量 recall@5 / RR
RRF recall@5 / RR

BM25 分数 · 色块 = 各词贡献

向量(玩具) 余弦相似度

RRF 融合 Σ 1/(k + 名次)

✎练习

题 1(算 IDF):语料共 46 条,词 favoritescount 出现在其中 2 条里。按 Lucene 的写法 \(\ln(1+\frac{N-n+0.5}{n+0.5})\),它的 IDF 是多少?
\(\ln(1 + 44.5/2.5) = \ln 18.8 \approx 2.934\)。对比"文章"出现在 19 条里:\(\ln(1 + 27.5/19.5) \approx 0.880\)。稀有词的 IDF 是常见词的 3 倍多。
题 2(算 RRF):k = 60。文档 X 在 BM25 里排第 2、在向量里排第 3;文档 Y 只在 BM25 里排第 1,向量没返回它。X 的 RRF 分数是多少(保留 4 位小数)?
X = 1/62 + 1/63 ≈ 0.0161 + 0.0159 = 0.0320;Y = 1/61 ≈ 0.0164。X 两路都只是"还不错",却比 Y 高了近一倍:RRF 奖励共识。上面演示里"注销以后 JWT 还能用"的 B33 就是这样被抬上来的。
题 3(选检索方式):测试 Agent 在网络面板里看到接口返回 ERR_SLUG_CONFLICT,要查历史 Bug 库里有没有报过。只能选一路,哪一路最可靠?
精确标识符是词法检索的主场。嵌入模型可能找到"讲保存失败的一类文档",但不保证精确包含这个错误码的那条排在前面(Anthropic 用 TS-999 举过同样的例子)。演示里玩具向量把 B07 排在第 5。
题 4(参数含义):把 BM25 的 b 从 0.75 调到 0,会发生什么?
b 只管长度归一化:\(1-b+b\,|d|/\text{avgdl}\) 在 b = 0 时恒等于 1。A 说的是 k1 = 0。IDF 和 b 无关。在演示里把 b 拖到 0,"一篇文章最多能加几个标签"里最长的 B21(42 个词元)分数从 5.58 涨到 5.91,追平并以极小差距超过 B36(5.913117 对 5.913074),升到第 4 名。
题 5(分块):需求文档按每块 100 字、无重叠切开。查"标题长度上限是多少",第 1 名的块里有"标题长",答案"120 个字符"却在下一块。最该先改什么?
问题出在切法:一句话被切成两半,换模型救不回来。A 把一堆无关块塞进上下文,又回到了"放进去也会被忽略"的问题。重叠能缓解但不保证,按语义边界切更稳。

7面试要点与代码

一句话讲清楚

上下文放不下全部资料,放进去中间的也容易被忽略,所以先检索 top-k。BM25 按词算分:IDF 让稀有词权重高,k1 让词频饱和,b 按文档长度打折,擅长错误码、接口名这类精确词;向量检索按嵌入的余弦相似度排,擅长同义改写,但不保证精确匹配。混合检索用 RRF 按名次融合,\(\sum 1/(k+r)\),k = 60 是 Cormack 2009 试点实验定的经验值,取值不关键。分块的大小和边界决定答案会不会被切断,都要在自己的标注集上用 recall@k 量出来。

追问准备

  1. 为什么不直接把两路分数加权相加?BM25 分数没有上界、随语料变化,余弦在 −1 到 1,量纲不同;要加权得先归一化,归一化方式本身又引入超参数。RRF 只用名次,不需要校准分数。
  2. RRF 的缺点?丢掉了分数里的"差多少"信息:第 1 名领先一大截和险胜,贡献一样;而且奖励两路共识,两路都"有点像"的无关文档可能被抬上来。
  3. 中文 BM25 怎么分词?词典分词(如 jieba)或字 bigram。bigram 不需要词典、召回高,但会引入"法登"这种噪声片段;查询和索引必须用同一个分词器。
  4. 检索效果怎么测?先标注"查询 → 相关文档",算 recall@k(前 k 名覆盖了多少相关文档)和 MRR(第一个相关文档名次倒数的平均)。改分词、改块大小、加混合检索,每一次都在同一个标注集上对比。指标和重排的细节见下一章「检索变好了吗?recall@k、MRR 与 nDCG」。

常见错误说法

❌ "有了向量检索就不需要 BM25":错误码、接口名、版本号这类精确词,嵌入不保证排在前面。
❌ "上下文窗口够大,就把所有文档都塞进去":资料少(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 源码,交互部分不受影响。