Spring Boot中集成Lucence

简介: 本文介绍了Lucene全文检索原理及其在Spring Boot中的集成应用。首先解析了全文检索的核心思想——通过建立索引提升搜索效率,然后详细演示了Lucene的分词、索引构建与查询过程,并结合中文分词、高亮显示等实战功能,展示了其在实际项目中的灵活运用。

1. Lucence 和全文检索

Lucene 是什么?看一下百度百科:

Lucene是一套用于全文检索和搜寻的开源程式库,由 Apache 软件基金会支持和提供。Lucene 提供了一个简单却强大的应用程式接口,能够做全文索引和搜寻。在 Java 开发环境里 Lucene 是一个成熟的免费开源工具。就其本身而言,Lucene 是当前以及最近几年最受欢迎的免费 Java 信息检索程序库。——《百度百科》

1.1 全文检索

这里提到了全文检索的概念,我们先来分析一下什么是全文检索,理解了全文检索之后,再理解 Lucene 的原理就非常简单了。  

何为全文检索?举个例子,比如现在要在一个文件中查找某个字符串,最直接的想法就是从头开始检索,查到了就OK,这种对于小数据量的文件来说,很实用,但是对于大数据量的文件来说,就有点吃力了。或者说找包含某个字符串的文件,也是这样,如果在一个拥有几十个 G 的硬盘中找那效率可想而知,是很低的。  

文件中的数据是属于非结构化数据,也就是说它没有什么结构可言,要解决上面提到的效率问题,首先我们得将非结构化数据中的一部分信息提取出来,重新组织,使其变得有一定结构,然后对这些有一定结构的数据进行搜索,从而达到搜索相对较快的目的。这就叫全文搜索。即先建立索引,再对索引进行搜索的过程。

1.2 Lucene 建立索引的方式

那么 Lucene 中是如何建立索引的呢?假设现在有两篇文章,内容如下:

文章1的内容为:Tom lives in Guangzhou, I live in Guangzhou too.  文章2的内容为:He once lived in Shanghai.

首先第一步是将文档传给分词组件(Tokenizer),分词组件会将文档分成一个个单词,并去除标点符号和停词。所谓的停词指的是没有特别意义的词,比如英文中的 a,the,too 等。经过分词后,得到词元(Token) 。如下:

文章1经过分词后的结果:[Tom] [lives] [Guangzhou] [I] [live] [Guangzhou]  文章2经过分词后的结果:[He] [lives] [Shanghai]

然后将词元传给语言处理组件(Linguistic Processor),对于英语,语言处理组件一般会将字母变为小写,将单词缩减为词根形式,如 ”lives” 到 ”live” 等,将单词转变为词根形式,如 ”drove” 到 ”drive” 等。然后得到词(Term)。如下:

文章1经过处理后的结果:[tom] [live] [guangzhou] [i] [live] [guangzhou] 文章2经过处理后的结果:[he] [live] [shanghai]

最后将得到的词传给索引组件(Indexer),索引组件经过处理,得到下面的索引结构:

关键词

文章号[出现频率]

出现位置

guangzhou

1[2]

3,6

he

2[1]

1

i

1[1]

4

live

1[2],2[1]

2,5,2

shanghai

2[1]

3

tom

1[1]

1

以上就是Lucene 索引结构中最核心的部分。它的关键字是按字符顺序排列的,因此 Lucene 可以用二元搜索算法快速定位关键词。实现时 Lucene 将上面三列分别作为词典文件(Term Dictionary)、频率文件(frequencies)和位置文件(positions)保存。其中词典文件不仅保存有每个关键词,还保留了指向频率文件和位置文件的指针,通过指针可以找到该关键字的频率信息和位置信息。 搜索的过程是先对词典二元查找、找到该词,通过指向频率文件的指针读出所有文章号,然后返回结果,然后就可以在具体的文章中根据出现位置找到该词了。所以 Lucene 在第一次建立索引的时候可能会比较慢,但是以后就不需要每次都建立索引了,就快了。

理解了 Lucene 的分词原理,接下来我们在 Spring Boot 中集成 Lucene 并实现索引和搜索的功能。

2. Spring Boot 中集成 Lucence

2.1 依赖导入

首先需要导入 Lucene 的依赖,它的依赖有好几个,如下:

