BM25 检索是什么

第一部分:BM25 检索介绍

BM25 是一种用于信息检索 的概率性排序函数,它用于估算文档与查询的相关性分数。它是经典的 TF-IDF 方案的进化,但效果通常更好,是现代搜索引擎中的基石算法之一(尽管很多最新系统已经转向基于神经网络的模型,但 BM25 依然是一个强大且高效的基线)。

核心思想

BM25 的核心思想是:一个查询中的每个词项(term)对文档的相关性都有贡献,但这个贡献不是线性的,它会随着该词项在文档中出现的频率(TF)增加而饱和,同时也会因为该词项的全局稀有度(IDF)而增加。

公式分解

最常用的 BM25 公式(Okapi BM25)如下:

对于一个查询 ( Q ),包含多个词项 ( q_1, q_2, ..., q_n ),文档 ( D ) 的得分是:

\\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 (1 - b + b \\cdot \\frac{\|D\|}{\\text{avgdl}})}

我们来分解这个公式:

  1. ( f(q_i, D) ): 词项 ( q_i ) 在文档 ( D ) 中的出现频率(Term Frequency)。

  2. ( |D| ): 文档 ( D ) 的长度(通常是文档中的词条数)。

  3. ( \text{avgdl} ): 整个文档集合中所有文档的平均长度。

  4. ( k_1 ) 和 ( b ): 自由参数,用于控制词项频率饱和度和文档长度归一化的影响。

    • ( k_1 ):控制词频的饱和度。值越大,饱和度变化越慢(高频词的影响越大)。通常设置在 1.22.0 之间。
    • ( b ):控制文档长度归一化的强度。b=1 表示完全归一化,b=0 表示忽略文档长度。通常设置在 0.50.8 之间。
  5. ( \text{IDF}(q_i) ): 词项 ( q_i ) 的逆文档频率。经典的 BM25 IDF 公式是:

    \\text{IDF}(q_i) = \\log \\left( \\frac{N - n(q_i) + 0.5}{n(q_i) + 0.5} + 1 \\right)

    • ( N ):文档集合中的文档总数。
    • ( n(q_i) ):包含词项 ( q_i ) 的文档数量(Document Frequency)。

    注意:有些实现(如 Lucene)会使用稍微不同的 IDF 公式。

公式如何工作?

  • 分子部分 f(q_i, D) * (k1 + 1): 随着词项在文档中出现次数 ( f ) 的增加,得分会上升。
  • 分母部分 f(q_i, D) + k1 * (1 - b + b * |D|/avgdl) : 这是一个归一化因子。
    • 它确保了当 ( f ) 变得非常大时,整个分式的增长会放缓并趋于饱和(由 ( k_1 ) 控制)。
    • |D|/avgdl 是相对文档长度。如果文档比平均长度长(|D|/avgdl > 1),分母会变大,从而惩罚长文档。参数 ( b ) 决定了这种惩罚的力度。这基于"Scope Hypothesis":一个词在短文档中出现 5 次比在长文档中出现 5 次更具信息量。

第二部分:C++ 实现

下面是一个简化但功能完整的 BM25 检索系统实现。它包含以下部分:

  1. Document: 表示一个文档。
  2. BM25: 核心算法类,负责建立索引和计算分数。
  3. Tokenizer: 一个简单的中英文分词器(使用空格分隔,实际应用中应使用更专业的分词库)。

代码实现

cpp 复制代码
#include <iostream>
#include <vector>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <cmath>
#include <sstream>
#include <algorithm>
#include <functional>

// 简单的分词器(按空格分割,仅用于演示)
// 在实际项目中,请使用如 Jieba(中文)、Spacy(英文)等专业分词库
class SimpleTokenizer {
public:
    static std::vector<std::string> tokenize(const std::string& text) {
        std::vector<std::string> tokens;
        std::istringstream iss(text);
        std::string token;
        while (iss >> token) {
            // 可以在这里添加更多的文本清洗逻辑,如转小写、去除标点等
            // 例如: std::transform(token.begin(), token.end(), token.begin(), ::tolower);
            tokens.push_back(token);
        }
        return tokens;
    }
};

