lucene使用

前言

基本上我们每个人每天都会或多或少的用到搜索引擎,百度的、谷歌的、360搜索,或者其他的。我们在搜索框内输入关键字然后点击搜索,搜索引擎会将与关键字匹配度最高的内容搜索到然后返回给我们。电影网站的搜索功能,输入关键字搜索,找到匹配的电影内容。购物网站的搜索功能,输入关键字搜索到匹配的商品,搜索功能无处不在。我们搜索的时候,一输入关键字,几乎是马上返回搜索结果的,类似于购物网站,商品以百万计,甚至是千万计,为什么我们一搜索就马上有结果呢?如果数据是存在数据库中的,从含有几千万条数据的表中按关键字查询,会相当耗时,不可能马上查到,那么对于海量数据的搜索是怎么实现的呢?

1、搜索理论

传统的搜索引擎搜索技术流程如下:

  • 用户发起查询请求给服务器。
  • 服务器发送SQL语句给数据库进行查询。
  • 数据库将查询结果返给服务器,服务器响应给用户。

如果用户比较少而且数据库的数据量比较小,那么这种方式实现搜索功能在企业中是比较常见的。也就是说不适用于数据量特别大的情况。

使用lucene后搜索流程如下:

  • 用户发起查询请求给服务器。
  • 服务器使用Lucene的API来和索引库交互。
  • 索引库从文档、web网页、数据库中采集数据,返给服务器。
  • 服务器将结果响应给用户。

为了解决数据库压力和速度的问题,我们的数据库就变成了索引库,我们使用 Lucene的API的来操作服务器上的索引库。这样完全和数据库进行了隔离。

两种方式的区别:
第一种与服务器交互的直接是数据库,而第二种与服务器交互的是索引库,使用Lucene将数据库和服务器进行了隔离。

(1)数据查询方法

先要了解数据查询有哪些方法,大体上分为两种:顺序扫描法倒排索引法,下面进行介绍。

a、顺序扫描法

例如要找内容包含一个字符串的文件,就是一个文档一个文档的找,对于每一个文档,从头找到尾,如果此文档包含此字符串,则此文档为我们要找的文件,接着找下一个文件,直到扫描完所有的文件。也就是说顺序扫描是从头到尾查找。

  • 优点
    由于是从头到尾查找,所以查询准确率高。
  • 缺点
    查询速度会随着查询数据量的增大, 越来越慢。
  • 使用场景
    数据库中的 like关键字模糊查询。
    文本编辑器的 Ctrl + F 查询功能。

b、倒排索引法

比如使用新华字典查询汉字,新华字典有偏旁部首的目录(索引),我们查字首先查这个目录,找到这个目录中对应的偏旁部首,就可以通过这个目录中的偏旁部首找到这个字所在的位置(文档)。Lucene会对文档建立倒排索引:首先提取资源中关键信息, 建立索引 (目录), 搜索时,根据关键字(目录),找到资源的位置。这种先建立索引,根据关键字找索引然后根据索引找资源的方式,就叫倒排索引。

算法描述:
查询前会先将查询的内容提取出来组成文档(正文),对文档进行切分词组成索引(目录),索引和文档有关联关系,查询的时候先查询索引,通过索引找文档的这个过程叫做全文检索。

为什么倒排索引会比顺序扫描快很多呢?

因为索引可以去掉重复的词,汉语常用的字和词大概等于字典加词典,常用的英文在牛津词典也有收录。如果用计算机的速度查询,,字典+词典+牛津词典这些内容是非常快的。但是用这些字典、词典组成的文章却是不计其数。索引的大小最多也就是字典+词典。所以通过查询索引,再通过索引和文档的关联关系找到文档速度比较快。 顺序扫描法则是直接去逐个查询那些不计其数的文章就算是计算的速度也会很慢。

  • 优点
    查询准确率高,查询速度快, 并且不会因为查询内容量的增加而使查询速度逐渐变慢。
  • 缺点
    索引文件会占用额外的磁盘空间, 也就是占用磁盘量会增大。
  • 使用场景
    海量数据查询。

(2)全文检索技术的应用场景

  • 站内搜索 (baidu贴吧、论坛、 京东、 淘宝等)。
  • 垂直领域的搜索 (818工作网等)。
  • 专业搜索引擎公司 (google、baidu等)。

2、Lucene介绍

先了解全文检索:
计算机索引程序通过扫描文章中的每一个词,对每一个词建立一个索引,指明该词在文章中出现的次数和位置,当用户查询时,检索程序就根据事先建立的索引进行查找,并将查找的结果反馈给用户的检索方式。

Lucene是apache软件基金会4 jakarta项目组的一个子项目,是一个开放源代码的全文检索引擎工具包,但它不是一个完整的全文检索引擎,而是一个全文检索引擎的架构,提供了完整的查询引擎和索引引擎,部分文本分析引擎(英文与德文两种西方语言)。

Lucene的目的是为软件开发人员提供一个简单易用的工具包,以方便的在目标系统中实现全文检索的功能,或者是以此为基础建立起完整的全文检索引擎。目前已经有很多应用程序的搜索功能是基于 Lucene 的,比如Eclipse的帮助系统的搜索功能。

Lucene能够为文本类型的数据建立索引,所以你只要能把你要索引的数据格式转化的文本的,Lucene就能对你的文档进行索引和搜索。比如你要对一些HTML文档,PDF文档进行索引的话你就首先需要把HTML文档和PDF文档转化成文本格式的,然后将转化后的内容交给Lucene进行索引,然后把创建好的索引文件保存到磁盘或者内存中,最后根据用户输入的查询条件在索引文件上进行查询。不指定要索引的文档的格式也使Lucene能够几乎适用于所有的搜索应用程序。

总结:

  • Lucene是一套用于全文检索和搜寻的开源程式库,由Apache软件基金会支持和提供。
  • Lucene提供了一个简单却强大的应用程式接口,能够做全文索引和搜寻, 在Java开发环境里,Lucene是一个成熟的免费开放源代码工具。
  • Lucene并不是现成的搜索引擎产品,但可以用来制作搜索引擎产品。

3、Lucene全文检索流程

(1)流程图

索引和检索的流程图如下:
在这里插入图片描述
绿色表示索引过程,对要搜索的原始内容进行索引构建一个索引库,索引过程如下:

  • 确定搜索内容
  • 获取文档
  • 创建文档
  • 分析文档(分词)
  • 创建索引

用户搜索流程如下:

  • 确定用户
  • 确定用户界面
  • 创建查询
  • 执行搜索,从索引库搜索
  • 渲染搜索结果

(2)索引流程

对文档索引的过程,将用户要搜索的文档内容进行索引,索引存储在索引库(index)中。

a、原始内容
原始内容是指要索引和搜索的内容。原始内容包括互联网上的网页、数据库中的数据、磁盘上的文件等。

b、获取文档
也叫数据采集。从互联网上、数据库、文件系统中等获取需要搜索的原始信息,这个过程就是信息采集,采集数据的目的是为了对原始内容进行索引。

采集的数据分类:

  • 对于互联网上网页,可以使用工具将网页抓取到本地生成html文件。
  • 数据库中的数据,可以直接连接数据库读取表中的数据。
  • 文件系统中的某个文件,可以通过I/O操作读取文件的内容。

在Internet上采集信息的软件通常称为爬虫或蜘蛛,也称为网络机器人,爬虫访问互联网上的每一个网页,将获取到的网页内容存储起来。

c、创建文档

获取原始内容的目的是为了索引,在索引前需要将原始内容创建成文档(Document),文档中包括一个一个的域(Field),域中存储内容。我们可以将磁盘上的一个文件当成一个document,Document中包括一些Field,如下图:
在这里插入图片描述
注意:
每个Document可以有多个Field,不同的Document可以有不同的Field,同一个Document可以有相同的Field(域名和域值都相同)。

d、分析文档

将原始内容创建为包含域(Field)的文档(document),需要再对域中的内容进行分析,分析成为一个一个的单词。

比如:vivo X23 8GB+128GB 幻夜蓝 全网通4G手机
那么拆分后就是:vivo, x23, 8GB, 128GB, 幻夜, 幻夜蓝, 全网, 全网通, 网通, 4G, 手机这些词,不同的分词器分的不一样。

e、创建索引

对所有文档分析得出的语汇单元进行索引,索引的目的是为了搜索,最终要实现只搜索被索引的语汇单元从而找到Document(文档)。创建索引是对语汇单元索引,通过词语找文档,这种索引的结构叫倒排索引结构。

倒排索引结构是根据内容(词汇)找文档,如下图:
在这里插入图片描述
倒排索引结构也叫反向索引结构,包括索引和文档两部分,索引即词汇表,它的规模较小,而文档集合较大,索引和文档存在着一种对应关系。

