机器学习:JavaScript全文搜索(第一部分:关联得分)

275 阅读7分钟

译者:hxh

原文链接

全文搜索, 与机器学习领域中大多数话题不一样,它是 web 开发者在日常工作中会遇到的问题。客户可能会要求你在某些地方提供搜索框,而你使用类似WHERE title LIKE %:query%的 SQL 语句实现搜索功能。刚开始没什么问题,不久之后,客户却向你抱怨道:“搜索出问题了!”

当然,你的搜索并没有_出问题_,只是搜索结果不是客户想要的。一般的 web 用户并真正了解如何精确匹配,从而导致最终的搜索结果会很不满意。于是你决定使用全文搜索。在 MySQL 中建立 FULLTEXT 索引并使用更高级的语法,如“MATCH()...AGAINST()”之类的来查询。

好了,对于规模不大的数据库,问题解决了。

但当你在成千上万的数据中搜索时,你会发现数据库查询变缓慢了。你意识到 MySQL 不是一个 _很好的_全文搜索工具。于是改用 ElasticSearch,重构部分代码,部署 Lucene 驱动的全文搜索集群,它工作地很好,迅速且准确。

这使你疑惑:Lucene 为什么能够工作的如此好?

本篇文章(TF-IDF,Okapi BM-25 和普通的相关度评分) 和下一篇文章(反向索引)将会讲述全文搜索的基本概念

相关性

对于每一个搜索,我们很容易的为每一篇文章确定一个“关联得分”。当用户搜索时,我们可以按关联得分而不是按发布时间排序。这样,相关性最高的文档,不管发布了多久,都会排在最前面。

计算文本之间的相关性有许多的方法,我们从简单的、基于统计的方法开始。这种方法不需要理解语言本身,只需要统计词语的使用、匹配度,以及文档中特殊词语出现率的权重来得到关联得分。

这种算法并不关心词性甚至是语义。它所关心的是常见词语和稀有词语,如果搜索查询中包含了常见词语和稀有词语,最好让稀有词语的权重更高, 常见词语的权重更低。

我们称这种算法为 [Okapi BM25](en.wikipedia.org/wiki/Okapi_… ""Okapi BM25""),它依赖于两个基础概念:_词频(“TF”)和_逆文档频率(“IDF”),用于统计一个词语在文档中的重要程度。

TF-IDF

词频,缩写为“TF”,一种简单的度量标准:某一词在文档中出现的次数。也可以用一个词语的出现次数除以该文档总词语数得到的值来表示它。例如,包含 100 个词的文档中,“the”出现了 8 次,因此 “the” 的词频为 8, 8 / 100 ,或者 8%(怎样表示由你决定)。

逆文档频率,缩写为“IDF”,相对复杂一些:词语越稀有,该值越高。它由总文档数除以包含该词语的文档的数量,得到的商取对数来表示。因此,越稀有的词语,“IDF”值越大。

如果将一个词的这两个值相乘(TF * IDF),会得到这个词在文档中的权重:“权重”定义为:这个词的稀有程度,以及在文档中出现的频率。

你可以将这个思想用于文档的搜索查询。对于搜索查询中的每一个词,计算它们的 TF-IDF 值,将它们相加,得分最高的就是与搜索查询语句最符合的文档。

很酷吧!

Okapi BM25

上述算法是_可用的_,但并不完美。它为我们提供了基于统计学的关联得分算法,但它可以进一步的优化。

Okapi BM25 被认为是至今为止最先进的排序算法 (so says ElasticSearch)。Okapi BM25 在 TF-IDF 的基础上增加了两个调节因子, k1 和 b,分别用来调节“词频饱和度”和“字段长度归一化”。什么鬼?

为了直观的描述词频饱和度,可以想象两篇差不多长度的、讨论棒球的文档。而整个语料库中没有太多与棒球相关的文档,因此“棒球”的 IDF 值很高 —— 换句话说,“棒球”是一个稀有且重要的词。这两篇文档的大量篇幅都在讨论棒球,但其中一篇文档中“棒球”这一词出现的频率更高。那么这篇文档的排名_真的_更高吗?两篇文档都大量提到了棒球,但从某方面来说,不管使用了“棒球” 40 次还是 80 次都一样。因为使用 30 次就足够了!

这就是“词频饱和度”。原始的 TF-IDF 算法没有饱和度,因此文档中使用“棒球” 80 次的得分是使用 40 次得分的一倍。有时需要这样,有时又不需要这样。

Okapi BM25 的“k1”,是对词频饱和度速率进行调节。k1 的值通常在 1.2 到 2.0 之间。值越低则越容易饱和(也就是说,上述两篇文档的得分相同,因为它们都有大量的“棒球”这一词)。