// 文档类
class Document {
public:
    int id;
    std::string content;
    std::vector<std::string> tokens;
    std::unordered_map<std::string, int> term_freq; // 词项 -> 频率
    double length;

    Document(int doc_id, const std::string& doc_content) : id(doc_id), content(doc_content) {
        tokens = SimpleTokenizer::tokenize(doc_content);
        length = static_cast<double>(tokens.size());
        // 计算词频
        for (const auto& token : tokens) {
            term_freq[token]++;
        }
    }
};

// BM25 主类
class BM25 {
private:
    std::vector<Document> documents_;
    double avgdl_; // 平均文档长度
    int num_docs_; // 文档总数 N
    std::unordered_map<std::string, int> doc_freq_; // 词项 -> 文档频率 n(q_i)
    double k1_;
    double b_;

public:
    BM25(double k1 = 1.5, double b = 0.75) : k1_(k1), b_(b) {}

    // 添加文档集,构建索引
    void addDocuments(const std::vector<Document>& docs) {
        documents_ = docs;
        num_docs_ = documents_.size();

        // 计算平均文档长度和文档频率
        double total_length = 0.0;
        for (const auto& doc : documents_) {
            total_length += doc.length;
            for (const auto& pair : doc.term_freq) {
                const std::string& term = pair.first;
                doc_freq_[term]++;
            }
        }
        avgdl_ = total_length / num_docs_;
        std::cout << "Index built. Total docs: " << num_docs_ << ", AvgDL: " << avgdl_ << std::endl;
    }

    // 计算一个词项的 IDF
    double computeIDF(const std::string& term) const {
        auto it = doc_freq_.find(term);
        if (it == doc_freq_.end()) {
            return 0.0; // 如果词项不在任何文档中,IDF为0
        }
        int n_qi = it->second; // n(q_i)
        // 使用 BM25 的标准 IDF 公式
        double idf = std::log((num_docs_ - n_qi + 0.5) / (n_qi + 0.5) + 1.0);
        return idf;
    }

    // 计算单个文档对单个词项的得分
    double computeTermScore(const std::string& term, const Document& doc) const {
        auto tf_it = doc.term_freq.find(term);
        if (tf_it == doc.term_freq.end()) {
            return 0.0;
        }
        int tf = tf_it->second; // f(q_i, D)
        double idf = computeIDF(term);

        // 计算 TF 部分
        double numerator = tf * (k1_ + 1);
        double denominator = tf + k1_ * (1 - b_ + b_ * (doc.length / avgdl_));

        return idf * (numerator / denominator);
    }

    // 计算一个查询与一个文档的相关性总分
    double computeScore(const std::vector<std::string>& query_terms, const Document& doc) const {
        double total_score = 0.0;
        for (const auto& term : query_terms) {
            total_score += computeTermScore(term, doc);
        }
        return total_score;
    }

    // 对查询进行检索,返回排序后的(文档ID, 得分)对
    std::vector<std::pair<int, double>> search(const std::string& query, int top_k = 10) const {
        std::vector<std::string> query_terms = SimpleTokenizer::tokenize(query);
        std::vector<std::pair<int, double>> results;

        // 为每个文档计算分数
        for (const auto& doc : documents_) {
            double score = computeScore(query_terms, doc);
            if (score > 0) {
                results.emplace_back(doc.id, score);
            }
        }

        // 按得分降序排序
        std::sort(results.begin(), results.end(),
                  [](const std::pair<int, double>& a, const std::pair<int, double>& b) {
                      return a.second > b.second; // 降序
                  });

        // 返回 top_k 个结果
        if (top_k > 0 && top_k < results.size()) {
            results.resize(top_k);
        }
        return results;
    }
};

