Learning AI Quality 返回 KuthorX Blog II博客首页

第 19 章

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

嵌入与检索:上下文放不下、放进去也会被忽略(Lost in the Middle),所以先检索 top-k。BM25 的公式、k1 和 b 各管什么,向量嵌入与余弦相似度,两者各自栽在哪类查询上,RRF 混合检索与 k = 60 的出处,分块大小和重叠的影响。在 46 条自己构造的 BugHunt 风格语料上用标准库实现并对比。一段讲解视频,一个能看到每个词贡献的检索演示。

测试 Agent 在被测应用上发现"收藏之后计数没变",提交 Bug 之前要先查两件事:历史 Bug 库里有没有人报过?需求文档对收藏计数是怎么规定的?查不到,就会重复报或者误报;查到了却排在第 30 名,等于没查到。这一章讲 Agent 怎么"查":词法检索 BM25、向量检索、把两者合起来的 RRF,以及长文档该怎么切块。所有实验都在一个自己构造的 46 条小语料上,用 Python 标准库实现,在本地跑。

讲解视频

互动演示

在 46 条 BugHunt 风格的文档上(B 开头是 Bug 报告,R 开头是需求片段)输入查询,三栏同时显示 BM25、向量和 RRF 融合后的排名。BM25 一栏每一行的色块是各个词元的贡献,点任一行能看到完整的分数分解:tf、df、idf、长度归一化的分母,以及余弦相似度和 RRF 的算式。可以拖 k1、b 和 RRF 的 k,看名次怎么变。8 个预设查询带人工标注,readout 显示三种方法的 recall@5 和第一个相关文档名次的倒数。向量一栏用的是手写的玩具嵌入,局限下面会讲。页面底部有自动判分的练习。

互动演示:同一个查询,三种检索 在新标签页打开

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

三个原因:

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

所以常见做法是 RAG( Lewis et al. 2020 ,NeurIPS)的思路:先检索出少量相关片段,再放进 prompt。

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

词法检索:从 TF-IDF 到 BM25

词法检索只看查询里的词在文档里出现没有、出现几次、这个词稀不稀有。TF-IDF 是词频乘逆文档频率:常见词(“文章"在 46 条里出现在 19 条中)权重低,稀有词(ERR_SLUG_CONFLICT 只出现在 2 条里)权重高。BM25 在此基础上加了两个修正,经典形式是:

$$ \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) $$

\(f(t,d)\) 是词 \(t\) 在文档 \(d\) 里的次数,\(|d|\) 是文档长度,avgdl 是平均长度,\(N\) 是文档总数,\(n(t)\) 是包含 \(t\) 的文档数。这个 IDF 是 Lucene BM25Similarity 的写法( BM25Similarity.java:138-141 ),永远为正;Robertson 原始的写法没有"1 +",一个词出现在一半以上的文档里时 IDF 会变成负数。另外 Lucene 的词频部分是 freq / (freq + k1 * (1 - b + b * dl / avgdl))( 同文件 :325-329 ),省掉了分子上的常数 \(k_1+1\)。\(k_1\) 固定时这只是整体缩放,不改变排名。