f、Lucene的底层存储结构
在这里插入图片描述
(3)搜索流程

搜索就是用户输入关键字,从索引中进行搜索的过程。根据关键字搜索索引,根据索引找到对应的文档,从而找到要搜索的内容。

a、用户

就是使用搜索的角色,用户可以是自然人,也可以是远程调用的程序。

b、用户搜索界面

全文检索系统提供用户搜索的界面供用户提交搜索的关键字,搜索完成展示搜索结果。如下图:
在这里插入图片描述
我们在使用搜索引擎的时候,在搜索框内输入关键字,点击搜索,然后搜索引擎反馈给我们匹配的内容。Lucene不提供制作用户搜索界面的功能,需要根据自己的需求开发搜索界面。

c、创建查询

用户输入查询关键字执行搜索之前需要先构建一个查询对象,查询对象中可以指定查询要查询关键字、要搜索的Field文档域等,查询对象会生成具体的查询语法,比如:
name:韩立 | 表示要搜索name这个Field域中,内容为“韩立”的文档。
name:韩立AND南宫婉 |表示要搜索即包括关键字“韩立” 并且也包括“南宫婉”的文档。

d、执行搜索
例如搜索语法为 “name:华为 AND 手机 ”

搜索索引过程:

在这里插入图片描述

  • 根据查询语法在倒排索引词典表中分别找出对应搜索词的索引,从而找到索引所链接的文档链表。那么找到的文档必须包括“华为”或者“手机”。
  • 由于是AND,所以要对包含华为和手机词语的链表进行交集,得到文档链表应该包括每一个搜索词语。
  • 获取文档中的Field域数据。

e、渲染结果

需要以一个友好的界面将查询结果展示给用户,用户根据搜索结果找自己想要的信息,为了帮助用户很快找到自己的结果,提供了很多展示的效果,比如搜索结果中将关键字高亮显示,百度提供的快照等,如下:
在这里插入图片描述
关键字“凡人修仙传”全部都以红色来高亮了,那么用户可以很明显的看到返回的结果是不是他想要的。

4、Lucene入门

(1)准备

需要去Lucene的官网下载好Lucene,这里使用的是7.7.2版本,因为比较稳定,解压后如下:
在这里插入图片描述
打开解压后的文件夹:
在这里插入图片描述
里面的依赖不会全部用到,只用analysis分词器包、core核心包、queryparser查询解析包就能实现lucene的功能。

(2)开发环境

  • 必须JDK1.8及以上。(Lucene7以上,必须使用JDK1.8及以上版本)
  • 数据库使用MySQL。

然后数据库中的表如下:
在这里插入图片描述
这个表是我导入一个sql脚本文件生成的,里面有将近一百万条数据。
在这里插入图片描述
(3)搭建工程

新建maven工程:
在这里插入图片描述

就两个空包,pom依赖如下:

	<!-- 父工程 -->
	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>2.1.4.RELEASE</version>
	</parent>

	<!-- 属性 -->
	<properties>
		<maven.compiler.source>1.8</maven.compiler.source>
		<maven.compiler.target>1.8</maven.compiler.target>
		<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
		<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
		<skipTests>true</skipTests>
	</properties>

	<dependencies>

		<!-- Commons工具包 -->
		<dependency>
			<groupId>commons-io</groupId>
			<artifactId>commons-io</artifactId>
			<version>2.6</version>
		</dependency>

		<!-- Lucene核心包 -->
		<dependency>
			<groupId>org.apache.lucene</groupId>
			<artifactId>lucene-core</artifactId>
			<version>7.7.2</version>
		</dependency>

		<!-- Lucene分词包 -->
		<dependency>
			<groupId>org.apache.lucene</groupId>
			<artifactId>lucene-analyzers-common</artifactId>
			<version>7.7.2</version>
		</dependency>

		<!-- Lucene查询包 -->
		<dependency>
			<groupId>org.apache.lucene</groupId>
			<artifactId>lucene-queryparser</artifactId>
			<version>7.7.2</version>
		</dependency>

		<!-- mysql数据库驱动 -->
		<dependency>
			<groupId>mysql</groupId>
			<artifactId>mysql-connector-java</artifactId>
		</dependency>

		<!-- IK中文分词器 -->
		<dependency>
			<groupId>org.wltea.ik-analyzer</groupId>
			<artifactId>ik-analyzer</artifactId>
			<version>8.1.0</version>
		</dependency>

		<!--web模块依赖 -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-web</artifactId>
		</dependency>

		<!-- thymeleaf模板 -->
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-thymeleaf</artifactId>
		</dependency>

		<!-- Json转换工具 -->
		<dependency>
			<groupId>com.alibaba</groupId>
			<artifactId>fastjson</artifactId>
			<version>1.2.51</version>
		</dependency>

		<!-- 测试包 -->
		<dependency>
			<groupId>junit</groupId>
			<artifactId>junit</artifactId>
			<scope>test</scope>
		</dependency>
	</dependencies>

(4)索引流程

a、数据采集

  • 创建pojo

在pojo包下新建:

public class Sku {
    
    private String id;// 商品ID
    private String name;// 商品名称
    private Integer price;// 价格
    private Integer num;// 库存数量
    private String image;// 图片
    private String categoryName;// 分类名称
    private String brandName;// 品牌名称
    private String spec;// 规格
    private Integer saleNum;// 销量

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public Integer getPrice() {
        return price;
    }

    public void setPrice(Integer price) {
        this.price = price;
    }

    public Integer getNum() {
        return num;
    }

    public void setNum(Integer num) {
        this.num = num;
    }

    public String getImage() {
        return image;
    }

    public void setImage(String image) {
        this.image = image;
    }

    public String getCategoryName() {
        return categoryName;
    }

    public void setCategoryName(String categoryName) {
        this.categoryName = categoryName;
    }

    public String getBrandName() {
        return brandName;
    }

    public void setBrandName(String brandName) {
        this.brandName = brandName;
    }

    public String getSpec() {
        return spec;
    }

    public void setSpec(String spec) {
        this.spec = spec;
    }

    public Integer getSaleNum() {
        return saleNum;
    }

    public void setSaleNum(Integer saleNum) {
        this.saleNum = saleNum;
    }

}
  • 创建dao和实现类

在dao包下创建接口和实现类。

dao如下:

public interface SkuDao {
    
    //查询所有数据,百万条
    List<Sku> querySkuAll();

}

dao的实现类如下:

public class SkuDaoImpl implements SkuDao{

