1. 爲什麼需要搜索引擎
如果被問及什麼是搜索引擎?相信有相當一部分人會列舉出百度或者谷歌的例子。的確,搜索業務是這些公司的核心業務之一。然而在目前互聯網普及的時代,搜索引擎幾乎可以說隨處可見:出門的時候會打開地圖軟件搜一下路線;餓了的時候搜一個餐廳或者搜一種喜歡吃的外賣;刷微博的時候想找到感興趣的內容……
凡此種種,不勝枚舉。搜索是我們獲取信息的最主要的途徑(本質上說,請教他人問題也是一種搜索,就是一個他人將自身的知識拿出來給我們解惑的過程),而在數據與信息爆炸的今天,要從中獲取我們感興趣的內容,更加離不開搜索引擎。
2. Whoosh簡介
Whoosh
模塊是一個開源的、純Python編寫的搜索引擎庫。或者更準確的說,Whoosh
是一個程序包,提供了許多類和函數來幫助程序員開發出自己的搜索引擎。它的官網在這裏,裏面有較爲詳細的功能說明,不過是用英文寫的。筆者是在工作中接觸到Whoosh
模塊的,隨着使用的不斷深入,越來越被其功能、設計及代碼風格所折服,因此,整理出來一些對該模塊的學習與使用總結,如果可以幫助到後來的學習者,那麼我會感到十分高興。
Whoosh
是純Python編寫的,這意味着,你可以在任何有Python環境的地方安裝這個模塊併成功運行,而不會報一堆莫名其妙的編譯環境依賴問題。特別是在無網絡的條件下,這種特性將會非常有用!
Whoosh
也是開源的,這意味着你可以拿到它的源碼,當你對任何一個功能有任何疑惑的時候,你都可以追溯到源碼去查看。另外,作者在源碼中還添加了非常詳細的註釋。開源的另一個好處就是,當你實例化一個對象之後,可以去源碼裏看看這個對象都有什麼方法,這通常會很有用。總結來說:當你剛入門一個新領域時,想實現一個功能或者遇到一個問題時,多看看源碼,因爲在絕大多數的情況下,這個功能或者問題,作者已經考慮進去並很好的解決了。
3. Whoosh的QuickStart
安裝Whoosh
,直接用pip install Whoosh
或者conda install Whoosh
。
在官方文檔中,給出了一個QuickStart的例子,這個例子涵蓋了實現一次搜索請求的整個週期:建立索引、解析搜索內容、返回搜索結果。我把這個例子稍作修改,複製過來:
from whoosh.index import create_in
from whoosh.fields import Schema, TEXT, ID
from whoosh.qparser import QueryParser
# 創建了一個schema
schema = Schema(title=TEXT(stored=True), path=ID(stored=True), content=TEXT)
ix = create_in("indexdir", schema)
writer = ix.writer()
# 對文檔進行建立索引
writer.add_document(title=u"First document", path=u"/a",
content=u"This is the first document we've added!")
writer.add_document(title=u"Second document", path=u"/b",
content=u"The second one is even more interesting!")
writer.commit()
# 利用一個searcher對象,對輸入的搜索語句進行解析,並返回結果
with ix.searcher() as searcher:
query = QueryParser('content', ix.schema).parse("first")
results = searcher.search(query)
print(results[0])
暫時可以不用理解導入的這幾個對象的功能,只需要掌握代碼的結構即可:
- 在
writer.commit()
之前,都是建立索引的過程; query = QueryParser('content', ix.schema).parse("first")
行對搜索的語句“first”進行了解析;results = searcher.search(query)
行進行搜索,並返回了搜索結果。
無論多麼複雜(或者說“精準”)的搜索引擎,其背後的實現邏輯都不會與上面的三個步驟有太大的出入。當然,一款好的搜索引擎涉及到的數學原理、技術細節以及可優化的地方是非常非常多的,但利用Whoosh
開發一個“玩具級”的搜索引擎,就足以幫我們看清搜索引擎的“廬山真面目”了。
4. QuickStart中的幾個關鍵對象
在上面的例子中,涉及了幾個重要的對象,包括:Schema
、FileIndex
、SegmentWriter
、Searcher
。下面分別對它們進行簡單的說明。
4.1 Schema
對象
俗話說,巧婦難爲無米之炊。這句話遷移到搜索上,大概可以改爲:“好引擎難搜無索引之數據”。意思是要想從數據中高效的獲取想要的內容,必須要對數據建立索引(關於什麼是索引,我覺得這又是一個非常大的話題,目前可以類比於圖書的目錄,知道索引之於搜索是必需的即可)。
要想對數據建立索引,那麼首先需要指定索引的“模式”,也就是所謂的Schema
。
想象一下,有一萬個網頁,每一個網頁分爲標題、鏈接和正文,如果讓我們人工去找某個主題的文章,我們怎麼做呢?大概率會先對標題瀏覽一遍,發現與主題有關的文章時,再去瀏覽它的正文。而網頁的鏈接在這個場景中並不能幫助我們判斷主題。
按照上述的思路,網頁的不同部分爲我們判斷其主題所提供的信息量是不同的。在我們瀏覽的時候,主觀上已經對標題和正文進行了區別對待,即:我們認爲文章的標題最能體現其主題,正文內容所起到的作用只是進一步幫助我們判斷。
那麼在利用程序對這些網頁建立索引時,也需要區別對待網頁的不同部分,這就是Schema
對象的作用。
在上面的例子中,對類Schema
進行實例化時一共創建了三個字段,分別是:“title”、“path”和“content”。“字段”這個詞翻譯自“Field”,指的是待建立索引的文檔中的信息片段(“A field is a piece of information for each document in the index”)。也就是說,在對文檔建立索引前,需要先將文檔內容按照字段列表進行切分。這同時也意味着,通常我們只能根據部分字段列表中的內容進行檢索。
爲什麼是部分呢?仍然拿上面的例子說明。字段列表包含了3部分的內容:標題、鏈接和正文,在一般情況下,我們輸入的檢索語句包含在標題或者正文中,而不會通過網頁的鏈接來搜索網頁,因此,“path”字段中的內容不會供用戶來檢索,而是在用戶選擇某個搜索結果時,定位到該結果的源網頁。
注意,“path”字段代表的內容雖然不供用戶來檢索,但是仍包含在索引中(indexed but not searchable)。
上面的例子在實例化一個schema時,用到了TEXT
和ID
兩個類,即字段的類型,指的是對字段中的內容的處理方式。這裏先作一個簡單的解釋:
TEXT
:主要用於對文檔主體部分的內容進行索引;可以通過參數設置,將該類型對應的字段內容保存至索引文件中。ID
:將字段內容視爲一個不可分割的整體,然後建立索引;也可以通過設置將字段內容保存至索引文件中。
我在早些時候,錯誤的認爲對一個字段的內容建立了索引,那麼當然可以通過索引文件恢復原始內容。而實際上,“建立索引”和“保存原始內容”是兩回事。所以在指定字段的類型時需要傳入參數來告訴程序是否將原始內容也保存到索引文件中。“保存原始內容”的操作通常是有用的:例如一個網頁的連接,如果不保存原始內容,那麼將無法連接到源網頁。
4.2 FileIndex
與SegmentWriter
對象
有了schema之後,接下來的工作就是按照schema對文檔建立索引了。爲了實現這個操作,首先需要創建一個Index
對象:
ix = create_in("indexdir", schema)
很明顯,本例中create_in
函數接收了兩個參數:
"indedir"
指定了索引的保存位置,需要注意的是,這個路徑必須存在,Whoosh
並不會自動創建。schema
是上文對Schema
實例化後的對象
經過這一行代碼,得到的ix
就是一個FileIndex
對象。
有了ix
之後,接下來需要創建一個SegmentWriter
對象:
writer = ix.writer()
至此,對文檔建立索引的所有信息(索引模式、索引對象、索引寫入對象)都已經具備,於是,可以通過writer
對象的add_document
方法來對文檔進行索引:
writer.add_document(title=u"First document", path=u"/a",
content=u"This is the first document we've added!")
writer.add_document(title=u"Second document", path=u"/b",
content=u"The second one is even more interesting!")
可以看出,add_document
函數的參數就是schema
中的字段。當然,在add_document
時,不必保證schema
中的所有字段都有值(Whoosh doesn’t care if you leave out a field from a document)。另外需要注意的是,所有需要索引的字段傳入的內容必須是Unicode編碼的,然而如果一個字段只是被保存原始值而沒有建立索引(後續會介紹這種字段類型),那麼它的值可以是任何可序列化的(pickle-able)對象。
最後,當然還要進行提交這次創建索引的過程:
writer.commit()
4.3 Searcher
對象
搜索過程需要一個Searcher
對象,這個對象是通過調用FileIndex
對象的searcher
方法建立的:
with ix.searcher as searcher():
...
通過with
語句,我們可以不用顯示地去關閉這個Searcher
對象。
注意,這個Searcher
對象可接受的參數是一個查詢對象。所以,我們必須將一個查詢語句轉化爲一個查詢對象,實現的過程是:
query = QueryParser('content', ix.schema).parse("first")
上述語句應該理解爲:在實例化一個查詢解析器的類QueryParser
時,需要傳入的參數至少包含字段名稱(content
) 和索引模式(ix.schema
) 兩個參數,然後調用該類的parse
方法對輸入的查詢語句進行解析,生成查詢對象。
查詢結果是一個Results
對象,其中的值是一個Hit
對象,這就要求我們在從Results
對象中獲取返回的查詢內容時,必須保證Searcher
對象時處於打開狀態。下面這種方式會報錯:
with ix.searcher() as searcher:
query = QueryParser('content', ix.schema).parse("first")
results = searcher.search(query)
print(results[0])
4.4 結果驗證
保證實例代碼的創建索引部分不變,更改搜索部分的代碼如下:
with ix.searcher() as searcher:
query = QueryParser('title', ix.schema).parse("document")
results = searcher.search(query)
for i in range(len(results)):
print(results[i])
運行後的結果爲:
<Hit {'path': '/a', 'title': 'First document'}>
<Hit {'path': '/b', 'title': 'Second document'}>
可以看出,在每個Hit
對象中,均沒有content
的內容。這是因爲我們在創建schema
時,字段content
並沒有指定保存原始內容,所以無法獲取該字段的原始內容,但仍可以通過該字段進行檢索:
with ix.searcher() as searcher:
query = QueryParser('content', ix.schema).parse("document")
results = searcher.search(query)
for i in range(len(results)):
print(results[i])
結果:
<Hit {'path': '/a', 'title': 'First document'}>