1. 搜索技术理论基础
1.1. 为什么要学习Lucene
原来的⽅式实现搜索功能,我们的搜索流程如下图:
上图就是原始搜索引擎技术,如果⽤户⽐较少⽽且数据库的数据量⽐较⼩,那么这种⽅式实现搜索功能在企业中是⽐较常⻅的。但是数据量过多时,数据库的压⼒就会变得很⼤,查询速度会变得⾮常慢。我们需要使⽤更好的解决⽅案来分担数据库的压⼒。
现在的⽅案(使⽤Lucene),如下图
为了解决数据库压⼒和速度的问题,我们的数据库就变成了索引库,我们使⽤Lucene的API的
来操作服务器上的索引库。这样完全和数据库进⾏了隔离。
1.2. 数据查询⽅法
1.2.1. 顺序扫描法
算法描述:
所谓顺序扫描,例如要找内容包含⼀个字符串的⽂件,就是⼀个⽂档⼀个⽂档的看,对于每⼀个⽂档,从头看到尾,如果此⽂档包含此字符串,则此⽂档为我们要找的⽂件,接着看下⼀个⽂件,直到扫描完所有的⽂件。
优点:
查询准确率⾼
缺点:
查询速度会随着查询数据量的增⼤, 越来越慢
使⽤场景:
数据库中的like关键字模糊查询
⽂本编辑器的Ctrl + F 查询功能
1.2.2. 倒排索引
先举⼀个栗⼦:
例如我们使⽤新华字典查询汉字,新华字典有偏旁部⾸的⽬录(索引),我们查字⾸先查这个⽬录,找到这个⽬录中对应的偏旁部⾸,就可以通过这个⽬录中的偏旁部⾸找到这个字所在的位置(⽂档)。
Lucene会对⽂档建⽴倒排索引
1、 提取资源中关键信息, 建⽴索引 (⽬录)
2、 搜索时,根据关键字(⽬录),找到资源的位置
算法描述:
查询前会先将查询的内容提取出来组成⽂档(正⽂), 对⽂档进⾏切分词组成索引(⽬录), 索引和⽂档有关联关系, 查询的时候先查询索引, 通过索引找⽂档的这个过程叫做全⽂检索。
切分词 : 就是将⼀句⼀句话切分成⼀个⼀个的词, 去掉停⽤词(的, 地, 得, a, an, the等)。去掉空格, 去掉标点符号, ⼤写字⺟转成⼩写字⺟, 去掉重复的词。
为什么倒排索引⽐顺序扫描快?
理解 : 因为索引可以去掉重复的词, 汉语常⽤的字和词⼤概等于, 字典加词典, 常⽤的英⽂在⽜津词典也有收录.如果⽤计算机的速度查询, 字典+词典+⽜津词典这些内容是⾮常快的. 但是⽤这些字典, 词典组成的⽂章却是千千万万不计其数. 索引的⼤⼩最多也就是字典+词典. 所以通过查询索引, 再通过索引和⽂档的关联关系找到⽂档速度⽐较快. 顺序扫描法则是直接去逐个查询那些不计其数的⽂章就算是计算的速度也会很慢.
优点:
查询准确率⾼
查询速度快, 并且不会因为查询内容量的增加, ⽽使查询速度逐渐变慢
缺点:
索引⽂件会占⽤额外的磁盘空间, 也就是占⽤磁盘量会增⼤。
使⽤场景:
海量数据查询, pb级数据查询
1.3. 全⽂检索技术应⽤场景
应⽤场景 :
1、 站内搜索 (baidu贴吧、论坛、 京东、 taobao)
2、 垂直领域的搜索 (818⼯作⽹)
3、 专业搜索引擎公司 (google、baidu)
2. Lucene介绍
2.1. 什么是全⽂检索
计算机索引程序通过扫描⽂章中的每⼀个词,对每⼀个词建⽴⼀个索引,指明该词在⽂章中出现的次数和位置,当⽤户查询时,检索程序就根据事先建⽴的索引进⾏查找,并将查找的结果反馈给⽤户的检索⽅式
2.2. 什么是Lucene
Lucene是apache软件基⾦会4 jakarta项⽬组的⼀个⼦项⽬,是⼀个开放源代码的全⽂检索引擎⼯具包,但它不是⼀个完整的全⽂检索引擎,⽽是⼀个全⽂检索引擎的架构,提供了完整的查询引擎和索引引擎,部分⽂本分析引擎(英⽂与德⽂两种⻄⽅语⾔)。
Lucene的⽬的是为软件开发⼈员提供⼀个简单易⽤的⼯具包,以⽅便的在⽬标系统中实现全⽂检索的功能,或者是以此为基础建⽴起完整的全⽂检索引擎。
⽬前已经有很多应⽤程序的搜索功能是基于 Lucene 的,⽐如 Eclipse 的帮助系统的搜索功能。Lucene 能够为⽂本类型的数据建⽴索引,所以你只要能把你要索引的数据格式转化的⽂本的,Lucene 就能对你的⽂档进⾏索引和搜索。⽐如你要对⼀些 HTML ⽂档,PDF ⽂档进⾏索引的话你就⾸先需要把 HTML ⽂档和 PDF ⽂档转化成⽂本格式的,然后将转化后的内容交给 Lucene 进⾏索引,然后把创建好的索引⽂件保存到磁盘或者内存中,最后根据⽤户输⼊的查询条件在索引⽂件上进⾏查询。不指定要索引的⽂档的格式也使 Lucene 能够⼏乎适⽤于所有的搜索应⽤程序。
3. Lucene全⽂检索的流程
3.1. 索引和搜索流程图
1、绿⾊表示索引过程,对要搜索的原始内容进⾏索引构建⼀个索引库,索引过程包括:
确定原始内容即要搜索的内容
获得⽂档
创建⽂档
分析⽂档
索引⽂档
2、红⾊表示搜索过程,从索引库中搜索内容,搜索过程包括:
⽤户通过搜索界⾯
创建查询
执⾏搜索,从索引库搜索
渲染搜索结果
3.2. 索引流程
对⽂档索引的过程,将⽤户要搜索的⽂档内容进⾏索引,索引存储在索引库(index)中。
3.2.1. 原始内容
原始内容是指要索引和搜索的内容。
原始内容包括互联⽹上的⽹⻚、数据库中的数据、磁盘上的⽂件等。
3.2.2. 获得⽂档(采集数据)
从互联⽹上、数据库、⽂件系统中等获取需要搜索的原始信息,这个过程就是信息采集,采集数据的⽬的是为了对原始内容进⾏索引。
采集数据分类:
1、对于互联⽹上⽹⻚,可以使⽤⼯具将⽹⻚抓取到本地⽣成html⽂件。
2、数据库中的数据,可以直接连接数据库读取表中的数据。
3、⽂件系统中的某个⽂件,可以通过I/O操作读取⽂件的内容。
在Internet上采集信息的软件通常称为爬⾍或蜘蛛,也称为⽹络机器⼈,爬⾍访问互联⽹上的每⼀个⽹⻚,将获取到的⽹⻚内容存储起来。
3.2.3. 创建⽂档
获取原始内容的⽬的是为了索引,在索引前需要将原始内容创建成⽂档(Document),⽂档中包括⼀个⼀个的域(Field),域中存储内容。这⾥我们可以将磁盘上的⼀个⽂件当成⼀个document,Document中包括⼀些Field,如下图:
注意:每个Document可以有多个Field,不同的Document可以有不同的Field,同⼀个Document可以有相同的Field(域名和域值都相同)
3.2.4. 分析⽂档
将原始内容创建为包含域(Field)的⽂档(document),需要再对域中的内容进⾏分析,分析成为⼀个⼀个的单词。⽐如下边的⽂档经过分析如下:
原⽂档内容:
vivo Z3 6GB+64GB 极光蓝 性能实⼒派 全⾯屏游戏⼿机 移动联通电信全⽹通4G⼿机
华为 HUAWEI 畅享9 Plus 4GB+64GB 幻夜⿊ 全⽹通 四摄超清全⾯屏⼤电池 移动联通电信4G⼿机 双卡双待
分析后得到的词:vivo, Z3, 6GB, 64GB, 极光, 极光蓝, 全⽹, 全⽹通, ⽹通, 4G, ⼿机, 华为, HUAWEI, 畅享9 。。。。
3.2.5. 索引⽂档
对所有⽂档分析得出的语汇单元进⾏索引,索引的⽬的是为了搜索,最终要实现只搜索被索引的语汇单元从⽽找到Document(⽂档)。
创建索引是对语汇单元索引,通过词语找⽂档,这种索引的结构叫倒排索引结构。
倒排索引结构是根据内容(词汇)找⽂档,如下图:
3.2.6 Lucene底层存储结构
4. Lucene⼊⻔
4.1. 开发环境
mysql文件去千锋要,navicat先新建表,然后导入sqsl文件即可
4.2. 创建Java⼯程
配置文件(其中ik见另一篇博客–maven 导本地包)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.nan</groupId>
<artifactId>luceneDemo</artifactId>
<version>1.0-SNAPSHOT</version>
<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>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.4.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.6</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>8.11.1</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analyzers-common</artifactId>
<version>8.11.1</version>
<exclusions>
<exclusion>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-queryparser</artifactId>
<version>8.11.1</version>
<exclusions>
<exclusion>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-backward-codecs</artifactId>
<version>8.11.1</version>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
<!-- mysql数据库驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>5.1.48</version>
</dependency>
<!-- IK中⽂分词器 -->
<dependency>
<groupId>com.nan</groupId>
<artifactId>luceneDemo</artifactId>
<version>8.5.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>
</dependencies>
</project>
提前创建索引库的文件夹(在我的D盘)
查询过程
4.3. 索引流程
4.3.1. 数据采集
在电商⽹站中,全⽂检索的数据源在数据库中,需要通过jdbc访问数据库中 sku 表的内容。
4.3.1.1. 创建pojo
用lombok或者直接生成get/set,在生成一个tostring
public class Sku {
//商品主键id
private String 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;
}
4.3.1.2. 创建DAO接⼝
public interface SkuDao {
/**
* 查询所有的Sku数据
* @return
**/
public List<Sku> querySkuList();
}
4.3.1.3. 创建DAO接⼝实现类
使⽤jdbc实现
public class SkuDaoImpl implements SkuDao{
@Override
public List<Sku> querySkuList() {
// 数据库链接
Connection connection = null;
// 预编译statement
PreparedStatement preparedStatement = null;
// 结果集
ResultSet resultSet = null;
// 商品列表
List<Sku> list = new ArrayList<Sku>();
try {
// 加载数据库驱动
Class.forName("com.mysql.jdbc.Driver");
// 连接数据库
connection =
(Connection) DriverManager.getConnection("jdbc:mysql://localhost:3306/你的表名?serverTimezone=GMT%2b8","你的用户名", "你的密码");
// SQL语句
String sql = "SELECT * FROM tb_sku";
// 创建preparedStatement
preparedStatement = (PreparedStatement) connection.prepareStatement(sql);
// 获取结果集
resultSet = preparedStatement.executeQuery();
// 结果集解析
while (resultSet.next()) {
Sku sku = new Sku();
sku.setId(resultSet.getString("id"));
sku.setName(resultSet.getString("name"));
sku.setSpec(resultSet.getString("spec"));
sku.setBrandName(resultSet.getString("brand_name"));
sku.setCategoryName(resultSet.getString("category_name"));
sku.setImage(resultSet.getString("image"));
sku.setNum(resultSet.getInt("num"));
sku.setPrice(resultSet.getInt("price"));
sku.setSaleNum(resultSet.getInt("sale_num"));
list.add(sku);
}
} catch (Exception e) {
e.printStackTrace();
}
return list;
}
}
4.3.2. 实现索引流程(此处代码已经是修改域后的,即完全符合数据库的)
- 采集数据
- 创建Document⽂档对象
- 创建分析器(分词器)
- 创建IndexWriterConfig配置信息类
- 创建Directory对象,声明索引库存储位置
- 创建IndexWriter写⼊对象
- 把Document写⼊到索引库中
- 释放资源
第三步注意用IK分词器,因为数据库内的是中文。
public class TestManager {
@Test
public void createIndexTest() throws Exception {
// 1. 采集数据
SkuDao skuDao = new SkuDaoImpl();
List<Sku> skuList = skuDao.querySkuList();
// 2. 创建Document⽂档对象
List<Document> documents = new ArrayList<Document>();
for (Sku sku : skuList) {
Document document = new Document();
// 向Document⽂档中添加Field域,参数:域名,域值,是否存储,Store.YES:表示存储到⽂档域中
// 商品Id, 不分词,索引,存储
document.add(new StringField("id", sku.getId(), Field.Store.YES));
// 商品名称, 分词, 索引, 存储
document.add(new TextField("name", sku.getName(), Field.Store.YES));
// 商品价格, 分词,索引,不存储, 不排序
document.add(new IntPoint("price", sku.getPrice()));
//添加价格存储⽀持
document.add(new StoredField("price", sku.getPrice()));
// 图⽚地址, 不分词,不索引,存储
document.add(new StringField("image", sku.getImage(), Field.Store.YES));
// 分类名称, 不分词, 索引, 存储
document.add(new StringField("categoryName", sku.getCategoryName(), Field.Store.YES));
// 品牌名称, 不分词, 索引, 存储
document.add(new StringField("brandName", sku.getBrandName(), Field.Store.YES));
// 把Document放到list中
documents.add(document);
}
// 3. 创建Analyzer分词器,分析⽂档,对⽂档进⾏分词
Analyzer analyzer = new IKAnalyzer();
// 4. 创建Directory对象,声明索引库的位置
//FSDirectory file system Directory 会将数据存储到硬盘中
Directory directory = FSDirectory.open(Paths.get("D:\\luceneDir"));
// 5. 创建IndexWriteConfig对象,写⼊索引需要的配置
IndexWriterConfig config = new IndexWriterConfig(analyzer);
// 6.创建IndexWriter写⼊对象
IndexWriter indexWriter = new IndexWriter(directory, config);
// 7.写⼊到索引库,要一个一个写,没有一起写的功能,通过IndexWriter添加⽂档对象document
for (Document doc : documents) {
indexWriter.addDocument(doc);
}
// 8.释放资源
indexWriter.close();
}
}
代表成功
4.4. 搜索流程
4.4.1. 输⼊查询语句
Lucene可以通过query对象输⼊查询语句。同数据库的sql⼀样,lucene也有固定的查询语法:最基本的有⽐如:AND, OR, NOT 等(必须⼤写)
举个栗⼦:
⽤户想找⼀个 name 域中包括 ⼿ 或 机 关键字的⽂档。
它对应的查询语句:name:⼿ OR name:机
4.4.1.1. 搜索分词
和索引过程的分词⼀样,这⾥要对⽤户输⼊的关键字进⾏分词,⼀般情况索引和搜索使⽤的分词器⼀致。
⽐如:输⼊搜索关键字“java学习”,分词后为java和学习两个词,与java和学习有关的内容都搜索出来了,如下:
4.4.2. 代码实现
- 创建Query搜索对象
- 创建Directory流对象,声明索引库位置
- 创建索引读取对象IndexReader
- 创建索引搜索对象IndexSearcher
- 使⽤索引搜索对象,执⾏搜索,返回结果集TopDocs
- 解析结果集
- 释放资源
IndexSearcher搜索⽅法如下:
代码实现(此处要用ik,搜素与之前索引时对应)
public class TestSearch {
@Test
public void testIndexSearch() throws Exception {
// 1. 创建Query搜索对象
// 创建分词器,搜词用不上,但你要敲了一个句子,搜句子的话,句子要进行切分词,在跟索引进行对比,查询
Analyzer analyzer = new IKAnalyzer();
// 创建搜索解析器,第⼀个参数:默认Field域,第⼆个参数:分词器
QueryParser queryParser = new QueryParser("brandName", analyzer);
// 创建搜索对象
Query query = queryParser.parse("华为手机");
// 2. 创建Directory流对象,声明索引库位置
Directory directory = FSDirectory.open(Paths.get("D:\\luceneDir"));
// 3. 创建索引读取对象IndexReader
IndexReader reader = DirectoryReader.open(directory);
// 4. 创建索引搜索对象
IndexSearcher searcher = new IndexSearcher(reader);
// 5. 使⽤索引搜索对象,执⾏搜索,返回结果集TopDocs
// 第⼀个参数:搜索对象,第⼆个参数:返回的数据条数,指定查询结果最顶部的n条数据返回
TopDocs topDocs = searcher.search(query, 10);
System.out.println("查询到的数据总条数是:" + topDocs.totalHits.value);
// 获取查询结果集
ScoreDoc[] docs = topDocs.scoreDocs;
// 6. 解析结果集
for (ScoreDoc scoreDoc : docs) {
// 获取⽂档的唯一id
int docID = scoreDoc.doc;
//根据文档id获取文档内容
Document doc = searcher.doc(docID);
System.out.println("=============================");
System.out.println("docID:" + docID);
System.out.println("id:" + doc.get("id"));
System.out.println("name:" + doc.get("name"));
System.out.println("price:" + doc.get("price"));
System.out.println("brandName:" + doc.get("brandName"));
System.out.println("image:" + doc.get("image"));
}
// 7. 释放资源
reader.close();
}
}
5. Field常用类型
5.1. Field属性
Field是⽂档中的域,包括Field名和Field值两部分,⼀个⽂档可以包括多个Field,Document只是Field的⼀个承载体,Field值即为要索引的内容,也是要搜索的内容。
域名:域值
是否分词(tokenized)
是:作分词处理,即将Field值进⾏分词,分词的⽬的是为了索引。⽐如:商品名称、商品描述等,这些内容⽤户要输⼊关键字搜索,由于搜索的内容格式⼤、内容多需要分词后将语汇单元建⽴索引
否:不作分词处理。⽐如:商品id、订单号、身份证号等
是否索引(indexed)
是:进⾏索引。将Field分词后的词或整个Field值进⾏索引,存储到索引域,索引的⽬的是为了搜索。⽐如:商品名称、商品描述分析后进⾏索引,订单号、身份证号不⽤分词但也要索引,这些将来都要作为查询条件。
否:不索引。⽐如:图⽚路径、⽂件路径等,不⽤作为查询条件的不⽤索引。
是否存储(stored)
是:将Field值存储在⽂档域中,存储在⽂档域中的Field才可以从Document中获取。⽐如:商品名称、订单号,凡是将来要从Document中获取的Field都要存储。
否:不存储Field值。⽐如:商品描述,内容较⼤不⽤存储。如果要向⽤户展示商品描述可以从系统的关系数据库
中获取
5.2. Field常⽤类型
下边列出了开发中常⽤ 的Filed类型,注意Field的属性,根据需求选择:
intpoint–索引+分词,不存储(拿不到文档数据)
storefield–与上面的正好相反,两个一起,起到了索引、分词、存储
所以Stringfield用于存储商品id等不需要分词的,textfield用于存商品名称,描述等大文本类型的。
5.3. Field修改
已完成修改,分析的内容上文就是。
6. 索引维护
6.1. 需求
管理⼈员通过电商系统更改图书信息,这时更新的是关系数据库,如果使⽤lucene搜索图书信息,需要在数据库表book信息变化时及时更新lucene索引库。
6.2. 添加索引
调⽤ indexWriter.addDocument(doc)添加索引。参考⼊⻔程序的创建索引。
6.3. 修改索引
更新索引是先删除再添加,建议对更新需求采⽤此⽅法并且要保证对已存在的索引执⾏更新,可以先查询出来,确定更新记录存在执⾏更新操作。如果更新索引的⽬标⽂档对象不存在,则执⾏添加。
对此条进行更新
代码
@Test
public void testIndexUpdate() throws Exception {
//1. 创建Document,需要变更的内容
Document document = new Document();
document.add(new StringField("id", "21233662915", Field.Store.YES));
document.add(new TextField("name", "八嘎八嘎", Field.Store.YES));
document.add(new StringField("image","你先别急.jpg", Field.Store.YES));
//2. 创建分词器
Analyzer analyzer = new IKAnalyzer();
//3. 创建Directory流对象
Directory directory = FSDirectory.open(Paths.get("D:\\luceneDir"));
// 创建IndexWriteConfig对象,写⼊索引需要的配置
IndexWriterConfig config = new IndexWriterConfig(analyzer);
// 创建写⼊对象
IndexWriter indexWriter = new IndexWriter(directory, config);
// 执⾏更新,会把所有符合条件的Document删除,再新增。
indexWriter.updateDocument(new Term("id", "21233662915"), document);
// 释放资源
indexWriter.close();
}
6.4. 删除索引
6.4.1. 删除指定索引
根据Term项删除索引,满⾜条件的将全部删除。
@Test
public void testIndexDelete() throws Exception {
// 创建分词器
Analyzer analyzer = new IKAnalyzer();
// 创建Directory流对象
Directory directory = FSDirectory.open(Paths.get("D:\\luceneDir"));
// 创建IndexWriteConfig对象,写⼊索引需要的配置
IndexWriterConfig config = new IndexWriterConfig(analyzer);
// 创建输出流对象
IndexWriter indexWriter = new IndexWriter(directory, config);
// 根据Term删除索引库,name:java
indexWriter.deleteDocuments(new Term("id", "21233662915"));
// 释放资源
indexWriter.close();
}
6.4.2. 删除全部索引(慎⽤)
将索引⽬录的索引信息全部删除,直接彻底删除,⽆法恢复。
建议参照关系数据库基于主键删除⽅式,所以在创建索引时需要创建⼀个主键Field,删除时根据此主键Field删除。
索引删除后将放在Lucene的回收站中,Lucene3.X版本可以恢复删除的⽂档,3.X之后⽆法恢复。
// 全部删除
indexWriter.deleteAll();
7. 分词器
7.1. 分词理解
在对Document中的内容进⾏索引之前,需要使⽤分词器进⾏分词 ,分词的⽬的是为了搜索。分词的主要过程就是先分词后过滤。
分词:采集到的数据会存储到document对象的Field域中,分词就是将Document中Field的value值切分成⼀个⼀个的词。
过滤:包括去除标点符号过滤、去除停⽤词过滤(的、是、a、an、the等)、⼤写转⼩写、词的形还原(复数形式转成单数形参、过去式转成现在式。。。)等。
什么是停⽤词?停⽤词是为节省存储空间和提⾼搜索效率,搜索引擎在索引⻚⾯或处理搜索请求时会⾃动忽略某些字或词,这些字或词即被称为Stop Words(停⽤词)。⽐如语⽓助词、副词、介词、连接词等,通常⾃身并⽆明确的意义,只有将其放⼊⼀个完整的句⼦中才有⼀定作⽤,如常⻅的“的”、“在”、“是”、“啊”等。
对于分词来说,不同的语⾔,分词规则不同。Lucene作为⼀个⼯具包提供不同国家的分词器
7.2. Analyzer使⽤时机
7.2.1. 索引时使⽤Analyzer
输⼊关键字进⾏搜索,当需要让该关键字与⽂档域内容所包含的词进⾏匹配时需要对⽂档域内容进⾏分析,需要经过Analyzer分析器处理⽣成语汇单元(Token)。分析器分析的对象是⽂档中的Field域。当Field的属性tokenized(是否分词)为true时会对Field值进⾏分析.
对于⼀些Field可以不⽤分析:
1、不作为查询条件的内容,⽐如⽂件路径
2、不是匹配内容中的词⽽匹配Field的整体内容,⽐如订单号、身份证号等。
7.2.2. 搜索时使⽤Analyzer
对搜索关键字进⾏分析和索引分析⼀样,使⽤Analyzer对搜索关键字进⾏分析、分词处理,使⽤分析后每个词语进⾏搜索。⽐如:搜索关键字:spring web ,经过分析器进⾏分词,得出:spring web拿词去索引词典表查找 ,找到索引链接到Document,解析Document内容。
对于匹配整体Field域的查询可以在搜索时不分析,⽐如根据订单号、身份证号查询等。
若是按照精确匹配,则可能一个也搜不到(这就是为什么之前强调要用ik,且要一样)
注意:搜索使⽤的分析器要和索引使⽤的分析器⼀致。
7.3. Lucene原⽣分词器
以下是Lucene中⾃带的分词器
7.3.1. StandardAnalyzer
特点 :
Lucene提供的标准分词器, 可以对⽤英⽂进⾏分词, 对中⽂是单字分词, 也就是⼀个字就认为是⼀个词.
如下是org.apache.lucene.analysis.standard.standardAnalyzer的部分源码:
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。
⽐如下边的⽂档经过分析器分析如下:
7.3.2. WhitespaceAnalyzer
特点 :
仅仅是去掉了空格,没有其他任何操作,不⽀持中⽂。
7.3.3. SimpleAnalyzer
特点 :
将除了字⺟以外的符号全部去除,并且将所有字⺟变为⼩写,需要注意的是这个分词器同样把数字也去除了,同样不⽀持中⽂。
7.3.4. CJKAnalyzer
特点 :
这个⽀持中⽇韩⽂字,前三个字⺟也就是这三个国家的缩写。对中⽂是⼆分法分词, 去掉空格,去掉标点符号。个⼈感觉对中⽂⽀持依旧很烂
7.4. 第三⽅中⽂分词器
7.4.1. 什么是中⽂分词器
学过英⽂的都知道,英⽂是以单词为单位的,单词与单词之间以空格或者逗号句号隔开。所以对于英⽂,我们可以简单以空格判断某个字符串是否为⼀个单词,⽐如I love China,love 和 China很容易被程序区分开来。
⽽中⽂则以字为单位,字⼜组成词,字和词再组成句⼦。中⽂“我爱中国”就不⼀样了,电脑不知道“中国”是⼀个词语还是“爱中”是⼀个词语。
把中⽂的句⼦切分成有意义的词,就是中⽂分词,也称切词。我爱中国,分词的结果是:我、爱、中国。
7.4.2. 第三⽅中⽂分词器简介
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⽉后没有在更新。
7.4.3. 使⽤中⽂分词器IKAnalyzer
IKAnalyzer继承Lucene的Analyzer抽象类,使⽤IKAnalyzer和Lucene⾃带的分析器⽅法⼀样,将Analyzer测试代码改为IKAnalyzer测试中⽂分词效果。如果使⽤中⽂分词器ik-analyzer,就需要在索引和搜索程序中使⽤⼀致的分词器:IK-analyzer
7.4.4. 扩展中⽂词库
如果想配置扩展词和停⽤词,就创建扩展词的⽂件和停⽤词的⽂件。
停⽤词典stopword.dic作⽤ :
停⽤词典中的词例如: a, an, the, 的, 地, 得等词汇, 凡是出现在停⽤词典中的字或者词, 在切分词的时候会被过滤掉.
扩展词典ext.dic作⽤ :
扩展词典中的词例如: 千锋教育, 贵州茅台等专有名词, 在汉语中⼀些公司名称, ⾏业名称, 分类,品牌等不是汉语中的词汇, 是专有名词. 这些分词器默认不识别, 所以需要放⼊扩展词典中, 效果是被强制分成⼀个词.
8.高级搜索
8.1 多关键词查询
在之前创建搜索对象时,我们直接写的是华为手机,此时被分割成是 or连接还是and连接呢?(因为是将两个结果集进行合并操作)
// 1. 创建Query搜索对象
// 创建分词器,搜词用不上,但你要敲了一个句子,搜句子的话,句子要进行切分词,在跟索引进行对比,查询
Analyzer analyzer = new IKAnalyzer();
// 创建搜索解析器,第⼀个参数:默认Field域,第⼆个参数:分词器
QueryParser queryParser = new QueryParser("brandName", analyzer);
// 创建搜索对象
Query query = queryParser.parse("华为手机");
将最后一行替换进行检测
Query query = queryParser.parse("name:⼿机 AND 华为");
Query query = queryParser.parse("name:⼿机 OR 华为");
结论:华为手机与name:⼿机 OR 华为 结果集相同。
8.2 数据范围查询
/**
*根据数值范围查询
* 需求:根据价格查询100-1000元的商品
*/
@Test
public void testRangeQuery() throws Exception{
//1.创建查询对象
//域名,起始值,结束值
Query query = IntPoint.newRangeQuery("price",100,1000);
//2.创建Directory目录对象,指定索引库的位置
FSDirectory dir = FSDirectory.open(Paths.get("D:\\luceneDir"));
//3.创建输入流对象
IndexReader reader = DirectoryReader.open(dir);
//4.创建搜索对象
IndexSearcher indexSearcher = new IndexSearcher(reader);
//5.搜索并返回结果
//查询10个
TopDocs topDocs = indexSearcher.search(query,10);
System.out.println("=======count==="+topDocs.totalHits);
//6.获取结果
ScoreDoc[] scoreDocs = topDocs.scoreDocs;
//7.遍历结果集
if(scoreDocs != null) {
for (ScoreDoc scoreDoc : scoreDocs) {
// 获取⽂档的唯一id
int docID = scoreDoc.doc;
//根据文档id获取文档内容
Document doc = indexSearcher.doc(docID);
System.out.println("=============================");
System.out.println("docID:" + docID);
System.out.println("id:" + doc.get("id"));
System.out.println("name:" + doc.get("name"));
System.out.println("price:" + doc.get("price"));
System.out.println("brandName:" + doc.get("brandName"));
System.out.println("image:" + doc.get("image"));
}
}
}
8.3 组合查询
/**
* 组合查询
* 需求:
* 根据商品名字查询,查询华为手机关键字
* 价格还要在100-1000范围内的商品
*/
@Test
public void testBoolQuery() throws ParseException, IOException {
//1.创建分词器
Analyzer analyzer = new IKAnalyzer();
//2.根据商品名称进行查询
QueryParser queryParser = new QueryParser("name", analyzer);
//根据关键字进行查询
Query query1 = queryParser.parse("⼿机华为");
//根据价格范围查询
Query query2 = IntPoint.newRangeQuery("price",100,1000);
//创建组合查询对象
//BooleanClause.Occur.SHOULD 或者 相当于or
//BooleanClause.Occur.MUST 并且 相当于and
//BooleanClause.Occur.MUST_NOT 非 相当于not
BooleanQuery.Builder booleanQuery = new BooleanQuery.Builder();
booleanQuery.add(query1, BooleanClause.Occur.MUST);
booleanQuery.add(query2,BooleanClause.Occur.MUST);
// 3. 创建Directory流对象,声明索引库位置
Directory directory = FSDirectory.open(Paths.get("D:\\luceneDir"));
// 4. 创建索引读取对象IndexReader
IndexReader reader = DirectoryReader.open(directory);
// 5. 创建索引搜索对象
IndexSearcher searcher = new IndexSearcher(reader);
// 6. 使⽤索引搜索对象,执⾏搜索,返回结果集TopDocs
TopDocs topDocs = searcher.search(booleanQuery.build(), 10);
System.out.println("查询到的数据总条数是:" + topDocs.totalHits.value);
// 获取查询结果集
ScoreDoc[] docs = topDocs.scoreDocs;
// 7. 解析结果集
for (ScoreDoc scoreDoc : docs) {
// 获取⽂档的唯一id
int docID = scoreDoc.doc;
//根据文档id获取文档内容
Document doc = searcher.doc(docID);
System.out.println("=============================");
System.out.println("docID:" + docID);
System.out.println("id:" + doc.get("id"));
System.out.println("name:" + doc.get("name"));
System.out.println("price:" + doc.get("price"));
System.out.println("brandName:" + doc.get("brandName"));
System.out.println("image:" + doc.get("image"));
}
// 8. 释放资源
reader.close();
}
9. 综合案例
9.1引入依赖
pom.xml和上面一样
9.2 项⽬加⼊⻚⾯和资源
页面和静态资源在千锋教育包里,复制粘贴到resources目录下
9.3. 创建包和启动类
和之前一样
9.4. 配置⽂件
项⽬的resources⽬录下创建application.yml内容如下:
多一个关闭thymelaeaf缓存。若不关闭,调试完后,重启tomcat不生效。
server:
port: 8080
spring:
thymeleaf:
cache: false
9.5. 业务代码:
9.5.1. 封装pojo
public class ResultModel {
// 商品列表
private List<Sku> skuList;
// 商品总数
private Long recordCount;
// 总⻚数
private Long pageCount;
// 当前⻚
private long curPage;
get+set+toString
}
9.5.2. controller代码
@Controller
//<form th:id="actionForm" th:action="list" th:method="POST"> 一一对应
//<div class="form">
// <input th:type="text" class="text"
// th:name="queryString" th:id="key" th:value="${queryString }">
// <input type="button" value="搜索" class="button" οnclick="query()">
//</div>
//<input th:type="hidden" th:name="price" th:id="price" th:value="${price }"/>
//<input th:type="hidden" th:name="page" th:id="page" th:value="${result.curPage }"/>
//</form>
@RequestMapping("/list")
public class SearchController {
@Autowired
private SearchService searchService;
@RequestMapping
// 价格在这里是范围,所以用string来接受
public String list(String queryString, String price, Integer page, Model model) throws Exception {
//1.处理当前页,页面参数
if (StringUtils.isEmpty(page)) {
//如果为空,page置1,没有第0页
page = 1;
}
if (page <= 0) {
page = 1;
}
//2.调用service搜索业务方法
ResultModel resultModel = searchService.search(queryString, price, page);
//3.封装返回的数据给页面,把参数返回给springMVC默认支持的model里面
//比如:
// <div class="pagin pagin-m">
// <span class="text"><i th:text="${result.curPage }"></i>/<i th:text="${result.pageCount }"></i></span>
// <a href="javascript:changePage(-1)" class="prev">上一页<b></b></a>
// <a href="javascript:changePage(1)" class="next">下一页<b></b></a>
// </div>
// <div class="total">
// <span>共<strong th:text="${result.recordCount }"></strong>个商品
// </span>
// </div>
model.addAttribute("result", resultModel);
//参数的回显
model.addAttribute("queryString", queryString);
model.addAttribute("price", price);
model.addAttribute("page", page);
//指定页面的位置--search.html,扩展名去掉
return "search";
}
}
9.5.3. service代码
service接⼝:
public interface SearchService {
/**
* 根据关键字全⽂检索
*
* @param queryString 查询关键字
* @param price 价格过滤条件
* @param page 当前⻚
*/
//把controller传入的三个参数复制过来
public ResultModel search(String queryString, String price, Integer page) throws Exception;
}
service实现类:
不要用增强for循环遍历。要保证是从start开始遍历到end截至
@Service
public class SearchServiceImpl implements SearchService {
//每⻚查询20条数据
public final static Integer PAGE_SIZE = 20;
@Override
public ResultModel search(String queryString, String price, Integer page) throws Exception {
long startTime = System.currentTimeMillis();
//1. 封装一个返回的分页对象
ResultModel resultModel = new ResultModel();
//2.处理分页
//从第⼏条开始查询
int start = (page - 1) * PAGE_SIZE;
//查询到多少条为⽌
int end = page * PAGE_SIZE;
//3.创建中文分词器
Analyzer analyzer = new IKAnalyzer();
//4.创建组合查询对象
BooleanQuery.Builder builder = new BooleanQuery.Builder();
//5. 根据查询关键字封装查询对象
QueryParser queryParser = new QueryParser("name", analyzer);
Query query1 = null;
//判断传⼊的查询关键字是否为空, 如果为空查询所有, 如果不为空, 则根据关键字查询
if (StringUtils.isEmpty(queryString)) {
query1 = queryParser.parse("*:*");
} else {
query1 = queryParser.parse(queryString);
}
//将关键字查询对象, 封装到组合查询对象中
builder.add(query1, BooleanClause.Occur.MUST);
//6. 根据价格范围封装查询对象
if (!StringUtils.isEmpty(price)) {
String[] split = price.split("-");
Query query2 = IntPoint.newRangeQuery("price", Integer.parseInt(split[0]), Integer.parseInt(split[1]));
//7.将价格查询对象, 封装到组合查询对象中
builder.add(query2, BooleanClause.Occur.MUST);
}
//8. 创建Directory⽬录对象, 指定索引库的位置
/**
* 使⽤MMapDirectory消耗的查询时间
* ====消耗时间为=========324ms
* ====消耗时间为=========18ms
*/
Directory directory = FSDirectory.open(Paths.get("D:\\luceneDir"));
//9. 创建输⼊流对象
IndexReader reader = DirectoryReader.open(directory);
//10. 创建搜索对象
IndexSearcher indexSearcher = new IndexSearcher(reader);
//11. 搜索并获取搜索结果
TopDocs topDocs = indexSearcher.search(builder.build(), end);
//12. 获取查询到的总条数
resultModel.setRecordCount(topDocs.totalHits.value);
//13. 获取查询到的结果集
ScoreDoc[] scoreDocs = topDocs.scoreDocs;
long endTime = System.currentTimeMillis();
System.out.println("====消耗时间为=========" + (endTime - startTime) + "ms");
//14. 遍历结果集封装返回的数据
List<Sku> skuList = new ArrayList<>();
if (scoreDocs != null) {
for (int i = start; i < end; i ++) {
//获取文档id--scoreDocs[i].doc
//通过查询到的⽂档编号, 找到对应的⽂档对象
Document document = reader.document(scoreDocs[i].doc);
//封装Sku对象
Sku sku = new Sku();
sku.setId(document.get("id"));
sku.setPrice(Integer.parseInt(document.get("price")));
sku.setImage(document.get("image"));
sku.setName(document.get("name"));
sku.setBrandName(document.get("brandName"));
sku.setCategoryName(document.get("categoryName"));
skuList.add(sku);
}
}
//15.封装查询到的结果集
resultModel.setSkuList(skuList);
//16.封装当前⻚
resultModel.setCurPage(page);
//17.总⻚数
Long pageCount = topDocs.totalHits.value % PAGE_SIZE > 0 ?
(topDocs.totalHits.value/PAGE_SIZE) + 1 :
topDocs.totalHits.value/PAGE_SIZE;
resultModel.setPageCount(pageCount);
return resultModel;
}
}
显示效果:
10. Lucene底层储存结构(⾼级)
10.1. 详细理解lucene存储结构
存储结构 :
索引库(Index) :
⼀个⽬录⼀个索引库,在Lucene中⼀个索引库是放在⼀个⽂件夹中的。比如商品数据–一个索引库,评论–另外一个索引库。
段(Segment) :
⼀个索引(逻辑索引)由多个段组成, 多个段可以合并, 以减少读取内容时候的磁盘IO.
Lucene中的数据写⼊会先写内存的⼀个Buffer,当Buffer内数据到⼀定量后会被flush成⼀个Segment,每个Segment有⾃⼰独⽴的索引,可独⽴被查询,但数据永远不能被更改。这种模式避免了随机写,数据写⼊都是批量追加,能达到很⾼的吞吐量。Segment中写⼊的⽂档不可被修改,但可被删除,删除的⽅式也不是在⽂件内部原地更改,⽽是会由另外⼀个⽂件保存需要被删除的⽂档的DocID,保证数据⽂件不可被修改。Index的查询需要对多个Segment进⾏查询并对结果进⾏合并,还需要处理被删除的⽂档,为了对查询进⾏优化,Lucene会有策略对多个Segment进⾏合并。
⽂档(Document) :
⽂档是我们建索引的基本单位,不同的⽂档是保存在不同的段中的,⼀个段可以包含多篇⽂档。新添加的⽂档是单独保存在⼀个新⽣成的段中,随着段的合并,不同的⽂档合并到同⼀个段中。
域(Field) :
⼀篇⽂档包含不同类型的信息,可以分开索引,⽐如标题,时间,正⽂,描述等,都可以
保存在不同的域⾥。
不同域的索引⽅式可以不同。
词(Term) :
词是索引的最⼩单位,是经过词法分析和语⾔处理后的字符串。
10.2. 索引库物理⽂件
10.3. 索引库⽂件扩展名对照表
10.4. 词典的构建
为何Lucene⼤数据量搜索快, 要分两部分来看 :
⼀点是因为底层的倒排索引存储结构.
另⼀点就是查询关键字的时候速度快, 因为词典的索引结构.
10.4.1. 词典数据结构对⽐
倒排索引中的词典位于内存,其结构尤为重要,有很多种词典结构,各有各的优缺点,最简单如排序数组,通过⼆分查找来检索数据,更快的有哈希表,磁盘查找有B树、B+树,但⼀个能⽀持TB级数据的倒排索引结构需要在时间和空间上有个平衡,下图列了⼀些常⻅词典的优缺点:
Lucene3.0之前使⽤的也是跳跃表结构,后换成了FST,但跳跃表在Lucene其他地⽅还有应⽤如倒排表合并和⽂档号索引。
10.4.2. 跳跃表原理
Lucene3.0版本之前使⽤的跳跃表结构后换成了FST结构
优点 :结构简单、跳跃间隔、级数可控,Lucene3.0之前使⽤的也是跳跃表结构,但跳跃表在Lucene其他地⽅还有应⽤如倒排表合并和⽂档号索引。
缺点 :模糊查询⽀持不好.
10.4.3. FST原理简析
FST, 全称Finite State Transducer, 中⽂翻译: 有限状态转换器或有限状态传感器。
FST最重要的功能是可以实现Key到Value的映射,相当于HashMap<Key,Value>。FST的内存消耗要⽐HashMap少很多,
但FST的查询速度⽐HashMap要慢。
FST在Lucene中被⼤量使⽤,例如:倒排索引的存储,同义词词典的存储,搜索关键字建议等。
Lucene现在采⽤的数据结构为FST,它的特点就是:
优点:内存占⽤率低,压缩率⼀般在3倍~20倍之间、模糊查询⽀持好、查询快
缺点:结构复杂、输⼊要求有序、更新不易
已知FST要求输⼊有序,所以Lucene会将解析出来的⽂档单词预先排序,然后构建FST,我们假设输⼊为abd,abe,acf,acg,那么整个构建过程如下:
按照26个英文字母的顺序
11. Lucene相关度排序(⾼级)
11.1.什么是相关度排序
Lucene对查询关键字和索引⽂档的相关度进⾏打分,得分⾼的就排在前边。
11.1.1. 如何打分
Lucene是在⽤户进⾏检索时实时根据搜索的关键字计算出来的,分两步:
- 计算出词(Term)的权重
- 根据词的权重值,计算⽂档相关度得分。
11.1.2. 什么是词的权重
明确索引的最⼩单位是⼀个Term(索引词典中的⼀个词),搜索也是要从Term中搜索,再根据Term找到⽂档,Term对⽂档的重要性称为权重,影响Term权重有两个因素:
Term Frequency (tf):
指此Term在此⽂档中出现了多少次。tf 越⼤说明越重要。 词(Term)在⽂档中出现的次数越多,说明此词(Term)对该⽂档越重要,如“Lucene”这个词,在⽂档中出现的次数很多,说明该⽂档主要就是讲Lucene技术的。
Document Frequency (df):
指有多少⽂档包含次Term。df 越⼤说明越不重要。⽐如,在⼀篇英语⽂档中,this出现的次数更多,就说明越重要吗?不是的,有越多的⽂档包含此词(Term), 说明此词(Term)太普通,不⾜以区分这些⽂档,因⽽重要性越低。
11.1.3. 怎样影响相关度排序
boost是⼀个加权值(默认加权值为1.0f),它可以影响权重的计算。
在索引时对某个⽂档中的field设置加权值⾼,在搜索时匹配到这个⽂档就可能排在前边。
在搜索时对某个域进⾏加权,在进⾏组合域查询时,匹配到加权值⾼的域最后计算的相关度得分就⾼。
设置boost是给域(field)或者Document设置的。
11.2.⼈为影响相关度排序
查询的时候, 通过设置查询域的权重, 可以⼈为影响查询结果
@Test
public void testSearch() throws Exception {
//1.创建分词器
Analyzer analyzer = new IKAnalyzer();
//2.设置从多个域中进行查询
String[] fields = {"name","brandName","categoryName"};
//3.设置权重--影响相关度排序的参数
Map<String, Float> boots = new HashMap<>();
boots.put("categoryName", 10000000000000f);
//4.创建多个域查询对象
MultiFieldQueryParser queryParser = new MultiFieldQueryParser(fields, analyzer, boots);
//5.设置查询的关键词
Query query = queryParser.parse("手机");
// 2. 创建Directory流对象,声明索引库位置
Directory directory = MMapDirectory.open(Paths.get("D:\\luceneDir"));
// 3. 创建索引读取对象IndexReader
IndexReader reader = DirectoryReader.open(directory);
// 4. 创建索引搜索对象
IndexSearcher searcher = new IndexSearcher(reader);
// 5. 使⽤索引搜索对象,执⾏搜索,返回结果集TopDocs
// 第⼀个参数:搜索对象,第⼆个参数:返回的数据条数,指定查询结果最顶部的n条数据返回
TopDocs topDocs = searcher.search(query, 10);
System.out.println("查询到的数据总条数是:" + topDocs.totalHits.value);
// 获取查询结果集
ScoreDoc[] docs = topDocs.scoreDocs;
// 6. 解析结果集
for (ScoreDoc scoreDoc : docs) {
// 获取⽂档
int docID = scoreDoc.doc;
Document doc = searcher.doc(docID);
System.out.println("=============================");
System.out.println("docID:" + docID);
System.out.println("id:" + doc.get("id"));
System.out.println("name:" + doc.get("name"));
System.out.println("price:" + doc.get("price"));
System.out.println("brandName:" + doc.get("brandName"));
System.out.println("image:" + doc.get("image"));
}
// 7. 释放资源
reader.close();
}
对应视频:https://www.bilibili.com/video/BV1Na411h7kk?p=36&spm_id_from=pageDriver