對JavaScript的全文檢索搜尋實現相關度評分的功能的方法

來源:互聯網
上載者:User

對JavaScript的全文檢索搜尋實現相關度評分的功能的方法

   這篇文章主要介紹了對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 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.documentsis 是一個儲存著所有文檔的資料庫,它儲存著文檔的全部原始文字,文檔的長度資訊和一個列表,列表裡面儲存著文檔中的所有詞語和詞語的數量與出現頻率。使用這個資料結構,我們可以很容易的和快速的(是的,非常快速,只需要時間複雜度為O(1)的哈表查詢時間)回答如下問題:在文檔 #3 中,'walk' 這個詞語出現了多少次?

  我們在還使用了另一個資料結構,this.terms。它表示語料庫中的所有詞語。通過這個資料結構,我們可以在O(1)時間內回答如下問題:'walk' 這個詞在多少個文檔中出現過?他們的 id 是什麼?

  最後,我們記錄了每個文檔的長度,並記錄了整個語料庫中文檔的平均長度。

  注意,上面的代碼中, idf 被初始化 0,而且 updateidf() 方法被注釋掉了。這是因為這個方法啟動並執行非常慢,並且只需在建立索引之後運行一次就可以了。既然運行一次就能滿足需求,就沒有必要運行5000次。先把它注釋掉,然後在大批量的索引操作之後運行,就可以節省很多時間。下面是這個函數的代碼:

  ?

1

2

3

4

5

6

7

8

9

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。

  ?

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

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 分數,然後按照由大到小的順序進行排序。當然了,在搜尋過程中遍曆語料庫中的每個文檔實是不明智。這個問題在 Part Two (反向索引和效能)中得到解決。

  上面的代碼已經做了很好的注釋,其要點如下:為每個文檔和每個詞語計算 BM25 分數。詞語的 idf 分數已經預先計算好了,使用的時候只需要查詢即可。詞語頻率作為文件屬性的一部分也已經預先計算好了。之後只需要簡單的四則運算即可。最後給每個文檔增加一個臨時變數 _score,然後根據 score 做降序排列並返回前 10 個結果。

  樣本,原始碼,注意事項和下一步計劃

  上面的樣本有很多方法進行最佳化,我們將在 “全文檢索搜尋”的第二部分中介紹它們,歡迎繼續收看。我希望我能在幾個星期之後完成它。下面列了下次將要提到的內容:

  反向索引和快速搜尋

  快速索引

  更好的搜尋結果

  為了這個示範,我編了一個小的維基百科爬蟲,爬到相當多(85000)維基百科文章的第一段。由於索引到所有85K檔案需要90秒左右,在我的電腦我已經削減了一半。不想讓你們僅僅為了一個簡單的全文本示範浪費你的膝上型電腦電量。

  因為索引是一個繁重的、模組化的CPU操作,我把它當成一個網路工作者。索引運行在一個後台線程上--在這裡你可以找到完整的原始碼。你也會發現到詞幹演算法和我的停用詞列表中的原始碼參考。至於代碼許可,還是一如既往為教育目的而免費,而不用於任何商業目的。

  最後是示範。一旦索引完成,嘗試尋找隨機的東西和短語,維基百科會知道的。注意,只有40000段的索引,所以你可能要嘗試一些新的話題。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.