LlamaIndex 系列【20】关键词检索(Keyword Search):BM 25 算法

文章目录

  • [1. BM25 检索](#1. BM25 检索)
    • [1.1 TF‑IDF 的缺点](#1.1 TF‑IDF 的缺点)
    • [1.2 计算公式](#1.2 计算公式)
    • [1.3 核心改进点一:词频饱和](#1.3 核心改进点一:词频饱和)
    • [1.4 核心改进点二:文档长度归一化](#1.4 核心改进点二:文档长度归一化)
  • [2. LlamaIndex BM25 检索器](#2. LlamaIndex BM25 检索器)
    • [2.1 构造函数 \init\](#2.1 构造函数 _init_)
    • [2.2 持久化相关](#2.2 持久化相关)
      • [2.2.1 get_persist_args()](#2.2.1 get_persist_args())
      • [2.2.2 persist](#2.2.2 persist)
      • [2.2.3 from_persist_dir](#2.2.3 from_persist_dir)
    • [2.3 from_defaults 方法(推荐使用)](#2.3 from_defaults 方法(推荐使用))
    • [2.4 私有 _retrieve 方法](#2.4 私有 _retrieve 方法)
    • [2.5 返回值结构:NodeWithScore](#2.5 返回值结构:NodeWithScore)
  • [3. 案例演示](#3. 案例演示)
    • [3.1 环境准备](#3.1 环境准备)
    • [3.2 案例 1:基于 nodes(中文场景)](#3.2 案例 1:基于 nodes(中文场景))
      • [3.2.1 文档切分](#3.2.1 文档切分)
      • [3.2.2 构建中文分词检索器](#3.2.2 构建中文分词检索器)
      • [3.2.3 执行检索](#3.2.3 执行检索)
      • [3.2.4 磁盘持久化](#3.2.4 磁盘持久化)
    • [3.3 案例 2:基于文档存储](#3.3 案例 2:基于文档存储)
    • [3.4 案例 3:元数据过滤](#3.4 案例 3:元数据过滤)

1. BM25 检索

BM25 是生产环境标准关键词检索算法,ElasticSearch 默认的关键字检索算法是就是 ‌BM25‌Best Matching 25),从 5.x 版本开始取代了早期的 TF-IDF,成为默认的相关性评分算法。‌‌

1.1 TF‑IDF 的缺点

TF‑IDF 检索算法依靠关键词词频匹配文档 ,把查询词和文档全部转为稀疏向量,再计算相似度打分,最后按分数从高到低返回结果。

影响分数的 3 个因子:

  • 关键词稀有度:词在全部文档里越少见,权重越高
  • 词频 TF:单词在当前文档出现多少次
  • 文档长度:长文档天然更容易命中更多词

TF‑IDF 存在的明显短板:

  • 词频没有上限,关键词反复堆砌分数会一直变高
  • 文档长度惩罚很弱,长文档更容易拿到高分
  • 不考虑查询词本身重复的权重
  • 没有可调参数,业务场景不好优化

TF‑IDF 只认单词不认意思,容易被长文本、重复关键词干扰打分,工业界大多换成 BM25 做关键词检索,再搭配稠密向量实现语义搜索。


1.2 计算公式

计算公式:

S c o r e = I D F ∗ T F ∗ ( k 1 + 1 ) T F + k 1 ∗ ( 1 − b + b ∗ d o c _ l e n a v g _ d o c _ l e n ) Score = IDF * \frac{TF*(k_1+1)}{TF + k_1*\big(1-b + b*\frac{doc\_len}{avg\_doc\_len}\big)} Score=IDF∗TF+k1∗(1−b+b∗avg_doc_lendoc_len)TF∗(k1+1)

保留 TF‑IDF 的核心思想:

  • IDF 控制词语全局稀有权重 ,但不再直接做 T F × I D F TF\times IDF TF×IDF;
  • 对 T F TF TF 做非线性饱和压缩 ,同时引入文档长度惩罚,由两个超参控制:
    • k 1 k_1 k1:词频饱和系数 ,控制词频增长何时封顶
    • b b b:文档长度惩罚系数,控制长文档扣分的强弱

两个超参通俗记忆:

参数 作用 调节效果
k 1 k_1 k1 控制词频饱和程度 调大 → 鼓励词重复;调小 → 压制关键词堆砌
b b b 控制文档长度惩罚力度 调大 → 偏好短文档;调小 → 允许优质长文档高分

1.3 核心改进点一:词频饱和

TF‑IDF 的词频得分呈线性增长 ,单词重复次数越多,得分越高,存在明显缺陷:文档可通过恶意堆砌关键词无限拉升分数,排序结果极易失真。

BM25 对 TF 进行非线性改造 ,实现词频饱和效果 ,对应公式核心部分:

T F ⋅ ( k 1 + 1 ) T F + k 1 ( ...   ) \frac{TF\cdot(k_1+1)}{TF + k_1(\dots)} TF+k1(...)TF⋅(k1+1)

公式解析:随着文档词频 T F TF TF 不断增大,分子、分母同步变大 ,整体分式不会无限线性上涨,而是逐渐收敛逼近最大值 k 1 + 1 \boldsymbol{k_1+1} k1+1。

因此关键词重复再多,得分增益会越来越弱,最终趋于平稳,彻底解决 TF‑IDF「词频越高、分数无限暴涨」的漏洞。

饱和强度由超参 k 1 \boldsymbol{k_1} k1 控制,常规取值 1.2~2.0

  • k 1 k_1 k1 越大:词频权重越强,增长越接近 TF‑IDF 线性效果
  • k 1 k_1 k1 越小:饱和速度越快,越能压制关键词堆砌刷分

示例:文档中关键词「水煮牛肉」出现 10 次得分为 X,重复至 20 次得分仅提升至 1.3X,不会随词频翻倍;持续堆砌关键词,得分几乎不再增长,排序更加公平合理。


1.4 核心改进点二:文档长度归一化

TF‑IDF 对文档长度无合理约束,长文档天然更容易命中关键词,仅凭篇幅优势就能获得更高分数,导致短小、精准的高质量文档排名靠后。

BM25 在公式分母中引入文档长度矫正项 ,实现长度归一化:

k 1 ∗ ( 1 − b + b ∗ d o c _ l e n a v g _ d o c _ l e n ) k_1*\big(1-b + b*\frac{doc\_len}{avg\_doc\_len}\big) k1∗(1−b+b∗avg_doc_lendoc_len)

公式解析:

  • d o c _ l e n doc\_len doc_len:当前文档长度
  • a v g _ d o c _ l e n avg\_doc\_len avg_doc_len:全部文档的平均长度
  • b b b:文档惩罚系数,取值范围 0 , 1 0,1 0,1
  • 当文档长于平均长度 : d o c _ l e n a v g _ d o c _ l e n > 1 \dfrac{doc\_len}{avg\_doc\_len} \gt 1 avg_doc_lendoc_len>1,分母变大,整体得分被平滑压低
  • 当文档短于平均长度:分母变小,得分相对提升,实现对长短文档的公平矫正。

超参 b \boldsymbol{b} b 控制惩罚力度:

  • b b b 越大:长文档惩罚越强,模型更偏好精炼短文
  • b b b 越小:弱化长度差异,允许优质长文档获得高分
  • b = 0 b=0 b=0:完全关闭文档长度惩罚
  • b = 1 b=1 b=1:启用最大强度的长度归一化

效果对比:

  • TF‑IDF 粗暴优待长文本,只要字数多就容易高分;
  • BM25 只奖励关键词密度高的文档,杜绝越长越高分的不公平现象,让排序更贴合检索真实意图。

2. LlamaIndex BM25 检索器

BM25Retriever 是 LlamaIndex 官方基于 bm25s 封装的稀疏关键词检索组件 ,继承自 BaseRetriever,完全兼容 LlamaIndex 检索器规范。

  • 核心能力:基于 BM25 算法做关键词打分召回
  • 独立能力:不需要 Embedding 向量模型即可完成检索
  • 典型用途:独立关键词检索、和向量检索组合实现混合检索

2.1 构造函数 *init*

方法签名:

python 复制代码
def __init__(
    nodes: Optional[List[BaseNode]] = None,
    stemmer: Optional[Stemmer.Stemmer] = None,
    language: str = "en",
    existing_bm25: Optional[bm25s.BM25] = None,
    similarity_top_k: int = DEFAULT_SIMILARITY_TOP_K,
    callback_manager: Optional[CallbackManager] = None,
    objects: Optional[List[IndexNode]] = None,
    object_map: Optional[dict] = None,
    verbose: bool = False,
    skip_stemming: bool = False,
    token_pattern: str = r"(?u)\b\w\w+\b",
    filters: Optional[MetadataFilters] = None,
    corpus_weight_mask: Optional[List[int]] = None,
)

异常规则:

  1. nodesexisting_bm25 不能同时为空;抛出 ValueError
  2. 如果设置 filters 过滤后全部文档被过滤,抛出 ValueError
  3. 当文档总数 < similarity_top_k,自动覆盖 similarity_top_k,输出警告日志

参数说明表:

参数 类型 默认值 说明
nodes List[BaseNode] None 用于构建索引的文档节点列表;二选一:nodes / existing_bm25
stemmer Stemmer.Stemmer 英文词干器 词干提取工具;中文场景一般不使用
language str en 停用词语种标识,bm25s 内置停用词表
existing_bm25 bm25s.BM25 None 外部预先构建好的 BM25 对象,复用索引,跳过训练
similarity_top_k int DEFAULT_SIMILARITY_TOP_K 召回结果数量上限;若文档不足会自动向下修正
callback_manager CallbackManager None 回调钩子,用于日志、埋点、链路追踪
objects List[IndexNode] None IndexNode 对象列表,面向对象检索场景
object_map dict None 对象 ID → Node 的映射字典
verbose bool False 是否打印分词、构建索引、检索的进度条
skip_stemming bool False 是否关闭词干还原;中文场景建议 = True
token_pattern str r"(?u)\b\w\w+\b" 分词正则表达式,用来切分单词
filters MetadataFilters None 元数据过滤规则;检索前过滤不满足条件的文档
corpus_weight_mask List[int] None 0/1权重掩码,控制哪些文档参与打分;传入filters时内部自动生成

2.2 持久化相关

2.2.1 get_persist_args()

  • 返回需要序列化保存的配置参数字典
  • 内部持久化辅助方法,业务代码一般不直接调用
python 复制代码
get_persist_args() -> Dict[str, Any]

2.2.2 persist

  • 功能:将 BM25 索引 + 完整语料 + 检索器配置保存到本地文件夹
  • 存储内容:bm25s 索引文件 + retriever.json 配置文件
  • 使用时机:服务启动前预构建索引,避免每次启动重建
python 复制代码
persist(path: str, encoding="utf-8", **kwargs)

2.2.3 from_persist_dir

  • 从磁盘目录加载完整检索器实例,无需重新生成 BM25 索引
python 复制代码
@classmethod
def from_persist_dir(path: str, encoding: str = "utf-8", **kwargs) -> BM25Retriever

2.3 from_defaults 方法(推荐使用)

from_defaults 是工厂类方法,推荐作为业务代码创建 BM25Retriever 的入口,相比直接调用 __init__ 做了一层封装:

  • 屏蔽底层构造细节,提供三种便捷数据源;
  • 自动从 VectorStoreIndex / BaseDocumentStore 读取文档节点;
  • 参数做校验,限制三选一传入数据源,防止多源冲突;
  • 标记废弃参数告警,向前兼容旧代码。

三种初始化入口(三选一,不能同时传多个

  1. nodes:直接传入节点列表,快速构建索引(测试场景)
  2. docstore:从文档存储读取全部节点(生产环境)
  3. index:从 VectorStoreIndex 内部自动读取 docstore

静态工厂方法签名:

python 复制代码
@classmethod
def from_defaults(
    index: Optional[VectorStoreIndex] = None,
    nodes: Optional[List[BaseNode]] = None,
    docstore: Optional[BaseDocumentStore] = None,
    stemmer: Optional[Stemmer.Stemmer] = None,
    language: str = "en",
    similarity_top_k: int = DEFAULT_SIMILARITY_TOP_K,
    verbose: bool = False,
    skip_stemming: bool = False,
    token_pattern: str = r"(?u)\b\w\w+\b",
    filters: Optional[MetadataFilters] = None,
    tokenizer: Optional[Callable[[str], List[str]]] = None,
) -> "BM25Retriever"

废弃提示:tokenizer 参数标记为废弃,未来版本移除,请使用 stemmer

2.4 私有 _retrieve 方法

私有实现方法,继承实现基类 BaseRetriever 的抽象检索逻辑,执行 BM25 打分检索,组装并返回带分数的文档节点列表。

python 复制代码
def _retrieve(self, query_bundle: QueryBundle) -> List[NodeWithScore]

核心处理逻辑:

  1. 取出查询字符串,执行分词、停用词过滤、词干处理
  2. 调用底层 bm25s.retrieve 计算 BM25 分数,传入权重掩码实现元数据过滤
  3. 根据返回索引,从 corpus 还原完整文档节点
  4. 封装为 NodeWithScore 对象列表返回
    • node:原始文档块
    • scoreBM25 匹配分值(分数越高匹配度越强)

业务层调用入口:retriever.retrieve(query_str)(上层封装)

2.5 返回值结构:NodeWithScore

属性 含义
node.node_id 文档唯一ID
node.text 文档原文片段
node.metadata 文档自定义元数据字典
score BM25计算得到的相关性浮点数

3. 案例演示

3.1 环境准备

步骤说明:

  1. 安装依赖:llama-indexllama-index-retrievers-bm25
  2. 准备数据:data 目录下放几个文档
python 复制代码
pip install llama-index-retrievers-bm25

3.2 案例 1:基于 nodes(中文场景)

一种做法是直接从 nodes 创建 BM25Retriever,并支持保存到磁盘、从磁盘加载。


3.2.1 文档切分

读取本地文件文档切片生成 Node

python 复制代码
from llama_index.core import SimpleDirectoryReader
from llama_index.core.node_parser import SentenceSplitter

# 加载文档
documents = SimpleDirectoryReader("./data").load_data()
# 初始化节点解析器
splitter = SentenceSplitter(chunk_size=512)
# 文档切片,chunk_size=512
nodes = splitter.get_nodes_from_documents(documents)

3.2.2 构建中文分词检索器

新版 llama-index-retrievers-bm25 默认不会对中文分词,需要用 jieba 分词后再建索引。

先定义支持中文分词的检索器:

python 复制代码
import copy
import jieba

from llama_index.core.vector_stores.utils import node_to_metadata_dict
from llama_index.retrievers.bm25 import BM25Retriever

def zh_seg(text: str) -> str:
    """用 jieba 做中文分词,空格连接。"""
    return " ".join(jieba.cut(text))


class ChineseBM25Retriever(BM25Retriever):
    """支持中文分词的 BM25 检索器。"""

    @classmethod
    def from_defaults(cls, nodes, similarity_top_k=2, **kwargs):
        original_nodes = nodes
        # 用分词后的文本建 BM25 索引,但 corpus 保留原始节点,保证返回的是原文
        segmented_nodes = [copy.deepcopy(node) for node in nodes]
        for node in segmented_nodes:
            node.set_content(zh_seg(node.get_content()))

        retriever = super().from_defaults(
            nodes=segmented_nodes,
            similarity_top_k=similarity_top_k,
            token_pattern=r"(?u)\b\w+\b",   # 按空格切分已分好的中文词
            skip_stemming=True,             # 关闭英文词干提取
            **kwargs,
        )
        # 换回原始节点,保证 node.text 输出的是原文
        retriever.corpus = [
            node_to_metadata_dict(node) | {"node_id": node.node_id}
            for node in original_nodes
        ]
        return retriever

    def _retrieve(self, query_bundle):
        # 查询词同样先做中文分词
        query_bundle.query_str = zh_seg(query_bundle.query_str)
        return super()._retrieve(query_bundle)

基于文档节点列表构建 BM25 检索器:

python 复制代码
bm25_retriever = ChineseBM25Retriever.from_defaults(
    nodes=nodes,                # 待构建索引的文档节点数组
    similarity_top_k=2,         # 设置召回Top2条相似度最高的结果
)

3.2.3 执行检索

python 复制代码
# 根据用户查询执行 BM25 关键词检索
retrieved_nodes = bm25_retriever.retrieve("星云")
# 遍历打印每一条召回的文档片段信息
for node in retrieved_nodes:
    print(f"节点ID: {node.node_id}")       # 打印文档唯一标识
    print(f"相似度分数: {node.score}")    # 打印BM25计算得到的匹配分值
    print(f"文本内容:\n{node.text}\n")     # 打印命中的原始文本片段

3.2.4 磁盘持久化

python 复制代码
# 保存检索器到文件夹
bm25_retriever.persist("./bm25_retriever")
# 从磁盘加载
loaded_bm25_retriever = BM25Retriever.from_persist_dir("./bm25_retriever")

3.3 案例 2:基于文档存储

BM25Retriever 搭配 docstore 来存放节点,docstore 可替换为 MongoDB / Redis / PostgreSQL 远程存储,适合生产环境。

python 复制代码
from llama_index.core.storage.docstore import SimpleDocumentStore
from llama_index.retrievers.bm25 import BM25Retriever
import Stemmer
from llama_index.core.response.notebook_utils import display_source_node

# 1. 创建文档存储,存入全部节点
docstore = SimpleDocumentStore()
docstore.add_documents(nodes)

# 2. 基于docstore创建BM25检索器
bm25_retriever = BM25Retriever.from_defaults(
    docstore=docstore,
    similarity_top_k=2,
    stemmer=Stemmer.Stemmer("english"),
    language="english",
)

# 执行检索
retrieved_nodes = bm25_retriever.retrieve("What happened at Viaweb and Interleaf?")
for node in retrieved_nodes:
    display_source_node(node, source_length=5000)

3.4 案例 3:元数据过滤

在关键词召回前,先按 metadata 条件过滤文档,实现属性筛选 + BM25 打分:

python 复制代码
from llama_index.core import Document
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.storage.docstore import SimpleDocumentStore
from llama_index.core.vector_stores.types import MetadataFilters, MetadataFilter, FilterOperator, FilterCondition
from llama_index.retrievers.bm25 import BM25Retriever
import Stemmer
from llama_index.core.response.notebook_utils import display_source_node

# 构造带自定义元数据的文档
documents = [
    Document(text="Hello, world!", metadata={"key": "1"}),
    Document(text="Hello, world! 2", metadata={"key": "2"}),
    Document(text="Hello, world! 3", metadata={"key": "3"}),
    Document(text="Hello, world! 2.1", metadata={"key": "2"}),
]

splitter = SentenceSplitter(chunk_size=512)
nodes = splitter.get_nodes_from_documents(documents)

docstore = SimpleDocumentStore()
docstore.add_documents(nodes)

# 定义过滤规则:key等于2
filters = MetadataFilters(
    filters=[MetadataFilter(key="key", value="2", operator=FilterOperator.EQ)],
    condition=FilterCondition.AND
)

# 带过滤器执行检索
retrieved_nodes = BM25Retriever.from_defaults(
    docstore=docstore,
    similarity_top_k=3,
    filters=filters,
    stemmer=Stemmer.Stemmer("english"),
    language="english",
).retrieve("Hello, world!")

for node in retrieved_nodes:
    display_source_node(node, source_length=5000)
相关推荐
Summer-Bright1 小时前
深度 | GPT-6 Astra 的相变:从「会答」到「会做」,OpenAI 把对齐做成了护城河
人工智能·gpt·ai·astra·gpt-6
然我2 小时前
从 Service 到生命周期:Agent Runtime 的插件内核
前端·javascript·agent
武雄(小星Ai)2 小时前
Cursor 断供 OpenAI 模型倒计时:11月12日前,AI 编程工具怎么选
ai·开发工具·对比评测
AIGC大时代2 小时前
评科研 LLM/Agent:从读论文抽检到 ERA 树搜索写可计分实证软件
llm·agent·评测·科学发现·google research
机械改造鹅2 小时前
从零开始拆解Pi系列——(10)slash 命令系统
agent
叭一下叭2 小时前
前端转Agent开发:如何自研一个记忆模块的?
agent
山顶夕景3 小时前
【Omni】OmniGAIA: Towards Native Omni-Modal AI Agents
agent·多模态·vlm·omni·全模态
lifallen3 小时前
Agent 框架不是 API 包装器,而是一个微型操作系统内核
人工智能·学习·ai·ai编程
HIT_Weston3 小时前
205、【Agent】【OpenCode】TUI 内部:.tsx 与 .ts 的分界线
人工智能·agent·opencode