Lucene还可以这样玩?SpringBoot集成Lucene实现自己的轻量级搜索引擎(附源码)

21 篇文章 4 订阅
17 篇文章 1 订阅

前言

哈喽,大家好,我是丸子。

搜索引擎想必大家都并不陌生,比如百度,谷歌都是常见的搜索引擎。

在我们实际的项目开发中,也经常遇到类似的业务需求,比如公司要开发一个知识库项目,知识库里有上百万条文章,要求我们能够输入关键字,查询出包含有关键字的文章内容,并且对关键字进行高亮处理,显示查询后的最佳摘要,这个时候传统的数据库LIKE查询虽然能勉强满足业务需求,但是查询速度令人无法忍受,这个时候就需要借助搜索引擎来进行处理。

在Java开发领域,Lucene可以算是开山鼻祖,现在常用的SolrElasticSearch底层都是基于Lucene,很多开发人员并没有系统的学习过Lucene,都是直接上手SolrElasticSearch进行开发,但实际上掌握Lucene的常用api,理解其底层原理还是比较重要的,这有利于我们对全文检索领域有更加深入的理解,同时我们也可以根据自己的业务需求定制个性化的搜索引擎,我所在的公司使用的就是基于Lucene自研的搜索引擎服务,针对公司独特的业务场景,使用起来特别方便。

本篇文章将详细讲解如何使用SpringBoot集成Lucene实现自己的轻量级搜索引擎,相关源码资料可以查看文末获取!

Lucene为什么查的快

Lucene之所以查的快,原因在于它内部使用了倒排索引算法,在这里简单的介绍一下原理:
普通查询是根据文章找关键字,而倒排索引是根据关键字找文章!

比如“我今天很开心,因为马上就要下班了”这句话,从中搜索“开心”,普通查询要遍历整句话,直到找到“开心”二字为止,效率低下。倒排索引则是对整句话使用分词器进行分词处理,从而“开心”二字可以直接指向这句话,搜索的时候直接就可以根据“开心”搜到所属的内容,达到快速响应的效果。

springBoot集成Lucene

下面我会以Demo的形式详细讲解springBoot如何集成Lucene实现增删查改,以及显示高亮和最佳摘要(demo全部资料和源码在文末获取)。

一.建表

以Mysql为例,创建数据库lucene_demo,建表article,作为数据源,之后对表内容进行增删查改的时候同步到Lucene索引数据,建表语句如下:

CREATE TABLE `article` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键id',
  `title` varchar(200) DEFAULT NULL COMMENT '标题',
  `content` longtext COMMENT '内容',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

二.创建SpringBoot项目

在这里我直接拿自己的代码生成器生成,配置好基础内容点击生成,即可生成一个完整的前后台项目框架,省去了搭建项目的繁琐步骤,这样我们可以在生成的代码基础上进行开发:
代码生成器
生成的项目结构和代码如下:生成的项目
三.引入Lucene相关依赖

pom.xml引入Lucene相关依赖:

<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自带的分词器对中文只能单字拆分,显然不符合我们的需求,但IK分词器解决了这个问题,他可以把一段话分成多组不同的中文单词,帮助建立搜索索引。

公共maven仓库中没有IK分词器的依赖,需要我们install一下,文末资料中有IK分词器的源码,可以导入idea直接install到自己的maven仓库,然后引入依赖到项目即可。
install
pom.xml引入Ik分词器相关依赖(因为之前已经引入了Lucene相关依赖,所以引入Ik的时候去除一下,防止依赖冲突):

 <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>

五.项目启动时加载IK分词器

最好在我们启动项目的时候就把IK分词器加载进内存当中,这样第一次查询就不必再进行加载,避免第一次查询因为加载分词器造成卡顿,创建init包,建立BusinessInitializer类,如下:
初始化加载IK分词器
代码如下:

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;

/**
 * 业务初始化器
 *
 * @author zrx
 */
@Component
public class BusinessInitializer implements ApplicationRunner {

	@Override
	public void run(ApplicationArguments args) {
		//加载ik分词器配置 防止第一次查询慢
		Dictionary.initial(DefaultConfig.getInstance());
	}
}