<!-- Lucence核心包 -->

<dependency>

<groupId>org.apache.lucene</groupId>

<artifactId>lucene-core</artifactId>

<version>5.3.1</version>

</dependency>


<!-- Lucene查询解析包 -->

<dependency>

<groupId>org.apache.lucene</groupId>

<artifactId>lucene-queryparser</artifactId>

<version>5.3.1</version>

</dependency>


<!-- 常规的分词(英文) -->

<dependency>

<groupId>org.apache.lucene</groupId>

<artifactId>lucene-analyzers-common</artifactId>

<version>5.3.1</version>

</dependency>


<!--支持分词高亮  -->

<dependency>

<groupId>org.apache.lucene</groupId>

<artifactId>lucene-highlighter</artifactId>

<version>5.3.1</version>

</dependency>


<!--支持中文分词  -->

<dependency>

<groupId>org.apache.lucene</groupId>

<artifactId>lucene-analyzers-smartcn</artifactId>

<version>5.3.1</version>

</dependency>

最后一个依赖是用来支持中文分词的,因为默认是支持英文的。那个高亮的分词依赖是最后我要做一个搜索,然后将搜到的内容高亮显示,模拟当前互联网上的做法,大家可以运用到实际项目中去。

2.2 快速入门

根据上文的分析,全文检索有两个步骤,先建立索引,再检索。所以为了测试这个过程,我新建两个 java 类,一个用来建立索引的,另一个用来检索。

2.2.1 建立索引

我们自己弄几个文件,放到 D:\lucene\data 目录下,新建一个 Indexer 类来实现建立索引功能。首先在构造方法中初始化标准分词器和写索引实例。

public class Indexer {


   /**

    * 写索引实例

    */

   private IndexWriter writer;


   /**

    * 构造方法,实例化IndexWriter

    * @param indexDir

    * @throws Exception

    */

   public Indexer(String indexDir) throws Exception {

       Directory dir = FSDirectory.open(Paths.get(indexDir));

       //标准分词器,会自动去掉空格啊,is a the等单词

       Analyzer analyzer = new StandardAnalyzer();

       //将标准分词器配到写索引的配置中

       IndexWriterConfig config = new IndexWriterConfig(analyzer);

       //实例化写索引对象

       writer = new IndexWriter(dir, config);

   }

}

在构造放发中传一个存放索引的文件夹路径,然后构建标准分词器(这是英文的),再使用标准分词器来实例化写索引对象。接下来就开始建立索引了,我将解释放到代码注释里,方便大家跟进。

/**

* 索引指定目录下的所有文件

* @param dataDir

* @return

* @throws Exception

*/

public int indexAll(String dataDir) throws Exception {

   // 获取该路径下的所有文件

   File[] files = new File(dataDir).listFiles();

   if (null != files) {

       for (File file : files) {

           //调用下面的indexFile方法,对每个文件进行索引

           indexFile(file);

       }

   }

   //返回索引的文件数

   return writer.numDocs();

}


/**

* 索引指定的文件

* @param file

* @throws Exception

*/

private void indexFile(File file) throws Exception {

   System.out.println("索引文件的路径:" + file.getCanonicalPath());

   //调用下面的getDocument方法,获取该文件的document

   Document doc = getDocument(file);

   //将doc添加到索引中

   writer.addDocument(doc);

}


/**

* 获取文档,文档里再设置每个字段,就类似于数据库中的一行记录

* @param file

* @return

* @throws Exception

*/

private Document getDocument(File file) throws Exception {

   Document doc = new Document();

   //开始添加字段

   //添加内容

   doc.add(new TextField("contents", new FileReader(file)));

   //添加文件名,并把这个字段存到索引文件里

   doc.add(new TextField("fileName", file.getName(), Field.Store.YES));

   //添加文件路径

   doc.add(new TextField("fullPath", file.getCanonicalPath(), Field.Store.YES));

   return doc;

}

这样就建立好索引了,我们在该类中写一个 main 方法测试一下:

