JavaScript全文搜索之相关度评分

全文搜索,与机器学习领域其他大多数问题不同,是一个 Web 程序员在日常工作中经常遇到的问题。客户可能要求你在某个地方提供一个搜索框,然后你会写一个类似 WHERE title LIKE %:query% 的 SQL 语句实现搜索功能。一开始,这是没问题,直到有一天,客户找到你跟你说,“搜索出错啦!”

当然,实际上搜索并没有“出错”,只是搜索的结果并不是客户想要的。一般的用户并不清楚如何做精确匹配,所以得到的搜索结果质量很差。为了解决问题,你决 定使用全文搜索。经过一阵枯燥的学习,你开启了 MySQL 的 FULLTEXT 索引,并使用了更高级的查询语法,如 “MATCH … AGAINST” 。

好了,问题解决,完结撒花!数据库规模不大的时候是没问题了。

但是当你的数据越来越多时,你会发现你的数据库也越来越慢了。MySQL 不是一个非常好用的全文搜索工具。所以你决定使用 ElasticSearch,重构代码,并部署 Lucene 驱动的全文搜索集群。你会发现它工作的非常好,又快又准确。

这时你不禁会想:为什么 Lucene 这么牛逼呢?

本篇文章(主要介绍 TF-IDF,Okapi BM-25 和普通的相关性评分 )和 下一篇文章 (主要介绍索引)将为你讲述全文搜索背后的基本概念。

相关性

对每一个搜索查询,我们很容易给每个文档定义一个“相关分数”。当用户进行搜索时,我们可以使用相关分数进行排序而不是使用文档出现时间来进行排序。这样,最相关的文档将排在第一个,无论它是多久之前创建的(当然,有的时候和文档的创建时间也是有关的)。

有很多很多种计算文字之间相关性的方法,但是我们要从最简单的、基于统计的方法说起。这种方法不需要理解语言本身,而是通过统计词语的使用、匹配和基于文档中特有词的普及率的权重等情况来决定“相关分数”。

这个算法不关心词语是名词还是动词,也不关心词语的意义。它唯一关心的是哪些是常用词,那些是稀有词。如果一个搜索语句中包括常用词和稀有词,你最好让包含稀有词的文档的评分高一些,同时降低常用词的权重。

这个算法被称为Okapi BM25。它包含两个基本概念 词语频率(term frequency) 简称词频(“TF”)和 文档频率倒数(inverse document frequency) 简写为(“IDF”).把它们放到一起,被称为 “TF-IDF”,这是一种统计学测度,用来表示一个词语 (term) 在文档中有多重要。

TF-IDF

词语频率(Term Frequency), 简称“TF”,
是一个很简单的度量标准:一个特定的词语在文档出现的次数。你可以把这个值除以该文档中词语的总数,得到一个分数。例如文档中有 100 个词,
‘the’ 这个词出现了 8 次,那么 'the' 的 TF 为 8 或 8/100 或 8%(取决于你想怎么表示它)。

逆向文件频率Inverse Document Frequency), 简称“IDF”,要复杂一些:一个词越稀有,这个值越高。它由总文件数目除以包含该词语之文件的数目,再将得到的商取对数得到。越是稀有的词,越会产生高的 “IDF”。

如果你将这两个数字乘到一起 (TF*IDF), 你将会得到一个词语在文档中的权重。“权重”的定义是:这个词有多稀有并且在文档中出现的多么频繁?

你可以将这个概念用于文档的搜索查询。在查询中的对于查询中的每个关键字,计算他们的 TF-IDF 分数,并把它们相加。得分最高的就是与查询语句最符合的文档。

Okapi BM25

上述算法是一个可用的算法,但并不太完美。它给出了一个基于统计学的相关分数算法,我们还可以进一步改进它。

Okapi BM25 是到目前为止被认为最先进的排名算法之一(所以被称为ElasticSearch)。Okapi BM25 在 TF-IDF 的基础上增加了两个可调参数,k1 和 b,, 分别代表 “词语频率饱和度(term frequency saturation)” 和 “字段长度规约”。这是什么鬼?


了能直观的理解“词语频率饱和度”,请想象两篇差不多长度的讨论棒球的文章。另外,我们假设所有文档(除去这两篇)并没有多少与棒球相关的内容,因此
“棒球” 这个词将具有很高的 IDF - 它极稀少而且很重要。
这两篇文章都是讨论棒球的,而且都花了大量的篇幅讨论它,但是其中一篇比另一篇更多的使用了“棒球”这个词。那么在这种情况,是否一篇文章真的要比另一篇
文章相差很多的分数呢?既然两个两个文档都是大篇幅讨论棒球的,那么“棒球”这个词出现 40 次还是 80 次都是一样的。事实上,30
次就该封顶啦!