参数管什么取值的效果
\(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( BM25Similarity.java:96-101 )和 Elasticsearch 的默认值都是 \(k_1=1.2,\ b=0.75\)。Manning 等人的 Introduction to Information Retrieval (§11.4.3)的说法是:没有开发集可调时,实验表明 \(k_1\) 取 1.2 到 2、\(b\) 取 0.75 是合理的;有开发集就在上面搜索参数。

算一个:\(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\times 2.2}{2+1.2\times(0.25+0.75\times 35/33.96)}\approx 4.00 $$

“文章"出现在 19 条里,IDF 只有 0.880。

还有一个细节:本章的代码对查询里重复出现的词只算一次。Lucene 用 k3 控制查询侧的重复词,默认构造函数里 k3 是负数,表示关闭,这时重复词按出现次数线性累加( BM25Similarity.java:44-46 、 :131-136 )。查询里没有重复词时,两者结果相同;本章 8 个预设查询的词元都没有重复。

中文怎么切词

BM25 的"词"由分词器决定。本章的实验不用词典分词:英文和数字按连续字母数字切,中文按相邻两个字切(bigram),无法登入 切成 无法 法登 登入。这和 Lucene CJKAnalyzer 里 CJKBigramFilter 的做法同类( CJKAnalyzer.java:33-35 )。好处是不需要词典,代价是会产生"法登"这种没有意义的片段,也会让字面重合得高分:查"一篇文章最多能加几个标签”,BM25 的第 2 名是收藏规则 R05(“同一用户对同一篇文章最多收藏一次”),它和查询共享"一篇 篇文 文章 章最 最多” 5 个词元,总分 11.38,只比真正相关的标签规则 R03(13.66)低一点。

向量检索:嵌入与余弦相似度

嵌入模型把一段文本映射成一个固定长度的向量,训练目标是让意思相近的文本方向相近。检索时把查询也变成向量,按余弦相似度排序:

$$ \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,再归一化。它保留了真实嵌入最关键的性质,同义说法落到同一方向;但有两个真实模型没有的极端缺陷:词典外的词完全看不见(错误码、接口路径对它是空气),也完全不管词序和否定。真实嵌入模型能部分编码错误码这类字符串,只是不保证精确命中的那条排在前面。另外,概念怎么归并是我按这个应用定的:Conduit 的 favorite 是带计数的爱心按钮,用户常叫"点赞",所以本语料把点赞和收藏放进同一个概念;在一般 App 里两者是不同功能。

Anthropic 那篇文章举的正是这个例子:用户查 “Error code TS-999”,嵌入模型可能找到一堆讲错误码的内容,却漏掉精确包含 TS-999 的那条,BM25 按字面找能找到。 BEIR (Thakur et al. 2021,NeurIPS Datasets and Benchmarks)在 18 个数据集上做零样本评测,结论之一是 BM25 是一个稳健的基线,稠密检索模型往往不如其他方法,泛化还有很大提升空间。

各自栽在哪里

在 46 条语料(36 条 Bug 报告 + 10 条需求片段)、8 个人工标注了相关文档的查询上跑三种方法:

查询类型BM25向量(玩具)RRF
新建帖子报 ERR_SLUG_CONFLICT精确标识符B07、R04 排第 1、2第 1 是无关的 B11,B07 排第 5R04、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 个查询的平均数,误差条很宽。

混合检索与 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 给它 1/62 + 1/63 = 0.0320,抬到第 2,压过了只在向量里排第 2 的需求 R02(0.0161)。融合不是免费的。

Elasticsearch 的 rrf retriever 把这个常数叫 rank_constant,默认值也是 60;另一个参数 rank_window_size 决定每一路取前多少名参与融合。本章的实验每一路取前 10 名,分数为 0 的文档不算返回。

分块:切多大、要不要重叠

长文档要先切成块再建索引,检索返回的是块。Anthropic 那篇文章提到块"通常不超过几百个 token",也提醒块大小、块边界和重叠都会影响检索效果。我用一份 238 字的编辑器需求全文做实验:查询"标题长度上限是多少",答案是"120 个字符",用 BM25 在"46 条语料 + 切出来的块"里检索,看第 1 名:

切法块数第 1 名第 1 名里有答案吗
整篇不切1整篇(238 字)有,但无关的 5 条规则也一起进了上下文
每块 100 字,无重叠3块 1(100 字)没有。边界正好切在"标题长 | 度上限为 120 个字符"中间,块 1 有"标题"“题长”,答案在块 2
每块 100 字,重叠 20 字3块 2(100 字)有,整句落在同一块里
每块 20 字,无重叠12块 5(20 字)没有。块太碎,问题和答案被拆开

结论要带条件:重叠能缓解"句子被切断",但不能保证,换一个边界位置照样会断;更稳的做法是按语义边界切(句子、条目、标题),每块再带上所属文档的标题。块太大会稀释(向量被无关内容平均、BM25 被长度惩罚、上下文里塞进无关内容),太小会丢失上下文。没有普适的最佳值,要在自己的数据上量。

动手实验

代码在学习目录的 week04_上下文与记忆/code/retrieval_lab/,只依赖标准库:

  • corpus.json:46 条文档、30 个概念的同义词词典、8 个标注了相关文档的查询、分块实验用的需求全文。
  • retrieval.py:分词、BM25(带分数分解)、玩具嵌入、RRF、recall@k 和 MRR,以及分块实验。指标函数和 RRF 有几条手算断言做自检。

核心部分(为便于阅读做了简化,完整实现见 retrieval.py):

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)

python3 retrieval.py 的真实输出(节选,* 是标注的相关文档,RR 是第一个相关文档名次的倒数):