public static void main(String[] args) {

       //索引保存到的路径

       String indexDir = "D:\\lucene";

       //需要索引的文件数据存放的目录

       String dataDir = "D:\\lucene\\data";

       Indexer indexer = null;

       int indexedNum = 0;

       //记录索引开始时间

       long startTime = System.currentTimeMillis();

       try {

           // 开始构建索引

           indexer = new Indexer(indexDir);

           indexedNum = indexer.indexAll(dataDir);

       } catch (Exception e) {

           e.printStackTrace();

       } finally {

           try {

               if (null != indexer) {

                   indexer.close();

               }

           } catch (Exception e) {

               e.printStackTrace();

           }

       }

       //记录索引结束时间

       long endTime = System.currentTimeMillis();

       System.out.println("索引耗时" + (endTime - startTime) + "毫秒");

       System.out.println("共索引了" + indexedNum + "个文件");

   }

我搞了两个 tomcat 相关的文件放到 D:\lucene\data 下了,执行完之后,看到控制台输出:

索引文件的路径:D:\lucene\data\catalina.properties

索引文件的路径:D:\lucene\data\logging.properties

索引耗时882毫秒

共索引了2个文件

然后我们去 D:\lucene\ 目录下可以看到一些索引文件,这些文件不能删除,删除了就需要重新构建索引,否则没了索引,就无法去检索内容了。

####2.2.2 检索内容

上面把这两个文件的索引建立好了,接下来我们就可以写检索程序了,在这两个文件中查找特定的词。

public class Searcher {


   public static void search(String indexDir, String q) throws Exception {


       //获取要查询的路径,也就是索引所在的位置

       Directory dir = FSDirectory.open(Paths.get(indexDir));

       IndexReader reader = DirectoryReader.open(dir);

       //构建IndexSearcher

       IndexSearcher searcher = new IndexSearcher(reader);

       //标准分词器,会自动去掉空格啊,is a the等单词

       Analyzer analyzer = new StandardAnalyzer();

       //查询解析器

       QueryParser parser = new QueryParser("contents", analyzer);

       //通过解析要查询的String,获取查询对象,q为传进来的待查的字符串

       Query query = parser.parse(q);


       //记录索引开始时间

       long startTime = System.currentTimeMillis();

       //开始查询,查询前10条数据,将记录保存在docs中

       TopDocs docs = searcher.search(query, 10);

       //记录索引结束时间

       long endTime = System.currentTimeMillis();

       System.out.println("匹配" + q + "共耗时" + (endTime-startTime) + "毫秒");

       System.out.println("查询到" + docs.totalHits + "条记录");


       //取出每条查询结果

       for(ScoreDoc scoreDoc : docs.scoreDocs) {

           //scoreDoc.doc相当于docID,根据这个docID来获取文档

           Document doc = searcher.doc(scoreDoc.doc);

           //fullPath是刚刚建立索引的时候我们定义的一个字段,表示路径。也可以取其他的内容,只要我们在建立索引时有定义即可。

           System.out.println(doc.get("fullPath"));

       }

       reader.close();

   }

}

ok,这样我们检索的代码就写完了,每一步解释我写在代码中的注释上了,下面写个 main 方法来测试一下:

public static void main(String[] args) {

   String indexDir = "D:\\lucene";

   //查询这个字符串

   String q = "security";

   try {

       search(indexDir, q);

   } catch (Exception e) {

       e.printStackTrace();

   }

}

查一下 security 这个字符串,执行一下看控制台打印的结果:

匹配security共耗时23毫秒

查询到1条记录

D:\lucene\data\catalina.properties

可以看出,耗时了23毫秒在两个文件中找到了 security 这个字符串,并输出了文件的名称。上面的代码我写的很详细,这个代码已经比较全了,可以用在生产环境上。

2.3 中文分词检索高亮实战

上文已经写了建立索引和检索的代码,但是在实际项目中,我们往往是结合页面做一些查询结果的展示,比如我要查某个关键字,查到了之后,将相关的信息点展示出来,并将查询的关键字高亮等等。这种需求在实际项目中非常常见,而且大多数网站中都会有这种效果。所以这一小节我们就使用 Lucene 来实现这种效果。

2.3.1 中文分词

我们新建一个 ChineseIndexer 类来建立中文索引,建立过程和英文索引一样的,不同的地方在于使用的是中文分词器。除此之外,这里我们不用通过读取文件去建立索引,我们模拟一下用字符串来建立,因为在实际项目中,绝大部分情况是获取到一些文本字符串,然后根据一些关键字去查询相关内容等等。代码如下:

