搜尋引擎核心技術與演算法 —— 詞項詞典與倒排索引最佳化

夕小瑶發表於2020-01-09
前言

首先回顧一下構建倒排索引的幾個主要步驟:
(1) 收集待建索引的文件;
(2) 對這些文件中的文字進行詞條化;
(3) 對第2步產生的詞條進行語言學預處理,得到詞項
(4) 根據詞項對所有文件建立索引。 可以看到,上訴過程中非常重要的一步就是獲得詞項,那麼詞項是什麼,又是怎麼獲得的呢?

詞項集合的確定

在確定詞項前,我們需要明確三個概念:

詞條:一段文字中有效詞的子序列,其中每個子序列稱為一個詞條。

詞條類:相同詞條構成的集合。

詞項:一個詞項指的是在資訊檢索系統詞典中所包含的某個可能經過歸一化處理的詞條類。(詞項集合和詞條集合可以完全不同,比如可以採用某一個分類體系中的類別標籤作為詞項。當然,在實際的資訊檢索系統中,詞項往往和詞條密切相關)

三者關係如下:

搜尋引擎核心技術與演算法 —— 詞項詞典與倒排索引最佳化

下面,讓我們一起學習這幾者是如何一步步變化得來的。

1.1 詞條化

詞條化過程詞條化的主要任務就是確定哪些才是正確的詞條。比如,對於簡單的句子將字串進行拆分並去掉標點符號即可。

搜尋引擎核心技術與演算法 —— 詞項詞典與倒排索引最佳化

然而,上面的例子僅僅代表的是一種最簡單的情況。實際上即使對於單詞之間存在空格的英文來說也存在很多難以處理的問題。比如,英文中的上撇號“’”既可以代表所有關係也可以代表縮寫,應當在詞條化過程中究竟應該如何對它進行處理?參考下面的例子:

搜尋引擎核心技術與演算法 —— 詞項詞典與倒排索引最佳化

對其中的“ O’Neill” 來說,詞條化的結果可能有如下幾種形式可以選擇,那麼到底哪一種才正確? 

搜尋引擎核心技術與演算法 —— 詞項詞典與倒排索引最佳化

對於可能的各種拆分策略來說,最後的選擇結果會決定哪些布林查詢會被匹配上、哪些不會被匹配上。給定查詢neill AND capital,上述五種拆分策略中有3種會被匹配上(即第1、4、5種情況)。而如果給定查詢o’neill AND capital,則在沒有對查詢進行任何預處理的情況下,上述策略中只有一種能匹配上。不管是輸入布林查詢或者自由文字查詢,人們總是希望對文件和查詢進行同樣的詞條化處理,這往往透過採用相同的詞條化工具來實現。這樣做能夠確保文字與查詢中的同一字串序列的處理結果相一致。


在詞條化的過程中,需要注意以下幾個問題:

(1)對大多數語言特別是一些特定領域的語言來說,往往有一些特定的詞條需要被識別成詞項,如程式語言“C++”和“C#”、“B-52”之類的飛行器名字或者叫“M*A*S*H”的電視秀節目等等,這時候就不能簡單的去掉文字中的符號了,這裡通常需要建立專有名詞字典來解決。