这就是 “词语频率饱和度。原生的 TF-IDF 算法没有饱和的概念,所以出现 80 次“棒球”的文档要比出现 40 次的得分高一倍。有些时候,这时我们所希望的,但有些时候我们并不希望这样。

此外,Okapi BM25 还有个 k1 参数,它用于调节饱和度变化的速率。k1 参数的值一般介于 1.2 到 2.0 之间。数值越低则饱和的过程越快速。(意味着两个上面两个文档有相同的分数,因为他们都包含大量的“棒球”这个词语)

字 段长度归约(Field-length
normalization)将文档的长度归约化到全部文档的平均长度上。这对于单字段集合(single-field collections)(例如
ours)很有用,可以将不同长度的文档统一到相同的比较条件上。对于双字段集合(例如 “title” 和
"body")更加有意义,它同样可以将 title 和 body 字段统一到相同的比较条件上。字段长度归约用 b 来表示,它的值在 0 和 1
之间,1 意味着全部归约化,0 则不进行归约化。

在Okapi BM25 维基百科中你可以了解Okapi算法的公式。既然都知道了式子中的每一项是什么,这肯定是很容易地就理解了。所以我们就不提公式,直接进入代码:


  1. BM25.Tokenize = function(text) { 
  2.  
  3. text = text 
  4.  
  5. .toLowerCase 
  6.  
  7. .replace(/\W/g, ' ') 
  8.  
  9. .replace(/\s+/g, ' ') 
  10.  
  11. .trim 
  12.  
  13. .split(' ') 
  14.  
  15. .map(function(a) { returnstemmer(a); }); 
  16.  
  17. //Filter out stopStems 
  18.  
  19. var out = ; 
  20.  
  21. for(var i = 0, len = text.length; i < len; i++) { 
  22.  
  23. if(stopStems.indexOf(text[i]) === -1) { 
  24.  
  25. out.push(text[i]); 
  26.  
  27.  


们定义了一个简单的静态方法Tokenize,目的是为了解析字符串到tokens的数组中。就这样,我们小写所有的tokens(为了减少熵)。我们运
行Porter Stemmer
算法来减少熵的量同时也提高匹配程度(“walking”和"walk"匹配是相同的)。而且我们也过滤掉停用词(很普通的词)为了更近一步减少熵值。在
我所写的概念深入之前,如果我过于解释这一节就请多担待。


  1. BM25.prototype.addDocument = function(doc) { 
  2.  
  3. if(typeof doc.id=== 'undefined') { throw new Error(1000, 'ID is a required property of documents.'); }; 
  4.  
  5. if(typeof doc.body === 'undefined') { throw new Error(1001, 'Body is a required property of documents.'); }; 
  6.  
  7. //Raw tokenized list of words 
  8.  
  9. var tokens = BM25.Tokenize(doc.body); 
  10.  
  11. //Will hold unique terms and their counts and frequencies 
  12.  
  13. var _terms = {}; 
  14.  
  15. //docObj will eventually be added to the documents database 
  16.  
  17. var docObj = {id: doc.id, tokens: tokens, body: doc.body}; 
  18.  
  19. //Count number of terms 
  20.  
  21. docObj.termCount = tokens.length; 
  22.  
  23. //Increment totalDocuments 
  24.  
  25. this.totalDocuments++; 
  26.  
  27. //Readjust averageDocumentLength 
  28.  
  29. this.totalDocumentTermLength += docObj.termCount; 
  30.  
  31. this.averageDocumentLength = this.totalDocumentTermLength / this.totalDocuments; 
  32.  
  33. //Calculate term frequency 
  34.  
  35. //First get terms count 
  36.  
  37. for(var i = 0, len = tokens.length; i < len; i++) { 
  38.  
  39. var term = tokens[i]; 
  40.  
  41. if(!_terms[term]) { 
  42.  
  43. _terms[term] = { 
  44.  
  45. count: 0, 
  46.  
  47. freq: 0 
  48.  
  49. }; 
  50.  
  51. }; 
  52.  
  53. _terms[term].count++; 
  54.  
  55.  
  56. //Then re-loop to calculate term frequency. 
  57.  
  58. //We'll also update inverse document frequencies here. 
  59.  
  60. var keys = Object.keys(_terms); 
  61.  
  62. for(var i = 0, len = keys.length; i < len; i++) { 
  63.  
  64. var term = keys[i]; 
  65.  
  66. //Term Frequency forthis document. 
  67.  
  68. _terms[term].freq = _terms[term].count / docObj.termCount; 
  69.  
  70. //Inverse Document Frequency initialization 
  71.  
  72. if(!this.terms[term]) { 
  73.  
  74. this.terms[term] = { 
  75.  
  76. n: 0, //Number of docs this term appears in, uniquely 
  77.  
  78. idf: 0 
  79.  
  80. }; 
  81.  
  82.  
  83. this.terms[term].n++; 
  84.  
  85. }; 
  86.  
  87. //Calculate inverse document frequencies 
  88.  
  89. //This is SLOWish so ifyou want to index a big batch of documents, 
  90.  
  91. //comment this out and run it once at the end of your addDocuments run 
  92.  
  93. //If you're only indexing a document or two at a timeyou can leave this in. 
  94.  
  95. //this.updateIdf; 
  96.  
  97. //Add docObj to docs db 
  98.  
  99. docObj.terms = _terms; 
  100.  
  101. this.documents[docObj.id] = docObj; 
  102.  
  103. }; 

这就是addDocument这种方法会奇迹般出现的地方。我们基本上建立和维护两个类似的数据结构:this.documents.和this.terms。

this.documentsis
是一个保存着所有文档的数据库,它保存着文档的全部原始文字,文档的长度信息和一个列表,列表里面保存着文档中的所有词语和词语的数量与出现频率。使用这
个数据结构,我们可以很容易的和快速的(是的,非常快速,只需要时间复杂度为O(1)的哈表查询时间)回答如下问题:在文档 #3 中,'walk'
这个词语出现了多少次?

我们在还使用了另一个数据结构,this.terms。它表示语料库中的所有词语。通过这个数据结构,我们可以在O(1)时间内回答如下问题:'walk' 这个词在多少个文档中出现过?他们的 id 是什么?

最后,我们记录了每个文档的长度,并记录了整个语料库中文档的平均长度。

注 意,上面的代码中, idf 被初始化 0,而且 updateidf
方法被注释掉了。这是因为这个方法运行的非常慢,并且只需在建立索引之后运行一次就可以了。既然运行一次就能满足需求,就没有必要运行5000次。先把它
注释掉,然后在大批量的索引操作之后运行,就可以节省很多时间。下面是这个函数的代码:


  1. BM25.prototype.updateIdf = function { 
  2.  
  3. varkeys = Object.keys(this.terms); 
  4.  
  5. for(vari = 0, len = keys.length; i < len; i++) { 
  6.  
  7. varnum = (this.totalDocuments - this.terms[term].n + 0.5); 
  8.  
  9. vardenom = (this.terms[term].n + 0.5); 
  10.  
  11. this.terms[term].idf = Math.max(Math.log10(num / denom), 0.01); 

这是一个非常简单的函数,但是由于它需要遍历整个语料库中的所有词语,并更新所有词语的值,这就导致它工作的就有点慢。这个方法的实现采用了逆向文档频率 (inverse document frequency) 的标准公式(你可以在Wikipedia上找到这个公式)— 由总文件数目除以包含该词语之文件的数目,再将得到的商取对数得到。我做了一些修改,让返回值一直大于0。


  1. BM25.prototype.search = function(query) { 
  2.  
  3. varqueryTerms = BM25.Tokenize(query); 
  4.  
  5. varresults = ; 
  6.  
  7. // Look at each document in turn. There are better ways to do this with inverted indices. 
  8.  
  9. varkeys = Object.keys(this.documents); 
  10.  
  11. for(varj = 0, nDocs = keys.length; j < nDocs; j++) { 
  12.  
  13. varid = keys[j]; 
  14.  
  15. // The relevance score for a document is the sum of a tf-idf-like 
  16.  
  17. // calculation for each query term. 
  18.  
  19. this.documents[id]._score = 0; 
  20.  
  21. // Calculate the score for each query term 
  22.  
  23. for(vari = 0, len = queryTerms.length; i < len; i++) { 
  24.  
  25. varqueryTerm = queryTerms[i]; 
  26.  
  27. // We've never seen this term before so IDF will be 0. 
  28.  
  29. // Means we can skip the whole term, it adds nothing to the score 
  30.  
  31. // and isn't in any document. 
  32.  
  33. if(typeofthis.terms[queryTerm] === 'undefined') { 
  34.  
  35. continue; 
  36.  
  37.  
  38. // This term isn't in the document, so the TF portion is 0 and this 
  39.  
  40. // term contributes nothing to the search score. 
  41.  
  42. if(typeofthis.documents[id].terms[queryTerm] === 'undefined') { 
  43.  
  44. continue; 
  45.  
  46.  
  47. // The term is in the document, let's go. 
  48.  
  49. // The whole term is : 
  50.  
  51. // IDF * (TF * (k1 + 1)) / (TF + k1 * (1 - b + b * docLength / avgDocLength)) 
  52.  
  53. // IDF is pre-calculated for the whole docset. 
  54.  
  55. varidf = this.terms[queryTerm].idf; 
  56.  
  57. // Numerator of the TF portion. 
  58.  
  59. varnum = this.documents[id].terms[queryTerm].count * (this.k1 + 1); 
  60.  
  61. // Denomerator of the TF portion. 
  62.  
  63. vardenom = this.documents[id].terms[queryTerm].count 
  64.  
  65. + (this.k1 * (1 - this.b + (this.b * this.documents[id].termCount / this.averageDocumentLength))); 
  66.  
  67. // Add this query term to the score 
  68.  
  69. this.documents[id]._score += idf * num / denom; 
  70.  
  71. if(!isNaN(this.documents[id]._score) && this.documents[id]._score > 0) { 
  72.  
  73. results.push(this.documents[id]); 
  74.  
  75.  
  76.  
  77. results.sort(function(a, b) { returnb._score - a._score; }); 
  78.  
  79. returnresults.slice(0, 10); 
  80.  
  81. }; 

最后,search 方法遍历所有的文档,并给出每个文档的 BM25 分数,然后按照由大到小的顺序进行排序。当然了,在搜索过程中遍历语料库中的每个文档实是不明智。这个问题在 Part Two (反向索引和性能)中得到解决。

上 面的代码已经做了很好的注释,其要点如下:为每个文档和每个词语计算 BM25 分数。词语的 idf
分数已经预先计算好了,使用的时候只需要查询即可。词语频率作为文档属性的一部分也已经预先计算好了。之后只需要简单的四则运算即可。最后给每个文档增加
一个临时变量 _score,然后根据 score 做降序排列并返回前 10 个结果。

示例,源代码,注意事项和下一步计划

上面的示例有很多方法进行优化,我们将在 “全文搜索”的第二部分中介绍它们,欢迎继续收看。我希望我能在几个星期之后完成它。下面列了下次将要提到的内容:

  • 反向索引和快速搜索
  • 快速索引
  • 更好的搜索结果

为了这个演示,我编了一个小的维基百科爬虫,爬到相当多(85000)维基百科文章的第一段。由于索引到所有85K文件需要90秒左右,在我的电脑我已经削减了一半。不想让你们仅仅为了一个简单的全文本演示浪费你的笔记本电脑电量。

因为索引是一个繁重的、模块化的CPU操作,我把它当成一个网络工作者。索引运行在一个后台线程上--在这里你可以找到完整的源代码。你也会发现到词干算法和我的停用词列表中的源代码参考。至于代码许可,还是一如既往为教育目的而免费,而不用于任何商业目的。

最后是演示。一旦索引完成,尝试寻找随机的东西和短语,维基百科会知道的。注意,只有40000段的索引,所以你可能要尝试一些新的话题。

来源:51CTO

时间: 2024-08-04 09:12:38

JavaScript全文搜索之相关度评分的相关文章

对JavaScript的全文搜索实现相关度评分的功能的方法

  这篇文章主要介绍了对JavaScript的全文搜索实现相关度评分的功能的方法,采用了一个名为Okapi BM25的算法,文中亦有介绍,需要的朋友可以参考下 全文搜索,与机器学习领域其他大多数问题不同,是一个 Web 程序员在日常工作中经常遇到的问题.客户可能要求你在某个地方提供一个搜索框,然后你会写一个类似 WHERE title LIKE %:query% 的 SQL 语句实现搜索功能.一开始,这是没问题,直到有一天,客户找到你跟你说,"搜索出错啦!" 当然,实际上搜索并没有&q

对JavaScript的全文搜索实现相关度评分的功能的方法_基础知识

 全文搜索,与机器学习领域其他大多数问题不同,是一个 Web 程序员在日常工作中经常遇到的问题.客户可能要求你在某个地方提供一个搜索框,然后你会写一个类似 WHERE title LIKE %:query% 的 SQL 语句实现搜索功能.一开始,这是没问题,直到有一天,客户找到你跟你说,"搜索出错啦!" 当然,实际上搜索并没有"出错",只是搜索的结果并不是客户想要的.一般的用户并不清楚如何做精确匹配,所以得到的搜索结果质量很差.为了解决问题,你决定使用全文搜索.经过

教你怎样在MySQL中提高全文搜索效率

 很多互联网应用程序都提供了全文搜索功能,用户可以使用一个词或者词语片断作为查询项目来定位匹配的记录.在后台,这些程序使用在一个SELECT查询中的LIKE语句来执行这种查询,尽管这种方法可行,但对于全文查找而言,这是一种效率极端低下的方法,尤其在处理大量数据的时候. MySQL针对这一问题提供了一种基于内建的全文查找方式的解决方案.在此,开发者只需要简单地标记出需要全文查找的字段,然后使用特殊的MySQL方法在那些字段运行搜索,这不仅仅提高了性能和效率(因为MySQL对这些字段做了索引来优化搜

Lucene.net 实现全文搜索

全文搜索 忙了几天终于实现一个简单的全文搜索在此回顾总结一下 本文介绍一下Lucene.Net 是什么?Lucene.Net 能作什么?以及怎么做的问题?最后给出 Lucene.Net 实现全文搜索的一个示例 1.Lucene.Net 是什么? Lucene.net 起初是一个开源项目然后转向商业化,也在Lucene.net 2.0已经发布,不过是要money D ,Lucene.net的命运有点类似于FreeTextBox ,它在 1.6.5 版本之后发布的 2.0 开始了商业路线,2.0 提

PostgreSQL SQL 语言:全文搜索

本文档为PostgreSQL 9.6.0文档,本转载已得到原译者彭煜玮授权.1. 介绍 全文搜索(或者文本搜索)提供了确定满足一个查询的自然语言文档的能力,并可以选择将它们按照与查询的相关度排序.最常用的搜索类型是找到所有包含给定查询词的文档并按照它们与查询的相似性顺序返回它们.查询和相似性的概念非常灵活并且依赖于特定的应用.最简单的搜索认为查询是一组词而相似性是查询词在文档中的频度. 文本搜索操作符已经在数据库中存在很多年了.PostgreSQL对文本数据类型提供了~.~*.LIKE和ILIK

DBSight J2EE全文搜索工具

DBSight 是一款高度17813.html">可定制的J2EE全文http://www.aliyun.com/zixun/aggregation/18308.html">搜索平台工具,是对初学者和专家而设计的可以扩展的即时全文搜索任何关系数据库.它能够添加全文搜索到任何一个SQL和JavaScript的网页. DBSight 具有内置的数据库履带牵引装置,抓取用户定义的SQL,增量索引,配置的结果排名,突出显示的搜索结果(如谷歌),计数和分类结果(如亚马逊),shard

Mysql match against 全文搜索的用法

对于大的数据库,将数据装载到一个没有 FULLTEXT 索引的表中,然后再使用 ALTER TABLE (或 CREATE INDEX) 创建索引,这将是非常快的.将数据装载到一个已经有 FULLTEXT 索引的表中,将是非常慢的. 1.使用Mysql全文检索fulltext的先决条件 表的类型必须是MyISAM 建立全文检索的字段类型必须是char,varchar,text 2.建立全文检索先期配置 由于Mysql的默认配置是索引的词的长度是4,所以要支持中文单字的话,首先更改这个. *Uni

MySQL中全文搜索详解介绍

二.语法       MATCH (col1,col2,...) AGAINST (expr [search_modifier])       search_modifier: { IN BOOLEAN MODE | WITH QUERY EXPANSION }       例如:SELECT * FROM tab_name WHERE MATCH (col1,col2) AGAINST (search_word);       这里的table需要是MyISAM类型的表,col1.col2需要

Solr全文搜索与MySQL查询性能比较

测试数据量:10407608Num Docs: 10407608 在项目中一个最常用的查询,查询某段时间内的数据,SQL查询获取数据,30s左右 SELECT * FROM `tf_hotspotdata_copy_test` WHERE collectTime BETWEEN '2014-12-06 00:00:00' AND '2014-12-10 21:31:55'; 对collectTime建立索引后,同样的查询,2s,快了很多. Solr索引 Solr查询,同样的条件,72ms "st