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. text = text
  3. .toLowerCase()
  4. .replace(/\W/g, ‘ ‘)
  5. .replace(/\s+/g, ‘ ‘)
  6. .trim()
  7. .split(‘ ‘)
  8. .map(function(a) { return stemmer(a); });
  9. // Filter out stopStems
  10. var out = [];
  11. for (var i = 0, len = text.length; i < len; i++) {
  12. if (stopStems.indexOf(text[i]) === -1) {
  13. out.push(text[i]);
  14. }
  15. }
  16. return out;
  17. };

复制代码

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

  1. BM25.prototype.addDocument = function(doc) {
  2. if (typeof doc.id === ‘undefined‘) { throw new Error(1000, ‘ID is a required property of documents.‘); };
  3. if (typeof doc.body === ‘undefined‘) { throw new Error(1001, ‘Body is a required property of documents.‘); };
  4. // Raw tokenized list of words
  5. var tokens = BM25.Tokenize(doc.body);
  6. // Will hold unique terms and their counts and frequencies
  7. var _terms = {};
  8. // docObj will eventually be added to the documents database
  9. var docObj = {id: doc.id, tokens: tokens, body: doc.body};
  10. // Count number of terms
  11. docObj.termCount = tokens.length;
  12. // Increment totalDocuments
  13. this.totalDocuments++;
  14. // Readjust averageDocumentLength
  15. this.totalDocumentTermLength += docObj.termCount;
  16. this.averageDocumentLength = this.totalDocumentTermLength / this.totalDocuments;
  17. // Calculate term frequency
  18. // First get terms count
  19. for (var i = 0, len = tokens.length; i < len; i++) {
  20. var term = tokens[i];
  21. if (!_terms[term]) {
  22. _terms[term] = {
  23. count: 0,
  24. freq: 0
  25. };
  26. };
  27. _terms[term].count++;
  28. }
  29. // Then re-loop to calculate term frequency.
  30. // We‘ll also update inverse document frequencies here.
  31. var keys = Object.keys(_terms);
  32. for (var i = 0, len = keys.length; i < len; i++) {
  33. var term = keys[i];
  34. // Term Frequency for this document.
  35. _terms[term].freq = _terms[term].count / docObj.termCount;
  36. // Inverse Document Frequency initialization
  37. if (!this.terms[term]) {
  38. this.terms[term] = {
  39. n: 0, // Number of docs this term appears in, uniquely
  40. idf: 0
  41. };
  42. }
  43. this.terms[term].n++;
  44. };
  45. // Calculate inverse document frequencies
  46. // This is SLOWish so if you want to index a big batch of documents,
  47. // comment this out and run it once at the end of your addDocuments run
  48. // If you‘re only indexing a document or two at a time you can leave this in.
  49. // this.updateIdf();
  50. // Add docObj to docs db
  51. docObj.terms = _terms;
  52. this.documents[docObj.id] = docObj;
  53. };

复制代码

这就是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. var keys = Object.keys(this.terms);
  3. for (var i = 0, len = keys.length; i < len; i++) {
  4. var term = keys[i];
  5. var num = (this.totalDocuments - this.terms[term].n + 0.5);
  6. var denom = (this.terms[term].n + 0.5);
  7. this.terms[term].idf = Math.max(Math.log10(num / denom), 0.01);
  8. }
  9. };

复制代码

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

  1. BM25.prototype.search = function(query) {
  2. var queryTerms = BM25.Tokenize(query);
  3. var results = [];
  4. // Look at each document in turn. There are better ways to do this with inverted indices.
  5. var keys = Object.keys(this.documents);
  6. for (var j = 0, nDocs = keys.length; j < nDocs; j++) {
  7. var id = keys[j];
  8. // The relevance score for a document is the sum of a tf-idf-like
  9. // calculation for each query term.
  10. this.documents[id]._score = 0;
  11. // Calculate the score for each query term
  12. for (var i = 0, len = queryTerms.length; i < len; i++) {
  13. var queryTerm = queryTerms[i];
  14. // We‘ve never seen this term before so IDF will be 0.
  15. // Means we can skip the whole term, it adds nothing to the score
  16. // and isn‘t in any document.
  17. if (typeof this.terms[queryTerm] === ‘undefined‘) {
  18. continue;
  19. }
  20. // This term isn‘t in the document, so the TF portion is 0 and this
  21. // term contributes nothing to the search score.
  22. if (typeof this.documents[id].terms[queryTerm] === ‘undefined‘) {
  23. continue;
  24. }
  25. // The term is in the document, let‘s go.
  26. // The whole term is :
  27. // IDF * (TF * (k1 + 1)) / (TF + k1 * (1 - b + b * docLength / avgDocLength))
  28. // IDF is pre-calculated for the whole docset.
  29. var idf = this.terms[queryTerm].idf;
  30. // Numerator of the TF portion.
  31. var num = this.documents[id].terms[queryTerm].count * (this.k1 + 1);
  32. // Denomerator of the TF portion.
  33. var denom = this.documents[id].terms[queryTerm].count
  34. + (this.k1 * (1 - this.b + (this.b * this.documents[id].termCount / this.averageDocumentLength)));
  35. // Add this query term to the score
  36. this.documents[id]._score += idf * num / denom;
  37. }
  38. if (!isNaN(this.documents[id]._score) && this.documents[id]._score > 0) {
  39. results.push(this.documents[id]);
  40. }
  41. }
  42. results.sort(function(a, b) { return b._score - a._score; });
  43. return results.slice(0, 10);
  44. };

