本文深入解析JavaScript中实现全文搜索的核心算法,重点讲解TF-IDF与Okapi BM25相关性评分机制。通过代码示例展示如何计算词频、逆向文件频率及字段长度归约,帮助开发者理解从基础统计到高级排名的演进,为构建高性能搜索引擎提供理论基础与实现参考。
全文搜索是Web开发中常见的需求,与机器学习领域的其他问题不同。当客户要求在系统中提供搜索功能时,开发者通常会编写类似 `WHERE title LIKE %:query%` 的SQL语句来实现。在数据量较小时,这种简单匹配方式通常能正常运行,直到客户反馈搜索结果不准确或出现异常。
实际上,搜索并未出错,只是结果不符合用户预期。普通用户往往不清楚精确匹配的含义,导致搜索结果质量低下。为解决这一问题,开发者通常会转向全文搜索技术,例如启用MySQL的FULLTEXT索引,并使用 `MATCH() ... AGAINST()` 等高级查询语法。
在数据库规模较小的情况下,上述方法足以解决问题。然而,随着数据量的增长,数据库查询速度会逐渐变慢。
当数据量持续增加时,MySQL作为全文搜索工具的性能瓶颈会显现出来。此时,开发者可能会选择重构代码,部署基于Lucene的ElasticSearch集群,以获得更快且更准确的搜索结果。
面对ElasticSearch的高效表现,开发者可能会好奇其背后的技术原理。
本文将主要介绍TF-IDF、Okapi BM25以及普通相关性评分算法,而下一篇文章将重点讲解索引机制,共同揭示全文搜索背后的基本概念。
相关性评分基础
相关性
对于每个搜索查询,我们可以为每个文档定义一个“相关分数”。在用户进行搜索时,系统应依据相关分数对结果进行排序,而非文档的创建时间。这样,最相关的文档将排在首位,无论其创建时间早晚(尽管在某些场景下,创建时间也是排序的参考因素之一)。
计算文字间相关性的方法众多,但本文从最简单的基于统计的方法开始讲解。该方法无需理解语言语义,而是通过统计词语的使用频率、匹配情况以及基于文档中特有词的普及率权重等指标来确定“相关分数”。
该算法不区分词语的词性(名词或动词)或其实际含义,仅关注词语的常用程度。如果一个查询包含常用词和稀有词,算法倾向于给包含稀有词的文档更高评分,同时降低常用词的权重。
这种算法被称为Okapi BM25,它包含两个核心概念:词语频率(Term Frequency,简称TF)和逆向文件频率(Inverse Document Frequency,简称IDF)。将两者结合即为“TF-IDF”,这是一种统计学度量,用于评估一个词语在文档中的重要程度。
TF-IDF算法详解
TF-IDF
词语频率(Term Frequency,简称TF)是一个简单的度量标准,表示特定词语在文档中出现的次数。可以将该值除以文档中词语的总数,得到一个分数。例如,若文档包含100个词,其中“the”出现8次,则“the”的TF为8、0.08或8%,具体取决于表示方式。
逆向文件频率(Inverse Document Frequency,简称IDF)则较为复杂:词语越稀有,该值越高。其计算方式为总文件数目除以包含该词语的文件数目,再对得到的商取对数。词语越稀有,产生的IDF值越高。
将这两个数值相乘(TF * IDF),即可得到词语在文档中的权重。这里的“权重”定义为:该词既稀有又在文档中频繁出现。
可将此概念应用于文档搜索查询。对于查询中的每个关键字,计算其TF-IDF分数并求和,得分最高的文档即为与查询最匹配的文档。
这一机制非常直观且有效。
Okapi BM25算法优化
Okapi BM25
上述算法虽然可用,但并非完美。它提供了一种基于统计的相关分数计算方法,我们仍可对其进行进一步优化。
Okapi BM25是目前被认为最先进的排名算法之一(因此被ElasticSearch采用)。它在TF-IDF的基础上增加了两个可调参数:k1和b,分别代表“词语频率饱和度(term frequency saturation)”和“字段长度归约(field-length normalization)”。
为了直观理解“词语频率饱和度”,假设有两篇长度相近的讨论棒球的文章,且其他文档中关于棒球的内容很少,因此“棒球”一词具有很高的IDF值(极稀有且重要)。两篇文章都大篇幅讨论棒球,但其中一篇使用“棒球”一词的次数更多。此时,是否一篇文档的评分应远高于另一篇?既然两篇都大篇幅讨论该主题,那么“棒球”出现40次还是80次并无本质区别,事实上,30次左右即可达到饱和。
这就是“词语频率饱和度”的概念。原生TF-IDF算法没有饱和机制,因此出现80次“棒球”的文档得分会是出现40次的两倍。有时这是期望的结果,但有时并非如此。
此外,Okapi BM25中的k1参数用于调节饱和度变化的速率。k1的值通常介于1.2到2.0之间。数值越低,饱和过程越快(意味着上述两个文档可能获得相同分数,因为它们都包含大量“棒球”一词)。
字段长度归约(Field-length normalization)将文档长度归一化到语料库的平均长度。这对于单字段集合(如本文示例)很有用,可将不同长度的文档置于相同的比较条件下。对于双字段集合(如“title”和“body”)更有意义,它可将title和body字段统一比较条件。字段长度归约用b表示,其值在0到1之间,1表示完全归一化,0表示不归一化。
JavaScript代码实现
算法
在Okapi BM25的维基百科页面中可以找到算法公式。既然已了解公式中各项的含义,理解起来并不困难。因此,我们跳过公式,直接进入代码实现:
BM25.Tokenize = function(text) {
text = text
.toLowerCase()
.replace(/\W/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.split(' ')
.map(function(a) { return stemmer(a); });
// Filter out stopStems
var out = [];
for (var i = 0, len = text.length; i < len; i++) {
if (stopStems.indexOf(text[i]) === -1) {
out.push(text[i]);
}
}
return out;
};
我们定义了一个简单的静态方法Tokenize(),用于将字符串解析为tokens数组。该方法将所有tokens转换为小写以减少熵值,运行Porter Stemmer算法以进一步减少熵值并提高匹配度(例如“walking”和“walk”被视为相同)。此外,我们还过滤掉停用词(常见词)以进一步降低熵值。在深入概念之前,若对这部分解释过多,敬请谅解。
BM25.prototype.addDocument = function(doc) {
if (typeof doc.id === 'undefined') { throw new Error(1000, 'ID is a required property of documents.'); };
if (typeof doc.body === 'undefined') { throw new Error(1001, 'Body is a required property of documents.'); };
// Raw tokenized list of words
var tokens = BM25.Tokenize(doc.body);
// Will hold unique terms and their counts and frequencies
var _terms = {};
// docObj will eventually be added to the documents database
var docObj = {id: doc.id, tokens: tokens, body: doc.body};
// Count number of terms
docObj.termCount = tokens.length;
// Increment totalDocuments
this.totalDocuments++;
// Readjust averageDocumentLength
this.totalDocumentTermLength += docObj.termCount;
this.averageDocumentLength = this.totalDocumentTermLength / this.totalDocuments;
// Calculate term frequency
// First get terms count
for (var i = 0, len = tokens.length; i < len; i++) {
var term = tokens[i];
if (!_terms[term]) {
_terms[term] = {
count: 0,
freq: 0
};
};
_terms[term].count++;
}
// Then re-loop to calculate term frequency.
// We'll also update inverse document frequencies here.
var keys = Object.keys(_terms);
for (var i = 0, len = keys.length; i < len; i++) {
var term = keys[i];
// Term Frequency for this document.
_terms[term].freq = _terms[term].count / docObj.termCount;
// Inverse Document Frequency initialization
if (!this.terms[term]) {
this.terms[term] = {
n: 0, // Number of docs this term appears in, uniquely
idf: 0
};
}
this.terms[term].n++;
};
// Calculate inverse document frequencies
// This is SLOWish so if you want to index a big batch of documents,
// comment this out and run it once at the end of your addDocuments run
// If you're only indexing a document or two at a time you can leave this in.
// this.updateIdf();
// Add docObj to docs db
docObj.terms = _terms;
this.documents[docObj.id] = docObj;
};
接下来是addDocument()方法,它负责建立和维护两个数据结构:this.documents和this.terms。
this.documents是一个保存所有文档的数据库,存储文档的原始文本、长度信息以及一个列表,列表中保存文档中的所有词语及其出现频率。利用该数据结构,我们可以快速(时间复杂度为O(1)的哈希表查询)回答如下问题:文档#3中“walk”一词出现了多少次?
我们还使用了另一个数据结构this.terms,表示语料库中的所有词语。通过该结构,我们可以在O(1)时间内回答如下问题:“walk”一词在多少个文档中出现过?其ID是什么?
最后,我们记录每个文档的长度,并计算整个语料库中文档的平均长度。
注意,上述代码中idf被初始化为0,且updateIdf()方法被注释掉。这是因为该方法运行速度较慢,且只需在建立索引后运行一次即可。既然运行一次就能满足需求,就没有必要运行5000次。先将其注释,然后在批量索引操作后运行,可节省大量时间。以下是该函数的代码:
BM25.prototype.updateIdf = function() {
var keys = Object.keys(this.terms);
for (var i = 0, len = keys.length; i < len; i++) {
var term = keys[i];
var num = (this.totalDocuments - this.terms[term].n + 0.5);
var denom = (this.terms[term].n + 0.5);
this.terms[term].idf = Math.max(Math.log10(num / denom), 0.01);
}
};
这是一个非常简单的函数,但由于需要遍历语料库中的所有词语并更新所有词语的值,因此运行速度较慢。该方法的实现采用了逆向文档频率(inverse document frequency)的标准公式(可在Wikipedia上找到)——由总文件数目除以包含该词语的文件数目,再对得到的商取对数。我做了一些修改,使返回值始终大于0。
BM25.prototype.search = function(query) {
var queryTerms = BM25.Tokenize(query);
var results = [];
// Look at each document in turn. There are better ways to do this with inverted indices.
var keys = Object.keys(this.documents);
for (var j = 0, nDocs = keys.length; j < nDocs; j++) {
var id = keys[j];
// The relevance score for a document is the sum of a tf-idf-like
// calculation for each query term.
this.documents[id]._score = 0;
// Calculate the score for each query term
for (var i = 0, len = queryTerms.length; i < len; i++) {
var queryTerm = queryTerms[i];
// We've never seen this term before so IDF will be 0.
// Means we can skip the whole term, it adds nothing to the score
// and isn't in any document.
if (typeof this.terms[queryTerm] === 'undefined') {
continue;
}
// This term isn't in the document, so the TF portion is 0 and this
// term contributes nothing to the search score.
if (typeof this.documents[id].terms[queryTerm] === 'undefined') {
continue;
}
// The term is in the document, let's go.
// The whole term is :
// IDF * (TF * (k1 + 1)) / (TF + k1 * (1 - b + b * docLength / avgDocLength))
// IDF is pre-calculated for the whole docset.
var idf = this.terms[queryTerm].idf;
// Numerator of the TF portion.
var num = this.documents[id].terms[queryTerm].count * (this.k1 + 1);
// Denomerator of the TF portion.
var denom = this.documents[id].terms[queryTerm].count
+ (this.k1 * (1 - this.b + (this.b * this.documents[id].termCount / this.averageDocumentLength)));
// Add this query term to the score
this.documents[id]._score += idf * num / denom;
}
if (!isNaN(this.documents[id]._score) && this.documents[id]._score > 0) {
results.push(this.documents[id]);
}
}
results.sort(function(a, b) { return b._score - a._score; });
return results.slice(0, 10);
};
最后是search()方法,它遍历所有文档,计算每个文档的BM25分数,然后按从大到小的顺序排序。当然,在搜索过程中遍历语料库中的每个文档并不明智。这个问题在第二部分(反向索引和性能)中得到解决。
上述代码已做好详细注释,其要点如下:为每个文档和每个词语计算BM25分数。词语的idf分数已预先计算好,使用时直接查询即可。词语频率作为文档属性的一部分也已预先计算好,之后只需进行简单的四则运算。最后,给每个文档增加一个临时变量_score,根据score做降序排列并返回前10个结果。 示例、源代码、注意事项和下一步计划
上述示例有许多优化空间,我们将在“全文搜索”的第二部分中介绍,欢迎继续观看。我希望在几周后完成它。下面列出了下次将要讨论的内容:
- 反向索引和快速搜索
- 快速索引
- 更好的搜索结果
为了演示,我编写了一个小型维基百科爬虫,抓取了相当数量(85000)维基百科文章的第一段。由于索引所有85K文件需要约90秒,在我的电脑上我已将其减半。不想让你们仅仅为了一个简单的全文本演示而浪费笔记本电脑电量。
由于索引是一个繁重且模块化的CPU操作,我将其作为Web Worker处理。索引运行在后台线程上——你可以在这里找到完整的源代码。你还会发现词干算法和停用词列表的源代码参考。至于代码许可,一如既往地为教育目的免费,不用于任何商业目的。
最后是演示。一旦索引完成,尝试搜索随机事物和短语,维基百科会给出结果。注意,只有40000段被索引,因此你可能需要尝试一些新话题。