此前针对项目中 RAG 检索做的一些优化工作,最近重新梳理了下。

这里基于个人实操经验,来对整个技术路径进行拆解、分析,同时也欢迎提出不同看法和优化经验、欢迎指出文档中的错漏。

1. 技术路径

BM25 稀疏向量 + 稠密向量 + LLM Reranker

这里的 LLM Reranker 泛指 LLM 或 Reranker 模型,可以使用小型 LLM 作为 Reranker,也可以使用更专业的 Reranker 模型。

2. BM25

简单介绍:BM25(Best Matching 25)是一种经典的信息检索算法,是基于Okapi TF-IDF算法的改进版本,旨在解决Okapi TF-IDF算法的一些不足之处。其被广泛应用于信息检索领域的排名函数,用于估计文档D与用户查询Q之间的相关性。它是一种基于概率检索框架的改进,特别是在处理长文档和短查询时表现出色。BM25的核心思想是基于词频(TF)和逆文档频率(IDF)来计算,同时还引入了文档的长度信息来计算文档D和查询Q之间的相关性。目前被广泛运用的搜索引擎ES就内置了BM25算法进行全文检索。

简单介绍下,更多详细原理可百度自搜。

2.1 业务场景

为避免信息敏感,以下所有内容均脱敏或使用其他更通用的词汇、表达方式。

引入 BM25 主要针对两类不同的检索召回场景:

1)识别 Query 中的短语、黑话等关键词,匹配历史相似案例的短文本(数量级在 10 万级,样本内容长度整体在 200 字符以内)

以中文分词后的词作为 Token,结合约 10 万条、单条长度约 200 字符的规模,去重后的词表 Token 数量可粗略按十万级到百万级预估。短语、黑话、专有名词和随机字符串较多时,词表规模可能进一步增大。

2)识别 Query 中的专业术语、专有名词,匹配 FAQ、SOP 文档等长文本(数量级 < 1000,样本内容长短不一,整体字符长度 > 800 字符)

以中文分词后的词作为 Token,结合少于 1000 条、单条长度整体超过 800 字符的规模,去重后的词表 Token 数量可粗略按万级到十万级预估。专业术语密度较高或文本内容差异较大时,词表规模会接近更高量级。

词表 Token 数量指分词后去重的 Token 数量,也就是 Sparse Embedding 的维度上限。实际数量取决于分词器、停用词过滤、词项归一化和数据内容,以上仅用于数量级估算。BM25 实际通过倒排索引保存文档中出现过的词项及其词频,并不要求为每篇文档显式生成完整的高维稀疏向量。

2.2 Unicode 标准化

作用: 将语义相同但编码形式不同的文本,统一转换为一种“标准”形式,从而确保后续分词、检索和文本分析的准确性与一致性。

常规的标准化形式:

形式 主要特点 对中文分词的典型影响
NFC 优先合成。将组合字符(如 e + U+0301)尽可能合并为预组字符(é)。 对简体中文影响不大,但能规范带变音符号的拉丁字符,适合作为常规标准化的第一步。
NFD 完全分解。将预组字符分解为基底字符和组合用符号。 对简体中文影响甚微,但可用于一些需要将字符拆解到最细粒度的特殊分析场景。
NFKC 合成 + 兼容等价。在NFC的基础上,还会进行“兼容等价”转换,例如将全角字母、带圈数字(①)转换为普通字母和数字(1)。 在中文分词中最常用。它能有效消除全角/半角带来的差异,显著提升分词和检索的准确率,是多数搜索引擎和分析器的首选。
NFKD 分解 + 兼容等价。在NFD的基础上,应用“兼容等价”转换。 与NFKC类似但更彻底,会对字符进行分解。在中文混排场景中较少直接使用,可能会过度分解一些符号。

本文中选择的标准化方法为常规 NFKC,即:

import unicodedata

document: str = "你好,世界!"

normalized = (
  unicodedata.normalize("NFKC", char)
  for char in document
)

如果文档处理存在其他需求,例如去除重音字符,可使用 NFKD + 过滤 “Mn” 组合标记。具体选用应结合具体使用场景,以上案例仅提供参考。

2.3 分词

业务文档主要由中文、少量英文/数字及其他常规字符构成,分词主要使用中文分词,本文使用 jieba。