语料 46 条,平均长度 33.96 个词元;k1=1.2, b=0.75, RRF k=60, 每路取前 10

【精确标识符】新建帖子报 ERR_SLUG_CONFLICT    相关:B07, R04
  查询词元:新建 建帖 帖子 子报 err_slug_conflict
  BM25 R@5=1.00 RR=1.00 | *B07(4.00)  *R04(3.20)
  向量   R@5=1.00 RR=0.50 |  B11(0.76)  *R04(0.71)   B14(0.67)   B10(0.63)  *B07(0.55)
  RRF  R@5=1.00 RR=1.00 | *R04(0.0323)  *B07(0.0318)   B11(0.0164)   B14(0.0159)   B10(0.0156)

【同义改写】点赞数量不对    相关:B12, B13, R05
  查询词元:点赞 赞数 数量 量不 不对
  BM25 R@5=0.33 RR=1.00 | *B13(3.25)
  向量   R@5=1.00 RR=1.00 | *B12(0.90)  *B13(0.85)  *R05(0.79)   B14(0.47)
  RRF  R@5=1.00 RR=1.00 | *B13(0.0325)  *B12(0.0164)  *R05(0.0159)   B14(0.0156)

【混合】注销以后 JWT 还能用    相关:B06, R02
  查询词元:注销 销以 以后 jwt 还能 能用
  BM25 R@5=0.50 RR=1.00 | *B06(7.48)   B33(4.24)
  向量   R@5=1.00 RR=1.00 | *B06(0.80)  *R02(0.62)   B33(0.50)   B35(0.47)   B05(0.46)
  RRF  R@5=1.00 RR=1.00 | *B06(0.0328)   B33(0.0320)  *R02(0.0161)   B35(0.0156)   B05(0.0154)

平均(8 个查询):
  BM25 recall@5 = 0.854   MRR = 0.938
  向量   recall@5 = 0.938   MRR = 0.875
  RRF  recall@5 = 1.000   MRR = 1.000

BM25 分数分解:一篇文章最多能加几个标签
  R03(41 个词元)总分 13.664
    篇文                 tf=1 df=6  idf=1.978 贡献=1.824
    文章                 tf=1 df=19 idf=0.880 贡献=0.811
    章最                 tf=1 df=2  idf=2.934 贡献=2.704
    最多                 tf=1 df=2  idf=2.934 贡献=2.704
    个标                 tf=2 df=4  idf=2.346 贡献=3.048
    标签                 tf=3 df=8  idf=1.710 贡献=2.573
  R05(36 个词元)总分 11.379
    一篇                 tf=1 df=2  idf=2.934 贡献=2.863
    篇文                 tf=1 df=6  idf=1.978 贡献=1.931
    文章                 tf=1 df=19 idf=0.880 贡献=0.859
    章最                 tf=1 df=2  idf=2.934 贡献=2.863
    最多                 tf=1 df=2  idf=2.934 贡献=2.863

recall@k 是前 k 名覆盖了多少相关文档,MRR 是"第一个相关文档名次的倒数"在所有查询上的平均。互动演示里的 JavaScript 版和这份 Python 逐行对应,我用 Playwright 在 3 组参数 × 9 个查询(8 个预设查询,加一个只含错误码的自定义查询"ERR_SLUG_CONFLICT 怎么处理")上对照过,名次完全一致,分数只差浮点舍入量级(绝对差不超过 4e-15)。检索指标和重排的细节见下一章「检索变好了吗?recall@k、MRR 与 nDCG」。

常见错误说法

  • “有了向量检索就不需要 BM25”:错误码、接口名、版本号这类精确词,嵌入不保证排在前面。
  • “上下文窗口够大,就把所有文档都塞进去”:资料少时(Anthropic 给的参考线是 20 万 token 以内)确实可以;资料多时放不下,放得下也有 Lost in the Middle。
  • “RRF 的 k = 60 是理论推导出来的最优值”:是试点实验里定下的经验值,原文说接近最优但取值不关键。
  • “RRF 总比单路好”:原文在 TREC 和 LETOR 上的结果是 RRF 几乎总能超过参与融合的最好单路,但它奖励共识,两路都"有点像"的无关文档会被抬上来;要在自己的标注集上量。
  • “BM25 的 b 越大越好”:b 只决定长度打折的力度,最佳值取决于语料,要在标注集上调。
  • “块切得越小越精确”:太小会把问题和答案拆开,实验里每块 20 字时第 1 名就不含答案。