黑白梦黑白梦

  • 文章
  • 专栏
  • 文章
  • 专栏
全部文章

BM25 检索算法:基本原理、中文分词适配与 Elasticsearch 实践

发布于 2026-09-13更新于 2026-09-14约 22 分钟

BM25(Best Matching 25)是现代搜索引擎与检索增强生成(RAG)系统中广泛采用的概率相关性评分算法。本文系统解析 BM25 的数学模型与参数调节机制,剖析常见开源实现在中文语料下因字符集不匹配导致评分异常的原因,提供基于原生标准的分词适配实现,并结合 Elasticsearch 呈现工业级参数调优与 RRF 混合检索实践。

核心概念与数学模型

BM25 是对传统 TF-IDF(词频-逆文档频率)算法的优化与扩展。该算法通过引入词频饱和度非线性抑制机制与文档长度归一化因子,降低长文档或高频词对相关性打分的过度干扰。相关理论源流可参考 Okapi BM25 规范说明。

评分核心要素

BM25 算法计算给定查询 QQQ(由若干词元 q1,q2,…,qnq_1, q_2, \dots, q_nq1​,q2​,…,qn​ 组成)与文档 DDD 的相关性得分时,主要依托三个核心维度:

  • 词频(Term Frequency, TF):查询词在目标文档中出现的频次。BM25 设定了词频饱和上限,当词频达到一定数值后,对总分的边际增益趋近于平缓,避免单一词汇重复出现导致分数无限制攀升。
  • 逆文档频率(Inverse Document Frequency, IDF):衡量查询词在整个语料库中的信息量。若某词在多数文档中均有出现,则其 IDF 权值降低;反之,罕见词汇具有更高的区分度与打分权重。
  • 文档长度归一化(Document Length Normalization):根据当前文档长度相对于语料库平均文档长度的比例调整打分,防止较长文档仅因词量丰富而获得非预期的分数优势。

数学公式与参数定义

给定文档集合 CCC,包含 ∣C∣|C|∣C∣ 篇文档,查询 QQQ 与文档 DDD 的 BM25 打分公式定义如下:

Score(D,Q)=∑i=1nIDF(qi)⋅f(qi,D)⋅(k1+1)f(qi,D)+k1⋅(1−b+b⋅∣D∣avgdl)\text{Score}(D, Q) = \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)}Score(D,Q)=i=1∑n​IDF(qi​)⋅f(qi​,D)+k1​⋅(1−b+b⋅avgdl∣D∣​)f(qi​,D)⋅(k1​+1)​

公式中的关键参数与变量含义如下:

  • f(qi,D)f(q_i, D)f(qi​,D):查询词 qiq_iqi​ 在文档 DDD 中出现的频次。
  • ∣D∣|D|∣D∣:文档 DDD 的实际词数长度。
  • avgdl\text{avgdl}avgdl:语料库中所有文档的平均词数长度。
  • k1k_1k1​:词频饱和度调节参数,用于控制词频对分数的增长斜率。取值通常在 1.2 至 2.0 之间,默认一般设为 1.2。
  • bbb:文档长度归一化调节参数,用于控制文档长度惩罚力度。取值在 0 至 1 之间,取 0 时关闭长度归一化,默认设为 0.75。
  • IDF(qi)\text{IDF}(q_i)IDF(qi​):逆文档频率,标准计算公式采用如下平滑形式(其中 NNN 为文档总数,n(qi)n(q_i)n(qi​) 为包含词元 qiq_iqi​ 的文档数):
IDF(qi)=ln⁡(N−n(qi)+0.5n(qi)+0.5+1)\text{IDF}(q_i) = \ln \left( \frac{N - n(q_i) + 0.5}{n(q_i) + 0.5} + 1 \right)IDF(qi​)=ln(n(qi​)+0.5N−n(qi​)+0.5​+1)

基础工程实现与使用

在 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;
};

该逻辑在中文语料下面临以下机制性阻断:

  • 字符集不匹配:JavaScript 正则表达式中的元字符 \w 仅等价于 ASCII 字符集 [A-Za-z0-9_],不包含 Unicode 统一汉字编码(CJK Unified Ideographs)。
  • 文档长度归零:对一段纯中文文本(如“分布式缓存与数据库索引优化方案”)执行 match(/\w+/g) 时,匹配结果为 null,计算所得的文档长度均为 0。
  • 分母零除导致 NaN:当语料库由纯中文构成时,平均文档长度 avgDocLength 同样为 0。在计算归一化公式 (b * docLength) / avgDocLength 时触发 0 / 0 运算,产生非数字值 NaN。随后所有算术操作发生级联传播,导致输出数组全量变为 [NaN, NaN, ...]。

词边界切分差异