jieba 中的分词方法

  • cut
  • cut_for_search
  • lcut:与 cut 的区别是直接返回 list 而非生成器
  • lcut_for_search:同上

业务场景主要是构建 RAG 库用于历史案例召回,这里我们选用 cut_for_search 或 lcut_for_search 均可。

以上内容基于 jieba 0.42 版本。

2.4 停用词过滤

准备初始停用词表,用于分词后过滤 Tokens 中无意义或无实际作用的词汇,例如“的”、“是”、“好的”等常规停用词,以及一些业务初始筛选的无意义词。

2.5 词干提取

需要注意的是,在常规英文或其他相似语言文档中需要进行词干提取,中文因为无词形变化(如 run -> ran 或 stem -> stemmed),故中文文档一般无需提取。

中文文档中的英文专有名词一般为原型,特别英文内容存在形态变化、且需要处理的就需要囊括词干提取。具体提取方法可自行百度。

2.6 词频(TF)统计与倒排索引构建

分词及后续处理后,得到文档的完整 Token 序列。下一步需要统计每个 Token 在当前文档中出现的次数,即词频(Term Frequency, TF),并将这些信息写入倒排索引。

倒排索引的核心结构为:Token -> [(Doc1, TF1), (Doc2, TF2), ...],存储的是原始统计信息(如词频 TF),而不包括后续查询时才动态计算的归一化权重。这样设计的好处是:

  • 索引构建时无需依赖全局统计量(如平均文档长度),降低构建复杂度。
  • 查询时可以灵活调整参数(如 b、k1),无需重建索引。

2.7 IDF 计算

IDF(Inverse Document Frequency,逆文档频率)用于衡量某个 Token 在整个语料库中的区分度:

  • 如果一个 Token 在大量文档中都出现,例如“问题”、“系统”、“用户”,它的区分度较低,对检索排序的贡献应该降低。
  • 如果一个 Token 只在少量文档中出现,例如业务黑话、专业术语、错误码、产品型号,它的区分度较高,对检索排序的贡献应该提高。

在实际实现中,IDF 不建议在业务侧手动维护,而是交给向量数据库或搜索引擎基于集合统计自动计算。以 Qdrant 为例,可以在创建 sparse vector 配置时启用 IDF modifier:

from qdrant_client import models

sparse_vectors_config = {
    "sparse": models.SparseVectorParams(
        modifier=models.Modifier.IDF,
    )
}

这样业务侧只需要完成以下几步:

1)对文档做标准化、分词、停用词过滤等预处理。

2)统计文档内每个 Token 的 TF,并生成 sparse vector:

{
    "indices": [token_id_1, token_id_2, token_id_3],
    "values": [tf_1, tf_2, tf_3],
}

3)写入 Qdrant 后,由 Qdrant 根据集合内文档统计维护 Token 的文档频率,并在检索时自动应用 IDF 权重。

4)查询侧通常只需要表达“Query 中出现了哪些 Token”,可以使用 binary weight,即出现的 Token 权重为 1.0、未出现的 Token 权重默认为 0。最终排序时,Qdrant 会结合 sparse index 中的统计信息计算稀疏向量相似度。

即查询时可忽略具体 Token 的权重,统一将存在的 Token 权重值赋值为 1.0:

import numpy as np

indices: list[int] = token_ids
values: list[float] = np.ones(len(indices), dtype=np.float32).tolist()

sparse_embedding = {
    "indices": indices,
    "values": values,
}

这种方式的好处是:

  • 不需要业务侧维护全局 DF / IDF 表,减少实现复杂度。
  • 文档增量写入、删除、更新后,IDF 统计由 Qdrant 侧统一处理。
  • 多实例服务下不需要同步本地 IDF 状态,避免不同实例打分不一致。
  • Sparse 向量仍然只保存文档中实际出现的 Token,避免显式构建完整高维向量。

需要注意的是,若 Qdrant 的 sparse vector 配置未开启 Modifier.IDF,则 Qdrant 不会自动补充 IDF 权重,传入的 sparse vector value 会直接参与稀疏向量打分。因此如果业务侧希望“IDF 的计算交给 Qdrant”,创建 collection 时必须显式配置 modifier=models.Modifier.IDF。

