一、前言
Lucene 是 apache 軟件基金會的一個子項目,由 Doug Cutting 開發,是一個開放源代碼的全文檢索引擎工具包,但它不是一個完整的全文檢索引擎,而是一個全文檢索引擎的庫,提供了完整的查詢引擎和索引引擎,部分文本分析引擎(英文與德文兩種西方語言)。Lucene 的目的是為軟件開發人員提供一個簡單易用的工具包,以方便的在目標系統中實現全文檢索的功能,或者是以此為基礎建立起完整的全文檢索引擎。Lucene 是一套用於全文檢索和搜尋的開源程式庫,由 Apache 軟件基金會支持和提供。
Lucene 提供了一個簡單卻強大的應用程式接口,能夠做全文索引和搜尋。在 Java 開發環境里 Lucene 是一個成熟的免費開源工具。就其本身而言,Lucene 是當前以及最近幾年最受歡迎的免費 Java 信息檢索程序庫。人們經常提到信息檢索程序庫,雖然與搜索引擎有關,但不應該將信息檢索程序庫與搜索引擎相混淆。
最開始 Lucene 只由 java 開發,供 java 程序調用,隨着 python 越來越火,Lucene 官網也提供了 python 版本的 lucene 庫,供 python 程序調用,即 PyLucene。
二、下載 Lucene
2.1 下載
訪問 Lucene 官網 http://lucene.apache.org/,可以看到 綠色和紅色兩個下載按鈕,分別提供 Lucene 和 Solr 的下載。
這里簡要說明一下 Lucene 和 Solr,Lucene 是一個做全文檢索的庫,開發者可以按照自己的實際業務需求來使用,而 Solr 是一個基於 Lucene 的全文檢索服務器。Solr 是在 Lucene 的基礎上進行擴展,並且提供了更加豐富的查詢語句,可擴展性和可配置性比 Lucene 更高。除此之外 Solr 還提供了一個完善的管理頁面,是一個產品級的全文搜索引擎。
官網首頁提供了最新版本的下載鏈接,如果需要下載使用歷史版本,可以訪問 http://archive.apache.org/dist/lucene/java/,可以下載 Lucene 所有的發行版本。此處下載 6.6.0 版本。
2.2 添加依賴
將下載的 Lucene 包解壓之后,找到如下的 jar 包,新建自己的工程,此處不使用 Maven,所以手動添加 jar 包到工程的 lib 目錄下,如下:
IKAnalyzer2012_u6.jar,此 jar 包在 IK 分詞器項目中,是單獨的一個工具包,需要額外在網上下載:IKAnalyzer2012_u6。IK 分詞器采用了特有的 "正向迭代最細粒度切分算法",即從左到右的 正向最大(最長)和最小(最短)匹配,支持細粒度和智能分詞兩種切分模式,可將分詞器擴展配置文件 IKAnalyzer.cfg.xml 放在項目的 class 根目錄,並在其中配置擴展詞典路徑。當 IKAnalyzer6x() 構造方法參數為空或者 false 時,是最細粒度分詞,為 true 時是智能分詞。
lucene-analyzers-common-6.6.0.jar:lucene-6.6.0/common/
lucene-analyzers-smartcn-6.6.0.jar:lucene-6.6.0/smartcn/
lucene-core-6.6.0.jar:lucene-6.6.0/core/
lucene-highlighter-6.6.0.jar:lucene-6.6.0/highlighter/
lucene-memory-6.6.0.jar:lucene-6.6.0/memory/
lucene-queries-6.6.0.jar:lucene-6.6.0/queries/
lucene-queryparser-6.6.0.jar:lucene-6.6.0/queryparser/
2.3 Lucene 架構
首先是信息采集的過程,文件系統、數據庫、萬維網以及手工輸入的文件都可以作為信息采集的對象,也是要搜索的文檔的來源,采集萬維網上的信息一般使用網絡爬蟲。完成信息采集之后到 Lucene 層面主要有兩個任務:索引文檔和搜索文檔。
索引文檔的過程完成由原始文檔到倒排索引的構建過程;
搜索文檔用以處理用戶查詢。然后當用戶輸入查詢關鍵詞,Lucene 完成文檔搜索任務,經過分詞、匹配、評分、排序等一系列過程之后返回用戶想要的文檔。
倒排索引(Inverted index),也常被稱為反向索引,是一種索引方法,被用來存儲在全文搜索下某個單詞在一個文檔或者一組文檔中的存儲位置的映射,它是文檔檢索系統中最常用的數據結構,包括詞項所在的文章號、詞項頻率、詞項位置等。
三、Lucene 索引詳解
3.1 創建實體模型
創建新聞實體類模型
package tup.lucene.index; /** * 新聞實體類 * @author moonxy * */ public class News { private int id;//新聞id private String title;//新聞標題 private String content;//新聞內容 private int reply;//評論數 public News() { } public News(int id, String title, String content, int reply) { super(); this.id = id; this.title = title; this.content = content; this.reply = reply; } // 省略 setter 和 getter 方法 }
3.2 創建索引
Lucene 索引文檔需要依靠 IndexWriter 對象,創建 IndexWriter 需要兩個參數:一個是 IndexWriterConfig 對象,該對象可以設置創建索引使用哪種分詞器,另一個是索引的保存路徑。IndexWriter 對象的 addDocument() 方法用於添加文檔,該方法的參數為 Document 對象,IndexWriter 對象一次可以添加多個文檔,最后調用 commit() 方法生成索引。
package tup.lucene.index; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Date; import org.apache.lucene.analysis.Analyzer; import org.apache.lucene.document.Document; import org.apache.lucene.document.Field; import org.apache.lucene.document.FieldType; import org.apache.lucene.document.IntPoint; import org.apache.lucene.document.StoredField; import org.apache.lucene.index.IndexOptions; import org.apache.lucene.index.IndexWriter; import org.apache.lucene.index.IndexWriterConfig; import org.apache.lucene.index.IndexWriterConfig.OpenMode; import org.apache.lucene.store.Directory; import org.apache.lucene.store.FSDirectory; import tup.lucene.ik.IKAnalyzer6x; /** * Lucene 創建索引 * @author moonxy * */ public class CreateIndex { public static void main(String[] args) { // 創建3個News對象 News news1 = new News(); news1.setId(1); news1.setTitle("安倍晉三本周會晤特朗普 將強調日本對美國益處"); news1.setContent("日本首相安倍晉三計划2月10日在華盛頓與美國總統特朗普舉行會晤時提出加大日本在美國投資的設想"); news1.setReply(672); News news2 = new News(); news2.setId(2); news2.setTitle("北大迎4380名新生 農村學生700多人近年最多"); news2.setContent("昨天,北京大學迎來4380名來自全國各地及數十個國家的本科新生。其中,農村學生共700余名,為近年最多..."); news2.setReply(995); News news3 = new News(); news3.setId(3); news3.setTitle("特朗普宣誓(Donald Trump)就任美國第45任總統"); news3.setContent("當地時間1月20日,唐納德·特朗普在美國國會宣誓就職,正式成為美國第45任總統。"); news3.setReply(1872); // 開始時間 Date start = new Date(); System.out.println("**********開始創建索引**********"); // 創建IK分詞器 Analyzer analyzer = new IKAnalyzer6x();//使用IK最細粒度分詞 IndexWriterConfig icw = new IndexWriterConfig(analyzer);
// CREATE 表示先清空索引再重新創建 icw.setOpenMode(OpenMode.CREATE); Directory dir = null; IndexWriter inWriter = null; // 存儲索引的目錄 Path indexPath = Paths.get("indexdir"); try { if (!Files.isReadable(indexPath)) { System.out.println("索引目錄 '" + indexPath.toAbsolutePath() + "' 不存在或者不可讀,請檢查"); System.exit(1); } dir = FSDirectory.open(indexPath); inWriter = new IndexWriter(dir, icw); // 設置新聞ID索引並存儲 FieldType idType = new FieldType(); idType.setIndexOptions(IndexOptions.DOCS); idType.setStored(true); // 設置新聞標題索引文檔、詞項頻率、位移信息和偏移量,存儲並詞條化 FieldType titleType = new FieldType(); titleType.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS); titleType.setStored(true); titleType.setTokenized(true); FieldType contentType = new FieldType(); contentType.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS); contentType.setStored(true); contentType.setTokenized(true); contentType.setStoreTermVectors(true); contentType.setStoreTermVectorPositions(true); contentType.setStoreTermVectorOffsets(true); Document doc1 = new Document(); doc1.add(new Field("id", String.valueOf(news1.getId()), idType)); doc1.add(new Field("title", news1.getTitle(), titleType)); doc1.add(new Field("content", news1.getContent(), contentType)); doc1.add(new IntPoint("reply", news1.getReply())); doc1.add(new StoredField("reply_display", news1.getReply())); Document doc2 = new Document(); doc2.add(new Field("id", String.valueOf(news2.getId()), idType)); doc2.add(new Field("title", news2.getTitle(), titleType)); doc2.add(new Field("content", news2.getContent(), contentType)); doc2.add(new IntPoint("reply", news2.getReply())); doc2.add(new StoredField("reply_display", news2.getReply())); Document doc3 = new Document(); doc3.add(new Field("id", String.valueOf(news3.getId()), idType)); doc3.add(new Field("title", news3.getTitle(), titleType)); doc3.add(new Field("content", news3.getContent(), contentType)); doc3.add(new IntPoint("reply", news3.getReply())); doc3.add(new StoredField("reply_display", news3.getReply())); inWriter.addDocument(doc1); inWriter.addDocument(doc2); inWriter.addDocument(doc3); inWriter.commit(); inWriter.close(); dir.close(); } catch (IOException e) { e.printStackTrace(); } Date end = new Date(); System.out.println("索引文檔用時:" + (end.getTime() - start.getTime()) + " milliseconds"); System.out.println("**********索引創建完成**********"); } }
執行之后,在控制台輸出如下:
**********開始創建索引********** 加載擴展詞典:dict/ext.dic 加載擴展停止詞典:dict/stopword.dic 加載擴展停止詞典:dict/ext_stopword.dic 索引文檔用時:1064 milliseconds **********索引創建完成**********
並且在項目中生成如下索引文件:
3.3 Luke 查看索引
索引創建完成以后生成了如上的一批特殊格式的文件,如果直接用工具打開,會顯示的都是亂碼。可以使用索引查看工具 Luke 來查看。
Luke 是開源工具,代碼托管在 GitHub 上,項目地址:https://github.com/DmitryKey/luke/releases,此處下載 luke 6.6.0,地址為 https://github.com/DmitryKey/luke/releases/download/luke-6.6.0/luke-6.6.0-luke-release.zip,如果在 Windows 中無法下載,可以在 Linux 中使用 wget 下載,命令為:wget https://github.com/DmitryKey/luke/releases/download/luke-6.6.0/luke-6.6.0-luke-release.zip。
下載后解壓,進入 luke 目錄,如果是在 Linux 平台,運行 luke.bat 即可啟動軟件,並在 Path 中輸入 index 存儲的目錄,即可打開索引文件,顯示出索引的具體內容。
注意:對於不同版本的 Lucene,需要選擇對應版本的 Luke,否則可能會出現不能正常解析的錯誤。
3.4 Lucene 查詢詳解
在 Lucene 中,處理用戶輸入的查詢關鍵詞其實就是構建 Query 對象的過程。Lucene 搜索文檔需要先讀入索引文件,實例化一個 IndexReader 對象,然后實例化出 IndexSearch 對象,IndexSearch 對象的 search() 方法完成搜索過程,Query 對象作為 search() 方法的對象。搜索結果保存在一個 TopDocs 類型的文檔集合中,遍歷 TopDocs 集合輸出文檔信息。
QueryParser 可以搜索單個字段,而 MultiFieldQueryParser 則可以查詢多個字段,並且多個字段之間是或的關系,所以在開發中,MultiFieldQueryParser 使用的較多。
package tup.lucene.queries; import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; import org.apache.lucene.analysis.Analyzer; import org.apache.lucene.document.Document; import org.apache.lucene.index.DirectoryReader; import org.apache.lucene.index.IndexReader; import org.apache.lucene.queryparser.classic.MultiFieldQueryParser; import org.apache.lucene.queryparser.classic.ParseException; import org.apache.lucene.queryparser.classic.QueryParser; import org.apache.lucene.queryparser.classic.QueryParser.Operator; import org.apache.lucene.search.IndexSearcher; import org.apache.lucene.search.Query; import org.apache.lucene.search.ScoreDoc; import org.apache.lucene.search.TopDocs; import org.apache.lucene.store.Directory; import org.apache.lucene.store.FSDirectory; import tup.lucene.ik.IKAnalyzer6x; /** * 單域搜索 * @author moonxy * */ public class QueryParseTest { public static void main(String[] args) throws ParseException, IOException { // 搜索單個字段 String field = "title"; // 搜索多個字段時使用數組 //String[] fields = { "title", "content" }; Path indexPath = Paths.get("indexdir"); Directory dir = FSDirectory.open(indexPath); IndexReader reader = DirectoryReader.open(dir); IndexSearcher searcher = new IndexSearcher(reader); Analyzer analyzer = new IKAnalyzer6x(false);//最細粒度分詞 QueryParser parser = new QueryParser(field, analyzer); // 多域搜索 //MultiFieldQueryParser multiParser = new MultiFieldQueryParser(fields, analyzer); // 關鍵字同時成立使用 AND, 默認是 OR parser.setDefaultOperator(Operator.AND); // 查詢語句 Query query = parser.parse("農村學生");//查詢關鍵詞 System.out.println("Query:" + query.toString()); // 返回前10條 TopDocs tds = searcher.search(query, 10); for (ScoreDoc sd : tds.scoreDocs) { // Explanation explanation = searcher.explain(query, sd.doc); // System.out.println("explain:" + explanation + "\n"); Document doc = searcher.doc(sd.doc); System.out.println("DocID:" + sd.doc); System.out.println("id:" + doc.get("id")); System.out.println("title:" + doc.get("title")); System.out.println("content:" + doc.get("content")); System.out.println("文檔評分:" + sd.score); } dir.close(); reader.close(); } }
控制台輸出如下:
加載擴展詞典:dict/ext.dic 加載擴展停止詞典:dict/stopword.dic 加載擴展停止詞典:dict/ext_stopword.dic Query:+title:農村 +title:村學 +title:學生 DocID:1 id:2 title:北大迎4380名新生 農村學生700多人近年最多 content:昨天,北京大學迎來4380名來自全國各地及數十個國家的本科新生。其中,農村學生共700余名,為近年最多... 文檔評分:2.320528
注意,在結果中打印了 DocID 和 id,前者是文檔的 ID,是 Lucene 為索引的每個文檔標記,后者是文檔自定義的 id 字段。
3.5 Lucene 查詢高亮
高亮功能一直都是全文檢索的一項非常優秀的模塊,在一個標准的搜索引擎中,高亮的返回命中結果,幾乎是必不可少的一項需求,因為通過高亮,可以在搜索界面上快速標記出用戶的搜索關鍵字,從而減少了用戶自己尋找想要的結果的時間,在一定程度上大大提高了用戶的體驗性和友好度。
Highlight 包含3個主要部分:
1)段划分器:Fragmenter
2)計分器:Score
3)格式化器:Formatter
package tup.lucene.highlfighter; import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; import org.apache.lucene.analysis.Analyzer; import org.apache.lucene.analysis.TokenStream; import org.apache.lucene.document.Document; import org.apache.lucene.index.DirectoryReader; import org.apache.lucene.index.IndexReader; import org.apache.lucene.queryparser.classic.ParseException; import org.apache.lucene.queryparser.classic.QueryParser; import org.apache.lucene.search.IndexSearcher; import org.apache.lucene.search.Query; import org.apache.lucene.search.ScoreDoc; import org.apache.lucene.search.TopDocs; import org.apache.lucene.search.highlight.Fragmenter; import org.apache.lucene.search.highlight.Highlighter; import org.apache.lucene.search.highlight.InvalidTokenOffsetsException; import org.apache.lucene.search.highlight.QueryScorer; import org.apache.lucene.search.highlight.SimpleHTMLFormatter; import org.apache.lucene.search.highlight.SimpleSpanFragmenter; import org.apache.lucene.search.highlight.TokenSources; import org.apache.lucene.store.Directory; import org.apache.lucene.store.FSDirectory; import tup.lucene.ik.IKAnalyzer6x; /** * Lucene查詢高亮 * @author moonxy * */ public class HighlighterTest { public static void main(String[] args) throws IOException, InvalidTokenOffsetsException, ParseException { String field = "title"; Path indexPath = Paths.get("indexdir"); Directory dir = FSDirectory.open(indexPath); IndexReader reader = DirectoryReader.open(dir); IndexSearcher searcher = new IndexSearcher(reader); Analyzer analyzer = new IKAnalyzer6x(); QueryParser parser = new QueryParser(field, analyzer); Query query = parser.parse("北大學生"); System.out.println("Query:" + query); // 查詢高亮 QueryScorer score = new QueryScorer(query, field); SimpleHTMLFormatter fors = new SimpleHTMLFormatter("<span style=\"color:red;\">", "</span>");// 定制高亮標簽 Highlighter highlighter = new Highlighter(fors, score);// 高亮分析器 // 返回前10條 TopDocs tds = searcher.search(query, 10); for (ScoreDoc sd : tds.scoreDocs) { // Explanation explanation = searcher.explain(query, sd.doc); // System.out.println("explain:" + explanation + "\n"); Document doc = searcher.doc(sd.doc); System.out.println("id:" + doc.get("id")); System.out.println("title:" + doc.get("title")); Fragmenter fragment = new SimpleSpanFragmenter(score); highlighter.setTextFragmenter(fragment); // TokenStream tokenStream = TokenSources.getAnyTokenStream(searcher.getIndexReader(), sd.doc, field, analyzer);// 獲取tokenstream // String str = highlighter.getBestFragment(tokenStream, doc.get(field));// 獲取高亮的片段 String str = highlighter.getBestFragment(analyzer, field, doc.get(field));// 獲取高亮的片段 System.out.println("高亮的片段:" + str); } dir.close(); reader.close(); } }
控制台輸出如下:
加載擴展詞典:dict/ext.dic 加載擴展停止詞典:dict/stopword.dic 加載擴展停止詞典:dict/ext_stopword.dic Query:title:北大學生 title:北大 title:大學生 title:大學 title:學生 id:2 title:北大迎4380名新生 農村學生700多人近年最多 高亮的片段:<span style="color:red;">北大</span>迎4380名新生 農村<span style="color:red;">學生</span>700多人近年最多
四、Tika 文件內容提取
Apache Tika 是一個用於文件類型檢測和文件內容提取的庫,是 Apache 軟件基金會的項目。Tika 可以檢測操作 1000 種不同類型的文檔,比如 PPT、PDF、DOC、XLS 等,所有的文本類型都可以通過一個簡單的接口被解析,Tika 廣泛應用於搜索引擎、內容分析、文本翻譯等領域。
Tika 下載的官網地址:http://tika.apache.org/download.html,其歷史版本下載地址為:http://archive.apache.org/dist/tika/,此處現在當前最新版 tika-app-1.18.jar
下載下來之后為一個 jar 包,但 Tika 可作為 GUI 工具使用,在 CMD 中先進入下載目錄,然后使用如下命令啟動 Tika GUI:
java -jar tika-app-1.18.jar -g
java -jar 表示啟動 jar 包,后面跟 jar 包的名字,-g(--gui) 參數表示以 GUI 的方式啟動 Tika(Start the Apache Tika GUI),當然前提是已經配置好了 java 環境變量。
界面如下:
可以點擊 File 菜單中的 Open...來打開一個本地文件或者輸入 URL 來打開遠程文件,也可直接將本地文件拖入到 Tika,Tika 將會自動識別文件類型並顯示文件信息。
默認顯示文件的元數據信息,如下:
點擊 View 菜單,里面可以選擇查看文件的具體內容,如選擇其中的 Plain Text,顯示如下:
將該 jar 包放入項目 lib 中,調用相應的接口就可以提取不同文件的內容,主要分為如下提取方法。
方法一:使用 Tika 對象提取文檔內容
package tup.tika.demo; import java.io.File; import java.io.IOException; import org.apache.tika.Tika; import org.apache.tika.exception.TikaException; /** * 使用Tika對象自動解析文檔,提取文檔內容 * @author moonxy * */ public class TikaExtraction { public static void main(String[] args) throws IOException, TikaException { Tika tika = new Tika(); // 新建存放各種文件的files文件夾 File fileDir = new File("files"); // 如果文件夾路徑錯誤,退出程序 if (!fileDir.exists()) { System.out.println("文件夾不存在, 請檢查!"); System.exit(0); } // 獲取文件夾下的所有文件,存放在File數組中 File[] fileArr = fileDir.listFiles(); String filecontent; for (File f : fileArr) { // 獲取文件名 System.out.println("File Name: " + f.getName()); filecontent = tika.parseToString(f);// 自動解析 // 獲取文件內容 System.out.println("Extracted Content: " + filecontent); } } }
在工程中新建 Files 目錄,放入需要解析的文件:
上述代碼中首先新建一個 File 對象指向存放各種文檔的文件夾 Files,通過 File 對象的 exists() 方法判斷目錄路徑是否存在,如果路徑錯誤則退出程序,打印提示信息。接下來,通過 listFiles() 方法獲取 files 目錄下所有的文件,存放在文件數組中。最后新建一個 Tika 對象,調用 parseToString() 方法獲取文檔內容,該方法的傳入參數為 File 對象。
方法二:使用 Parser 接口提取文檔內容
package tup.tika.demo; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import org.apache.tika.exception.TikaException; import org.apache.tika.metadata.Metadata; import org.apache.tika.parser.AutoDetectParser; import org.apache.tika.parser.ParseContext; import org.apache.tika.parser.Parser; import org.apache.tika.sax.BodyContentHandler; import org.xml.sax.SAXException; /** * 使用Parser接口自動解析文檔,提取文檔內容 * @author moonxy * */ public class ParserExtraction { public static void main(String[] args) throws IOException, SAXException, TikaException { // 新建存放各種文件的files文件夾 File fileDir = new File("files"); // 如果文件夾路徑錯誤,退出程序 if (!fileDir.exists()) { System.out.println("文件夾不存在, 請檢查!"); System.exit(0); } // 獲取文件夾下的所有文件,存放在File數組中 File[] fileArr = fileDir.listFiles(); // 創建內容處理器對象 BodyContentHandler handler = new BodyContentHandler(); // 創建元數據對象 Metadata metadata = new Metadata(); FileInputStream inputStream = null; Parser parser = new AutoDetectParser(); // 自動檢測分析器 ParseContext context = new ParseContext(); for (File f : fileArr) { // 獲取文件名 System.out.println("File Name: " + f.getName()); inputStream = new FileInputStream(f); parser.parse(inputStream, handler, metadata, context); // 獲取文件內容 System.out.println(f.getName() + ":\n" + handler.toString()); } } }
使用 Parse 接口自動提取內容和單一的提取一種文檔的區別在於實例化對象不一樣,AutoDetectParser 是 CompositeParser 的子類,它能夠自動檢測文件類型,並使用相應的方法把接收到的文檔自動發送給最接近的解析器類。
可以參考官方文檔,將文檔解析為不同的格式,如上面都是解析為純文本格式(Plain text),也可解析為 html 格式(Structured text)等,如:
http://tika.apache.org/1.18/examples.html#Parsing_to_XHTML