(2)字元序列型別包括郵件地址(如jblack@mail.yahoo.com)、URL(如http://stuff.big.com/new/specials.html)、IP地址(如142.32.48.231)和包裹追蹤號碼(1Z9999W99845399981)等等。一種做法是不對包括貨幣量、數字、URL等在內的詞條進行索引,這是因為如果對這些詞條進行索引則會顯著擴大索引的詞彙量。當然,這樣做會對使用者的搜尋產生一些限制。比如,人們可能會在程式缺陷(bug)庫中搜尋錯誤發生的行號,但是經過上述處理之後的系統顯然不能返回正確結果。如果這類資料需要詞條化,那麼利用正則是一個不錯的辦法。

(3)即使根據空格進行拆分有時也會將概念上本應該看成單個詞條的物件分開,比如一些名稱(San Francisco,Los Angeles)、外來短語(au fait)或那些書寫時可分可合的複合詞(white space vs whitespace)。其他的例子還包括電話號碼[(800)234-2333]、日期(Mar11,1983)等。如果在空格處拆分這些物件可能會導致很差的檢索結果,比如,輸入York University(約克大學)時會返回包含New York University(紐約大學)的文件。連字元和空格甚至會互相影響。這種情況就和中文文字中分詞類似了。

(4)對於一些主要的東亞語言(如漢語、日語、韓語和泰語等)來說,由於詞之間並不存在空格,所以問題更加嚴重。分詞的方法包括基於詞典的最大匹配法(採用啟發式規則來進行未定義詞識別)和基於機器學習序列模型的方法(如隱馬爾可夫模型或條件隨機場模型)等,後者需要在手工切分好的語料上進行訓練(分詞作為NLP領域一個非常重要的研究內容,我們後面會專門獨立一章來介紹分詞常用演算法ヾ(◍°∇°◍)ノ゙)。由於存在多種切分可能,上述分詞方法都有可能導致錯誤的切分結果,因此,永遠不能保證只能得到一個完全一致的唯一切分結果。另一個解決方法則摒棄了基於詞的索引策略而採用短字元序列的方法(如字元的k-gram方法)。這種方法並不關心詞項是否會跨越詞的邊界。該方法之所以能夠引起人們的興趣主要有以下3個原因:第一,一個漢字更像是一個音節而不是字元,它往往具有語義資訊;第二,大部分詞都很短(最常見的漢語詞長度是2個字);第三,由於缺乏公認的分詞標準,詞的邊界有時也很難確定。

1.2 去停用詞

某些情況下,一些常見詞在文件和使用者需求進行匹配時價值並不大,需要徹底從詞彙表中去除。這些詞稱為停用詞(stop word)。一個常用的生成停用詞表的方法就是將詞項按照文件集頻率(collection frequency,每個詞項在文件集中出現的頻率)從高到低排列,然後手工選擇那些語義內容與文件主題關係不大的高頻詞作為停用詞。停用詞表中的每個詞將在索引過程中被忽略。

搜尋引擎核心技術與演算法 —— 詞項詞典與倒排索引最佳化

 英文常用停用詞表

不對停用詞建立索引一般情況下不會對系統造成太大的影響,比如搜尋時採用the或by進行查詢似乎沒有什麼意義。但是,對於短語查詢來說情況並非如此,比如短語查詢President of the United States中包含兩個停用詞,但是它比查詢President AND“United States”更精確。如果忽略掉to,那麼flights to London(因為這裡的to並不是以介詞的身份出現)的意義將會丟失。搜尋Vannevar Bush的那篇經典文章As we may think時,如果將前3個單詞都看作停用詞,那麼搜尋將會很困難,因為系統只返回包含think的文章。更為嚴重的是,一些特定的查詢型別會受到更大的影響。比如一些歌名或者著名的詩歌片段可能全部由常用的停用片語成(如To be or not to be,Let It Be,I don’t want to be等)。


1.3 詞條歸一化

將文件和查詢轉換成一個個的詞條之後,最簡單的情況就是查詢中的詞條正好和文件中的詞條相一致。然而在很多情況下,即使詞條之間並不完全一致,但實際上人們希望它們之間能夠進行匹配。比如查詢USA時我們希望能夠返回包含U.S.A.的文件。

詞條歸一化(token normalization)就是將看起來不完全一致的多個詞條歸納成一個等價類,以便在它們之間進行匹配的過程。

最常規的做法有以下兩種:

(1)隱式地建立等價類,每類可以用其中的某個元素來命名。比如,在文件和查詢中,都把詞條anti-discriminatory和antidiscriminatory對映成詞項antidiscriminatory,這樣對兩個詞中的任一個進行搜尋,都會返回包含其中任一詞的文件。這種處理方法的優點在於:一方面,等價類的建立過程是隱式的,不需要事先計算出等價類的全部元素,在對映規則下輸出相同結果的詞項一起構成等價類集合;另一方面,僅僅構建“去除字元”這種對映規則也比較容易。

(2)顯示建立等價類,維護多個非歸一化詞條之間的關聯關係。該方法可以進一步擴充套件成同義詞詞表的手工構建,比如將car和automobile歸成同義詞。這些詞項之間的關係可以透過兩種方式來實現。第一種常用的方式是採用非歸一化的詞條進行索引,併為某個查詢詞項維護一張由多個片語成的查詢擴充套件詞表。當輸入一個查詢詞項時,則根據擴充套件詞表進行擴充套件並將擴充套件後得到的多個詞所對應的倒排記錄表合在一塊(如下圖一)。另一種方式是在索引構建時就對詞進行擴充套件(如下圖二)。比如,對於包含automobile的文件,我們同時也用car來索引(同樣,包含car的文件也用automobile來索引)。


搜尋引擎核心技術與演算法 —— 詞項詞典與倒排索引最佳化

圖一


搜尋引擎核心技術與演算法 —— 詞項詞典與倒排索引最佳化圖二

另一方面,由於兩個關聯詞的擴充套件詞表之間可以存在交集但不必完全相同,所以上述兩種方式相對於隱式建立等價類的方法來說更具靈活性。這也意味著從不同關聯詞出發可以進行不對稱的擴充套件。下圖出了一個例子。該例子中,如果使用者輸入windows,那麼我們希望返回包含Windows作業系統的文件。但是如果使用者輸入window,雖然此時可以和小寫的windows相匹配,但是不太可能會和Windows作業系統中的Windows相匹配。

搜尋引擎核心技術與演算法 —— 詞項詞典與倒排索引最佳化

隱式建立等價類或查詢擴充套件的使用幅度仍然是個開放的問題。適度使用絕對沒錯,但是過度使用很容易會在無意間造成非預期的擴充套件結果。例如,透過刪除U.S.A.中的句點可以把它轉化成USA,由於在首字母省略用法中存在這種轉換模式,所以上面的做法乍看上去非常合理。但是,如果輸入查詢C.A.T.,返回的很多包含cat的文件卻肯定不是我們想要的結果。

接下來我們將給出一些在實際當中會遇到的詞條歸一化問題及其對策

(1)重音及變音符號問題

英語中變音符號的使用越來越少見,儘管如此,人們很可能希望cliche和cliché或者naive和naïve能匹配。這可以透過在詞條歸一化時去掉變音符號來實現。而在許多其他語言中,變音符號屬於文字系統的常規部分,不同的變音符號表示不同的發音。有時候,不同單詞之間的區別只是重音不同。比如,西班牙語中,peña的意思是“懸崖”,而pena的意思卻是“悲哀”。然而,關鍵並不是規範或者語言學問題,而是使用者如何構造查詢來查詢包含這些詞的文件。

(2)大小寫轉換問題

大小寫轉換(case-folding)問題的一個一般處理策略是將所有的字母都轉換成小寫。這種做法通常的效果不錯,比如這樣可以允許句首的Automobile和查詢automobile匹配。對於Web搜尋引擎來說,這種做法也很有好處,因為大多數使用者輸入ferrari時實際想找的是Ferrari(法拉利)車。

(3)英語中的其他問題

英語中還存在一些獨特的歸一化做法。比如,使用者希望將ne’er和never、英式英語的拼寫方式colour和美式英語的拼寫方式color等同起來。日期、時間和其他類似的物件往往以多種形式出現,這給歸一化造成了額外的負擔。人們可能希望將3/12/91和Mar.12,1991統一起來。但是,要正確處理這個例子將會十分複雜,因為在美國,3/12/91指的1991年3月12日(Mar.12,1991),而在歐洲,卻指的是1991年12月3日(3Dec.1991)

1.4 詞幹還原和詞性歸併

出於語法上的要求,文件中常常會使用詞的不同形態,比如organize、organizes和organizing。另外,語言中也存在大量意義相近的同源詞,比如democracy、democratic和democratization。在很多情況下,如果輸入其中一個詞能返回包含其同源詞的文件,那麼這樣的搜尋似乎非常有用。

詞幹還原詞形歸併的目的都是為了減少屈折變化的形式,並且有時會將派生詞轉化為基本形式。

詞幹還原:通常指的是一個很粗略的去除單詞兩端詞綴的啟發式過程,並且希望大部分時間它都能達到這個正確目的,這個過程也常常包括去除派生詞綴。

詞形歸併:通常指利用詞彙表和詞形分析來去除屈折詞綴,從而返回詞的原形或詞典中的詞的過程,返回的結果稱為詞元

搜尋引擎核心技術與演算法 —— 詞項詞典與倒排索引最佳化

這兩個過程的區別還在於:詞幹還原在一般情況下會將多個派生相關詞合併在一起,而詞形歸併通常只將同一詞元的不同屈折形式進行合併。詞幹還原或詞形歸併往往透過在索引過程中增加外掛程式的方式來實現,這類外掛程式有很多,其中既有商業軟體也有開源軟體。


基於跳錶的快速合併演算法

上一章我們講解了排記錄表的基本合併演算法:同時在兩個表中遍歷,並且最後演算法的時間複雜度為記錄表大小的線性函式。假定兩個表的大小分別是m和n,那麼合併過程有O(m+n)次操作。很自然的一個問題就是我們能否做得更好?也就是說,能否在亞線性時間內完成合並過程?下面我們將看到,如果索引變化不太頻繁的話那麼答案是肯定的。

如果待合併的兩個倒排表資料量很大, 但是交集很少時, 會是什麼情況呢?

[1, 2, 3, 4, 5, ... 10001, 10005][1, 10001, 10008]

如果對這兩個做合併操作, 最後的交集結果只有  [1, 10001] 2個元素, 但是卻要做10001次移動和比較操作, 所以肯定有什麼辦法來最佳化這一點. 可能你已經想到了, 我們做了這麼多無用比較, 是因為我們每次指標向前移動的步子太小了點, 如果我們在每次比較後向前多移動一點, 可以忽略很多無用的操作. 這就是跳錶的思想.

跳錶(skip list)—— 在構建索引的同時在倒排記錄表上建立跳錶(如下圖所示)。跳錶指標能夠提供捷徑來跳過那些不可能出現在檢索結果中的記錄項。構建跳錶的兩個主要問題是:在什麼位置設定跳錶指標?如何利用跳錶指標進行倒排記錄表的快速合併?

搜尋引擎核心技術與演算法 —— 詞項詞典與倒排索引最佳化

我們以上圖為例來先考慮快速合併的問題。假定我們在兩個表中遍歷一直到發現共同的記錄8為止,將8放入結果表中之後我們繼續移動兩個表的指標。假定第一個表的指標移到16處,而第二個表的指標移到41處,兩者中較小項為16。這時候我們並不繼續移動上面的表指標,而是檢查跳錶指標的目標項,此時為28,仍然比41要小,因此此時可以直接把上表的表指標移到28處,這樣就跳過了19和23兩項。基於跳錶的倒排記錄表合併演算法有很多變形,它們的主要不同可能在於跳錶檢查的時機不一樣。

我們再考察另一個問題,即在什麼位置上放置跳錶指標?這裡存在一個指標個數和比較次數之間的折中問題。跳錶指標越多意味著跳躍的步長越短,那麼在合併過程中跳躍的可能性也更大,但同時這也意味著需要更多的指標比較次數和更多的儲存空間。跳錶指標越少意味著更少的指標比較次數,但同時也意味著更長的跳躍步長,也就是說意味著更少的跳躍機會。放置跳錶指標位置的一個簡單的啟發式策略是,在每個㏒₂P處均勻放置跳錶指標,其中P是倒排記錄表的長度。這個策略在實際中效果不錯,但是仍然有提高的餘地,因為它並沒有考慮查詢詞項的任何分佈細節。

# 基於跳錶的倒排記錄錶快速合併演算法a = range(10008)b = [1, 10001, 10008]
i = j = 0result = []step = 100count = 0while i < len(a) and j < len(b):    if a[i] == b[j]:        result.append(a[i])        i = i + 1        j = j + 1        count = count + 1    elif a[i] < b[j]:        while (i + step < len(a)) and a[i + step] <= b[j]:            i = i + step            count = count + 1        else:            i = i + 1            count = count + 1    else:        while (j + step < len(b)) and b[j + step] <= a[i]:            j = j + step            count = count + 1        else:            j = j + 1            count = count + 1print(result)  # [1, 10001]print(count)  # 207

上面程式碼中故意構造了一個很大的集合 [0 ... 10007], 然後用變數count作為計數器來分析兩個演算法分別執行的操作次數, 可以看到採用跳錶演算法時(我們模擬了step=100)的計算次數是207, 而用之前的方式計算次數是10008, 可見效能提升了很多倍.

這裡有幾點說明下:

1. 這裡為了簡單說明跳錶的思路, 全部用了陣列表示倒排表, 其實真實的資料結構應該是連結串列結構(linked list). 這才符合磁碟儲存結構. 

2. 跳錶的原始結構演算法比這個複雜, 而且根據場景的不同, 跳錶有不同的實現. 這裡因為不是利用跳錶的快速查詢功能, 所以沒有多級指標索引概念, 詳細跳錶實現查考: Skip Lists: A Probabilistic Alternative to Balanced Trees


含位置資訊的倒排記錄表

先來看一個問題,當使用者將“Stanford University”這個查詢中的兩個詞看成一個整體的時候,使用者是為了查詢和Stanford University這所高校相關的資訊。但是如果是基於布林查詢(詳見第一章)的話,將會被拆解成Stanford AND University進行查詢,從而一篇含有句子The inventor Stanford Ovshinsky never went to university的文件會推送給使用者,這並不是我們想要的。那麼如何解決這個問題呢?這裡引入二元詞索引。

3.1 二元詞索引

處理短語查詢的一個辦法就是將文件中每個接續詞對看成一個短語。例如,文字 Friends,Romans, Countrymen 會產生如下的二元接續詞對

friends romansromans countrymen

這種方法將每個接續詞對看成詞項,這樣馬上就能處理兩個詞構成的短語查詢,更長的查詢可以分成多個短查詢來處理。比如,按照上面的方法可以將查詢 stanford university palo alto

分成如下的布林查詢:

“stanford university” AND “university palo” AND “palo alto”

可以期望該查詢在實際中效果會不錯,但是偶爾也會有錯誤的返回例子。對於該布林查詢返回的文件,我們並不知道其是否真正包含最原始的四詞短語。在所有可能的查詢中,用名詞和名詞短語來表述使用者所查詢的概念具有相當特殊的地位。但是相關的名詞往往被各種虛詞分開,比如短語the abolition of slavery或者renegotiation of the constitution。這種情況下,可以採用如下方法來建立二元詞索引:首先對文字進行詞條化然後進行詞性標註,這樣就可以把每個詞項歸成名詞(N,也包括專有名詞)、虛詞(X,冠詞和介詞)和其他詞。然後將形式為NX*N非詞項序列看成一個擴充套件的二元詞。利用上述演算法,可以將查詢cost overruns on a power plant分析成“cost overruns” AND “overruns power” AND “power plant”,實際上忽略中間的那個二元詞所形成的查詢的效果會更好。如果使用更精確的詞性模式來定義擴充套件二元詞可能會取得更好的結果。

二元詞索引的概念可以擴充套件到更長的詞序列(三元、四元...),如果索引中包含變長的詞序列,通常就稱為短語索引(phrase index)。實際上,利用二元詞索引來處理單個詞的查詢不太方便(必須要掃描整個詞彙表來發現包含該查詢詞的二元詞),因此同時還需要有基於單個詞的索引。儘管總有可能得到錯誤的匹配結果,但是在長度為3或者更長的索引短語上發生匹配錯誤的可能性實際上卻很小。然而在另一方面,儲存更長的短語很可能會大大增加詞彙表的大小。窮盡所有長度超過2的短語並維護其索引絕對是一件令人生畏的事情,即使只窮盡所有的二元詞也會大大增加詞彙表的大小。

3.2 位置資訊索引

很顯然,基於上面談到的原因,二元詞索引並非標準的解決方案。實際中更常用的一種方式是採用所謂的位置資訊索引(positional index,簡稱位置索引)。在這種索引中,對每個詞項,以如下方式儲存倒排記錄

搜尋引擎核心技術與演算法 —— 詞項詞典與倒排索引最佳化

單詞be的文件頻率是178239,在文件1中出現2次,位置分別是17、25。

為處理短語查詢,仍然需要訪問各個詞項的倒排記錄表。像以往一樣,這裡可以採用最小文件頻率優先的策略,從而可以限制後續合併的候選詞項的數目。在合併操作中,同樣可以採用前面提到的各種技術來實現,但是這裡不只是簡單地判斷兩個詞項是否出現在同一文件中,而且還需要檢查它們出現的位置關係和查詢短語的一致性。這就需要計算出詞之間的偏移距離。

舉個例子,假如使用者輸入"boy friend"進行搜尋, 如果只要出現了"boy" 或者 "friend"的文件都搜尋出來, 那麼下面三篇文件都滿足要求:

  1. the boy and the girl are good friends

  2. you are my boy friend

  3. the boy has many friends.

現在使用者應該只想搜出文件 2 出來. 基於"位置資訊索引"方式, 我們可以做到這一點.

這種搜尋方法類似於k詞近鄰搜尋 —— a /k b

這裡,/k 意味著“ 從左邊或右邊相距在 k 個詞之內,若k=1,則意味著a、b相鄰” 。很顯然,位置索引能夠用於鄰近搜尋,而二元詞索引則不能。

有了這個索引儲存結構, 要找出不同的短語就比較容易了, 比如使用者想搜尋"boy friend", 就可以轉化成 boy /1 friend 即可以完成要求。只要找出在文件中, boy出現的位置剛好在friend前一個位置的所有文件. 所以文件2滿足我們的要求被搜尋出來. 下面用python簡單實現下這個演算法:

# p1, p2是兩個上述結構的倒排記錄表, k是兩個詞項的位置在k以內def positional_interset(p1, p2, k):  result = [] # 最終的搜尋結果, 以(文件id, 詞項1的位置, 詞項2的位置)形式儲存    while p1 is not None and p2 is not None: # 當p1, 和 p2 都沒有達到最尾部時        if p1.docId == p2.docId: # 如果兩個詞項出現在同一個文件中            l = [] # 臨時變數, 用來儲存計算過程中滿足位置距離的位置對資訊            pp1 = p1.position            pp2 = p2.position            while pp1 is not None: # 先固定pp1的位置, 迴圈移動pp2的位置進行檢查                while pp2 is not None:                    if abs(pp1.pos - pp2.pos) <= k: # 如果pp1和pp2的距離小於k, 則滿足要求                        l.append(pp2.pos) # 新增到臨時變數                        pp2 = pp2.next # pp2向後移一個位置                    elif pp2.pos > pp1.pos: # 如果pp2當前的位置相對pp1已經超過給定的範圍(構不成短語要求), 則停止移動pp2, 後續後把pp1再往前移動一個位置                        break                while not l and abs(l[0] - pp1.pos) > k: # 當每次移動一次pp1時, l裡面會儲存上一次計算所得的pp2的一些位置, 這裡要過濾那些相對於當前pp1最新位置, 那些不再滿足要求的pp2的位置                    del l[0]                for p in l:                    result.append[(p1.docId, pp1.pos, p)] # 把最終的結果加入到結果集中                pp1 = pp1.next # pp1向前移動一個位置, 重複上次邏輯計算            p1 = p1.next            p2 = p2.next        elif p1.docId < p2.docId:            p1 = p1.next        else:            p2 = p2.next

 毋庸置疑,採用位置索引會加深倒排記錄表合併操作的漸進複雜性,這是因為需要檢查的項的個數不再受限於文件數目而是文件集中出現的所有的詞條的個數 T。也就是說,布林查詢的複雜度為Θ (T)而不是Θ (N)。然而,由於使用者往往期望能夠進行短語搜尋和鄰近搜尋,所以實際中的大部分應用並沒有其他選擇而不得不採用這種做法。

3.3 混合索引機制

二元詞索引和位置索引這兩種策略可以進行有效的合併。假如使用者通常只查詢特定的短語,如Michael Jackson,那麼基於位置索引的倒排記錄表合併方式效率很低。一個混合策略是:對某些查詢使用短語索引或只使用二元詞索引,而對其他短語查詢則採用位置索引。短語索引所收錄的那些較好的查詢可以根據使用者最近的訪問行為日誌統計得到,也就是說,它們往往是那些高頻常見的查詢。當然,這並不是唯一的準則。處理開銷最大的短語查詢往往是這樣一些短語,它們中的每個詞都非常常見,但是組合起來卻相對很少見。

Williams等人(2004)評估了一個更復雜的混合索引機制,其中除了包含上面兩種形式的索引外,還在它們之間引入了一個部分後續詞索引(next word index),即對每個詞項,有個後續詞索引記錄了它在文件中的下一個詞項。論文的結論是,雖然比僅僅使用位置索引增加了26%的空間,但是面對典型的Web短語混合查詢,其完成時間大概是隻使用位置索引的1/4。

本章節主要對詞項的形成倒排索引的兩個升級版演算法做了一個粗略的介紹。雖然這是搜尋引擎中最基礎的東西,但值得細細挖掘的地方還有很多,畢竟每一個小點的改善都可以極大的提高使用者體驗,搜尋引擎學習之路道阻且長呀~加油(`・ω・´)

相關文章