引入IK的配置文件IKAnalyzer.cfg.xml以及扩展字典ext.dic和停止词字典stopword.dic,可以添加和屏蔽某些词语,把配置文件放入resources下:
Ik配置文件
在这里我们添加两个扩展词小螺旋丸小千鸟,查询的时候可以用来做测试,如果测试的时候可以被完整标记高亮,说明词语被成功识别,因为IK自带的字典里,没有这两个单词,IK自带的字典位于IK源码的resources包下,感兴趣的朋友可以通过源码自行查看:
添加扩展词
添加完毕,我们启动项目,发现词典被成功加载,如下:
词典被加载
接下来我们进行增删查改的开发。

六.增删查改业务开发:

1、配置索引库存放位置

首先我们需要配置索引的存放位置,可以把它理解为一个数据库,只不过这个数据库存放的是一些索引文件,我们在yml中指定位置,创建Config配置类,用@value注解获取它的值,方便随时在代码中获取,如下:
yml和Config
2、增删查改的时候同步索引

数据库的增删查改方法代码生成器已经帮助我们生成完毕,只需要在原来的功能基础上添加对于索引库相关的代码逻辑即可!

首先是添加和更新操作,添加更新放在一起,根据主键id判断,如果索引中存在此id,则更新,否则添加,在service实现类中添加addOrUpIndex方法,同时每次添加和更新的时候都要调一下此方法,同步索引,代码基本每一行都有完整注释,如下:

/**
	 * 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 {
			//创建索引目录文件
			File indexFile = new File(config.getIndexLibrary());
			File[] files = indexFile.listFiles();
			// 1. 创建分词器,分析文档,对文档进行分词
			analyzer = new IKAnalyzer();
			// 2. 创建Directory对象,声明索引库的位置
			directory = FSDirectory.open(Paths.get(config.getIndexLibrary()));
			// 3. 创建IndexWriteConfig对象,写入索引需要的配置
			IndexWriterConfig writerConfig = new IndexWriterConfig(analyzer);
			// 4.创建IndexWriter写入对象
			indexWriter = new IndexWriter(directory, writerConfig);
			// 5.写入到索引库,通过IndexWriter添加文档对象document
			Document doc = new Document();
			//查询是否有该索引,没有添加,有则更新
			TopDocs topDocs = null;
			//判断索引目录文件是否存在文件,如果没有文件,则为首次添加,有文件,则查询id是否已经存在
			if (files != null && files.length != 0) {
				//创建查询对象
				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 不分词 直接建索引 存储
			doc.add(new StringField("id", String.valueOf(entity.getId()), Field.Store.YES));
			//TextField 分词 建索引 存储
			doc.add(new TextField("title", entity.getTitle(), Field.Store.YES));
			//TextField 分词 建索引 存储
			doc.add(new TextField("content", entity.getContent(), Field.Store.YES));
			//如果没有查询结果,添加
			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("添加索引库出错:" + 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();
			}
		}
	}

代码应该很容易就可以看明白,这里我们把实体的titlecontent进行分词,并存储为索引文件,所以接下来查询的时候也要根据这两个字段来进行查询,查询的时候我们要对查询结果进行分页,Lucene的分页方式比较特别,他没有类似数据库那种提供开始和结束下标定位元素的方法,而是只能指定查询的总条目数,然后把所有的命中结果查询出来,比如一共有100条数据,查询第一页返回10条,查询第十页则会返回100条,需要我们在逻辑上对查询结果进行分页,取我们想要的数据,也可以利用Luncene提供的SearchAfter方法进行查询,它可以根据指定的最后一个元素查询接下来指定数目的元素,但这需要我们查询出前n个元素然后取最后一个元素传给SearchAfter方法,两种方法效率上并没有太大区别,毕竟Lucene本身就很快。但这也涉及到一个问题,如果查询的数据量过多,比如上千万条可能会导致内存溢出,这就需要我们根据业务做一个取舍,用户在查询的时候通常只会看前几页的数据,所以我们可以指定一下最大的查询数量,比如10000条,无论实际符合条件的结果有多少,我们最多只查询前10000条,这样问题便得到解决,其实很多搜索引擎也是这样做的!

如果你说我就要看全部的数据,那就涉及到了数据的分布式存储,在分页的时候就需要每台服务器进行查询然后汇总查询结果,这里的问题就比较复杂了,在此处不做深究,以后可以专门聊一聊,其实业界已经有了几种比较成熟的解决方案,可以较好的解决分布式存储的分页问题。

这里代码中并没有指定查询的最大数量,毕竟是个demo,没必要弄的这么复杂,代码如下:

    @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();
		//沒有索引文件,不然沒有查詢結果
		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()));
			//多项查询条件
			QueryParser queryParser = new MultiFieldQueryParser(new String[]{"title", "content"}, analyzer);
			//单项
			//QueryParser queryParser = new QueryParser("title", analyzer);
			Query query = queryParser.parse(!StringUtils.isEmpty(keyWord) ? keyWord : "*:*");
			indexReader = DirectoryReader.open(directory);
			//索引查询对象
			IndexSearcher indexSearcher = new IndexSearcher(indexReader);
			TopDocs topDocs = indexSearcher.search(query, 1);
			//获取条数
			int total = (int) topDocs.totalHits.value;
			pageData.setCount(total);
			int realPage = total % limit == 0 ? total / limit : total / limit + 1;
			pageData.setTotalPage(realPage);
			//获取结果集
			ScoreDoc lastSd = null;
			if (page > 1) {
				int num = limit * (page - 1);
				TopDocs tds = indexSearcher.search(query, num);
				lastSd = tds.scoreDocs[num - 1];
			}
			//通过最后一个元素去搜索下一页的元素 如果lastSd为null,查询第一页
			TopDocs tds = indexSearcher.searchAfter(lastSd, query, limit);
			QueryScorer queryScorer = new QueryScorer(query);
			//最佳摘要
			SimpleSpanFragmenter fragmenter = new SimpleSpanFragmenter(queryScorer, 200);
			//高亮前后标签
			SimpleHTMLFormatter formatter = new SimpleHTMLFormatter("<b><font color='red'>", "</font></b>");
			//高亮对象
			Highlighter highlighter = new Highlighter(formatter, queryScorer);
			//设置高亮最佳摘要
			highlighter.setTextFragmenter(fragmenter);
			//遍历查询结果 把标题和内容替换为带高亮的最佳摘要
			for (ScoreDoc sd : tds.scoreDocs) {
				Document doc = indexSearcher.doc(sd.doc);
				ArticleEntity articleEntity = new ArticleEntity();
				Integer id = Integer.parseInt(doc.get("id"));
				//获取标题的最佳摘要
				String titleBestFragment = highlighter.getBestFragment(analyzer, "title", doc.get("title"));
				//获取文章内容的最佳摘要
				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("全文檢索出错:" + 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();
				}
			}
		}
	}

最后是删除索引,根据唯一标识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);
			//根据id字段进行删除
			indexWriter.deleteDocuments(new Term("id", String.valueOf(entity.getId())));
		} catch (Exception e) {
			e.printStackTrace();
			throw new RuntimeException("删除索引库出错:" + 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的后台增删查改功能开发完毕!

3、利用swagger测试

接下来我们利用swagger对功能进行测试,测试之前我们把controller层增删查改方法的 @LoginRequired 注解去掉(@LoginRequired是代码生成器最新版添加的注解,可以控制方法必须登录才可以调用),这样可以不必登录,打开swagger,添加一条数据,如下:
添加数据
如上,数据添加成功,数据库数据添加成功,Lucene索引文件夹也生成了相关索引文件,如下:
数据库Lucene索引文件
接下里我们测一下全文检索功能,如下:
查询结果
删除功能也可正常使用并同步删除索引,此处就不截图了。这样一来,后台api测试完毕,符合预期效果,接下来进入前台实现阶段。

4、前台实现

前台实现没有什么好说的,就是跟后端对接口进行交互,前端真是我的硬伤,我根据代码生成器生成的列表页做了调整,最终实现效果如下:
前端效果
前台代码就不贴了,没有太大意义,毕竟有了后台的数据返回,前台有n多种展示方式,大家根据自己的习惯去对接口就好了,完整的前后台代码以及sql文件等可于文末获取。

结语

本篇文章我们利用Lucene自己实现了一个非常轻量的搜索引擎,其实我们可以利用反射把它做成一个通用的查询框架,这样无论实体的属性名称怎么变,都可以灵活应对。

全文检索在Java开发领域是一个重要的知识点,需要我们深入理解和掌握,希望通过本篇文章可以让你对Lucene有一个更加全面的认识,代码生成器不出意外本月会更新一版,我们下次更新,再见啦!

附:关注公众号 螺旋编程极客 获取更多精彩内容,我们一起进步,一起成长,回复 1024 可获取本篇文章的项目源码等资料,期待您的关注!

  • 2
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值