复制代码

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

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

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

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

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

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

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

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

时间: 2024-10-11 01:43:15

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

全文搜索存储引擎 Elasticsearch 一点点

开始请大家想一个问题,如何统计一个Web站点的有效PV? 针对用户请求的URL,统计时做模式匹配-------->即用户真正去打开一个站点的有效页面并对每个页面的入口的访问做一个统计浏览量: 简要搜索引擎 搜索引擎在互联网上特别多有专业(Startpage,Google,Yahoo,Baidu)等也有非专业开源(北大搜索.任何基于Lucene库的二次开发搜索代理引擎)等:其重点都是用来做海量数据搜索存储.分析,并且根据用户指定的filter来过滤出用户所需要的数据.而背后所需基础组件无外乎是 索

Lucene及全文搜索实现原理

Lucene及全文搜索实现原理 全文搜索 全文搜索是指计算机索引程序通过扫描文章中的每一个词,对每一个词建立一个索引,指明该词在文章中出现的次数和位置,当用户查询时,检索程序就根据事先建立的索引进行查找,并将查找的结果反馈给用户的检索方式.这个过程类似于通过字典中的检索字表查字的过程.全文搜索搜索引擎数据库中的数据. ????全文搜索的过程主要分为两个部分,索引的建立以及索引的搜索. 国内外的全文搜索常用的检索模型主要有向量模型,布尔模型等. 布尔模型 布尔模型是第一个信息检索的模型,可能也是最

ElasticSearch基础3:全文搜索

全文搜索 所有查询会或多或少的执行相关度计算,但不是所有查询都有分析阶段.和一些特殊的完全不会对文本进行操作的查询(如 bool 或 function_score )不同,文本查询可以划分成两大家族: 基于词项的查询 如 term 或 fuzzy 这样的底层查询不需要分析阶段,它们对单个词项进行操作.用 term 查询词项 Foo 只要在倒排索引中查找 准确词项 ,并且用 TF/IDF 算法为每个包含该词项的文档计算相关度评分 _score .记住 term 查询只对倒排索引的词项精确匹配,这点

ElasticSearch结构化搜索和全文搜索

https://segmentfault.com/a/1190000019753737?utm_source=tag-newest 1.结构化搜索 1.1 精确值查找 过滤器很重要,因为它们执行速度非常快,不会计算相关度(直接跳过了整个评分阶段)而且很容易被缓存.请尽可能多的使用过滤式查询. term 查询会查找我们指定的精确值.作为其本身, term 查询是简单的.它接受一个字段名以及我们希望查找的数值:{ "term" : { "price" : 20 } }

php+中文分词scws+sphinx+mysql打造千万级数据全文搜索

Sphinx是由俄罗斯人Andrew Aksyonoff开发的一个全文检索引擎.意图为其他应用提供高速.低空间占用.高结果 相关度的全文搜索功能.Sphinx可以非常容易的与SQL数据库和脚本语言集成.当前系统内置MySQL和PostgreSQL 数据库数据源的支持,也支持从标准输入读取特定格式 的XML数据.Sphinx创建索引的速度为:创建100万条记录的索引只需3-4分钟,创建1000万条记录的索引可以在50分钟内完成,而只包含最新10万条记录的增量索引,重建一次只需几十秒.Sphinx的

【转】 Mysql全文搜索match...against的用法

原文链接 http://blog.csdn.net/manbujingxin/article/details/6656992 前提:mysql只支持英文内容的全文索引,所以只考虑英文的全文搜索. 假定数据表名为post,有三列:id.title.content.id是自增长序号, title是varchar,content是text,给content添加全文索引.mysql全文搜索有三种模式:一.自然语言查找.这是mysql默认的全文搜索方式,sql示例: 1 select  id,title 

[Elasticsearch] 全文搜索 (一) - 基础概念和match查询

全文搜索(Full Text Search) 现在我们已经讨论了搜索结构化数据的一些简单用例,是时候开始探索全文搜索了 - 如何在全文字段中搜索来找到最相关的文档. 对于全文搜索而言,最重要的两个方面是: 相关度(Relevance) 查询的结果按照它们对查询本身的相关度进行排序的能力,相关度可以通过TF/IDF,参见什么是相关度,地理位置的邻近程度(Proximity to a Geo-location),模糊相似性(Fuzzy Similarity)或者其它算法进行计算. 解析(Analys

如何在MySQL中获得更好的全文搜索结果

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

初识Lucene 4.5全文搜索

近期想研究下lucene,但网络上的教程大多都是lucne 3.x版本的讲解.可是lucene版本的更新速度快的惊人,目前已经到了4.8版了,只好去查阅官方文档.虽然英文不大好,但稍微对比了下发现3.x版本至4.x版本的修改非常之大.接下来我就以4.5版来操作,分享下我对luence的初步认识. 先给大家看一张图(来至<Lucene  in  action>): 此图很形象的描述了lucene的基本流程,简而言之就是:1.创建索引:2.检索索引. 太深的道理与原理我目前也还是一知半解,所以就以