RAG
本文主要来分享本人在学习RAG的过程。本次学习过程基于一次 agent 的 RAG 模块设计。
/topic.png)
简单入门
简单来说,检索增强生成(Retrieval-Augmented Generation,RAG)的存在目的以及设计动机很简单。就是基于当前已经存在的语料库(知识库)中检索出一些与本次询问的问题有关的信息,然后将这些信息作为上下文提供给大语言模型来引导生成更准确、更可靠的答案。
整体来说可以将RAG切分为三个阶段。检索(Retrieval)是指从现有的知识库中查询相关的内容;增强是将检索的内容融入提示词中,辅助模型生成;生成则由模型来结合检索内容以及现有知识来输出更加准确以及有价值的答案。
对于一个RAG应用来说。其主要存在两个应用流程,其中一个是数据准备阶段,系统通过数据提取、文本分割、索引化等,将传入的外部只是构建成一个可以检索的数据库。随后在应用阶段,系统会基于用户所提供的查询/prompt,来在数据库中检索相关的信息,将其注入Prompt中,并最终驱动大语言模型生成答案
系统架构
在本文中,我们重要来分析检索阶段。对于RAG来说,检索是 RAG 的核心步骤,而方法存在很多。经过长久的发展,现在已经存在丰富的技术方案可供选择。在本次设计中,我们主要借鉴 agentty [1]中的基础检索方案实现。设计动机是常规的词法检索算法(如BM25等)无法理解词之间的语义,就比如在BM25中部署和上限之间零交集,相应的分数为0,对于这块不足,我们需要使用embedding模型来利用其在训练后所获得的语义理解能力。但是,embedding模型在关键词匹配等规则性检索的场景无法工作,而此时刚好由词法检索来补齐这块能力。通过综合应用俩者,俩者能够互补。这也是我们在最后为什么使用 RRF 的原因。这两路的分数实际上不可通约(BM25 分数无上界 vs cosine∈[-1,1]),只有名次才是通用货币。
/image-20260911082312739.png)
BM25
简单介绍
在继续之前,我们需要简单来了解一些BM25是什么。BM25算法,如果从效果上看,其是一个能够让你在一个数据集中来检索出有关查询的相关数据的算法。如果想要更好的理解它,我们需要简单的了解一下其的发展历史。
在BM25之前,也存在一些常用的词法检索算法。就比如朴素的检索思路:如果我查询语句中的一次词在一个文档中频繁出现,那么通常意味着这个文档中的内容与我的查询语句成相关关系。基于这个思路,TF(Term Frequency)出现了,其描述了一个词在一篇文档中的出现频率,如果值越高,那么通常意味着这个文档更可能是相关的文档。这是一版初期的检索方案。但是,这版方案也存在缺陷:对于每一个词,它的价值可能是不同的。就比如说“的”字,其普遍的出现在每个文档中,但是其提供了什么信息量吗?并没有,相反,其会拉高一个文档的TF打分。而这一定程度上会抑制其他更有价值的值的打分贡献。为了来解决这个问题,出现了另外一个影响打分的因子:**逆文档频率 IDF(Inverse Document Frequency)**。
IDF所描述的是一个词在当前知识库中的出现频率,如果说TF描述的是一个词在一个文档中有多重要,那么IDF描述的就是一个词在整个文档集合中有多稀有。而对于一个词来说,其如果在整个文档中文档出现频率越少,那么在基于这个词来进行查询时,对应的存在该词的文档的价值自然也就越大。至此,通过引入IDF,我们引入了词的价值来辅助判断文档与查询内容的关联性。
有了TF-IDF,此时我们能够有一个可以接受的检索方案了。但是,这个方案仍然存在许多缺陷,或者说,这个方案仍然可以发展,我们仍然可以引入更多的机制来处理当前方案中薄弱的点,就比如说:
- 一个文档中频繁出现一个词,甚至于一个文档就是有一个词重复多次堆砌而成的。在这种情况下,TF对于分数的贡献将会相当的高。但是实际检索出来的数据却是无价值/价值低的
- 文档长度对于其所存在的词的价值也有影响。就比如说,一篇10w 文本长度的文档中出现两次 “并发编程与实践”与一篇500文本长度的文档中出现两次 “并发编程与实践”的意义是不一样的。前者可能只是偶然,而后者则可能本身就围绕该词为主题。
随着这些问题的不断暴露,业界也出现了演进的算法。BM25就是其中一者。就比如说其为了解决第一个问题,其会在用一个词对于一份文档打分的时候,通过一些函数来提高分数的边际效应,我们仍然需要使得一个词出现次数增加能使得相关的文档关联性增加,但是需要控制增长的速度,就比如使用log函数来调整增长曲线。而对于第二个问题,其在对于文档打分时引入了文档长度的打分因素。使得长度本身也成为评价指标的一部分。通过如此,其又处理了两个常见的问题。当然,对应的BM25可能还存在很多需要解决的问题,就比如词语顺序(词距)之间的关系等等。但是这不是我们这次关心的重点,这自然的作为后续的学习内容即可。我们只需要知道,问题是不断发现、不断解决的。算法/方案也是不断迭代、不断成熟的。而对于我们这里,常规的BM25算法足够作为学习以及使用的锚点了。
落地
经历了前文,我们对于BM25算法应该有一个基础的理解。如果抛开所有的策略,核心其实只有一个:我们持有一个查询词,然后拿着这个查询词来为每个文档打分。分高的通常代表相关性越高,越可能包含相关的内容。
分词
当我们进行查询时,输入的通常不是一个单独的词,而是一段自然语言。例如在搜索引擎中,我们可能会搜索”什么是数据库”。
那么,我们是否可以直接将整个查询语句作为一个整体来进行匹配?理论上当然可以,但这样会带来一个问题:整个句子作为一个整体进行匹配,要求查询和文档具有较高的文本重合程度,难以处理部分匹配的情况。
例如,查询”什么是数据库”,而文档内容为”什么是数据库索引“。虽然两者显然具有较高的相关性,但如果将整个查询句子看做一个独立的检索单元,那么文档中的”什么是数据库索引“并不等于”什么是数据库“,因此很难建立有效的匹配关系。
因此,在搜索之前,通常需要将查询文本划分为更小的检索单元,也就是term。例如:什么是数据库→什么 / 是 / 数据库。这样,查询和文档就不再需要进行整个句子的完全匹配,而可以分别判断其中包含哪些相同的 term。
更重要的是,term 不仅是进行匹配的基本单位,也是信息检索模型进行统计的基本单位。例如 BM25 中需要根据 term 在文档中的出现次数(TF)以及 term 在整个文档集合中的出现范围(DF/IDF)计算相关性得分。因此,分词的核心目的并不是简单的“把句子拆小”,而是将自然语言转换为一组可以被独立匹配、统计以及索引的检索单元。在此基础上,系统可以进一步来建立倒排索引,根据查询中的 term 快速找到可能相关的文档,再由 BM25 等排序模型计算这些文档与查询的相关性。
我们接下来来看到一个实际的代码例子:
1 | void tokenize(std::string_view s, std::vector<std::string>& out) { |
这里不对于内容进行赘诉,自行来理解分析即可。我们只需要了解,对于一个检索算法,其的检索单元一般都为一个 term。通过将一个原始句子拆分为一个个 term,我们能够将句子的语义分散到多个 term 中一次来辅助后续的检索。当然,这个切分阶段不可避免的会造成一些信息的损失以及失真,当然也存在为了处理这些问题而出现的一些新机制,这里不深入讨论,所以分词这块也是一块相当有趣的领域,不同的分词策略会导致同一个句子会被拆分为不同的 term。而这些不同的结果又可能会导致后续打分等阶段的差异。简单来说,如何分词、分词质量如何是影响最后检索质量的重要一环。
分块
前文我们介绍了检索算法中的一等公民 term 以及产生 term 的过程。需要注意的是,分词并不只发生在用户查询语句上。对于作为检索数据来源的语料。我们同样需要进行分词,从而建立后续的倒排索引以及相关统计信息。那么问题来了:用户查询的数据来源是文档,我们是否可以直接将整个文档作为一次检索的对象?
从算法上来看当然可以,我们完全可以将一个为完整的文档进行分词,然后计算查询语句与整个文档之间的 BM25 得分。但是在实际的 RAG 系统中,这种做法通常并不合理,因为一个文档本身可能非常庞大,而用户真正需要的往往只是其中的一小部分内容。例如,一份包含数百页内容的数据库教程可能同时介绍数据库基础、SQL、事务、索引以及 Buffer Pool 等内容。当用户查询”什么是 Buffer Pool”时,我们真正希望检索出来的并不是整份数据库教程,而是其中介绍 Buffer Pool 的相关内容。
因此,我们通常会在文档进入检索系统之前,将一个较大的文档划分为多个较小的 Chunk(文本块):
1 | Document |
通过如此的切分,在后续的检索过程中,就可以将 Chunk 作为基本的检索对象,而不是将整份原始文档作为一个整体。分块的核心目的并不是简单地“把数据变小”,而是确定一个合理的检索粒度,使检索系统能够定位到文档中真正与查询相关的局部内容。如果完全不进行分块,那么检索系统即使能够正确判断某个文档与查询相关,最终返回的也可能是一个非常庞大的完整文档。对于 RAG 而言,这会带来两个问题:一方面,检索结果过于粗糙,无法精确定位相关内容;另一方面,将整个文档交给后续的 LLM 会占用大量上下文空间,其中可能包含大量与当前问题无关的信息。
分块同时会使得不同长度的原始文档被转换成相对统一、可控的检索单元。例如一个包含数千行的文档可以被划分为几十个 Chunk,而较短的文档可能只产生一个 Chunk。这样,BM25 面对的就不再是长度差异极大的完整文档,而是一组粒度更加接近的检索对象。
因此,可以将分块理解为 RAG 数据预处理过程中的一个重要环节。它位于原始文档和检索算法之间,负责将面向人的完整文档转换为适合机器进行检索的基本数据单元。需要注意的是,分块本身并不是检索算法本身的要求。例如 BM25 完全可以直接对完整文档进行计算。分块更多是应用层为了改善检索粒度、控制检索结果大小以及适配后续 LLM 上下文而进行的数据组织。
在这里,我们可以先将前文中介绍的两个概念联系起来:
1 | 原始文档 → 分块(Chunking) → Chunk → 分词(Tokenization) → Term → 建立索引 → BM25 检索 |
其中,分块解决的是”什么应该作为一次检索的对象“,分词解决的是”检索对象内部应该以什么作为匹配以及统计等操作的基本单位“。在此基础上,下一个问题就是:一个 Chunk 到底应该有多大?应该按照固定字符数进行划分,还是应该按照段落、标题等文档结构进行划分?如果一个 Chunk 太小,可能缺少完整上下文;如果太大,又会重新出现检索粒度过粗的问题。这里实际上没有一个标准答案,当然,那种“按照语义划分”的说法无法辩驳,但是这话本来就是我们这里的目的,没有讨论的价值。我们主要是需要根据本身的数据源的情况来定制化相对应的划分策略。就比如说 Chunk Size、Overlap 以及结构化分块等措施来尽可能保留语义。
索引
现在,我们已经基本走完了 BM25检索之前的数据预处理过程。首先,我们将原始文档划分为多个 Chunk,从而确定检索的基本对象;随后,再对 Chunk 进行分词,将其中的文本转换为一个个独立匹配以及统计的 Term。那么问题来了:当知识库已经被转换为大量 Term 之后,我们应该如何利用、组织这些 Term 呢?生成 Term 本身只是手段不是目的,如何做才能在用户真正发起查询时快速找到相关的 Chunk 呢?
最直接的方式当然是每次查询的时候重新遍历整个知识库。假设知识库中存在 10000 个 Chunk,当用户查询“数据库事务”时,我们可以一次读取每个 Chunk,将其分词,然后判断其中是否出现“数据库”和“事务”,最后计算相关性。但是这种做法存在一个明显的问题:相同的工作会随着每次查询不断重复。
对于知识库来说,其中的内容通常不会随着每一次查询发生变化,而用户的查询却会不断变化。因此,我们可以在知识库建立或更新时,提前对于其中的数据进行统计以及组织,把后续检索需要的信息保存下来。这样,当用户在真正发起查询时,就可以直接利用这些已经计算好的信息,而不需要重新处理整个知识库。这个提前建立的数据结构,就是索引(Index)。
因此,可以将索引建立理解成一次针对知识库的预计算过程:
1 | 知识库 → Chunk → 分词 → Term → 统计 Term 在各个 Chunk 中的信息 → 建立索引 |
需要特别注意的是,索引建立阶段只处理知识库本身,并不处理用户未来会输入的 Query。例如当前知识库中存在三个 Chunk:
- D1: 数据库通过事务保证数据的一致性
- D2: 数据库索引可以提高查询效率
- D3: 事务需要满足 ACID 特性
经过分词过后,我们可以得到:
- D1 → 数据库 / 通过 / 事务 / 保证 / 数据 / 一致性
- D2 → 数据库 / 索引 / 可以 / 提高 / 查询 / 效率
- D3 → 事务 / 需要 / 满足 / ACID / 特性
此时系统需要做的事情,就是统计这些 Term 在知识库中的分布情况。首先,对于每个 Chunk,我们需要统计其中每个 Term 出现了多少次,这就是 **TF(Term Frequency)**。例如:
1 | D1: |
如果一个 Term 在同一个 Chunk 中重复出现,那么这些出现次数就会被合并统计,这也就是一个词袋发挥作用的场景,其中会储存 term → count之类的映射,例如:
1 | D2: |
在 BM25 中,DF 会进一步参与 IDF 的计算。一个 Term 如果几乎出现在整个知识库的所有 Chunk 中,那么它的区分能力就比较弱;如果只出现在少数 Chunk 中,那么它通常具有更高的区分能力。因此,在建立索引的过程中,我们实际上是在提前收集 BM25 后续计算所需要的统计信息,包括:
1 | Term |
除此之外,BM25 还需要知道整个知识库中 Chunk 的平均长度,因此索引建立过程中还需要计算:Average Document Length。这些信息最终需要以一种能够根据 Term 快速找到相关 Chunk 的方式保存下来。这里就引出了 BM25 中非常重要的数据结构:倒排索引(Inverted Index)。所谓“倒排”,可以简单理解为:我们不是按照“Chunk → 它包含哪些 Term”进行组织,而是反过来按照“Term → 它出现在哪些 Chunk”进行组织。例如:
1 | 数据库 → D1, D2 |
这样,当用户输入:“数据库 事务”时,系统就不再需要遍历所有的 Chunk,而是可以直接通过索引找到:数据库 → D1, D2,事务 → D1, D3。从而快速确定哪些 Chunk 是候选结果,再进一步使用 BM25 来计算它们的相关性得分。由此,整个 BM25 索引建立过程就可以理解为:
1 | 知识库 → Chunk → 分词 → Term → 统计每个 Chunk 内的 TF → 建立 Term → Chunk 的倒排关系 → 计算 DF → 计算 Chunk Length → 计算 Average Document Length → 保存索引 |
需要注意的是,这些工作主要发生在知识库建立或更新阶段,而不是用户查询阶段。当用户真正发起 Query 时,系统不需要重新统计整个知识库的 TF、DF 和文档长度,而是可以直接利用已经建立好的索引,再结合 Query 中的 Term 计算 BM25 得分并进行排序。因此,索引的本质可以理解成:
将知识库中原本分散在各个 Chunk 中的 Term 信息进行预计算和组织,使检索阶段能够快速定位候选 Chunk,并直接利用已经保存的统计信息进行相关性计算。
下面是个人实现中一个索引建立的逻辑,仅供参考:
1 | Bm25Index build_bm25(const std::vector<Chunk>& chunks) { |
查询
通过前面一系列的学习,我们已经理解了一个信息检索系统中的索引建立过程。虽然其被称之为索引,但是本质上,我们可以将其看做是一些统计信息的集合,索引建立过程可以理解为对知识库进行的一次离线预计算。在计算过程中将知识库划分成 Chunk,对其中的内容进行分词,并建立了 Term 到 Chunk 的倒排关系,同时保存了后续 BM25 计算所需要的 TF、DF、Chunk 长度以及平均 Chunk 长度等统计信息。
那么,接下来的问题是:索引有什么用呢?当用户真正输入一个 Query 时,系统需要做什么呢?
与索引阶段不同,查询阶段面对的是用户实时输入的内容,而不是整个知识库。它的核心任务可以概括为:将 Query 转换成 Term,然后利用索引找到包含这些 Term 的 Chunk,根据已经保存的统计信息计算相关性得分,最后按照得分进行排序并返回 Top-K 结果。
这整个过程可以见到表示为:
1 | Query → 分词 → Term → 查询倒排索引 ↓→找到包含这些 Term 的 Chunk → 计算 BM25 相关性得分 → 排序 → Top-K |
这里正是我们之前所构建的索引将会实际发挥作用的场景。首先,检索系统需要对于用户的查询语句进行分词,需要注意的是这块的分词逻辑需要与 Chunk 的分词逻辑保持一致。否则可能会导致一定的效果劣化。然后,系统会利用本条查询语句所分出来的 Term 来查询已经建立好的倒排索引。假设系统将查询语句分词之后出现了”数据库“ 和“事务”两个 Term,在通过索引查询之后,其可能得到 数据库 → D1, D2, D5;事务 → D1, D5, D8这个查询结果。此时就可以知道,D1 和 D5 同时包含查询中的两个 Term,因此它们很可能与当前查询更加相关。这里需要注意,倒排索引的作用主要是帮助我们快速找到候选 Chunk,而真正决定 Chunk 排名的工作由 BM25 完成。
当我们已经通过倒排索引找到候选 Chunk 后,接下来的问题就从“哪些 Chunk 可能相关”变成了“这些 Chunk 到底有多相关”。这也是 BM25 真正发挥作用的地方。
在这里,我其实之前一直存在一个问题,就是数学有什么用?然后再之后,这个问题变成了:为什么数学可以表示那么多东西?直到最近,我看了一篇 Terence Tao 的播客 [2]。其中有一段话让我印象深刻,下面是大概的翻译:
我们看到某种现象,它迫切需要一个解释,或者需要进一步的研究。它可能不是一个为了应付眼前问题而急需马上解决的现象,但看起来它应该有。一个有意思的答案,数学差不多完全就是这样。由好奇心驱使的,数学里有些规律,形状里有些规律,这些只是大家在试图做别的事时顺便观察到的。 而我们想要更好的理解它,到某个节点,其他科学家能够把那个规律和自己所研究的东西联系起来。某种数学上的数字规律可能出现在昆虫集群的行为里、或者出现在股市里等等。
这段话给我深化了一些认知:现实中先存在问题和现象,而不是先存在公式和算法。人们观察现象,发现其中稳定的规律,再把这些规律抽象成可以描述和计算的数学对象。随着抽象不断深入,经验规律会逐渐形成数学模型、公式和算法;而建立起来的数学体系又可以脱离最初的问题独立发展,并在其他领域中重新解释新的现象。数学与现实因此不是单向的“工具关系”,而是一个不断相互塑造的循环。
因此,面对一个公式,与其只问“这个公式为什么能解决这个问题”,不如先回到它产生的问题:当时观察到了什么现象?存在什么矛盾?希望保留什么性质、消除什么偏差?公式中的每一个结构又是在回应哪个问题。这样公式就不再是一串需要记忆的符号,而成为对某类现象的一种高度压缩的表达。
相关性计算
从前面的分析可以看到,我们手中已经有了完成这件事情所需要的大部分信息:一个 Term 在当前 Chunk 中出现了多少次,即 TF;这个 Term 在整个知识库中出现了多少次,即 DF;当前 Chunk 有多长,以及所有 Chunk 的平均长度。BM25 所做的事情,本质上就是尝试利用这些信息,把“相关”这种原本比较模糊的概念转换成一个可以计算和比较的分数。
可以先不看公式,而从三个非常直观的显示问题出发:这个词在当前 Chunk(文档) 中重要吗?这个词在整个知识库中有区分度吗?这个 Chunk 是否因为太长而天然占据优势?
首先是 TF(Term Frequency)。如果 Query 中包含“数据库”,而某个 Chunk 中“数据库”只出现一次,另一个 Chunk 中出现了很多次,我们通常会认为后者与“数据库”这个查询之间的联系更强。因此,Term 在当前 Chunk 中出现得越多,通常意味着这个 Chunk 与我们想要知道的内容之间联系越深,算法本身应该更加偏向输出这类内容。
但这里又出现了一个新的问题:一个词出现 10 次,真的应该比出现 1 次的 Chunk 相关 10 倍吗? 显然通常不是。一个词从 1 次增加到 2 次,可能确实说明相关性增强;但从 100 次增加到 101 次,对相关性的提升通常已经非常有限。这个现象也很容易理解,在一份文档中,如果其想要解释一个概念,其往往不是会重新叙述这个概念的名字,其中的其他内容可能大部分都是针对于概念的延伸,一个出现频率适中的文档可能会比一个频繁出现的文档更加专注于相对应的主题。因此 BM25 没有让 TF 无限线性增长,而是引入了所谓的 TF Saturation(词频饱和):词频增加仍然会提高得分,但增加速度会逐渐变慢。公式中的 (k_1) 就是控制这一变化速度的重要参数。
第二个问题是 IDF(Inverse Document Frequency)。仅仅知道“数据库”在一个 Chunk 中出现很多次还不够,因为如果整个知识库里几乎每个 Chunk 都在讨论数据库,那么这个词其实并不能帮助我们区分具体的 Chunk。这个实际的场景也好理解,就比如说如果当前很多的文档都说明其与 AI 有关,那么用 AI 来筛选文档就很难筛选出一份有价值的文档,此时预期仍然带着这个词去查询,我们就不如换个方向来进行检索,也许查询语句的其他内容能够更好的来辅助筛选。相反,如果 Query 中出现的是一个非常少见的 Term,例如某个具体的函数名、错误码或者专有名词,那么包含它的 Chunk 往往具有更强的区分能力。因此,一个 Term 在整个知识库中出现得越少,它对当前查询的贡献通常就应该越大。
这里就可以看到 TF 与 IDF 实际上在回答两个不同的问题:TF 关注当前 Chunk:这个词在这里有多重要?;IDF 关注整个知识库:这个词有多稀有?因此,一个 Term 对某个 Chunk 的贡献,可以直观理解成“它在这个 Chunk 中出现得比较多,同时在整个知识库中又比较少见”。BM25 正是通过把 TF 与 IDF 结合起来,形成当前 Term 对当前 Chunk 的相关性贡献。
第三个问题是 Chunk 长度。假设两个 Chunk 都包含“数据库”这个词,一个只有 100 个 Term,另一个却有 2000 个 Term,那么后者天然拥有更多机会重复出现查询词。如果单纯比较词频,长 Chunk 很容易因为“内容多”而占据优势。因此 BM25 又引入了 Length Normalization(长度归一化)。Chunk 越长,在计算词频贡献时就需要进行一定程度的修正;而接近平均长度的 Chunk 则不需要进行过强的调整。公式中的 (b) 就主要控制这一长度归一化的程度。
这样一来,BM25 的结构实际上就变得非常清楚了:
TF:当前 Chunk 里这个词出现得多不多;
IDF:这个词在整个知识库里稀不稀有;
Length Normalization:避免 Chunk 仅仅因为更长就天然占优。
于是,一个 Query Term 对某个 Chunk 的最终贡献,就可以理解成这三个因素共同作用的结果:当前 Term 对 Chunk 的贡献≈ 词频 TF × 词的区分度 IDF × 长度修正。BM25 并不是简单把这三个数字直接相乘,而是通过具体的数学形式对 TF 进行饱和,并根据 Chunk 长度进行归一化。不过从理解算法的角度,这种拆分已经足以帮助我们掌握其核心思想。
多个 Term 如何共同决定一个 Chunk 的排名
前面我们假设 Query 只有一个 Term,但实际查询通常包含多个 Term。例如:Query:数据库 事务。经过分词后得到 数据库 和 事务 两个 Term。对于某个 Chunk,如果同时包含这两个 Term,那么它就会分别获得来自两个 Term 的相关性贡献,最后将这些贡献累加起来。因此,可以把整个计算过程理解为:
1 | Query「数据库 事务」 |
例如前面得到:数据库 → D1, D2, D5 事务 → D1, D5, D8。那么 D1 和 D5 都能够获得“数据库”和“事务”两部分贡献,而 D2 和 D8 只能获得其中一个 Term 的贡献。在其他条件相近的情况下,D1 和 D5 通常就会获得更高的最终得分。这也解释了为什么倒排索引和 BM25 实际上承担的是两个不同的职责:倒排索引负责缩小搜索范围,告诉我们“哪些 Chunk 值得计算”;BM25 负责计算这些候选 Chunk 与当前 Query 的相关性,告诉我们“谁更值得排在前面”。
从这个角度看,倒排索引本身并不直接产生“最相关”的答案,它更像是在海量知识库中完成了一次高效的候选集筛选;真正的排序工作发生在候选集进入 BM25 之后。
公式本身
到这里,再回头看 BM25 的公式,原本一串比较干燥的数学符号实际上已经可以与前面的现象一一对应:
这里我们更重要的的是知道每一步到底在解决什么问题。其中,
表示:Query 中不同 Term 的贡献最终需要汇总起来;(TF(t,D)) 表示:当前 Term 在当前 Chunk 中出现了多少次;(IDF(t)) 表示:这个 Term 在整个知识库中的区分能力有多强;而
则是在处理:当前 Chunk 相对于平均 Chunk 到底有多长。至于 (k_1) 和 (b),它们并不是在描述某个新的现实对象,而更像是对模型行为的控制参数:(k_1) 主要控制 TF 饱和的程度,(b) 主要控制长度归一化的程度。
因此,回到前面关于“为什么数学能够表示现实”的讨论,BM25 这个公式其实就是一个很典型的例子。现实中并不存在一个叫做“BM25”的东西。我们首先观察到一些搜索中的现象:词出现得更多通常意味着更相关;稀有词更具有区分度;长文档不能因为天然包含更多词就获得不公平的优势;词频增加带来的价值还会逐渐减弱。然后,我们把这些现象分别抽象成 TF、IDF、文档长度、TF Saturation 等数学概念,再把它们组合成一个可以计算的模型。最终,一个原本无法直接进行精确计算的“相关性”,就被转换成了一个可以比较大小的数值。从更高一层来看,BM25 并不是“拿一个神奇的公式解决搜索问题”,而是:
先观察搜索中的现实现象,再将这些现象抽象成数学量,最后利用数学把这些量组合成一个可计算、可比较的相关性模型。
而查询阶段真正做的事情,就是把用户的 Query 带入这个模型,让知识库中不同 Chunk 的这些现象被统一计算,最终形成一个排序结果。整个 BM25 检索过程,到这里就可以闭环了:
1 | 知识库 → Chunk → Term → TF / DF / Chunk Length → 倒排索引 |
至此,前面所讨论的分词、分块、索引和 BM25 其实已经不再是几个孤立的概念,而是一条完整的信息检索链路:分词决定我们用什么作为匹配单位,分块决定我们以什么作为返回单位,索引负责高效找到候选 Chunk,而 BM25 则负责对候选 Chunk 进行相关性排序。
总结
本文是我围绕 RAG 稀疏检索的一次实践性总结。一开始,我的目标只是理解 BM25 如何完成检索,但随着实际实现逐步展开,我发现,真正值得留下来的并不只是 BM25 本身,而是从问题出发、逐步建立抽象,再将抽象落地为可运行系统并通过实验验证的过程。
回头看整个检索链路,其实并没有一开始就存在一个完整的“BM25 方案”。最初只有一个很具体的问题:如何从知识库中找到与用户 Query 更相关的内容? 随着问题逐渐拆解,才出现了后面的一个个概念:文本太粗,就需要分块;整句匹配粒度太高,就需要分词;每次查询都扫描全部内容效率太低,就需要建立倒排索引;词频能够反映一定程度的相关性,于是有了 TF;常见词区分度低,于是引入 IDF;长 Chunk 容易因为内容更多而天然占优,于是需要长度归一化;词频无限增长又不符合实际,于是进一步引入 TF Saturation。最终,这些对不同现象的观察,被组合成了 BM25。
因此,BM25 对我而言不再只是一个需要记忆的公式,而是一个从现实问题中逐步生长出来的数学模型。先观察现象,再进行抽象;先建立模型,再用数学描述;最后把模型转化为数据结构和代码,并通过实验检验它是否真的有效。 公式只是这一过程中的一个中间产物,它将复杂的经验规律压缩成了可以计算和比较的形式。
这在一定程度上也让我重新理解了数学。数学并不是与现实割裂的抽象符号。很多时候,它只是人们面对现实中的问题、现象与矛盾时,对其中稳定关系进行抽象和压缩的一种方式。一旦这种抽象建立起来,它又能够被迁移到新的问题中,帮助我们理解原本看似不同的现象。因此,数学与工程并不是简单的“工具与被工具”关系,而是在问题、抽象、实现和验证之间不断相互推动。
不要从结论开始,而要从问题开始;不要只记住公式,而要理解公式在解决什么矛盾;不要停留在概念层面,而要把它真正实现出来,再通过测试和实验观察它的行为。
先有问题与现象,再有抽象与模型;先理解为什么存在,再理解如何实现;最后通过实验验证模型是否真的解释了现实。
公式不是起点,也不是终点。它是对规律的一次压缩,也是继续追问问题的起点。
参考文献
[1] https://agentty.org/blog/agentty-0-2-9-retrieval-engine/
[2] https://www.youtube.com/watch?v=OOMx2BHHWtE
[3] https://jalammar.github.io/illustrated-word2vec/