這篇文章主要介紹了對JavaScript的全文搜索實現相關度評分的功能的方法,采用了一個名為Okapi BM25的算法,文中亦有介紹,需要的朋友可以參考下
全文搜索,與機器學習領域其他大多數問題不同,是一個 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 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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"匹配是相同的)。而且我們也過濾掉停用詞(很普通的詞)為了更近一步減少熵值。在我所寫的概念深入之前,如果我過於解釋這一節就請多擔待。
?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 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 un