2.8 BM25 查询评分

查询时,BM25 根据倒排索引中存储的 TF 统计信息、全局 IDF 以及文档长度信息,动态计算每个文档的得分:

\text{Score}(Q, D) = \sum_{i=1}^{n} \text{IDF}(q_i) \cdot \frac{f(q_i, D) \cdot (k_1 + 1)}{f(q_i, D) + k_1 \cdot \left(1 - b + b \cdot \frac{|D|}{\text{avgdl}}\right)}

  • q_i:查询内容中的每个词
  • \text{IDF}(q_i):查询词的逆文档频率
  • f(q_i, D):查询词在文档 D 中的词频(TF)
  • |D|:当前文档的长度
  • \text{avgdl}:整个文档集合的平均长度
  • b:归一化强度系数(0 ~ 1,常用经验值为 0.75),控制文档长度对得分的影响程度
  • k_1:常数项,用于平衡 TF 和 IDF 的权重,常用经验值为 1.2

其中 \left(1 - b + b \cdot \frac{|D|}{\text{avgdl}}\right) 即为文档长度归一化因子,其作用是:

  • 当文档长度 |D| 大于平均长度 \text{avgdl} 时,该因子 > 1,词频贡献被削弱(惩罚长文档)。
  • 当文档长度 |D| 小于平均长度 \text{avgdl} 时,该因子 < 1,词频贡献被增强(奖励短文档)。

该归一化因子的计算依赖于查询时的全局统计量 \text{avgdl},是在查询时动态计算的,而非索引构建时预先计算。

3. 混合检索

简单介绍 :混合检索(Hybrid Retrieval)是一种结合了多种检索技术的检索方法,旨在提高检索效率和准确性。

混合检索结合了向量检索和关键词检索的优点。向量检索基于语义相似性,适合处理复杂查询和上下文扩展,而关键词检索则在精确匹配(如订单ID、品牌名称)和低频词汇的检索中表现出色。通过混合检索,可以先利用关键词检索锁定精确信息,再通过向量检索扩展相关上下文,从而实现更全面的结果。本文中关键词匹配使用 BM25。

3.1 MRL Dense Embedding

简单介绍 :MRL Dense Embedding 是一种基于深度学习的文本表示方法,通过 Matryoshka 表示学习(Matryoshka Representation Learning)训练得到。其核心思想是在训练过程中通过嵌套损失函数(Matryoshka Loss)约束模型,使得完整向量的前若干维(如前 256 维)本身就具备良好的语义表征能力。

在常规向量 RAG 中,比较简单的做法是获取 Embedding 向量后直接检索相似内容,得到 TopK 及相似度分数。现在我们就能基于 MRL 稠密向量做这么一件事:

假设原本需要检索 2056 维向量的 Top15 相似文档。

1)基于 MRL Dense Embedding 获取 Top50 相似文档;

2)基于 MRL Dense Embedding 的检索结果,进一步执行 Full Dense Embedding 检索获取 Top15 相似文档。

这么做的好处是:

  • 基于 MRL 较低维度向量实现快速检索 Collection,提升检索速度;
  • 将二次检索范围限定在 MRL 检索结果范围内,避免 Full Dense Embedding 在 Collection 中大量的相似度计算工作,整体提升效率。

获得 MRL Dense Embedding:

我们可以通过 Embedding 模型直接获取 2056 维的 Full Dense Embedding;然后取前 256 维,归一化后得到 MRL Dense Embedding。

注意:直接截取前 256 维的前提是 Embedding 模型已经经过 MRL 训练。如果模型未经 MRL 训练,直接截断会导致严重的语义信息丢失,请确认所使用的 Embedding 模型支持 MRL 特性。

3.2 文档向量构建

基于以上内容,我们可以得出文档完整 embed 及 upsert 的流程:

  • 计算稀疏向量
  • 计算稠密向量
  • 基于稠密向量计算 MRL 向量

Qdrant Collection 推荐配置示例:

{
    "full_2056": {
        "size": 2056,
        "distance": "cosine"
    },
    "mrl_256": {
        "size": 256,
        "distance": "cosine",
        "quantization_config": {
            "scalar": {
                "type": "int8",
                "quantile": 0.99
            }
        }
    },
    "sparse_vector_config": {
        "sparse": {
            "index": null,
            "modifier": "idf"
        }
    }
}