// 示例用法
int main() {
    // 1. 创建文档集合
    std::vector<Document> docs = {
        Document(1, "BM25 algorithm information retrieval"),
        Document(2, "Information retrieval is the science of searching for documents"),
        Document(3, "BM25 is a probabilistic retrieval model"),
        Document(4, "The best search algorithms use BM25 and neural networks"),
        Document(5, "C++ implementation of BM25 scoring function")
    };

    // 2. 初始化 BM25,可以调整 k1 和 b 参数
    BM25 bm25(1.5, 0.75);

    // 3. 添加文档,构建索引
    bm25.addDocuments(docs);

    // 4. 执行查询
    std::string query = "BM25 retrieval";
    auto results = bm25.search(query);

    // 5. 打印结果
    std::cout << "Query: \"" << query << "\"" << std::endl;
    std::cout << "Top " << results.size() << " results:" << std::endl;
    for (const auto& result : results) {
        std::cout << "Doc ID: " << result.first << ", Score: " << result.second << std::endl;
        // 可以在这里打印文档内容
        for (const auto& doc : docs) {
            if (doc.id == result.first) {
                std::cout << "   Content: " << doc.content << std::endl;
                break;
            }
        }
    }

    return 0;
}

代码说明

  1. SimpleTokenizer: 这是一个非常基础的分词器。在生产环境中,你需要根据你的语言(中文、英文等)替换成更强大的分词器。
  2. Document 类 : 在构造时,它会自动分词并计算词频 (term_freq)。
  3. BM25 类
    • addDocuments: 该方法接收文档集合,计算全局统计信息(平均文档长度 avgdl_ 和每个词项的文档频率 doc_freq_)。这相当于"构建索引"的过程。
    • computeIDF: 根据 BM25 公式计算 IDF。
    • computeTermScorecomputeScore: 分别是计算单个词项得分和整个查询得分的核心函数。
    • search: 对外提供的搜索接口。它对待检索的每个文档进行打分,然后排序返回 Top-K 结果。
  4. 主函数: 演示了从创建文档、构建索引到执行查询并输出结果的完整流程。

如何运行和改进

  • 编译 : 使用支持 C++11 或更高版本的编译器。

    bash 复制代码
    g++ -std=c++11 -O2 bm25_demo.cpp -o bm25_demo
  • 运行./bm25_demo

可能的改进

  • 高效索引 : 当前实现是线性扫描所有文档("暴力检索")。对于大规模文档集,你需要使用倒排索引来只计算包含至少一个查询词的文档的分数。
  • 更好的文本预处理: 实现大小写归一化、词干提取(Stemming)、停用词过滤等。
  • 参数调优: ( k_1 ) 和 ( b ) 参数对结果影响很大,需要在你的数据集上进行调优。
  • 内存优化 : 对于海量文档,doc_freq_ 和文档的 term_freq 需要更高效的内存存储。
相关推荐
Moment3 小时前
写代码也能享受?这款显示器让调试变得轻松又高效!😎😎😎
前端·后端
无双_Joney4 小时前
[更新迭代 - 1] Nestjs 在24年底更新了啥?(bug修复篇)
前端·后端·node.js
stark张宇4 小时前
从入门到放弃?一份让PHP学习持续正反馈的知识清单
后端·php
sunbin4 小时前
软件授权管理系统-整体业务流程图(优化版)
后端
一只专注做软件的湖南人4 小时前
京东商品评论接口(jingdong.ware.comment.get)技术解析:数据拉取与情感分析优化
前端·后端·api
TZOF5 小时前
TypeScript的新类型(二):unknown
前端·后端·typescript
xiaoye37085 小时前
Spring Boot 详细介绍
java·spring boot·后端
TZOF5 小时前
TypeScript的新类型(三):never
前端·后端·typescript
我不是混子5 小时前
如何实现数据脱敏?
java·后端