
第一部分: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}})}
我们来分解这个公式:
-
( f(q_i, D) ): 词项 ( q_i ) 在文档 ( D ) 中的出现频率(Term Frequency)。
-
( |D| ): 文档 ( D ) 的长度(通常是文档中的词条数)。
-
( \text{avgdl} ): 整个文档集合中所有文档的平均长度。
-
( k_1 ) 和 ( b ): 自由参数,用于控制词项频率饱和度和文档长度归一化的影响。
- ( k_1 ):控制词频的饱和度。值越大,饱和度变化越慢(高频词的影响越大)。通常设置在
1.2
到2.0
之间。 - ( b ):控制文档长度归一化的强度。
b=1
表示完全归一化,b=0
表示忽略文档长度。通常设置在0.5
到0.8
之间。
- ( k_1 ):控制词频的饱和度。值越大,饱和度变化越慢(高频词的影响越大)。通常设置在
-
( \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 检索系统实现。它包含以下部分:
- Document: 表示一个文档。
- BM25: 核心算法类,负责建立索引和计算分数。
- 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;
}
代码说明
- SimpleTokenizer: 这是一个非常基础的分词器。在生产环境中,你需要根据你的语言(中文、英文等)替换成更强大的分词器。
- Document 类 : 在构造时,它会自动分词并计算词频 (
term_freq
)。 - BM25 类 :
addDocuments
: 该方法接收文档集合,计算全局统计信息(平均文档长度avgdl_
和每个词项的文档频率doc_freq_
)。这相当于"构建索引"的过程。computeIDF
: 根据 BM25 公式计算 IDF。computeTermScore
和computeScore
: 分别是计算单个词项得分和整个查询得分的核心函数。search
: 对外提供的搜索接口。它对待检索的每个文档进行打分,然后排序返回 Top-K 结果。
- 主函数: 演示了从创建文档、构建索引到执行查询并输出结果的完整流程。
如何运行和改进
-
编译 : 使用支持 C++11 或更高版本的编译器。
bashg++ -std=c++11 -O2 bm25_demo.cpp -o bm25_demo
-
运行 :
./bm25_demo
可能的改进:
- 高效索引 : 当前实现是线性扫描所有文档("暴力检索")。对于大规模文档集,你需要使用倒排索引来只计算包含至少一个查询词的文档的分数。
- 更好的文本预处理: 实现大小写归一化、词干提取(Stemming)、停用词过滤等。
- 参数调优: ( k_1 ) 和 ( b ) 参数对结果影响很大,需要在你的数据集上进行调优。
- 内存优化 : 对于海量文档,
doc_freq_
和文档的term_freq
需要更高效的内存存储。