混合检索
Chunbin Lv4

混合检索实战笔记

问题的起源

某一天我发现我的财报站 AI 对话带的上下文太多了,因为我的上下文就是通过向量比对然后取出 top10 的,所以无关的东西特别多,简单测试了下大概 10 个 chunk 只有一个有用,所以我决定优化一下。

思路

我们采用BM25算法补上向量的缺点和RRF算法对两种算法筛出来的结果进行融合,然后把top10改成top5,为什么要这么处理呢,因为原本只依靠向量拉出的东西,其实很多句子短是拉不开差距的,甚至有很多无关消息会比正确的chunk排的位置前,所以只是单纯缩小TOPK的K,有些问题会拿不到需要的chunk,所以我们得这么做。下面是这几个算法的具体公式:

BM25 算法

BM25 的全名是 Best Matching 25(第 25 次迭代,名字就是这么来的)。它是 TF-IDF 的继承者,1994 年前后由 Robertson 等人在概率检索模型框架下推导出来的,到今天仍然是 Elasticsearch / Lucene 的默认打分函数。
它由三部分组成:

  • 词频,但会饱和:出现 20 次和 10 次的差别,远小于 2 次和 1 次的差别。防关键词堆砌。
  • IDF:稀有词更值钱。这是它的灵魂,也是向量完全没有的能力——向量空间里没有”这个词在全库多罕见”的概念。
    公式:
  • 文档长度归一化:长文档天然容易撞上任意关键词,按 |D|/avgdl 惩罚,其中|D|代表文档长度,avgdl代表平均文档长度。

它的优点和缺点

优点:

  1. 精确关键词匹配:对专有名词、代码、报表科目名(比如”应收账款周转率”)这种必须原样命中的词,BM25 比向量更可靠——向量检索是语义近似匹配,遇到生僻词/罕见组合容易召回不到。
  2. 补全了向量没有词频的缺点:IDF 让稀有词天然获得更高权重,向量空间里没有”这个词在全库多罕见”的概念。
  3. 可解释性强:命中的原因就是关键词本身,排查召回问题时比向量的黑盒相似度直观得多。
  4. 计算成本低:不依赖 embedding 模型调用,纯本地倒排索引 + 打分,速度快、无需 GPU/API 调用开销。

缺点:

  1. 没有语义上的识别(这是向量的优点):查询词和文档用了不同措辞(同义词、近义表达、跨语言)时,BM25 完全召回不到,比如问”营收增长”,文档里写的是”收入同比提升”就匹配不上。
  2. 依赖分词质量:中文没有天然分隔符,切词(这里用的是 trigram)的好坏直接决定召回率,切错就漏检。
  3. 无法理解上下文和指代:比如”它””这家公司”这种指代词,BM25 无法关联到具体实体。
  4. 对短查询/短文档不够鲁棒:词频统计在文本很短时区分度有限,容易被单个高权重词主导排序。

所以他们两个是比较互补的:BM25 补关键词精确匹配和可解释性,向量补语义泛化,两者共同覆盖对方的盲区。

RRF 算法

现在有了 BM25,和原来的向量比较,我们需要把他们融合起来

Reciprocal Rank Fusion (RRF) 是一种简单有效的数据融合算法,是一种将具有不同相关性指标的多个结果集组合成单个结果集的方法。

各参数含义:

  • d:一个候选文档(在这里就是一个 chunk)
  • D:所有候选文档的集合,也就是向量路和 BM25 路各自召回结果的并集
  • M:参与融合的检索方法集合。这里 M = {向量检索, BM25},所以求和就是两项相加
  • m:M 中的某一种具体检索方法(向量或 BM25)
  • r_m(d):文档 d 在方法 m 的结果列表里的排名(rank),从 1 开始。比如某个 chunk 在向量路结果里排第 3,在 BM25 路结果里排第 8,这两个排名分别代入公式;如果某个 chunk 只出现在一路里,另一路直接不贡献这一项(相当于该路的求和项为 0)
  • k:平滑常数,论文里取的经验值是 60。作用是压缩排名差距——没有 k 的话,第 1 名和第 2 名的分数差(1 - 0.5 = 0.5)远大于第 100 名和第 101 名的差(≈0.0001),排名越靠前差距被无限放大;加上 k 之后,1/(60+1) 和 1/(60+2) 差距很小,使得融合更看重”两路是否都认可这个结果”,而不是单路的名次波动

直觉理解:一个 chunk 只要在两路里都排得比较靠前,两项相加后分数就会明显高于只在一路里靠前的 chunk——这正是”融合”的核心目的,弥补向量和 BM25 各自的短板。

最终结果

整体的流程如下,然后我增加了一些测试集,用于统计命中率,这次的优化把整体命中率提升了10.5pt的同时还减少了TOPK到5

flowchart TD
    A[用户提问] --> B1
    A --> C1
    Note1["Promise.allSettled<br/>两路独立成败"]
    A -.- Note1

    subgraph V[向量路]
        direction TB
        B1[embedding-3] --> B2[Chroma query] --> B3["WHERE doc_id(预过滤)"] --> B4["20 条"]
    end

    subgraph K[BM25 路]
        direction TB
        C1[切 trigram] --> C2[FTS5 MATCH] --> C3["WHERE doc_id(预过滤)"] --> C4["≤20 条"]
    end

    B4 --> D["RRF 融合 k=60"]
    C4 --> D
    Note2["按 doc_id#chunk_index 去重"]
    D -.- Note2

    D --> E["取前 maxK=5"]
    E --> F["取 [§章节名] 正文 ×5 → system prompt"]
    F --> G["glm-4.7-flash 流式生成"]
    G --> H["analyzeCitations():统计模型引用了几块"]
 评论
评论插件加载失败
正在加载评论插件
由 Hexo 驱动 & 主题 Keep
访客数 访问量