前言
哈嘍住册,大家好,我是丸子瓮具。
搜索引擎想必大家都并不陌生荧飞,比如百度凡人,谷歌都是常見(jiàn)的搜索引擎。
在我們實(shí)際的項(xiàng)目開發(fā)中叹阔,也經(jīng)常遇到類似的業(yè)務(wù)需求挠轴,比如公司要開發(fā)一個(gè)知識(shí)庫(kù)項(xiàng)目,知識(shí)庫(kù)里有上百萬(wàn)條文章耳幢,要求我們能夠輸入關(guān)鍵字岸晦,查詢出包含有關(guān)鍵字的文章內(nèi)容,并且對(duì)關(guān)鍵字進(jìn)行高亮處理帅掘,顯示查詢后的最佳摘要委煤,這個(gè)時(shí)候傳統(tǒng)的數(shù)據(jù)庫(kù)LIKE查詢雖然能勉強(qiáng)滿足業(yè)務(wù)需求,但是查詢速度令人無(wú)法忍受修档,這個(gè)時(shí)候就需要借助搜索引擎來(lái)進(jìn)行處理碧绞。
在Java開發(fā)領(lǐng)域,Lucene可以算是開山鼻祖吱窝,現(xiàn)在常用的Solr和ElasticSearch底層都是基于Lucene讥邻,很多開發(fā)人員并沒(méi)有系統(tǒng)的學(xué)習(xí)過(guò)Lucene,都是直接上手Solr或ElasticSearch進(jìn)行開發(fā)院峡,但實(shí)際上掌握Lucene的常用api兴使,理解其底層原理還是比較重要的,這有利于我們對(duì)全文檢索領(lǐng)域有更加深入的理解照激,同時(shí)我們也可以根據(jù)自己的業(yè)務(wù)需求定制個(gè)性化的搜索引擎发魄,我所在的公司使用的就是基于Lucene自研的搜索引擎服務(wù),針對(duì)公司獨(dú)特的業(yè)務(wù)場(chǎng)景俩垃,使用起來(lái)特別方便励幼。
本篇文章將詳細(xì)講解如何使用SpringBoot集成Lucene實(shí)現(xiàn)自己的輕量級(jí)搜索引擎,相關(guān)源碼資料可以查看文末獲瓤诹苹粟!
Lucene為什么查的快
Lucene之所以查的快,原因在于它內(nèi)部使用了倒排索引算法跃闹,在這里簡(jiǎn)單的介紹一下原理:
普通查詢是根據(jù)文章找關(guān)鍵字嵌削,而倒排索引是根據(jù)關(guān)鍵字找文章!
比如“我今天很開心望艺,因?yàn)轳R上就要下班了”這句話苛秕,從中搜索“開心”,普通查詢要遍歷整句話找默,直到找到“開心”二字為止艇劫,效率低下。倒排索引則是對(duì)整句話使用分詞器進(jìn)行分詞處理啡莉,從而“開心”二字可以直接指向這句話港准,搜索的時(shí)候直接就可以根據(jù)“開心”搜到所屬的內(nèi)容旨剥,達(dá)到快速響應(yīng)的效果。
springBoot集成Lucene
下面我會(huì)以Demo的形式詳細(xì)講解springBoot如何集成Lucene實(shí)現(xiàn)增刪查改浅缸,以及顯示高亮和最佳摘要(demo全部資料和源碼在文末獲裙熘摹)。
一.建表
以Mysql為例衩椒,創(chuàng)建數(shù)據(jù)庫(kù)lucene_demo蚌父,建表article,作為數(shù)據(jù)源毛萌,之后對(duì)表內(nèi)容進(jìn)行增刪查改的時(shí)候同步到Lucene索引數(shù)據(jù)苟弛,建表語(yǔ)句如下:
CREATE TABLE `article` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵id',
`title` varchar(200) DEFAULT NULL COMMENT '標(biāo)題',
`content` longtext COMMENT '內(nèi)容',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
二.創(chuàng)建SpringBoot項(xiàng)目
在這里我直接拿自己的代碼生成器生成,配置好基礎(chǔ)內(nèi)容點(diǎn)擊生成阁将,即可生成一個(gè)完整的前后臺(tái)項(xiàng)目框架膏秫,省去了搭建項(xiàng)目的繁瑣步驟,這樣我們可以在生成的代碼基礎(chǔ)上進(jìn)行開發(fā):
生成的項(xiàng)目結(jié)構(gòu)和代碼如下:
三.引入Lucene相關(guān)依賴
pom.xml引入Lucene相關(guān)依賴:
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>8.1.0</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analyzers-common</artifactId>
<version>8.1.0</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-queryparser</artifactId>
<version>8.1.0</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-highlighter</artifactId>
<version>8.1.0</version>
</dependency>
四.引入IK分詞器依賴
目前市面上有不少中文分詞器做盅,但最受歡迎的還是IK分詞器缤削,Lucene自帶的分詞器對(duì)中文只能單字拆分,顯然不符合我們的需求吹榴,但I(xiàn)K分詞器解決了這個(gè)問(wèn)題亭敢,他可以把一段話分成多組不同的中文單詞,幫助建立搜索索引图筹。
公共maven倉(cāng)庫(kù)中沒(méi)有IK分詞器的依賴帅刀,需要我們install一下,文末資料中有IK分詞器的源碼远剩,可以導(dǎo)入idea直接install到自己的maven倉(cāng)庫(kù)扣溺,然后引入依賴到項(xiàng)目即可。
pom.xml引入Ik分詞器相關(guān)依賴(因?yàn)橹耙呀?jīng)引入了Lucene相關(guān)依賴民宿,所以引入Ik的時(shí)候去除一下娇妓,防止依賴沖突):
<dependency>
<groupId>org.wltea.ik-analyzer</groupId>
<artifactId>ik-analyzer</artifactId>
<version>8.1.0</version>
<exclusions>
<exclusion>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analyzers-common</artifactId>
</exclusion>
<exclusion>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-queryparser</artifactId>
</exclusion>
<exclusion>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
</exclusion>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</exclusion>
</exclusions>
</dependency>
五.項(xiàng)目啟動(dòng)時(shí)加載IK分詞器
最好在我們啟動(dòng)項(xiàng)目的時(shí)候就把IK分詞器加載進(jìn)內(nèi)存當(dāng)中像鸡,這樣第一次查詢就不必再進(jìn)行加載活鹰,避免第一次查詢因?yàn)榧虞d分詞器造成卡頓,創(chuàng)建init包只估,建立BusinessInitializer類志群,如下:
代碼如下:
package lucenedemo.init;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import org.wltea.analyzer.cfg.DefaultConfig;
import org.wltea.analyzer.dic.Dictionary;
/**
* 業(yè)務(wù)初始化器
*
* @author zrx
*/
@Component
public class BusinessInitializer implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
//加載ik分詞器配置 防止第一次查詢慢
Dictionary.initial(DefaultConfig.getInstance());
}
}
引入IK的配置文件IKAnalyzer.cfg.xml以及擴(kuò)展字典ext.dic和停止詞字典stopword.dic,可以添加和屏蔽某些詞語(yǔ)蛔钙,把配置文件放入resources下:
在這里我們添加兩個(gè)擴(kuò)展詞小螺旋丸和小千鳥锌云,查詢的時(shí)候可以用來(lái)做測(cè)試,如果測(cè)試的時(shí)候可以被完整標(biāo)記高亮吁脱,說(shuō)明詞語(yǔ)被成功識(shí)別桑涎,因?yàn)镮K自帶的字典里彬向,沒(méi)有這兩個(gè)單詞,IK自帶的字典位于IK源碼的resources包下攻冷,感興趣的朋友可以通過(guò)源碼自行查看:
添加完畢娃胆,我們啟動(dòng)項(xiàng)目,發(fā)現(xiàn)詞典被成功加載等曼,如下:
接下來(lái)我們進(jìn)行增刪查改的開發(fā)里烦。
六.增刪查改業(yè)務(wù)開發(fā):
1、配置索引庫(kù)存放位置
首先我們需要配置索引的存放位置禁谦,可以把它理解為一個(gè)數(shù)據(jù)庫(kù)胁黑,只不過(guò)這個(gè)數(shù)據(jù)庫(kù)存放的是一些索引文件,我們?cè)趛ml中指定位置州泊,創(chuàng)建Config配置類丧蘸,用@value注解獲取它的值,方便隨時(shí)在代碼中獲取遥皂,如下:
2触趴、增刪查改的時(shí)候同步索引:
數(shù)據(jù)庫(kù)的增刪查改方法代碼生成器已經(jīng)幫助我們生成完畢,只需要在原來(lái)的功能基礎(chǔ)上添加對(duì)于索引庫(kù)相關(guān)的代碼邏輯即可渴肉!
首先是添加和更新操作冗懦,添加更新放在一起,根據(jù)主鍵id判斷仇祭,如果索引中存在此id披蕉,則更新,否則添加乌奇,在service實(shí)現(xiàn)類中添加addOrUpIndex方法没讲,同時(shí)每次添加和更新的時(shí)候都要調(diào)一下此方法,同步索引礁苗,代碼基本每一行都有完整注釋爬凑,如下:
/**
* mapper文件里增加 useGeneratedKeys="true" keyProperty="id" keyColumn="id"屬性,否則自增主鍵映射不上
*
* @param entity
*/
@Override
public void add(ArticleEntity entity) {
dao.add(entity);
addOrUpIndex(entity);
}
@Override
public void update(ArticleEntity entity) {
dao.update(entity);
addOrUpIndex(entity);
}
/**
* 添加或更新索引
* @param entity
*/
private void addOrUpIndex(ArticleEntity entity) {
IndexWriter indexWriter = null;
IndexReader indexReader = null;
Directory directory = null;
Analyzer analyzer = null;
try {
//創(chuàng)建索引目錄文件
File indexFile = new File(config.getIndexLibrary());
File[] files = indexFile.listFiles();
// 1. 創(chuàng)建分詞器,分析文檔试伙,對(duì)文檔進(jìn)行分詞
analyzer = new IKAnalyzer();
// 2. 創(chuàng)建Directory對(duì)象,聲明索引庫(kù)的位置
directory = FSDirectory.open(Paths.get(config.getIndexLibrary()));
// 3. 創(chuàng)建IndexWriteConfig對(duì)象嘁信,寫入索引需要的配置
IndexWriterConfig writerConfig = new IndexWriterConfig(analyzer);
// 4.創(chuàng)建IndexWriter寫入對(duì)象
indexWriter = new IndexWriter(directory, writerConfig);
// 5.寫入到索引庫(kù),通過(guò)IndexWriter添加文檔對(duì)象document
Document doc = new Document();
//查詢是否有該索引疏叨,沒(méi)有添加潘靖,有則更新
TopDocs topDocs = null;
//判斷索引目錄文件是否存在文件,如果沒(méi)有文件蚤蔓,則為首次添加卦溢,有文件,則查詢id是否已經(jīng)存在
if (files != null && files.length != 0) {
//創(chuàng)建查詢對(duì)象
QueryParser queryParser = new QueryParser("id", analyzer);
Query query = queryParser.parse(String.valueOf(entity.getId()));
indexReader = DirectoryReader.open(directory);
IndexSearcher indexSearcher = new IndexSearcher(indexReader);
//查詢獲取命中條目
topDocs = indexSearcher.search(query, 1);
}
//StringField 不分詞 直接建索引 存儲(chǔ)
doc.add(new StringField("id", String.valueOf(entity.getId()), Field.Store.YES));
//TextField 分詞 建索引 存儲(chǔ)
doc.add(new TextField("title", entity.getTitle(), Field.Store.YES));
//TextField 分詞 建索引 存儲(chǔ)
doc.add(new TextField("content", entity.getContent(), Field.Store.YES));
//如果沒(méi)有查詢結(jié)果,添加
if (topDocs != null && topDocs.totalHits.value == 0) {
indexWriter.addDocument(doc);
//否則单寂,更新
} else {
indexWriter.updateDocument(new Term("id", String.valueOf(entity.getId())), doc);
}
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("添加索引庫(kù)出錯(cuò):" + e.getMessage());
} finally {
if (indexWriter != null) {
try {
indexWriter.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (indexReader != null) {
try {
indexReader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (directory != null) {
try {
directory.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (analyzer != null) {
analyzer.close();
}
}
}
代碼應(yīng)該很容易就可以看明白贬芥,這里我們把實(shí)體的title和content進(jìn)行分詞,并存儲(chǔ)為索引文件宣决,所以接下來(lái)查詢的時(shí)候也要根據(jù)這兩個(gè)字段來(lái)進(jìn)行查詢誓军,查詢的時(shí)候我們要對(duì)查詢結(jié)果進(jìn)行分頁(yè),Lucene的分頁(yè)方式比較特別疲扎,他沒(méi)有類似數(shù)據(jù)庫(kù)那種提供開始和結(jié)束下標(biāo)定位元素的方法昵时,而是只能指定查詢的總條目數(shù),然后把所有的命中結(jié)果查詢出來(lái)椒丧,比如一共有100條數(shù)據(jù)壹甥,查詢第一頁(yè)返回10條,查詢第十頁(yè)則會(huì)返回100條壶熏,需要我們?cè)谶壿嬌蠈?duì)查詢結(jié)果進(jìn)行分頁(yè)句柠,取我們想要的數(shù)據(jù),也可以利用Luncene提供的SearchAfter方法進(jìn)行查詢棒假,它可以根據(jù)指定的最后一個(gè)元素查詢接下來(lái)指定數(shù)目的元素溯职,但這需要我們查詢出前n個(gè)元素然后取最后一個(gè)元素傳給SearchAfter方法,兩種方法效率上并沒(méi)有太大區(qū)別帽哑,畢竟Lucene本身就很快谜酒。但這也涉及到一個(gè)問(wèn)題,如果查詢的數(shù)據(jù)量過(guò)多妻枕,比如上千萬(wàn)條可能會(huì)導(dǎo)致內(nèi)存溢出僻族,這就需要我們根據(jù)業(yè)務(wù)做一個(gè)取舍,用戶在查詢的時(shí)候通常只會(huì)看前幾頁(yè)的數(shù)據(jù)屡谐,所以我們可以指定一下最大的查詢數(shù)量述么,比如10000條,無(wú)論實(shí)際符合條件的結(jié)果有多少愕掏,我們最多只查詢前10000條度秘,這樣問(wèn)題便得到解決,其實(shí)很多搜索引擎也是這樣做的饵撑!
如果你說(shuō)我就要看全部的數(shù)據(jù)剑梳,那就涉及到了數(shù)據(jù)的分布式存儲(chǔ),在分頁(yè)的時(shí)候就需要每臺(tái)服務(wù)器進(jìn)行查詢?nèi)缓髤R總查詢結(jié)果肄梨,這里的問(wèn)題就比較復(fù)雜了阻荒,在此處不做深究挠锥,以后可以專門聊一聊众羡,其實(shí)業(yè)界已經(jīng)有了幾種比較成熟的解決方案,可以較好的解決分布式存儲(chǔ)的分頁(yè)問(wèn)題蓖租。
這里代碼中并沒(méi)有指定查詢的最大數(shù)量粱侣,畢竟是個(gè)demo羊壹,沒(méi)必要弄的這么復(fù)雜,代碼如下:
@Override
public PageData<ArticleEntity> fullTextSearch(String keyWord, Integer page, Integer limit) {
List<ArticleEntity> searchList = new ArrayList<>(10);
PageData<ArticleEntity> pageData = new PageData<>();
File indexFile = new File(config.getIndexLibrary());
File[] files = indexFile.listFiles();
//沒(méi)有索引文件齐婴,不然沒(méi)有查詢結(jié)果
if (files == null || files.length == 0) {
pageData.setCount(0);
pageData.setTotalPage(0);
pageData.setCurrentPage(page);
pageData.setResult(new ArrayList<>());
return pageData;
}
IndexReader indexReader = null;
Directory directory = null;
try (Analyzer analyzer = new IKAnalyzer()) {
directory = FSDirectory.open(Paths.get(config.getIndexLibrary()));
//多項(xiàng)查詢條件
QueryParser queryParser = new MultiFieldQueryParser(new String[]{"title", "content"}, analyzer);
//單項(xiàng)
//QueryParser queryParser = new QueryParser("title", analyzer);
Query query = queryParser.parse(!StringUtils.isEmpty(keyWord) ? keyWord : "*:*");
indexReader = DirectoryReader.open(directory);
//索引查詢對(duì)象
IndexSearcher indexSearcher = new IndexSearcher(indexReader);
TopDocs topDocs = indexSearcher.search(query, 1);
//獲取條數(shù)
int total = (int) topDocs.totalHits.value;
pageData.setCount(total);
int realPage = total % limit == 0 ? total / limit : total / limit + 1;
pageData.setTotalPage(realPage);
//獲取結(jié)果集
ScoreDoc lastSd = null;
if (page > 1) {
int num = limit * (page - 1);
TopDocs tds = indexSearcher.search(query, num);
lastSd = tds.scoreDocs[num - 1];
}
//通過(guò)最后一個(gè)元素去搜索下一頁(yè)的元素 如果lastSd為null油猫,查詢第一頁(yè)
TopDocs tds = indexSearcher.searchAfter(lastSd, query, limit);
QueryScorer queryScorer = new QueryScorer(query);
//最佳摘要
SimpleSpanFragmenter fragmenter = new SimpleSpanFragmenter(queryScorer, 200);
//高亮前后標(biāo)簽
SimpleHTMLFormatter formatter = new SimpleHTMLFormatter("<b><font color='red'>", "</font></b>");
//高亮對(duì)象
Highlighter highlighter = new Highlighter(formatter, queryScorer);
//設(shè)置高亮最佳摘要
highlighter.setTextFragmenter(fragmenter);
//遍歷查詢結(jié)果 把標(biāo)題和內(nèi)容替換為帶高亮的最佳摘要
for (ScoreDoc sd : tds.scoreDocs) {
Document doc = indexSearcher.doc(sd.doc);
ArticleEntity articleEntity = new ArticleEntity();
Integer id = Integer.parseInt(doc.get("id"));
//獲取標(biāo)題的最佳摘要
String titleBestFragment = highlighter.getBestFragment(analyzer, "title", doc.get("title"));
//獲取文章內(nèi)容的最佳摘要
String contentBestFragment = highlighter.getBestFragment(analyzer, "content", doc.get("content"));
articleEntity.setId(id);
articleEntity.setTitle(titleBestFragment);
articleEntity.setContent(contentBestFragment);
searchList.add(articleEntity);
}
pageData.setCurrentPage(page);
pageData.setResult(searchList);
return pageData;
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("全文檢索出錯(cuò):" + e.getMessage());
} finally {
if (indexReader != null) {
try {
indexReader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (directory != null) {
try {
directory.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
最后是刪除索引,根據(jù)唯一標(biāo)識(shí)id刪除即可柠偶,代碼如下:
@Override
public void delete(ArticleEntity entity) {
dao.delete(entity);
//同步刪除索引
deleteIndex(entity);
}
private void deleteIndex(ArticleEntity entity) {
//刪除全文檢索
IndexWriter indexWriter = null;
Directory directory = null;
try (Analyzer analyzer = new IKAnalyzer()) {
directory = FSDirectory.open(Paths.get(config.getIndexLibrary()));
IndexWriterConfig writerConfig = new IndexWriterConfig(analyzer);
indexWriter = new IndexWriter(directory, writerConfig);
//根據(jù)id字段進(jìn)行刪除
indexWriter.deleteDocuments(new Term("id", String.valueOf(entity.getId())));
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("刪除索引庫(kù)出錯(cuò):" + e.getMessage());
} finally {
if (indexWriter != null) {
try {
indexWriter.close();
} catch (IOException e) {
e.printStackTrace();
}
}
if (directory != null) {
try {
directory.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
至此情妖,Lucene的后臺(tái)增刪查改功能開發(fā)完畢!
3诱担、利用swagger測(cè)試
接下來(lái)我們利用swagger對(duì)功能進(jìn)行測(cè)試毡证,測(cè)試之前我們把controller層增刪查改方法的 @LoginRequired 注解去掉(@LoginRequired是代碼生成器最新版添加的注解,可以控制方法必須登錄才可以調(diào)用)蔫仙,這樣可以不必登錄料睛,打開swagger,添加一條數(shù)據(jù)摇邦,如下:
如上恤煞,數(shù)據(jù)添加成功,數(shù)據(jù)庫(kù)數(shù)據(jù)添加成功施籍,Lucene索引文件夾也生成了相關(guān)索引文件居扒,如下:
接下里我們測(cè)一下全文檢索功能,如下:
刪除功能也可正常使用并同步刪除索引丑慎,此處就不截圖了苔货。這樣一來(lái),后臺(tái)api測(cè)試完畢立哑,符合預(yù)期效果夜惭,接下來(lái)進(jìn)入前臺(tái)實(shí)現(xiàn)階段。
4铛绰、前臺(tái)實(shí)現(xiàn)
前臺(tái)實(shí)現(xiàn)沒(méi)有什么好說(shuō)的诈茧,就是跟后端對(duì)接口進(jìn)行交互,前端真是我的硬傷捂掰,我根據(jù)代碼生成器生成的列表頁(yè)做了調(diào)整敢会,最終實(shí)現(xiàn)效果如下:
前臺(tái)代碼就不貼了,沒(méi)有太大意義这嚣,畢竟有了后臺(tái)的數(shù)據(jù)返回鸥昏,前臺(tái)有n多種展示方式,大家根據(jù)自己的習(xí)慣去對(duì)接口就好了姐帚,完整的前后臺(tái)代碼以及sql文件等可于文末獲取吏垮。
結(jié)語(yǔ)
本篇文章我們利用Lucene自己實(shí)現(xiàn)了一個(gè)非常輕量的搜索引擎,其實(shí)我們可以利用反射把它做成一個(gè)通用的查詢框架,這樣無(wú)論實(shí)體的屬性名稱怎么變膳汪,都可以靈活應(yīng)對(duì)唯蝶。
全文檢索在Java開發(fā)領(lǐng)域是一個(gè)重要的知識(shí)點(diǎn),需要我們深入理解和掌握遗嗽,希望通過(guò)本篇文章可以讓你對(duì)Lucene有一個(gè)更加全面的認(rèn)識(shí)粘我,代碼生成器不出意外本月會(huì)更新一版,我們下次更新痹换,再見(jiàn)啦征字!
附:關(guān)注公眾號(hào) 螺旋編程極客 獲取更多精彩內(nèi)容,我們一起進(jìn)步娇豫,一起成長(zhǎng)柔纵,回復(fù) 1024 可獲取本篇文章的項(xiàng)目源碼等資料,期待您的關(guān)注锤躁!