JavaScript实现全文搜索:TF-IDF与Okapi BM25算法详解

作者:闭包旅人时间:2026-10-29 11:41:36浏览:366

本文深入解析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段被索引,因此你可能需要尝试一些新话题。

作者最新文章