3.3 混合检索

以下是混合检索的完整流程:

Query
  -> embed
    -> 获取稀疏向量
    -> 获取稠密向量
    -> 基于稠密向量计算 MRL 稠密向量
  -> query
    -> 相似度检索
      -> 基于 MRL 执行 Prefetch
      -> 基于 Prefetch 结果进一步检索
    -> 关键词匹配
      -> 基于稀疏向量匹配获得 BM25 检索结果 
    -> 得到两个结果集

3.4 结果集合并

引入 RRF 算法进行结果集合并。RRF 算法是一种基于排名的融合算法,可以有效融合多个检索结果,提高检索结果的准确性和多样性。

RRF 算法的公式如下:

\text{RRF}(D) = \sum_{i=1}^{n} \frac{1}{k + \text{rank}_i(D)}

  • D:待融合的文档
  • n:参与融合的检索结果集数量
  • \text{rank}_i(D):文档 D 在第 i 个检索结果集中的排名,通常从 1 开始
  • k:平滑常数,用于降低排名靠后的结果对最终分数的影响,常用值为 60

Qdrant 本身就支持 RRF 算法,具体原理和使用方式可以查看 Qdrant 官方使用文档。

由于 BM25 得分和稠密向量相似度分数的量纲不同,直接加权求和会导致分数不可比。使用 RRF 融合可以避免该问题,因为它只依赖排名位置而非原始分数。若后续采用加权求和等其他融合方式,建议先对各路分数进行 Min-Max 归一化处理。

4. LLM Reranker

到这一步,前置的混合检索就基本完成,后续就是基于混合检索的 TopK 结果 + 重排策略 Prompt 进行重排。

可以混合检索前 Top50,重排后取 Top15;也可直接对混合检索的 Top15 进行重排。

LLM Reranker 通常有上下文长度限制。若混合检索返回的 TopK 文档拼接后超过模型上下文窗口,需要结合业务场景设计截断策略(如按文档得分截断或按字符数截断),或对文档进行分段后逐段处理。

5. 混合检索优化

本节重点介绍基于以上 BM25 混合检索技术路径的优化思路,思路维度不一而足,具体策略应结合实际业务场景,避免进行纯技术堆砌。下面我将结合上文中提到的两类业务场景进行介绍。

5.1 数据预处理

数据清洗:

  • 针对场景1中历史案例正文这类短文本文档,可以通过人工清洗或 LLM 精炼,将一些无实际意义的内容进行剔除,让文本更为精炼;
  • 针对场景2中 FAQ、SOP 等长文档,在文本精炼的基础上,进行 QA 或流程步骤拆分,形成系列子文档(即前置 chunk 处理);
  • …

Chunk策略:

  • 确定不同 Chunk 边界,考虑短文本内容是否必须 chunk,避免无效 chunk;
  • 长文档则确定 Chunk 策略(如 QA 拆分、流程步骤拆分、自然段落或其他 Separator 拆分,然后确定是否需要 Overlap 以及确定 Overlap 边界);
  • …

5.2 停用词库迭代

Collection 中文档持续迭代、补充的同时,稀疏向量空间也是不断变化的,这就可能导致后续的关键词匹配,命中到一些实际无意义的词从而影响关键词的匹配结果。

因而我们在使用 BM25 稀疏向量检索的同时,可以维护一个 Token 命中词频表。

通过定期回收命中词频表中高频但经评估无实际意义的 Token,将其纳入停用词表,从而提升后续关键词匹配的准确性和效率。

5.3 Query Normalize / Rewrite

对待检索文本进行标准化或重写。

  • 标准化:将检索文本进行标准化处理,如去除标点符号、特殊字符等,以减少检索结果的歧义。
  • 重写:根据业务需求,对检索文本进行重写,以提高检索结果的准确性和相关性。

5.4 其他策略

  • 调整 BM25 归一化强度系数或其他参数,不同 Collection 基于实际文档情况调整参数;
  • 引入 Benchmark 测评,分析混合检索效果,针对性地进行效果优化;
  • 调整 Rerank 模型或 Rerank Prompt 提高重排效果;
  • …