字段长度归一化可以认为是该文档的长度除以所有文档的平均长度。将不同长度的文档缩放到一定范围内进行比较,对于单字段集合(如 ours)很有效。对于多字段集合(如“title”和“body”)更有效,同样的将 title 和 body 缩放到一定范围内比较。“b”的数值范围在 0 到 1 之间,1 则全部归一化,0 则均不归一化。

算法实现

你可以在Okapi BM25 维基百科页面查看 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
	// 过滤 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()静态方法,将字符串解析为一组 token。所有 token 转换为小写(为了降低熵),使用波特词干算法(Porter Stemmer Algorithm)降低语料库熵的同时提高匹配率(这样“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
	// tokenized 之后的原始词
    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
	// docObj 最后会被加入到文档数据库中
    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.
	// 计算逆文档频率
	// 如果你想批量处理文档速会很慢
	// 如果你只是建立一两篇文档索引updateIdf,可以加入一下注释内容
   // this.updateIdf(); 
    // Add docObj to docs db
	// 将 docObj 添加到文档数据库中
    docObj.terms = _terms;
    this.documents[docObj.id] = docObj;
}; 

addDocument()方法是最神奇的。我们实际上是构建和维护两个相似的数据结构:this.documentsthis.terms

this.documents是我们的个人的文档数据库,但除了完整的、原始的文档之外,还存储了文档长度,文档中所有的 token 以及它们的数量和频率。使用这种数据结构我们可以容易且快速(超快速,时间复杂度为 O(1)的哈希表查找)的响应查询。例如:一篇文档中,“walk”出现了多少次?

同时我们构建了第二个数据结构,this.terms。它存储着语料库中的所有词语。通过这个时间复杂度为 O(1) 的数据结构,我们可以快速的回答“‘walk’这个词在文档库中出现了多少次?它的 idf 值是什么?”。

最后,记录每一个文档的文档长度,也记录了整个语料库的平均长度。

当然,正如你所看到的,上述的 idf 初始化为 0,而且我把uodateIdf()注释了。因为这个方法运行起来很慢,只需要在建立索引操作之后运行一次就可以了。既然运行一次就足够了。就没必要运行 50000 次了。在批量索引操作之后,把注释去掉,并运行这行代码,这样才能提升速度。该方法具体实现如下:

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

虽然这是个简单的方法,但因为它遍历了整个语料库的词语列表,并对每一个词进行更新。这一操作是很昂贵的。这个方法是按照逆文档频率的标准公式实现的,(你可以在 on Wikipedia)找到这个公式——总文档数除以含有该词的文档数,得到的商取对数。我做了一些修改,使返回值总是大于零。

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.
		// 每个文档的关联得分为搜索语句中每一个词的 tf-idf 值得总和。
        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.
			// 该词如果在语料库中没有出现过,那么它的 IDF 值为 0。也就是说,可以跳过整个词,不需要加到总得分上,也不会添加到文档中。
            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.
			// 该词没有在文档中出现,则它的词频为 0,不影响这篇文档的搜索关联得分
            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.
			// 该词在文档中有出现,继续。
			// 公式: IDF * (TF * (k1 + 1)) / (TF + k1 * (1 - b + b * docLength / avgDocLength))
            var idf = this.terms[queryTerm].idf;
            // Numerator of the TF portion.
			// 词频 TF 的分子部分
            var num = this.documents[id].terms[queryTerm].count * (this.k1 + 1);
            // Denomerator of the TF portion.
			// 词频 TF 的分母部分
            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保存关联得分,然后按照得分升(降)排序,返回前十篇文档。

Demo,源码,说明,系下一步

上述代码有很多的方式可以优化,我们将会在“全局搜索”第二部分进行探索。我希望能在短时间完成第二部分。以下是将会完成的事情:

  • 快速搜索的反向索引
  • 更快的建立索引
  • 得到更好的搜索结果

在 demo 中,我构建了一个维基百科小爬虫,用来抓取维基百科中大量(85,000)文章中的第一段落。由于在我的电脑上建立 85000 篇文档索引大概花了 90 分钟,因此我会在 demo 中减少语料库中的一半文档,我不希望在一个简单的全文搜索 demo 把你们电脑的电量在建立维基百科文章的索引上。

因为建立索引是 CPU 密集型且阻塞的操作,所以我用 WebWorker 实现这个功能。建立索引的工作是在后台线程运行的——你可以在这里查找到完整的代码。你也可以在源码中找到分词算法和我的停用词列表,代码许可证一如往常,用于学习是免费的,但不能有任何商业用途。

最后,demo 效果如下。索引建立完成后,可以尝试搜索任何维基百科可能知道的词语或者短语。注意,这里只索引了 40,000 段段落,所以你可能要尝试搜索一些主题才能找到系统知道的东西。