BM25(Best Matching 25)是现代搜索引擎与检索增强生成(RAG)系统中广泛采用的概率相关性评分算法。本文系统解析 BM25 的数学模型与参数调节机制,剖析常见开源实现在中文语料下因字符集不匹配导致评分异常的原因,提供基于原生标准的分词适配实现,并结合 Elasticsearch 呈现工业级参数调优与 RRF 混合检索实践。
BM25 是对传统 TF-IDF(词频-逆文档频率)算法的优化与扩展。该算法通过引入词频饱和度非线性抑制机制与文档长度归一化因子,降低长文档或高频词对相关性打分的过度干扰。相关理论源流可参考 Okapi BM25 规范说明。
BM25 算法计算给定查询 (由若干词元 组成)与文档 的相关性得分时,主要依托三个核心维度:
给定文档集合 ,包含 篇文档,查询 与文档 的 BM25 打分公式定义如下:
公式中的关键参数与变量含义如下:
在 Node.js 与 TypeScript 环境下,标准的英文 BM25 算法实现主要依托分词计数与倒排索引统计。
实现标准 Okapi BM25 算法:
export interface BMConstants {
k1?: number;
b?: number;
}
export interface BMDocument {
document: string;
score: number;
}
export type BMSorter = (a: BMDocument, b: BMDocument) => number;
// 统计英文单词数(使用 ASCII 正则)
export const getAsciiWordCount = (text: string): number => {
return ((text || '').match(/\w+/g) || []).length;
};
// 统计词频
export const getTermFrequency = (
term: string,
corpus: string,
): number => {
if (!term || !corpus) return 0;
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const matches = corpus.match(new RegExp(escaped, 'gi'));
return matches ? matches.length : 0;
};
// 计算逆文档频率
export const getIDF = (
term: string,
documents: string[],
): number => {
if (!term || documents.length === 0) return 0;
const lowerTerm = term.toLowerCase();
const relevantCount = documents.filter((doc) =>
doc.toLowerCase().includes(lowerTerm),
).length;
return Math.log(
(documents.length - relevantCount + 0.5) /
(relevantCount + 0.5) +
1,
);
};
// 执行标准 BM25 评分
export default function standardBM25(
documents: string[],
keywords: string[],
constants?: BMConstants,
sorter?: BMSorter,
): number[] | BMDocument[] {
if (!documents || documents.length === 0) return [];
const b = constants?.b ?? 0.75;
const k1 = constants?.k1 ?? 1.2;
const docLengths = documents.map((doc) =>
getAsciiWordCount(doc),
);
const avgDocLength =
docLengths.reduce((acc, len) => acc + len, 0) /
documents.length;
const idfMap = new Map<string, number>();
for (const keyword of keywords) {
idfMap.set(keyword, getIDF(keyword, documents));
}
const scores = documents.map((doc, idx) => {
const docLength = docLengths[idx] ?? 0;
const score = keywords
.map((keyword) => {
const idf = idfMap.get(keyword) || 0;
const tf = getTermFrequency(keyword, doc);
if (tf === 0) return 0;
return (
(idf * (tf * (k1 + 1))) /
(tf +
k1 * (1 - b + (b * docLength) / (avgDocLength || 1)))
);
})
.reduce((acc, curr) => acc + curr, 0);
if (sorter) {
return { document: doc, score };
}
return score;
});
if (sorter) {
return (scores as BMDocument[]).sort(sorter);
}
return scores as number[];
}
调用算法并执行排序输出:
const documents = [
'Redis cluster configuration and node failover mechanism guidelines.',
'PostgreSQL connection pool optimization for high concurrency queries.',
'Distributed cache cluster invalidation strategy and persistence architecture.',
];
const keywords = ['Redis', 'cluster'];
const results = standardBM25(documents, keywords) as number[];
console.log('检索得分结果:', results);
// 输出:[ 1.45, 0, 0.47 ]
将上述基础实现直接应用于中文语料时,会发生计算失效,典型表现为所有文档的评分全部返回 NaN。
在多数轻量级开源实现(例如 npm 社区常见的 okapibm25 包)中,文档长度统计逻辑通常编写为:
const getWordCount = (corpus: string) => {
return ((corpus || '').match(/\w+/g) || []).length;
};
该逻辑在中文语料下面临以下机制性阻断:
\w 仅等价于 ASCII 字符集 [A-Za-z0-9_],不包含 Unicode 统一汉字编码(CJK Unified Ideographs)。match(/\w+/g) 时,匹配结果为 null,计算所得的文档长度均为 0。avgDocLength 同样为 0。在计算归一化公式 (b * docLength) / avgDocLength 时触发 0 / 0 运算,产生非数字值 NaN。随后所有算术操作发生级联传播,导致输出数组全量变为 [NaN, NaN, ...]。英文文本依靠自然空格划分词元,而中文属于连写表意文字系统。若未实施合理分词,直接以整句或错误切片进行精确匹配,会导致以下检索失真:
解决中文 BM25 检索失效的核心在于:引入兼顾中文词元与西文字符的通用分词器,并重构词数统计与零除保护机制。
在现代 Node.js(v16 及以上版本)与现代浏览器环境中,ECMAScript 提供了原生内置的文本分词标准 API——Intl.Segmenter。
采用原生 Intl.Segmenter 具备以下技术特征:
zh-CN 区域设置并配置 granularity: 'word',可自动识别汉字词元界限,并原生保留英文字词与数字完整性。编写支持中英双语与零除保护的 Okapi BM25 实现:
// 转义正则表达式特殊字符
const escapeRegex = (str: string): string => {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
};
// 初始化中文区域词元分词器
const segmenter = new Intl.Segmenter('zh-CN', {
granularity: 'word',
});
// 分词并提取有效词元列表
export const tokenize = (text: string): string[] => {
if (!text) return [];
const segments = Array.from(segmenter.segment(text));
return segments
.filter((segment) => segment.isWordLike)
.map((segment) => segment.segment.toLowerCase());
};
// 基于分词器的真实词数统计
export const getWordCount = (corpus: string): number => {
if (!corpus) return 0;
const segments = Array.from(segmenter.segment(corpus));
return segments.filter((segment) => segment.isWordLike).length;
};
// 词频统计
export const getTermFrequency = (
term: string,
corpus: string,
): number => {
if (!term || !corpus) return 0;
const matches = corpus.match(
new RegExp(escapeRegex(term), 'gi'),
);
return matches ? matches.length : 0;
};
// 逆文档频率计算
export const getIDF = (
term: string,
documents: string[],
): number => {
if (!term || documents.length === 0) return 0;
const lowerTerm = term.toLowerCase();
const relevantCount = documents.filter((doc) =>
doc.toLowerCase().includes(lowerTerm),
).length;
return Math.log(
(documents.length - relevantCount + 0.5) /
(relevantCount + 0.5) +
1,
);
};
export interface BMDocument {
document: string;
score: number;
}
export interface BMConstants {
b?: number;
k1?: number;
}
export type BMSorter = (
firstEl: BMDocument,
secondEl: BMDocument,
) => number;
// 支持中文与双语混合的 BM25 算法实现
export default function BM25(
documents: string[],
keywords: string[],
constants?: BMConstants,
sorter?: BMSorter,
): number[] | BMDocument[] {
if (!documents || documents.length === 0) {
return [];
}
const b = constants?.b ?? 0.75;
const k1 = constants?.k1 ?? 1.2;
// 使用分词器计算文档长度,设定下限兜底避免长度为 0
const docLengths = documents.map((document) =>
Math.max(1, getWordCount(document)),
);
const averageDocumentLength =
docLengths.reduce((acc, len) => acc + len, 0) /
documents.length;
// 预先缓存各关键词的 IDF 值
const idfByKeyword = keywords.reduce((map, keyword) => {
map.set(keyword, getIDF(keyword, documents));
return map;
}, new Map<string, number>());
// 计算每篇文档的总得分
const scores = documents.map((document, index) => {
const docLength = docLengths[index]!;
const score = keywords
.map((keyword) => {
const idf = idfByKeyword.get(keyword) || 0;
const tf = getTermFrequency(keyword, document);
if (tf === 0) return 0;
return (
(idf * (tf * (k1 + 1))) /
(tf +
k1 *
(1 -
b +
(b * docLength) / (averageDocumentLength || 1)))
);
})
.reduce((acc, curr) => acc + curr, 0);
if (sorter) {
return { score, document } as BMDocument;
}
return score;
});
if (sorter) {
return (scores as BMDocument[]).sort(sorter);
}
return scores as number[];
}
执行中文、英文与中英混合语料的检索测试:
const corpus = [
'Redis 内存淘汰策略配置与缓存击穿防护实践。',
'微服务架构下基于 gRPC 的跨服务调用与链路追踪。',
'分布式系统中的缓存击穿防护与数据库容灾。',
'PostgreSQL 高并发事务隔离级别与死锁排查指南。',
];
// 场景一:纯中文关键词检索
const resultZh = BM25(corpus, ['缓存', '击穿']);
console.log('纯中文检索分值:', resultZh);
// 输出:[ 1.44, 0, 1.35, 0 ](无 NaN,命中第 1 条与第 3 条文档)
// 场景二:中英混合关键词检索
const resultMixed = BM25(corpus, ['Redis', '缓存']);
console.log('中英混合检索分值:', resultMixed);
// 输出:[ 1.97, 0, 0.68, 0 ](首条文档同时命中两项关键词,得分显著占优)
Elasticsearch(及底层 Apache Lucene)是工业界应用 BM25 算法最广泛的分布式检索系统。在企业级搜索架构中,理解 BM25 在 Elasticsearch 中的内置机制与参数配置是检索性能调优的基础。相关配置与机制规范可参考 Elasticsearch 相似度模块官方文档。
自 Elasticsearch 5.0(Lucene 6.0)起,官方将全文检索字段的默认相关性打分算法由传统 TF-IDF(Classic Similarity,基于向量空间模型)正式变更为 BM25(BM25Similarity)。驱动该项演进的核心考量包括:
在 Elasticsearch 索引创建时,可以通过自定义 similarity 模块调整 BM25 的两个核心参数:
k1(默认 1.2):控制词频饱和度的增长速度。在专业文档搜索场景下,若期望词频出现多次时持续提供区分度,可将其上调至 1.5 至 2.0;若更看重词元是否出现而非出现次数,可将其下调至 0.8 至 1.0。b(默认 0.75):控制文档长度惩罚的严格程度。对于结构化短文本(如错误代码、商品类目或唯一短标题),文档长度天然差异较大但不应受长度惩罚,通常将 b 调低至 0 到 0.3;对于篇幅差异巨大的长文章集合,可保持 0.75。配置自定义 BM25 相似度参数:
PUT /tech_articles
{
"settings": {
"index": {
"similarity": {
"custom_bm25": {
"type": "BM25",
"k1": 1.2,
"b": 0.5,
"discount_overlaps": true
}
}
}
},
"mappings": {
"properties": {
"title": {
"type": "text",
"similarity": "custom_bm25"
},
"content": {
"type": "text"
}
}
}
}
在 Elasticsearch 中应用 BM25 时,分词器的选择直接决定了词项(Term)的生成质量与文档词数(Field Length)的计算结果。
standard 分词器未内置中文分词词库,处理汉字时退化为按单字切分(Unigram)。在 BM25 评分过程中,词频和逆文档频率均以单字为统计单位,导致复合技术词汇(如“分布式缓存”)被拆碎,丧失专有名词的词义区分度。analysis-ik)或 ICU 分词插件(analysis-icu)。其中 IK 插件提供两种常用模式:ik_max_word:细粒度切分,将文本穷尽切分出所有可能的词元组合,适合在建立倒排索引(analyzer)时使用,以提升召回率。ik_smart:粗粒度切分,按语义切分出最精简的词元,适合在用户查询输入(search_analyzer)时使用,以保障查准率与 BM25 精确命中。配置中文分词与 BM25 联合索引:
PUT /technical_docs
{
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart"
},
"content": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart"
}
}
}
}
在 Elasticsearch 8.x 及更高版本中,引擎原生支持将 BM25 文本全文检索与基于 HNSW 索引的密集向量检索(dense_vector)结合,通过倒数排名融合(RRF)算法实现多路召回与重排。参考规范见 Elasticsearch 倒数排名融合官方说明。
执行 BM25 与密集向量混合检索请求:
POST /technical_docs/_search
{
"query": {
"multi_match": {
"query": "Redis 缓存击穿防护",
"fields": ["title^2", "content"]
}
},
"knn": {
"field": "content_vector",
"query_vector": [0.015, -0.023, 0.082],
"k": 20,
"num_candidates": 100
},
"rank": {
"rrf": {
"window_size": 50,
"rank_constant": 60
}
}
}
在生产级应用(如 RAG 检索管线或企业搜索服务)中,BM25 算法通常与密集向量嵌入(Dense Embeddings)协同工作,各取所长。
Math.max(1, count) 兜底,防止极端空文档输入引发级联 NaN。Kubernetes 与 K8s、PostgreSQL 与 PG 之间建立同义词或统一映射关系),防止字面匹配因术语简写或中英混杂导致检索遗漏。