    @Override
    public List<Sku> querySkuAll() {
        // 数据库连接
        Connection conn = null;
        // 预编译Statement
        PreparedStatement ps = null;
        // 结果集
        ResultSet set = null;
        // 商品列表
        List<Sku> list = new ArrayList<>();
        try {
            // 加载驱动
            Class.forName("com.mysql.cj.jdbc.Driver");
            // 获取连接
            conn = DriverManager.getConnection(
                    "jdbc:mysql://rm-m5e130nm7h37n6v982o.mysql.rds.aliyuncs.com:3306/lucene_test?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8",
                    "xxxxxxxxx", "xxxxxxxx");
            // 准备查询语句
            String sql = "SELECT * FROM tb_sku";
            // 获取PrepareStatement
            ps = conn.prepareStatement(sql);
            // 执行查询,获取结果集
            set = ps.executeQuery();
            while (set.next()) {
                Sku sku = new Sku();
                sku.setId(set.getString("id"));
                sku.setName(set.getString("name"));
                sku.setSpec(set.getString("spec"));
                sku.setBrandName(set.getString("brand_name"));
                sku.setCategoryName(set.getString("category_name"));
                sku.setImage(set.getString("image"));
                sku.setNum(set.getInt("num"));
                sku.setPrice(set.getInt("price"));
                sku.setSaleNum(set.getInt("sale_num"));
                list.add(sku);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
        return list;
    }

}

b、实现索引流程

过程如下:

  • 采集数据
  • 创建Document文档对象
  • 创建分析器(分词器)
  • 创建IndexWriterConfig配置信息类
  • 创建Directory对象,声明索引库存储位置
  • 创建IndexWriter写入对象
  • 把Document写入到索引库中
  • 释放资源

测试包下建立一个测试类,测试类中写:

public class TestManager {
    
    //测试创建索引库
    @Test
    public void testCreateIndex() throws IOException {
        //数据采集
        SkuDao skuDao = new SkuDaoImpl();
        List<Sku> list = skuDao.querySkuAll();
        //文档集合
        List<Document> documents = new ArrayList<>();
        //往集合中添加文档
        for(Sku sku:list) {
            //创建文档对象
            Document doc = new Document();
            //为文档对象创建域对象
            doc.add(new TextField("id", sku.getId(),Field.Store.YES));
            doc.add(new TextField("name", sku.getName(),Field.Store.YES));
            doc.add(new TextField("price", String.valueOf(sku.getPrice()),Field.Store.YES));
            doc.add(new TextField("image", sku.getImage(),Field.Store.YES));
            doc.add(new TextField("categoryName", sku.getCategoryName(),Field.Store.YES));
            doc.add(new TextField("brandName", sku.getBrandName(),Field.Store.YES));
            documents.add(doc);
        }
        //创建分词器
        Analyzer analyzer = new StandardAnalyzer();
        //创建Directory对象,声明索引库的位置
        Directory directory = FSDirectory.open(Paths.get("e:/dir"));
        //创建索引写入配置对象
        IndexWriterConfig indexWriterConfig = new IndexWriterConfig(analyzer);
        //创建索引写入对象
        IndexWriter indexWriter = new IndexWriter(directory, indexWriterConfig);
        //通过写对象写入到索引库
        for(Document doc:documents) {
            indexWriter.addDocument(doc);
        }
        //关闭流
        indexWriter.close();
        System.out.println("索引库创建完成!");
    }
    

}

实现要创建好dir文件夹,执行testCreateIndex方法,因为数据量大,可能会报错堆溢出,注意设置Xmx和Xms的大小,方法执行完后控制台如下:
在这里插入图片描述
然后查看dir文件夹:
在这里插入图片描述
那么索引库就创建完成了。

(5)使用luke查看索引

在这里插入图片描述文件夹不要有中文,因为是windows系统,所以我这里运行luke.bat,会弹出以下界面:
在这里插入图片描述
indexPath选择你之前创建的索引库位置,然后点ok。
在这里插入图片描述
说明:
IndexPath:索引库位置。
Number of Fields:域的个数。
Number of Documents:文档个数。
Number of Terms:切分出来的词的个数。

左下角显示出了每个域的名称,以及按照每个域切分出来的词所占总terms数的百分比,可以看到按照id切分出来的占大部分,选择域然后点击Show to terms可以看到切出来的分词,比如我选择name:
在这里插入图片描述
然后下面Num of terms可以控制看到的数量。
在这里插入图片描述
Browse terms in field:选择域名。
First Term:词汇表中的第一个词。
Browse documents by term:通过词查询文档。
First Doc:通过词查询第一个文档。
下面显示的是查询出来的文档。

(6)搜索流程

a、输入查询语句查询
在这里插入图片描述
Query Parser:查询功能。
Analyzer:分词器。
Similarity:相似度。
Sort:排序。
Field Values:可以控制哪些域显示哪些不显示出来。比如它这里的image值太长了,price我也不想显示出来:
在这里插入图片描述
然后Search搜索,可以看到下面只显示我勾选的域。

Default Field:是默认按照那个域来查询。
Default operator:可以选择OR或AND。
Query expression:查询的关键字。

比如我现在想查询name中有华为同时又包含Pro的文档:
在这里插入图片描述
域选择name,然后右边的查询表达式为:
name:华为 AND name:Pro
如果是或者的话,那么AND改为OR就行了,需要注意的是,OR和AND必须要大写才会有效。

关于搜索分词:

和索引过程的分词一样,这里要对用户输入的关键字进行分词,一般情况索引和搜索使用的分词器一致。比如我搜索的关键字是“java学习“,那么搜索前会先将它切分为java和学习两个词,然后再对这两个词分别进行搜索,最后取搜索结果中的交集,既包含java又包含学习的文档会返回。

b、代码实现搜索

流程如下:

  • 创建Query搜索对象
  • 创建Directory流对象,声明索引库位置
  • 创建索引读取对象IndexReader
  • 创建索引搜索对象IndexSearcher
  • 使用索引搜索对象,执行搜索,返回结果集TopDocs
  • 解析结果集
  • 释放资源

IndexSearcher搜索方法如下:
在这里插入图片描述
新建测试类TestSearch,如下:

public class TestSearch {
    
    //测试文本搜索
    @Test
    public void testIndexSearch() throws Exception {
        //创建分词器
        Analyzer analyzer = new StandardAnalyzer();
        //创建搜索解析器,第一个参数是默认的域,第二个参数是分词器
        QueryParser queryParser = new QueryParser("name", analyzer);
        //基于搜索表达式创建搜索对象
        Query query = queryParser.parse("华为AND手机");
        //创建Directory流对象,声明索引库位置
        Directory directory = FSDirectory.open(Paths.get("e:/dir"));
        //创建索引读取对象
        IndexReader indexReader = DirectoryReader.open(directory);
        //创建索引搜索对象
        IndexSearcher indexSearcher = new IndexSearcher(indexReader);
        //执行搜索,返回TopDocs结果集,第一个参数是查询对象,第二个参数是分页时返回的记录条数
        TopDocs topDocs = indexSearcher.search(query, 10);
        System.out.println("查询到的数据总条数:" + topDocs.totalHits + "条!");
        //获取查询结果集,返回ScoreDoc类型数组
        ScoreDoc []scoreDocs = topDocs.scoreDocs;
        //遍历结果集
        for(ScoreDoc doc:scoreDocs) {
            //获取文档ID
            int docId = doc.doc;
            //获取文档
            Document document = indexSearcher.doc(docId);
            System.out.println("----------------------------------");
            //通过域名获取域值
            System.out.println("id:" + document.get("id"));
            System.out.println("name:" + document.get("name"));
            System.out.println("price:" + document.get("price"));
            System.out.println("image:" + document.get("image"));
            System.out.println("brandName:" + document.get("brandName"));
            System.out.println("categoryName:" + document.get("categoryName"));
        }
        //关闭读取对象
        indexReader.close();
    }

}

测试testIndexSearch方法,执行完毕控制台输出如下:
在这里插入图片描述
总记录是24147条,但是由于设置了分页的参数,所以只返回了最顶部的10条数据。那么使用Java代码简单的创建索引库和实现搜索功能就完成了,下面进一步进行研究。

5、Field类型

Field是文档中的域,包括Field名和Field值两部分,一个文档可以包括多个Field,Document只是Field的一个承载体,Field值即为要索引的内容,也是要搜索的内容。

(1)Field属性

  • 是否分词 (tokenized)
    是:作分词处理,即将Field值进行分词,分词的目的是为了索引。
    比如:商品名称、商品描述等,这些内容用户要输入关键字搜索,由于搜索的内容格式大、内容多需要分词后将语汇单元建立索引
    否:不作分词处理。
    比如:商品id、订单号、身份证号等。

  • 是否索引 (indexed)
    是:进行索引。将Field分词后的词或整个Field值进行索引,存储到索引域,索引的目的是为了搜索。
    比如:商品名称、商品描述分析后进行索引,订单号、身份证号不用分词但也要索引,这些将来都要作为查询条件。
    否:不索引。
    比如:图片路径、文件路径等,不用作为查询条件的不用索引。

  • 是否存储 (stored)
    是:将Field值存储在文档域中,存储在文档域中的Field才可以从Document中获取。
    比如:商品名称、订单号,凡是将来要从Document中获取的Field都要存储。
    否:不存储Field值。
    比如:商品描述,内容较大不用存储。如果要向用户展示商品描述可以从系统的关系数据库中获取。

(2)Field常用类型

下边列出了开发中常用 的Filed类型,注意Field的属性,根据需求选择:
在这里插入图片描述
在这里插入图片描述
(3)修改Field类型

修改创建索引库的Field域类型,重新创建索引库,我这里先删除之前删除的索引库,下面重新创建一个。在TestManager中添加如下方法:

    //测试创建索引库,选择合适的Field域
    @Test
    public void testCreateIndex2() throws  Exception{
        //数据采集
        SkuDao skuDao = new SkuDaoImpl();
        List<Sku> list = skuDao.querySkuAll();
        //文档容器
        List<Document> documents = new ArrayList<>();
        //遍历
        for(Sku sku:list) {
            //文档对象
            Document doc = new Document();
            //为文档添加域
            //商品ID,不分词,索引,存储
            doc.add(new StringField("id", sku.getId(),Field.Store.YES));
            //商品名称,分词,索引,存储
            doc.add(new TextField("name", sku.getName(),Field.Store.YES));
            //商品价格,分词,索引,存储
            doc.add(new IntPoint("price", sku.getPrice()));
            doc.add(new StoredField("price", sku.getPrice()));
            //图片地址,不分词,不索引,存储
            doc.add(new StoredField("image", sku.getImage()));
            //分类名称,不分词,索引,存储
            doc.add(new StringField("categoryName", sku.getCategoryName(),Field.Store.YES));
            //品牌名称,不分词,索引,存储
            doc.add(new StringField("brandName", sku.getBrandName(),Field.Store.YES));
            documents.add(doc);
        }
        //分词器
        Analyzer analyzer = new StandardAnalyzer();
        //声明索引库位置
        Directory directory = FSDirectory.open(Paths.get("e:/dir"));
        //创建索引写入配置对象
        IndexWriterConfig indexWriterConfig = new IndexWriterConfig(analyzer);
        //创建索引写入对象
        IndexWriter indexWriter = new IndexWriter(directory, indexWriterConfig);
        //开始写入
        for(Document doc:documents) {
            indexWriter.addDocument(doc);
        }
        //关闭流
        indexWriter.close();
        System.out.println("索引库创建完成!");
    }

执行这个方法,控制台如下:
在这里插入图片描述
再查看dir文件夹:
在这里插入图片描述
索引库创建成功。

6、索引维护

(1)需求

管理人员通过电商系统更改图书信息,这时更新的是关系数据库,如果使用lucene搜索图书信息,需要在数据库表book信息变化时及时更新lucene索引库。

(2)添加索引

indexWriter.addDocument(doc);

这其实就是添加索引,略。

(3)修改索引

更新索引是先删除再添加,建议对更新需求采用此方法并且要保证对已存在的索引执行更新,可以先查询出来,确定更新记录存在执行更新操作。如果更新索引的目标文档对象不存在,则执行添加。
在这里插入图片描述
现在我想更新id为100000003145的文档。

在TestManager中添加以下方法:

    //测试索引库的修改
    @Test
    public void testUpdateIndex() throws IOException {
        Analyzer analyzer = new StandardAnalyzer();
        Directory directory = FSDirectory.open(Paths.get("e:/dir"));
        IndexWriterConfig config = new IndexWriterConfig(analyzer);
        IndexWriter indexWriter = new IndexWriter(directory, config);
      //创建文档对象
        Document doc = new Document();
        //要修改的内容
        doc.add(new StringField("id", "100000003145", Field.Store.YES));
        doc.add(new TextField("name", "华为nova 2s",Field.Store.YES));
        doc.add(new FloatPoint("price", 2599.6f));
        doc.add(new StoredField("image", "http://xxx.cc.com"));
        doc.add(new StringField("category", "中端手机", Field.Store.YES));
        doc.add(new StringField("brandName", "HUAWEI", Field.Store.YES));
        //执行更新
        indexWriter.updateDocument(new Term("id","100000003145"), doc);
        //关闭流
        indexWriter.close();
        System.out.println("更新完成,请查看!");
    }

执行这个方法,控制台如下:
在这里插入图片描述
现在重新打开luke搜索一下id为100000003145的文档:
在这里插入图片描述
文档内容已经变了,说明修改成功。修改使用的是updateDocument方法,注意修改索引是先将原索引删除,然后再执行添加的,并不是直接修改,这点注意。

(4)删除索引

a、删除指定索引

根据Term项删除索引,满足条件的将全部删除。下面按id进行删除,在TestManager中添加以下方法:

    //测试删除索引,按指定条件删除
    @Test
    public void testDeleteIndex() throws IOException {
        Analyzer analyzer = new StandardAnalyzer();
        Directory directory = FSDirectory.open(Paths.get("e:/dir"));
        IndexWriterConfig config = new IndexWriterConfig(analyzer);
        IndexWriter indexWriter = new IndexWriter(directory, config);
        //根据Term删除索引
        indexWriter.deleteDocuments(new Term("id","100000003145"));
        indexWriter.close();
        System.out.println("删除完成,请查看!");
    }

执行这个方法,控制台如下:
在这里插入图片描述
重新打开luke,搜索id为100000003145的文档:
在这里插入图片描述
搜不到,说明删除成功。删除使用的是deleteDocuments方法。

b、删除全部索引

将索引目录的索引信息全部删除,直接彻底删除,无法恢复。
建议参照关系数据库基于主键删除方式,所以在创建索引时需要创建一个主键Field,删除时根据此主键Field删除。索引删除后将放在Lucene的回收站中,Lucene3.X版本可以恢复删除的文档,3.X之后无法恢复。

在TestManager中添加以下方法:

    //测试删除全部索引
    @Test
    public void testDeleteAllIndex() throws IOException {
        Analyzer analyzer = new StandardAnalyzer();
        Directory directory = FSDirectory.open(Paths.get("e:/dir"));
        IndexWriterConfig config = new IndexWriterConfig(analyzer);
        IndexWriter indexWriter = new IndexWriter(directory, config);
        //根据Term删除索引
        indexWriter.deleteAll();
        indexWriter.close();
        System.out.println("删除完成,请查看!");
    }

这个方法就不执行了,创建索引库挺慢的。删除全部使用deleteAll方法。这个慎用,因为删除无法恢复。

7、分词器

(1)分词的理解

在对Document中的内容进行索引之前,需要使用分词器进行分词 ,分词的目的是为了搜索。分词的主要过程就是先分词后过滤。过滤包括去除标点符号过滤、去除停用词过滤(的、是、 a、an、the等)、大写转小写、词的形还原(复数形式转成单数形参、过去式转成现在式。。。)等。

停用词

停用词是为节省存储空间和提高搜索效率,搜索引擎在索引页面或处理搜索请求时会自动忽略某些字或词,这些字或词即被称为Stop Words(停用词)。比如语气助词、副词、介词、连接词等,通常自身并无明确的意义,只有将其放入一个完整的句子中才有一定作用,如常见的“的”、“在”、“是”、“啊”等。

对于分词来说,不同的语言,分词规则不同。Lucene作为一个工具包提供不同国家的分词器。

(2)Analyzer的使用时机

a、索引时使用Analyzer

输入关键字进行搜索,当需要让该关键字与文档域内容所包含的词进行匹配时需要对文档域内容进行分析,需要经过Analyzer分析器处理生成语汇单元(Token)。分析器分析的对象是文档中的Field域。当Field的属性tokenized(是否分词)为true时会对Field值进行分析,对于一些 Field可以不用分析,如下:

  • 不作为查询条件的内容,比如文件路径。
  • 不是匹配内容中的词而匹配Field的整体内容,比如订单号、身份证号等。

b、搜索时使用Analyzer

对搜索关键字进行分析和索引分析一样,使用Analyzer对搜索关键字进行分析、分词处理,使用分析后每个词语进行搜索。比如:搜索关键字:spring web ,经过分析器进行分词,得出:spring web拿词去索引词典表查找 ,找到索引链接到Document,解析Document内容。对于匹配整体Field域的查询可以在搜索时不分析,比如根据订单号、身份证号查询等。
注意:搜索使用的分析器要和索引使用的分析器一致。

(3)Lucene原生分词器

Lucene中自带了一些分词器:StandardAnalyzer、WhitespaceAnalyzer、SimpleAnalyzer、CJKAnalyzer、SmartChineseAnalyzer。以下将进行分别介绍。

a、StandardAnalyzer

  • 特点:Lucene提供的标准分词器, 可以对用英文进行分词, 对中文是单字分词, 也就是一个字就认为是一个词。

以下是部分源码:

protected TokenStreamComponents createComponents(String fieldName) {
      final StandardTokenizer src = new StandardTokenizer();
      src.setMaxTokenLength(this.maxTokenLength);
      TokenStream tok = new LowerCaseFilter(src);
      TokenStream tok = new StopFilter(tok, this.stopwords);
      return new TokenStreamComponents(src, tok) {
      protected void setReader(Reader reader) {
           src.setMaxTokenLength(StandardAnalyzer.this.maxTokenLength);
           super.setReader(reader);
   }
 };
}

Tokenizer就是分词器,负责将reader转换为语汇单元即进行分词处理,Lucene提供了很多的分词器,也可以使用第三方的分词,比如IKAnalyzer一个中文分词器。
TokenFilter是分词过滤器,负责对语汇单元进行过滤,TokenFilter可以是一个过滤器链儿,Lucene提供了很多的分词器过滤器,比如大小写转换、去除停用词等。

如下图是语汇单元的生成过程:
在这里插入图片描述
从一个 Reader字符流开始,创建一个基于Reader的Tokenizer分词器,经过三个TokenFilter生成语汇单元Token。
比如下边的文档经过分析器分析如下:
在这里插入图片描述
分析后得到的多个语汇单元:
在这里插入图片描述
上面的例子都是使用的StandardAnalyzer标准分词器,这里不再代码演示。

b、WhitespaceAnalyzer

  • 特点:仅仅是去掉了空格,没有其他任何操作,不支持中文。

新建测试类TestAnalyzer,添加以下方法:

    //测试WhitespaceAnalyzer分词器
    @Test
    public void testWhitespaceAnalyzer() throws IOException {
        Analyzer analyzer = new WhitespaceAnalyzer();
        Directory directory = FSDirectory.open(Paths.get("e:/dir"));
        IndexWriterConfig config = new IndexWriterConfig(analyzer);
        IndexWriter indexWriter = new IndexWriter(directory, config);
        Document document = new Document();
        document.add(new TextField("name", "vivo X23 8GB+128GB 幻夜蓝",Field.Store.YES));
        //添加文档对象
        indexWriter.addDocument(document);
        indexWriter.close();
        System.out.println("创建完毕!");
    }

执行这个方法,控制台如下:
在这里插入图片描述
索引库我是删除了的,这里重新创建的,打开dir文件夹:
在这里插入图片描述
使用luke查看:
在这里插入图片描述
只有1条文档,而且可以看到分词时将空格去掉了。

c、SimpleAnalyzer

  • 特点:是将除了字母以外的符号全部去除,并且将所有字母与变为小写,需要注意的是这个分词器会把数字也全部去掉,而且也不支持中文。

先清空索引库。

在TestAnalyzer中添加以下方法:

    //测试SimpleAnalyzer分词器
    @Test
    public void testSimpleAnalyzer() throws Exception{
        Analyzer analyzer = new SimpleAnalyzer();
        Directory directory = FSDirectory.open(Paths.get("e:/dir"));
        IndexWriterConfig config = new IndexWriterConfig(analyzer);
        IndexWriter indexWriter = new IndexWriter(directory, config);
        Document document = new Document();
        document.add(new TextField("name", "vivo X23 8GB+128GB 幻夜蓝",Field.Store.YES));
        //添加文档对象
        indexWriter.addDocument(document);
        indexWriter.close();
        System.out.println("创建完毕!");
    }

执行这个方法,控制台:
在这里插入图片描述
使用luke查看:
在这里插入图片描述
可以看到数字和符号全部都去掉了,而且字母全变成了小写。

d、CJKAnalyzer

  • 特点:这个支持中日韩文字,前三个字母也就是这三个国家的缩写。对中文是二分法分词, 去掉空格, 去掉标点符号。

先清空索引库。在TestAnalyzer中添加以下方法:

    //测试CJKAnalyzer分词器
    @Test
    public void testCJKAnalyzer() throws Exception{
        Analyzer analyzer = new CJKAnalyzer();
        Directory directory = FSDirectory.open(Paths.get("e:/dir"));
        IndexWriterConfig config = new IndexWriterConfig(analyzer);
        IndexWriter indexWriter = new IndexWriter(directory, config);
        Document document = new Document();
        document.add(new TextField("name", "vivo X23 8GB+128GB 幻夜蓝",Field.Store.YES));
        //添加文档对象
        indexWriter.addDocument(document);
        indexWriter.close();
        System.out.println("创建完毕!");
    }

执行这个方法,控制台:
在这里插入图片描述
使用luke查看:
在这里插入图片描述
可以看到分词效果不是很好。

e、SmartChineseAnalyzer

  • 特点:对中文支持也不是很好,扩展性差,扩展词库,禁用词库和同义词库等不好处理。

这个分词器就不测试了,直接跳过。

(4)中文分词器

英文是以单词为单位的,单词与单词之间以空格或者逗号句号隔开。所以对于英
文,我们可以简单以空格判断某个字符串是否为一个单词,比如I love China,love 和 China很容易被程序区分开来。
而中文则以字为单位,字又组成词,字和词再组成句子。中文“我爱中国”就不一样了,电脑不知道“中国”是一个词语还是“爱中”是一个词语。把中文的句子切分成有意义的词,就是中文分词,也称切词。我爱中国,分词的结果是:我、爱、中国。

a、第三方中文分词器简介

  • paoding : 庖丁解牛最新版在 https://code.google.com/p/paoding/ 中最多支持Lucene 3.0,且最新提交的代码在 2008-06-03,在svn中最新也是2010年提交,已经过时,不予考虑。
  • mmseg4j :最新版已从 https://code.google.com/p/mmseg4j/ 移至 https://github.com/chenlb/mmseg4j-solr,支持Lucene 4.10,且在github中最新提交代码是2014年6月,从09年~14年一共有:18个版本,也就是一年几乎有3个大小版本,有较大的活跃度,用了mmseg算法。
  • IK-analyzer : 最新版在 https://code.google.com/p/ik-analyzer/上,支持Lucene 4.10从2006年12月推出1.0版开始, IKAnalyzer已经推出了4个大版本。最初,它是以开源项目Luence为应用主体的,结合词典分词和文法分析算法的中文分词组件。从3.0版本开 始,IK发展为面向Java的公用分词组件,独立于Lucene项目,同时提供了对Lucene的默认优化实现。在2012版本中,IK实现了简单的分词 歧义排除算法,标志着IK分词器从单纯的词典分词向模拟语义分词衍化。 但是也就是2012年12月后没有在更新。
  • ansj_seg :最新版本在 https://github.com/NLPchina/ansj_seg tags仅有1.1版本,从2012年到2014年更新了大小6次,但是作者本人在2014年10月10日说明:“可能我以后没有精力来维护ansj_seg了”,现在由”nlp_china”管理。2014年11月有更新。并未说明是否支持Lucene,是一个由CRF(条件随机场)算法所做的分词算法。
  • imdict-chinese-analyzer :最新版在 https://code.google.com/p/imdict-chinese-analyzer/ , 最新更新也在2009年5月,下载源码,不支持Lucene 4.10 。是利用HMM(隐马尔科夫链)算法。
  • Jcseg :最新版本在git.oschina.net/lionsoul/jcseg,支持Lucene 4.10,作者有较高的活跃度。利用mmseg算法。

b、使用IK分词器

经过比较,使用IK分词器。IKAnalyzer继承Lucene的Analyzer抽象类,使用IKAnalyzer和Lucene自带的分析器方法一样,如果使用中文分词器ik-analyzer,就需要在索引和搜索程序中使用一致的分词器:IK-analyzer。

  • pom依赖
		<!-- IK中文分词器 -->
		<dependency>
			<groupId>org.wltea.ik-analyzer</groupId>
			<artifactId>ik-analyzer</artifactId>
			<version>8.1.0</version>
		</dependency>

前面其实已经引入了。

  • 配置文件
    在resources文件夹下,ext.dic为扩展词典,可以自行添加新的专有名词。stopword.dic为停用词典,IKAnalyzer.xml里面可以配置ext.dic和stopword.dic。

ext.dic的作用:在汉语中一些公司名称, 行业名称, 分类, 品牌等不是汉语中的词汇, 是专有名词. 这些分词器默认不识别, 所以需要放入扩展词典中, 效果是被强制分成一个词。

stopword.dic的作用:停用词典中的词例如: a, an, the, 的, 地, 得等词汇, 凡是出现在停用词典中的字或者词, 在切分词的时候会被过滤掉。

IKAnalyzer.xml的作用:配置ext.dic和stopword.dic的加载。

ext.dic内容如下:
在这里插入图片描述
stopword.dic内容如下:
在这里插入图片描述
IKAnalyzer.xml内容如下:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
    <comment>IK Analyzer 扩展配置</comment>
    <!--用户可以在这里配置自己的扩展字典 -->
    <entry key="ext_dict">ext.dic;</entry>

    <!--用户可以在这里配置自己的扩展停止词字典-->
    <entry key="ext_stopwords">stopword.dic;</entry>

</properties>

也就是说,在这个文件里面配置了扩展词典和停用词典才会起作用。下面进行测试,在TestAnalyzer中添加以下方法:

    //测试IK中文分词器
    @Test
    public void testIKAnalyzer() throws Exception{
        Analyzer analyzer = new IKAnalyzer();
        Directory directory = FSDirectory.open(Paths.get("e:/dir"));
        IndexWriterConfig config = new IndexWriterConfig(analyzer);
        IndexWriter indexWriter = new IndexWriter(directory, config);
        Document document = new Document();
        document.add(new TextField("name", 
                "vivo X23 8GB+128GB 幻夜蓝,水滴屏全面屏,游戏手机.移动联通电信全网通4G手机",
                Field.Store.YES));
        //添加文档对象
        indexWriter.addDocument(document);
        indexWriter.close();
        System.out.println("创建完毕!");
    }

执行这个方法,控制台如下:
在这里插入图片描述
可以看到引用了扩展词典和停用词典。
使用luke打开索引库查看:
在这里插入图片描述
可以看到,IK分词器对中文分词的效果非常好,一共切出了25个词。

8、Lucene高级搜索

搜索前还是先建立索引库,用最早的那个将近一百万条文档的,这里就不再重复了。

(1)文本搜索
QueryParser支持默认搜索域,第一个参数为默认搜索域。如果在执行parse方法的时候,查询语法中包含域名则从指定的这个域名中搜索,如果只有查询的关键字,则从默认搜索域中搜索结果。
需求描述 : 查询名称中包含华为手机关键字的结果。
在TestSearch中添加以下方法:

    //测试文本搜索
    @Test
    public void testIndexSearch() throws Exception {
        //创建分词器
        Analyzer analyzer = new StandardAnalyzer();
        //创建搜索解析器,第一个参数是默认的域,第二个参数是分词器
        QueryParser queryParser = new QueryParser("name", analyzer);
        //基于搜索表达式创建搜索对象
        Query query = queryParser.parse("华为手机");
        //创建Directory流对象,声明索引库位置
        Directory directory = FSDirectory.open(Paths.get("e:/dir"));
        //创建索引读取对象
        IndexReader indexReader = DirectoryReader.open(directory);
        //创建索引搜索对象
        IndexSearcher indexSearcher = new IndexSearcher(indexReader);
        //执行搜索,返回TopDocs结果集,第一个参数是查询对象,第二个参数是分页时返回的记录条数
        TopDocs topDocs = indexSearcher.search(query, 10);
        System.out.println("查询到的数据总条数:" + topDocs.totalHits + "条!");
        //获取查询结果集,返回ScoreDoc类型数组
        ScoreDoc []scoreDocs = topDocs.scoreDocs;
        //遍历结果集
        for(ScoreDoc doc:scoreDocs) {
            //获取文档ID
            int docId = doc.doc;
            //获取文档
            Document document = indexSearcher.doc(docId);
            System.out.println("----------------------------------");
            //通过域名获取域值
            System.out.println("id:" + document.get("id"));
            System.out.println("name:" + document.get("name"));
            System.out.println("price:" + document.get("price"));
            System.out.println("image:" + document.get("image"));
            System.out.println("brandName:" + document.get("brandName"));
            System.out.println("categoryName:" + document.get("categoryName"));
        }
        //关闭读取对象
        indexReader.close();
    }

执行这个方法,控制台如下:
在这里插入图片描述
(2)数值范围搜索

需求描述 : 查询价格大于等于100, 小于等于1000的商品。

在TestSearch中添加以下方法:

    //测试数值范围搜索
    @Test
    public void testNumberIndexSearch() throws IOException {
        //创建查询对象
        Query query = IntPoint.newRangeQuery("price", 100, 1000);
        Directory directory = FSDirectory.open(Paths.get("e:/dir"));
        IndexReader indexReader = DirectoryReader.open(directory);
        IndexSearcher indexSearcher = new IndexSearcher(indexReader);
        TopDocs topDocs = indexSearcher.search(query, 10);
        System.out.println("查询到的数据总条数:" + topDocs.totalHits + "条!");
        ScoreDoc []scoreDocs = topDocs.scoreDocs;
        for(ScoreDoc doc:scoreDocs) {
            int docId = doc.doc;
            Document document = indexSearcher.doc(docId);
            System.out.println("----------------------------------");
            System.out.println("id:" + document.get("id"));
            System.out.println("name:" + document.get("name"));
            System.out.println("price:" + document.get("price"));
            System.out.println("image:" + document.get("image"));
            System.out.println("brandName:" + document.get("brandName"));
            System.out.println("categoryName:" + document.get("categoryName"));
        }
        indexReader.close();
    }

执行这个方法,控制台:
在这里插入图片描述
(3)组合查询

需求描述 : 查询价格大于等于100,小于等于1000,并且名称中不包含华为手机关键字的商品。
BooleanClause.Occur.MUST 必须,相当于and,并且。
BooleanClause.Occur.MUST_NOT 不必须,相当于not,非。
BooleanClause.Occur.SHOULD 应该,相当于or,或者。
注意 : 如果逻辑条件中, 只有MUST_NOT,或者多个逻辑条件都是MUST_NOT,无效,查询不出任何数据。

在TestSearch中添加以下方法:

    //测试组合查询
    @Test
    public void testBooleanSearch() throws ParseException, IOException {
        //基于数值范围的查询对象
        Query query = IntPoint.newRangeQuery("price", 100, 1000);
        Analyzer analyzer =  new StandardAnalyzer();
        //对象解析器
        QueryParser queryParser = new QueryParser("name",analyzer);
        //文本查询对象
        Query query2 = queryParser.parse("华为手机");
        //创建组合查询对象
        BooleanQuery.Builder builder = new BooleanQuery.Builder();
        //将两个查询对象添加到组合查询对象中
        builder.add(new BooleanClause(query,BooleanClause.Occur.MUST));
        builder.add(new BooleanClause(query2,BooleanClause.Occur.MUST));
        Directory directory = FSDirectory.open(Paths.get("e:/dir"));
        IndexReader reader = DirectoryReader.open(directory);
        IndexSearcher searcher = new IndexSearcher(reader);
        TopDocs topDocs = searcher.search(builder.build(), 10);
        System.out.println("查询的结果一共:" + topDocs.totalHits + "条!");
        ScoreDoc []docs = topDocs.scoreDocs;
        for(ScoreDoc doc:docs) {
            int docId = doc.doc;
            Document document = searcher.doc(docId);
            System.out.println("----------------------------------");
            System.out.println("id:" + document.get("id"));
            System.out.println("name:" + document.get("name"));
            System.out.println("price:" + document.get("price"));
            System.out.println("image:" + document.get("image"));
            System.out.println("brandName:" + document.get("brandName"));
            System.out.println("categoryName:" + document.get("categoryName"));
        }
        reader.close();
    }

执行这个方法,控制台:
在这里插入图片描述
可以看到总记录数只有243条。

9、搜索案例

(1)pom依赖

已经全部加入了,这里就不再贴了。

(2)静态资源准备
在这里插入图片描述
(3)工程搭建完善

添加一个controller包,在一级包下创建启动类:

@SpringBootApplication
public class LuceneTestApplication {
    
    public static void main(String[] args) {
        SpringApplication.run(LuceneTestApplication.class,args);
    }

}

yml的配置如下:

server:
  port: 8888
  
spring:
  application:
    name: lucene-test
  thymeleaf:
    cache: false

(4)业务代码

  • pojo封装

在pojo包下创建一个封装类:

public class ResultModel {
    
    //商品列表
    private List<Sku> skuList;
    
    //商品总数
    private Long recordCount;
    
    //总页数
    private Integer pageCount;
    
    //当前页码
    private Integer curPage;

    
    public List<Sku> getSkuList() {
        return skuList;
    }

    
    public void setSkuList(List<Sku> skuList) {
        this.skuList = skuList;
    }

    
    public Long getRecordCount() {
        return recordCount;
    }

    
    public void setRecordCount(Long recordCount) {
        this.recordCount = recordCount;
    }

    
    public Integer getPageCount() {
        return pageCount;
    }

    
    public void setPageCount(Integer pageCount) {
        this.pageCount = pageCount;
    }

    
    public Integer getCurPage() {
        return curPage;
    }

    
    public void setCurPage(Integer curPage) {
        this.curPage = curPage;
    }

    
}
  • Service和实现类

新建一个service包,有以下接口:

public interface SearchService {
    
    ResultModel search(String queryString,String price,Integer page) throws Exception;;

}

实现类如下:

@Service
public class SearchServiceImpl implements SearchService {
    
    // 每页的查询数量
    private final static Integer PAGE_SIZE = 20;

    @Override
    public ResultModel search(String queryString, String price, Integer page) 
            throws Exception {
        // 对象封装模型
        ResultModel resultModel = new ResultModel();
        // 从第几条开始查询
        int start = (page - 1) * PAGE_SIZE;
        // 查询到第几条
        int end = page * PAGE_SIZE;
        // 分词器,使用IK分词器
        Analyzer analyzer = new IKAnalyzer();
        // 组合查询对象
        BooleanQuery.Builder builder = new BooleanQuery.Builder();
        // 查询对象解析器
        QueryParser queryParser = new QueryParser("name", analyzer);
        Query query1 = null;
        // 判断关键字是否为空
        if (StringUtils.isEmpty(queryString)) {
            // 为空查询所有
            query1 = queryParser.parse("*:*");
        } else {
            // 不为空按关键字查询
            query1 = queryParser.parse(queryString);
        }
        // 封装组合查询对象
        builder.add(new BooleanClause(query1, BooleanClause.Occur.MUST));
        // 判断价格范围是否为空
        if (!StringUtils.isEmpty(price)) {
            // 字符串分割
            String[] str = price.split("-");
            // 按价格设置查询对象
            Query query2 = IntPoint.newRangeQuery("price", Integer.parseInt(str[0]), Integer.parseInt(str[1]));
            // 添加组合对象
            builder.add(new BooleanClause(query2, BooleanClause.Occur.MUST));
        }
        // 索引库位置
        Directory directory = FSDirectory.open(Paths.get("e:/dir"));
        // 流对象
        IndexReader reader = DirectoryReader.open(directory);
        // 搜索对象
        IndexSearcher searcher = new IndexSearcher(reader);
        // 查询
        TopDocs topDocs = searcher.search(builder.build(), end);
        System.out.println("查询的总条数是:" + topDocs.totalHits);
        resultModel.setRecordCount(topDocs.totalHits);
        // 获取查询的结果集
        ScoreDoc[] sDocs = topDocs.scoreDocs;
        List<Sku> list = new ArrayList<>();
        if (sDocs != null) {
            for (int i = start; i < end; i++) {
                //通过编号获取文档
                Document document = reader.document(sDocs[i].doc);
                //封装成Sku对象
                Sku sku = new Sku();
                sku.setId(document.get("id"));
                sku.setName(document.get("name"));
                sku.setPrice(Integer.parseInt(document.get("price")));
                sku.setImage(document.get("image"));
                sku.setBrandName(document.get("brandName"));
                sku.setCategoryName(document.get("categoryName"));
                //添加
                list.add(sku);
            }
        }
        //封装查询到的结果集
        resultModel.setSkuList(list);
        resultModel.setCurPage(page);
        Integer pageTotal = (int) (topDocs.totalHits % PAGE_SIZE == 0
                ?(topDocs.totalHits / PAGE_SIZE):(topDocs.totalHits / PAGE_SIZE)+1);
        resultModel.setPageCount(pageTotal);
        return resultModel;
    }

}
  • controller

在controller包下新建SearchController,如下:

@Controller
@RequestMapping("/list")
public class SearchController {
    
    @Autowired
    SearchService searchService;
    
    @RequestMapping
    public String query(String queryString,String price,Integer page,Model model)throws Exception{
        //处理当前页面
        if(StringUtils.isEmpty(page)) {
            page = 1;
        }
        if(page <= 0) {
            page = 1;
        }
        //查询
        ResultModel result = searchService.search(queryString, price, page);
        //添加数据进model模型
        model.addAttribute("result", result);
        model.addAttribute("queryString", queryString);
        model.addAttribute("price", price);
        model.addAttribute("page", page);
        return "search";
    }

}

然后启动工程进行测试,访问localhost:8888/list:
在这里插入图片描述
输入手机然后点击搜索:
在这里插入图片描述
选择价格区间0-500,再搜索:
在这里插入图片描述
点击下一页:
在这里插入图片描述
OK,那么这个简单的搜索案例就完成了。

10、Lucene底层储存结构

(1)lucene存储结构

存储结构如下图:
在这里插入图片描述
说明如下:

索引 (Index):

  • 一个目录一个索引,在Lucene中一个索引是放在一个文件夹中的,这里所说的索引可以理解为索引库,是名词。

段(Segment) :

  • 一个索引 (逻辑索引)由多个段组成,多个段可以合并,以减少读取内容时候的磁盘IO。
  • Lucene中的数据写入会先写内存的一个Buffer,当Buffer内数据到一定量后会被flush成一个Segment,每个Segment有自己独立的索引,可独立被查询,但数据永远不能被更改。这种模式避免了随机写,数据写入都是批量追加,能达到很高的吞吐量。Segment中写入的文档不可被修改,但可被删除,删除的方式也不是在文件内部原地更改,而是会由另外一个文件保存需要被删除的文档的DocID,保证数据文件不可被修改。Index的查询需要对多个Segment进行查询并对结果进行合并,还需要处理被删除的文档,为了对查询进行优化,Lucene会有策略对多个Segment进行合并。

文档(Document) :

  • 文档是我们建索引的基本单位,不同的文档是保存在不同的段中的,一个段可以包含多篇文档。
  • 新添加的文档是单独保存在一个新生成的段中,随着段的合并,不同的文档合并到同一个段中。

域(Field) :

  • 一篇文档包含不同类型的信息,可以分开索引,比如标题,时间,正文,描述等,都可以保存在不同的域里。
  • 不同域的索引方式可以不同。
  • 词是索引的最小单位,是经过词法分析和语言处理后的字符串。

(2)索引库物理文件

前面所创建的索引都在dir文件夹里,dir中内容如下:
在这里插入图片描述

(3)索引库文件扩展名对照表
在这里插入图片描述
在这里插入图片描述
(4) 词典的构建

为何Lucene大数据量搜索快, 要分两部分来看 :

  • 因为底层的倒排索引存储结构。
  • 查询关键字的时候速度快 ,因为词典的索引结构。

a、词典数据结构对比

倒排索引中的词典位于内存,其结构尤为重要,有很多种词典结构,各有各的优缺点,最简单如排序数组,通过二分查找来检索数据,更快的有哈希表,磁盘查找有B树、B+树,但一个能支持TB级数据的倒排索引结构需要在时间和空间上有个平衡,下表列了一些常见词典的优缺点:
在这里插入图片描述
Lucene3.0之前使用的也是跳跃表结构,后换成了FST,但跳跃表在Lucene其他地方还有应用如倒排表合并和文档号索引。

b、跳跃表原理

Lucene3.0版本之前使用的跳跃表结构,之后换成了FST结构。

  • 优点:结构简单、跳跃间隔、级数可控,Lucene3.0之前使用的也是跳跃表结构,但跳跃表在Lucene其他地方还有应用如倒排表合并和文档号索引。
  • 缺点:模糊查询支持不好。

先了解单链表:

单链表中查询一个元素即使是有序的,我们也不能通过二分查找法的方式缩减查询时间。通俗的讲也就是按照链表顺序一个一个找。

如下图:
在这里插入图片描述
查找85这个节点, 需要查找7次。

再来看跳跃表:
在这里插入图片描述

现在我要查询30这个数。过程如下:

  • 在level3层,查询3次,查询到1结尾,退回到37节点。
  • 在level2层,从37节点开始查询,查询2次,查询到1结尾,退回到71节点。
  • 在level1层,从71节点开始查询,查询1次,查询到85节点。

也就是说只需要查询6次。明显看到这种跳跃表要比单链表的效率高。

c、 FST原理

已知FST要求输入有序,所以Lucene会将解析出来的文档单词预先排序,然后构建FST,我们假设输入为abd,abe,acf,acg,那么整个构建过程如下:
在这里插入图片描述
在这里插入图片描述

11、Lucene的优化

(1)解决磁盘I/O

  • setMaxBufferedDocs:控制写入一个新的segment前内存中保存的document的数目,设置较大的数目可以加快建索引速度。数值越大索引速度越快, 但是会消耗更多的内存
  • forceMerge:设置N个文档合并为一个段。数值越大索引速度越快,搜索速度越慢;值越小索引速度越慢,搜索速度越快。更高的值意味着索引期间更低的段合并开销,但同时也意味着更慢的搜索速度,因为此时的索引通常会包含更多的段。如果该值设置的过高,能获得更高的索引性能。但若在最后进行索引优化,那么较低的值会带来更快的搜索速度,因为在索引操作期间程序会利用并发机制完成段合并操作。故建议对程序分别进行高低多种值的测试,利用计算机的实际性能来告诉你最优值。

下面通过代码来进行测试:

    //测试未优化前创建索引库的速度
    @Test
    public void test() throws  Exception{
        //数据采集
        SkuDao skuDao = new SkuDaoImpl();
        List<Sku> list = skuDao.querySkuAll();
        //文档容器
        List<Document> documents = new ArrayList<>();
        //遍历
        for(Sku sku:list) {
            //文档对象
            Document doc = new Document();
            //为文档添加域
            doc.add(new StringField("id", sku.getId(),Field.Store.YES));
            doc.add(new TextField("name", sku.getName(),Field.Store.YES));
            doc.add(new IntPoint("price", sku.getPrice()));
            doc.add(new StoredField("price", sku.getPrice()));
            doc.add(new StoredField("image", sku.getImage()));
            doc.add(new StringField("categoryName", sku.getCategoryName(),Field.Store.YES));
            doc.add(new StringField("brandName", sku.getBrandName(),Field.Store.YES));
            documents.add(doc);
        }
        Long start = System.currentTimeMillis();
        Analyzer analyzer = new StandardAnalyzer();
        Directory directory = FSDirectory.open(Paths.get("e:/dir"));
        IndexWriterConfig indexWriterConfig = new IndexWriterConfig(analyzer);
        IndexWriter indexWriter = new IndexWriter(directory, indexWriterConfig);
        for(Document doc:documents) {
            indexWriter.addDocument(doc);
        }
        Long end = System.currentTimeMillis();
        System.out.println("===============创建时间===========" + (end - start) + "ms");
        indexWriter.close();
        System.out.println("索引库创建完成!");
    }

执行这个方法,控制台输出:
在这里插入图片描述
然后测试使用setMaxBufferedDocs方法限制内存最大文档个数:

    //测试优化后创建索引库的速度
    @Test
    public void test2() throws  Exception{
        //数据采集
        SkuDao skuDao = new SkuDaoImpl();
        List<Sku> list = skuDao.querySkuAll();
        //文档容器
        List<Document> documents = new ArrayList<>();
        //遍历
        for(Sku sku:list) {
            //文档对象
            Document doc = new Document();
            //为文档添加域
            doc.add(new StringField("id", sku.getId(),Field.Store.YES));
            doc.add(new TextField("name", sku.getName(),Field.Store.YES));
            doc.add(new IntPoint("price", sku.getPrice()));
            doc.add(new StoredField("price", sku.getPrice()));
            doc.add(new StoredField("image", sku.getImage()));
            doc.add(new StringField("categoryName", sku.getCategoryName(),Field.Store.YES));
            doc.add(new StringField("brandName", sku.getBrandName(),Field.Store.YES));
            documents.add(doc);
        }
        Long start = System.currentTimeMillis();
        Analyzer analyzer = new StandardAnalyzer();
        Directory directory = FSDirectory.open(Paths.get("e:/dir"));
        IndexWriterConfig indexWriterConfig = new IndexWriterConfig(analyzer);
        //设置内存中文档达到某个值时向磁盘写入一次
        //设置过大会消耗内存,但能提高写入速度
        indexWriterConfig.setMaxBufferedDocs(100000);
        IndexWriter indexWriter = new IndexWriter(directory, indexWriterConfig);
        for(Document doc:documents) {
            indexWriter.addDocument(doc);
        }
        indexWriter.close();
        Long end = System.currentTimeMillis();
        System.out.println("===============创建时间===========" + (end - start) + "ms");
        System.out.println("索引库创建完成!");
    }

执行这个方法,控制台:
在这里插入图片描述
再测试forceMerge设置段的最大文件个数:

    //测试优化后创建索引库的速度
    @Test
    public void test2() throws  Exception{
        //数据采集
        SkuDao skuDao = new SkuDaoImpl();
        List<Sku> list = skuDao.querySkuAll();
        //文档容器
        List<Document> documents = new ArrayList<>();
        //遍历
        for(Sku sku:list) {
            //文档对象
            Document doc = new Document();
            //为文档添加域
            doc.add(new StringField("id", sku.getId(),Field.Store.YES));
            doc.add(new TextField("name", sku.getName(),Field.Store.YES));
            doc.add(new IntPoint("price", sku.getPrice()));
            doc.add(new StoredField("price", sku.getPrice()));
            doc.add(new StoredField("image", sku.getImage()));
            doc.add(new StringField("categoryName", sku.getCategoryName(),Field.Store.YES));
            doc.add(new StringField("brandName", sku.getBrandName(),Field.Store.YES));
            documents.add(doc);
        }
        Long start = System.currentTimeMillis();
        Analyzer analyzer = new StandardAnalyzer();
        Directory directory = FSDirectory.open(Paths.get("e:/dir"));
        IndexWriterConfig indexWriterConfig = new IndexWriterConfig(analyzer);
        //设置内存中文档达到某个值时向磁盘写入一次
        //设置过大会消耗内存,但能提高写入速度
        //indexWriterConfig.setMaxBufferedDocs(100000);
        IndexWriter indexWriter = new IndexWriter(directory, indexWriterConfig);
        //设置每个段文件的最大文档数
        //数值越大,索引速度越快,搜索速度越慢
        indexWriter.forceMerge(50000);
        for(Document doc:documents) {
            indexWriter.addDocument(doc);
        }
        indexWriter.close();
        Long end = System.currentTimeMillis();
        System.out.println("===============创建时间===========" + (end - start) + "ms");
        System.out.println("索引库创建完成!");
    }

控制台:
在这里插入图片描述
(2)选择合适的分词器

不同的分词器分词效果不同, 所用时间也不同。
虽然StandardAnalyzer切分词速度快过IKAnalyzer,但是由于StandardAnalyzer对中文支持不好,所以为了追求好的分词效果,为了追求查询时的准确率,也只能用IKAnalyzer分词器,IKAnalyzer支持停用词典和扩展词典,可以通过调整两个词典中的内容,来提升查询匹配的精度。

(3)选择合适的位置存放索引库
在这里插入图片描述
(4)搜索api的选择

  • 尽量使用TermQuery代替QueryParser。
  • 尽量避免大范围的日期查询。

12、Lucene相关度排序

(1)相关度排序的概念

Lucene对查询关键字和索引文档的相关度进行打分,得分高的就排在前边。

(2)如何打分

Lucene是在用户进行检索时实时根据搜索的关键字计算出来的,分两步:

  • 计算出词(Term)的权重。
  • 根据词的权重值,计算文档相关度得分。

(3)词的权重

明确索引的最小单位是一个Term(索引词典中的一个词),搜索也是要从Term中搜索,再根据Term找到文档,Term对文档的重要性称为权重,影响Term权重有两个因素:

  • Term Frequency (tf) : 指此Term在此文档中出现了多少次。tf 越大说明越重要。 词(Term)在文档中出现的次数越多,说明此词(Term)对该文档越重要,如“Lucene”这个词,在文档中出现的次数很多,说明该文档主要就是讲Lucene技术的。
  • Document Frequency (df) : 指有多少文档包含次Term。df 越大说明越不重要。 比如,在一篇英语文档中,this出现的次数更多,就说明越重要吗?不是的,有越多的文档包含此词(Term), 说明此词(Term)太普通,不足以区分这些文档,因而重要性越低。

(4)如何影响相关度排序

boost是一个加权值(默认加权值为1.0f),它可以影响权重的计算。可以通过以下两种行为来影响相关度的排序:

  • 在索引时对某个文档中的field设置加权值高,在搜索时匹配到这个文档就可能排在前边。
  • 在搜索时对某个域进行加权,在进行组合域查询时,匹配到加权值高的域最后计算的相关度得分就高。

设置boost是给域(field)或者Document设置的。

下面以代码来进行测试,在TestSearch中添加以下方法:

    //测试相关度排序
    @Test
    public void testSearchSort() throws ParseException, IOException {
        Analyzer analyzer = new IKAnalyzer();
        //查询的域名
        String []fields = {"name","brandName","categoryName"};
        //设置权重
        Map<String,Float> boost = new HashMap<>();
        //给categoryName的权重设为1000000,默认是1
        boost.put("categoryName", 10000000f);
        //根据多个域进行搜索
        MultiFieldQueryParser multiFieldQueryParser = new MultiFieldQueryParser(
                fields, analyzer,boost);
        //搜索对象
        Query query = multiFieldQueryParser.parse("手机");
        Directory directory = FSDirectory.open(Paths.get("e:/dir"));
        IndexReader reader = DirectoryReader.open(directory);
        IndexSearcher searcher = new IndexSearcher(reader);
        TopDocs topDocs = searcher.search(query, 10);
        System.out.println("查询的结果一共:" + topDocs.totalHits + "条!");
        ScoreDoc []docs = topDocs.scoreDocs;
        for(ScoreDoc doc:docs) {
            int docId = doc.doc;
            Document document = searcher.doc(docId);
            System.out.println("----------------------------------");
            System.out.println("id:" + document.get("id"));
            System.out.println("name:" + document.get("name"));
            System.out.println("price:" + document.get("price"));
            System.out.println("image:" + document.get("image"));
            System.out.println("brandName:" + document.get("brandName"));
            System.out.println("categoryName:" + document.get("categoryName"));
        }
        reader.close();
    }

执行这个方法,控制台:
在这里插入图片描述
可以看到categoryName此时的相关度是最高,优先匹配categoryName域。

13、Lucene使用注意事项

  • 关键词区分大小写:OR、AND、TO等关键词是区分大小写的,lucene只认大写的,小写的当做普通单词。
  • 读写互斥性:同一时刻只能有一个对索引的写操作,在写的同时可以进行搜索。
  • 文件锁:在写索引的过程中强行退出将在tmp目录留下一个lock文件,使以后的写操作无法进行,可以将其手工删除。
  • 时间格式 :lucene只支持一种时间格式yyMMddHHmmss,所以你传一个yy-MM-dd HH:mm:ss的时间给lucene它是不会当作时间来处理的。
  • 设置boost:有些时候在搜索时某个字段的权重需要大一些,例如你可能认为标题中出现关键词的文章比正文中出现关键词的文章更有价值,你可以把标题的boost设置的更大,那么搜索结果会优先显示标题中出现关键词的文章。

14、总结

对lucene有了一个简单的认识,知道它是用来干什么的,以及了解了lucene的简单使用,比如使用Lucene原生API创建索引库、修改索引、搜索等,对于海量数据的读写用数据库肯定是不行的,那么建立索引库是个不错的选择,搜索速度非常快。

  • 2
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值