英文文本依靠自然空格划分词元,而中文属于连写表意文字系统。若未实施合理分词,直接以整句或错误切片进行精确匹配,会导致以下检索失真:

  • 长词切分缺失:查询“索引优化”时,若文档中包含“数据库索引机制与性能优化指南”,在未做词元化切分的前提下,字面匹配可能返回匹配频次为 0。
  • 长度权重扭曲:如果文档中偶发夹杂极少量英文单词或数字符号(如邮箱地址),文档长度会被错误计算为仅有 1 到 2 个词,从而严重扭曲长度惩罚因子的平衡机制。

中文分词适配方案与完整实现

解决中文 BM25 检索失效的核心在于:引入兼顾中文词元与西文字符的通用分词器,并重构词数统计与零除保护机制。

分词技术选型:Intl.Segmenter

在现代 Node.js(v16 及以上版本)与现代浏览器环境中,ECMAScript 提供了原生内置的文本分词标准 API——Intl.Segmenter。

采用原生 Intl.Segmenter 具备以下技术特征:

  • 标准内置:无需引入庞大的外部字典文件或依赖本地编译的 C++ 原生扩展(如 node-jieba),环境部署成本低。
  • 语言感知与混合分词:指定 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 中的工业级 BM25 实践

Elasticsearch(及底层 Apache Lucene)是工业界应用 BM25 算法最广泛的分布式检索系统。在企业级搜索架构中,理解 BM25 在 Elasticsearch 中的内置机制与参数配置是检索性能调优的基础。相关配置与机制规范可参考 Elasticsearch 相似度模块官方文档。

默认相似度模型演进

自 Elasticsearch 5.0(Lucene 6.0)起,官方将全文检索字段的默认相关性打分算法由传统 TF-IDF(Classic Similarity,基于向量空间模型)正式变更为 BM25(BM25Similarity)。驱动该项演进的核心考量包括:

  • 词频非线性抑制:传统 TF-IDF 的得分随词频呈无上限线性或次线性增长,容易导致某一高频词汇出现多次后主导检索打分;BM25 通过饱和度上限规避了单词过度累积分数的问题。
  • 更平滑的长度归一化:传统模型对短文档惩罚偏弱,且在长短文档混合场景下区分度不足;BM25 通过可调参数 bbb 提供可配置的长度控制。

索引参数配置与调优

在 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)的计算结果。

  • 默认分词器的局限:Elasticsearch 内置的 standard 分词器未内置中文分词词库,处理汉字时退化为按单字切分(Unigram)。在 BM25 评分过程中,词频和逆文档频率均以单字为统计单位,导致复合技术词汇(如“分布式缓存”)被拆碎,丧失专有名词的词义区分度。
  • 工业级中文分词插件:生产环境中通常配置 IK 分词插件(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"
      }
    }
  }
}

基于 RRF 的混合检索查询

在 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)协同工作,各取所长。

BM25 与向量检索的能力互补

  • 稀疏检索(BM25)优势:依赖字面精确匹配,擅长处理人名、特定商品编号、订单号、错误代码以及罕见专有名词,不易产生向量相似度在泛化场景下的意图漂移。
  • 密集检索(Embeddings)优势:依赖向量余弦相似度,擅长捕捉同义词、意图泛化、多语言翻译及模糊语义关联。
  • 排名融合机制(RRF):由于 BM25 得分(范围通常为 0 至数十不等)与向量余弦相似度(范围为 0 至 1)量纲不同,直接进行数值加权相加易导致 BM25 主导结果。推荐使用倒数排名融合(Reciprocal Rank Fusion)算法基于排名位置合并两者结果:
RRF(d)=∑m∈M1k+rm(d)RRF(d) = \sum_{m \in M} \frac{1}{k + r_m(d)}RRF(d)=m∈M∑​k+rm​(d)1​

生产工程考量与边界防护

  • 零除兜底保护:在计算单篇文档长度与语料平均长度时,强制执行 Math.max(1, count) 兜底,防止极端空文档输入引发级联 NaN。
  • 关键词停用词过滤:对于高频虚词(如“的”、“了”、“在”等),虽然 IDF 会对其权值进行抑制,但显式引入停用词表预先过滤能够降低无意义的正则比对开销。
  • 专有名词一致性维护:在涉及跨语种检索与技术文档索引时,关键技术术语与组件名称应保持稳定的规范基准(如 Kubernetes 与 K8s、PostgreSQL 与 PG 之间建立同义词或统一映射关系),防止字面匹配因术语简写或中英混杂导致检索遗漏。
目录
核心概念与数学模型评分核心要素数学公式与参数定义基础工程实现与使用基础算法实现基础检索调用中文检索场景下的失效原因剖析词数统计缺陷与零除异常词边界切分差异中文分词适配方案与完整实现分词技术选型:Intl.Segmenter适配实现的完整代码中文与混合场景验证Elasticsearch 中的工业级 BM25 实践默认相似度模型演进索引参数配置与调优工业级中文分词映射基于 RRF 的混合检索查询混合检索与工程优化建议BM25 与向量检索的能力互补生产工程考量与边界防护
上一篇现代 CSS 视觉合成、流体布局与特性查询实操笔记

©2015-2026 黑白梦 粤ICP备15018165号

联系: heibaimeng@foxmail.com