文章目录
- [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,
)
异常规则:
nodes和existing_bm25不能同时为空;抛出ValueError- 如果设置
filters过滤后全部文档被过滤,抛出ValueError - 当文档总数 <
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读取文档节点; - 参数做校验,限制三选一传入数据源,防止多源冲突;
- 标记废弃参数告警,向前兼容旧代码。
三种初始化入口(三选一,不能同时传多个)
nodes:直接传入节点列表,快速构建索引(测试场景)docstore:从文档存储读取全部节点(生产环境)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]
核心处理逻辑:
- 取出查询字符串,执行分词、停用词过滤、词干处理
- 调用底层
bm25s.retrieve计算BM25分数,传入权重掩码实现元数据过滤 - 根据返回索引,从
corpus还原完整文档节点 - 封装为
NodeWithScore对象列表返回node:原始文档块score:BM25匹配分值(分数越高匹配度越强)
业务层调用入口:
retriever.retrieve(query_str)(上层封装)
2.5 返回值结构:NodeWithScore
| 属性 | 含义 |
|---|---|
node.node_id |
文档唯一ID |
node.text |
文档原文片段 |
node.metadata |
文档自定义元数据字典 |
score |
BM25计算得到的相关性浮点数 |
3. 案例演示
3.1 环境准备
步骤说明:
- 安装依赖:
llama-index、llama-index-retrievers-bm25 - 准备数据:
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)