public class ChineseIndexer {


   /**

    * 存放索引的位置

    */

   private Directory dir;


   //准备一下用来测试的数据

   //用来标识文档

   private Integer ids[] = {1, 2, 3};

   private String citys[] = {"上海", "南京", "青岛"};

   private String descs[] = {

           "上海是个繁华的城市。",

           "南京是一个文化的城市南京,简称宁,是江苏省会,地处中国东部地区,长江下游,濒江近海。全市下辖11个区,总面积6597平方公里,2013年建成区面积752.83平方公里,常住人口818.78万,其中城镇人口659.1万人。[1-4] “江南佳丽地,金陵帝王州”,南京拥有着6000多年文明史、近2600年建城史和近500年的建都史,是中国四大古都之一,有“六朝古都”、“十朝都会”之称,是中华文明的重要发祥地,历史上曾数次庇佑华夏之正朔,长期是中国南方的政治、经济、文化中心,拥有厚重的文化底蕴和丰富的历史遗存。[5-7] 南京是国家重要的科教中心,自古以来就是一座崇文重教的城市,有“天下文枢”、“东南第一学”的美誉。截至2013年,南京有高等院校75所,其中211高校8所,仅次于北京上海;国家重点实验室25所、国家重点学科169个、两院院士83人,均居中国第三。[8-10] 。",

           "青岛是一个美丽的城市。"

   };


   /**

    * 生成索引

    * @param indexDir

    * @throws Exception

    */

   public void index(String indexDir) throws Exception {

       dir = FSDirectory.open(Paths.get(indexDir));

       // 先调用 getWriter 获取IndexWriter对象

       IndexWriter writer = getWriter();

       for(int i = 0; i < ids.length; i++) {

           Document doc = new Document();

           // 把上面的数据都生成索引,分别用id、city和desc来标识

           doc.add(new IntField("id", ids[i], Field.Store.YES));

           doc.add(new StringField("city", citys[i], Field.Store.YES));

           doc.add(new TextField("desc", descs[i], Field.Store.YES));

           //添加文档

           writer.addDocument(doc);

       }

       //close了才真正写到文档中

       writer.close();

   }


   /**

    * 获取IndexWriter实例

    * @return

    * @throws Exception

    */

   private IndexWriter getWriter() throws Exception {

       //使用中文分词器

       SmartChineseAnalyzer analyzer = new SmartChineseAnalyzer();

       //将中文分词器配到写索引的配置中

       IndexWriterConfig config = new IndexWriterConfig(analyzer);

       //实例化写索引对象

       IndexWriter writer = new IndexWriter(dir, config);

       return writer;

   }


   public static void main(String[] args) throws Exception {

       new ChineseIndexer().index("D:\\lucene2");

   }

}

这里我们用 id、city、desc 分别代表 id、城市名称和城市描述,用他们作为关键字来建立索引,后面我们获取内容的时候,主要来获取城市描述。南京的描述我故意写的长一点,因为下文检索的时候,根据不同的关键字会检索到不同部分的信息,有个权重的概念在里面。然后执行一下 main 方法,将索引保存到 D:\lucene2\ 中。

2.3.2 中文分词查询

中文分词查询代码逻辑和默认的查询差不多,有一些区别在于,我们需要将查询出来的关键字标红加粗等需要处理,需要计算出一个得分片段,这是什么意思呢?比如我搜索 “南京文化” 跟搜索 “南京文明”,这两个搜索结果应该根据关键字出现的位置,返回的结果不一样才对,这在下文会测试。我们先看一下代码和注释:

public class ChineseSearch {


   private static final Logger logger = LoggerFactory.getLogger(ChineseSearch.class);


   public static List<String> search(String indexDir, String q) throws Exception {


       //获取要查询的路径,也就是索引所在的位置

       Directory dir = FSDirectory.open(Paths.get(indexDir));

       IndexReader reader = DirectoryReader.open(dir);

       IndexSearcher searcher = new IndexSearcher(reader);

       //使用中文分词器

       SmartChineseAnalyzer analyzer = new SmartChineseAnalyzer();

       //由中文分词器初始化查询解析器

       QueryParser parser = new QueryParser("desc", analyzer);

       //通过解析要查询的String,获取查询对象

       Query query = parser.parse(q);


       //记录索引开始时间

       long startTime = System.currentTimeMillis();

       //开始查询,查询前10条数据,将记录保存在docs中

       TopDocs docs = searcher.search(query, 10);

       //记录索引结束时间

       long endTime = System.currentTimeMillis();

       logger.info("匹配{}共耗时{}毫秒", q, (endTime - startTime));

       logger.info("查询到{}条记录", docs.totalHits);


       //如果不指定参数的话,默认是加粗,即<b><b/>

       SimpleHTMLFormatter simpleHTMLFormatter = new SimpleHTMLFormatter("<b><font color=red>","</font></b>");

       //根据查询对象计算得分,会初始化一个查询结果最高的得分

       QueryScorer scorer = new QueryScorer(query);

       //根据这个得分计算出一个片段

       Fragmenter fragmenter = new SimpleSpanFragmenter(scorer);

       //将这个片段中的关键字用上面初始化好的高亮格式高亮

       Highlighter highlighter = new Highlighter(simpleHTMLFormatter, scorer);

       //设置一下要显示的片段

       highlighter.setTextFragmenter(fragmenter);


       //取出每条查询结果

       List<String> list = new ArrayList<>();

       for(ScoreDoc scoreDoc : docs.scoreDocs) {

           //scoreDoc.doc相当于docID,根据这个docID来获取文档

           Document doc = searcher.doc(scoreDoc.doc);

           logger.info("city:{}", doc.get("city"));

           logger.info("desc:{}", doc.get("desc"));

           String desc = doc.get("desc");


           //显示高亮

           if(desc != null) {

               TokenStream tokenStream = analyzer.tokenStream("desc", new StringReader(desc));

               String summary = highlighter.getBestFragment(tokenStream, desc);

               logger.info("高亮后的desc:{}", summary);

               list.add(summary);

           }

       }

       reader.close();

       return list;

   }

}

每一步的注释我写的很详细,在这就不赘述了。接下来我们来测试一下效果。

2.3.3 测试一下

这里我们使用 thymeleaf 来写个简单的页面来展示获取到的数据,并高亮展示。在 controller 中我们指定索引的目录和需要查询的字符串,如下:

@Controller

@RequestMapping("/lucene")

public class IndexController {


   @GetMapping("/test")

   public String test(Model model) {

       // 索引所在的目录

       String indexDir = "D:\\lucene2";

       // 要查询的字符

//        String q = "南京文明";

       String q = "南京文化";

       try {

           List<String> list = ChineseSearch.search(indexDir, q);

           model.addAttribute("list", list);

       } catch (Exception e) {

           e.printStackTrace();

       }

       return "result";

   }

}

直接返回到 result.html 页面,该页面主要来展示一下 model 中的数据即可。

<!DOCTYPE html>

<html lang="en" xmlns:th="http://www.thymeleaf.org">

<head>

   <meta charset="UTF-8">

   <title>Title</title>

</head>

<body>

<div th:each="desc : ${list}">

   <div th:utext="${desc}"></div>

</div>

</body>

</html>

这里注意一下,不能使用 th:test,否则字符串中的 html 标签都会被转义,不会被渲染到页面。下面启动服务,在浏览器中输入 http://localhost:8080/lucene/test,测试一下效果,我们搜索的是 “南京文化”。

再将 controller 中的搜索关键字改成 “南京文明”,看下命中的效果。

可以看出,不同的关键词,它会计算一个得分片段,也就是说不同的关键字会命中不同位置的内容,然后将关键字根据我们自己设定的形式高亮显示。从结果中可以看出,Lucene 也可以很智能的将关键字拆分命中,这在实际项目中会很好用。

3. 总结

本节课首先详细的分析了全文检索的理论规则,然后结合 Lucene,系统的讲述了在 Spring Boot 的集成步骤,首先快速带领大家从直观上感受 Lucene 如何建立索引已经如果检索,其次通过中文检索的具体实例,展示了 Lucene 在全文检索中的广泛应用。Lucene 不难,主要就是步骤比较多,代码不用死记硬背,拿到项目中根据实际情况做对应的修改即可。

相关文章
|
2月前
|
消息中间件 存储 缓存
RabbitMQ工作模型
工作队列模型通过多个消费者共同消费一个队列中的消息,实现任务的并行处理。默认情况下,消息平均分配给消费者,可能导致处理能力不同的消费者负载不均。通过设置`prefetch=1`,可实现“能者多劳”,即处理速度快的消费者自动接收更多消息,提升整体效率。发布订阅模型则通过交换机(Exchange)将一条消息转发给多个队列,支持Fanout、Direct、Topic等类型交换机,实现广播或多条件路由消息,满足不同业务场景需求。
|
2月前
|
Java 测试技术 数据库
Spring Boot中的项目属性配置
本文介绍Spring Boot中配置管理的常用方法:通过`@Value`读取单个配置,使用`@ConfigurationProperties`封装多个配置项,并实现开发与生产环境配置文件(如application-dev.yml和application-pro.yml)的灵活切换,提升项目可维护性。
|
2月前
|
SQL 关系型数据库 数据库
分布式事务
本文介绍了分布式事务的概念、典型场景及解决方案。在微服务架构下,一次业务操作需跨多个数据库和远程调用协作完成,传统本地事务无法保证整体一致性。通过Seata框架可实现分布式事务控制,其AT模式无侵入、高性能,基于两阶段提交与undo log实现最终一致;XA模式则提供强一致性但性能较低。文章还结合下单、支付等场景演示了Seata的集成与应用。
|
2月前
|
JSON 缓存 Java
Spring Boot集成 Swagger2 展现在线接口文档
Swagger是一款用于生成和管理API文档的工具,解决前后端分离架构中接口文档更新不及时的问题。通过集成Swagger2,可自动生成在线接口文档,支持实时查看与测试接口,提升开发效率。本文介绍其在Spring Boot中的配置与常用注解使用方法。
|
2月前
|
存储 Java API
Spring Boot使用slf4j进行日志记录
本文介绍了在Spring Boot项目中使用SLF4J结合Logback进行日志管理的方法。通过配置`application.yml`和`logback.xml`,实现日志级别、输出格式、文件存储与滚动策略的灵活控制,并推荐使用SLF4J门面模式替代直接调用具体日志实现,提升系统可维护性与扩展性。
|
2月前
|
XML Java 数据库连接
Spring Boot集成MyBatis
本文系统讲解Spring Boot集成MyBatis的两种方式:基于XML和注解。涵盖依赖配置、yml设置、驼峰命名映射,并详解@Select、@Insert等注解用法及@Param、@Results问题解决方案,结合实战示例,具有较强实用性,适用于日常开发参考。
|
2月前
|
监控 Java API
Spring Boot中的切面AOP处理
AOP(面向切面编程)旨在分离关注点,将核心业务与辅助逻辑解耦。通过Spring Boot中的@Aspect、@Pointcut、@Before、@After等注解,可实现日志记录、性能监控、事务管理等功能,提升代码模块化与可维护性,灵活应对业务变化。
|
2月前
|
前端开发 JavaScript Java
Spring Boot中使用拦截器
本文详解Spring Boot拦截器的原理与使用,涵盖定义、配置、静态资源处理及登录校验、注解控制等实战场景,助你掌握AOP思想在请求拦截中的应用。
|
Java Maven Android开发
eclipse创建maven项目
本文介绍了在Eclipse中创建Maven项目的步骤,包括打开Eclipse、选择Java项目、完成项目创建以及自动下载插件的过程。
399 2
eclipse创建maven项目
|
10月前
|
存储 安全 网络安全
勒索病毒最新变种.wxx勒索病毒来袭,如何恢复受感染的数据?
近年来,勒索病毒威胁日益严峻,“.wxx勒索病毒”作为新型变种,以高隐蔽性、强加密性和复杂传播机制对数据安全造成严重挑战。本文深入解析其特征与加密机制,提供数据恢复方案及预防策略。建议通过技术加固、管理强化和应急响应三位一体构建防护体系,同时强调备份重要性与专业求助必要性。如需帮助,可联系技术服务号(sjhf91)或关注“91数据恢复”。共同抵御勒索病毒,保护数据安全!
2168 3
勒索病毒最新变种.wxx勒索病毒来袭,如何恢复受感染的数据?