一:了解搜索技术
1.搜索引擎
搜索引擎的功能主要是三部分:
①:爬行和抓取数据(爬虫多用Python来编写)
②:对数据做预处理(提取文字,中文分词、建立倒排索引)
③:提供搜索功能(用户输入关键词后,去索引库搜索数据)
在上述三个步骤中,java要解决的往往是后两个步骤:数据处理和搜索。
2.数据库搜索的问题
①:数据库数据单表存储能力有限,无法存储海量数据
- 解决大数据,可以进行分库分表。但是分库分表会增加业务复杂度
②:搜索只能通过模糊匹配,效率极低 - 模糊搜索可能导致全表扫描,效率非常差
3.什么是倒排索引(搜索与数据库的根本区别–传统查找和倒排索引)
①:传统查找(线性查找):采用数据按行存储,查找时逐行扫描,或者根据索引查找,然后匹配搜索条件,效率较差。概括来讲是先找到文档,然后看是否匹配。
②:倒排索引:首先对文档数据按照id进行索引存储,然后对文档中的数据分词,记录对词条进行索引,并记录词条在文档中出现的位置。这样查找时只要找到了词条,就找到了对应的文档。概括来讲是先找到词条,然后看看哪些文档包含这些词条。
③:创建倒排索引流程
当我们需要把这些数据创建倒排索引时,会分为两步:
1)创建文档列表
首先给每一条原始的文档数据创建文档编号(docID),创建索引,形成文档列表:
2)创建倒排索引列表(词条列表)
然后对文档中的数据进行分词,得到词条。对词条进行编号,并以词条创建索引。然后记录下包含该词条的所有文档编号(及其它信息)。
④:搜索流程
1)当用户输入任意的内容时,首先对用户输入的内容进行分词,得到用户要搜索的所有词条
2)然后拿着这些词条去倒排索引列表中进行匹配。找到这些词条就能找到包含这些词条的所有文档的编号。
3) 然后根据这些编号去文档列表中找到文档
举例:
例如用户要搜索关键词:拉斯跳槽
- 首先对这句话进行分词,得到3个词条:拉斯、跳槽
- 然后去倒排索引列表搜索(有索引,速度快),得到三个词所在的文档编号:0、2、3、4
- 然后根据编号到文档列表查找(有索引,速度快),即可得到原始文档信息了。
总结:(重要)
1.传统的查询是:先找到这条记录(文档),然后在这条记录(文档)中找对应的词语
2.倒排索引是:先用这个词语找到对应的文档id,再用这个id查询出这个文档信息
注意:并且倒排索引的俩次查询都是有索引的(词语有索引,文档id有索引–俩个条件都是唯一的)
3.为什么搜索很快的原因:就是应运了倒排索引(俩次查询都是有索引的)
4.倒排索引的步骤:
①:创建文档列表(给每一条原始的文档数据创建文档编号(docID),创建索引)
②:创建倒排索引列表(对文档中的数据进行分词,得到词条。对词条进行编号,并以词条创建索引。然后记录下包含该词条的所有文档编号(及其它信息)。)
③:搜索流程(a.当用户输入任意的内容时,首先对用户输入的内容进行分词,得到用户要搜索的所有词条 b.然后拿着这些词条去倒排索引列表中进行匹配 c.找到这些词条就能找到包含这些词条的所有文档的编号。然后根据这些编号去文档列表中找到文档)
二:Lucene概述
1.概述:在java语言中,对倒排索引的实现中最广为人知的就是Lucene了,目前主流的java搜索框架都是依赖Lucene来实现的。
①:Lucene是一套用于全文检索和搜寻的开源程序库,由Apache软件基金会支持和提供
②:Lucene提供了一个简单却强大的应用程序接口(API),能够做全文索引和搜寻,在Java开发环境里Lucene是一个成熟的免费开放源代码工具
③:Lucene并不是现成的搜索引擎产品,但可以用来制作搜索引擎产品
2.Lucene的底层使用原理就是–倒排索引(重要)
3.Lucene版本
①:最新版本到9版本了
②:我们一般用4版本的,比较稳定和支持成熟的框架使用(比如es)
三:Lucene的基本使用(创建,查询,修改,删除)
1.创建索引
①:准备要添加的文档数据:Document
②:初始化索引写出工具:IndexWriter
- 设定索引存储目录Directory
- 设定其他配置:IndexWriterConfig
- 设定分词器:Analyzer
- 设定Lucene版本
③:写出索引
④:快速入门
a.添加依赖
<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>cn.itcast.demo</groupId>
<artifactId>lucene-demo</artifactId>
<version>1.0.0-SNAPSHOT</version>
<dependencies>
<!-- Junit单元测试 -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
</dependency>
<!-- lucene核心库 -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>4.10.2</version>
</dependency>
<!-- Lucene的查询解析器 -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-queryparser</artifactId>
<version>4.10.2</version>
</dependency>
<!-- lucene的默认分词器库 -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analyzers-common</artifactId>
<version>4.10.2</version>
</dependency>
<!-- lucene的高亮显示 -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-highlighter</artifactId>
<version>4.10.2</version>
</dependency>
</dependencies>
</project>
b.代码实现
// 创建索引
@Test
public void testCreate() throws Exception{
// 1.创建文档对象
Document document = new Document();
document.add(new StringField("", "", Store.NO));
// 创建并添加字段信息。参数:字段的名称、字段的值、是否存储,这里选Store.YES代表存储到文档列表。Store.NO代表不存储
document.add(new StringField("id", "1", Store.YES));
// 这里我们title字段需要用TextField,即创建索引又会被分词。StringField会创建索引,但是不会被分词
document.add(new TextField("title", "谷歌地图之父跳槽facebook", Field.Store.YES));
// 2.1索引目录类,指定索引在硬盘中的位置(目录)
Directory directory = FSDirectory.open(new File("C:\\lesson\\indexDir"));
// 2.2创建分词器对象(配置1)
Analyzer analyzer = new StandardAnalyzer();
// 2.3索引写出工具的配置对象(配置2)
IndexWriterConfig conf = new IndexWriterConfig(Version.LATEST, analyzer);
// 2.创建索引的写出工具类。参数:索引的目录和配置信息
IndexWriter indexWriter = new IndexWriter(directory, conf);
// 3.把文档交给IndexWriter(添加文档)
indexWriter.addDocument(document);
// 4.提交
indexWriter.commit();
// 关闭
indexWriter.close();
}
c.查看目录
d.索引查看工具
2.创建索引时的细节
1):覆盖或追加
我们在写索引时,可以在IndexConfigWriter中配置写入模式:覆盖或者追加:
可以有3种模式:
- CREATE:每次写入都覆盖以前的数据
- APPEND:不覆盖数据,而是使用以前的索引数据后追加
- CREATE_OR_APPEND:如果不存在则创建新的,如果存在则追加数据
2):Field字段类型(重要)
灵魂4连问–选择存储的字段类型
3):分词器
①.a.IK分词器官方版本是不支持Lucene4.X的,有人基于IK的源码做了改造,支持了Lucene4.X,我们可以通过maven引入其依赖:
<dependency>
<groupId>com.janeluo</groupId>
<artifactId>ikanalyzer</artifactId>
<version>2012_u6</version>
</dependency>
b.然后修改代码中的分词器类型:
c.再次测试
可以了。
②:停用词典和扩展词典(重要)
a.IK分词器的词库有限,如果是词库中没有出现的词条,不会被正确分词,例如这样一句话:谷歌地图之父跳槽facebook, 加入了兴业社区,屌爆了啊
如图:红色的词条是没有正确分词的;蓝色的词条是没有意义的词语。
我们期待:兴业社区、屌爆了能作为一个完整词条;并且一些无关词语如:了、啊、额、入了可以不被分词。
b.新增加的词条可以通过配置文件添加到IK的词库中,也可以把一些不用的词条去除。
4)批量创建索引(多个文档新增)
// 批量创建索引
@Test
public void testCreate2() throws Exception{
// 创建文档的集合
Collection<Document> docs = new ArrayList<>();
// 创建文档对象
Document document1 = new Document();
document1.add(new StringField("id", "1", Store.YES));
document1.add(new TextField("title", "谷歌地图之父跳槽facebook", Store.YES));
docs.add(document1);
// 创建文档对象
Document document2 = new Document();
document2.add(new StringField("id", "2", Store.YES));
document2.add(new TextField("title", "谷歌地图之父加盟FaceBook", Store.YES));
docs.add(document2);
// 创建文档对象
Document document3 = new Document();
document3.add(new StringField("id", "3", Store.YES));
document3.add(new TextField("title", "谷歌地图创始人拉斯离开谷歌加盟Facebook", Store.YES));
docs.add(document3);
// 创建文档对象
Document document4 = new Document();
document4.add(new StringField("id", "4", Store.YES));
document4.add(new TextField("title", "谷歌地图之父跳槽Facebook与Wave项目取消有关", Store.YES));
docs.add(document4);
// 创建文档对象
Document document5 = new Document();
document5.add(new StringField("id", "5", Store.YES));
document5.add(new TextField("title", "谷歌地图之父拉斯加盟社交网站Facebook", Store.YES));
docs.add(document5);
// 索引目录类,指定索引在硬盘中的位置
Directory directory = FSDirectory.open(new File("C:\\lesson\\indexDir"));
// 引入IK分词器
Analyzer analyzer = new IKAnalyzer();
// 索引写出工具的配置对象
IndexWriterConfig conf = new IndexWriterConfig(Version.LATEST, analyzer);
// 设置打开方式:OpenMode.APPEND 会在索引库的基础上追加新索引。
// OpenMode.CREATE会先清空原来数据,再提交新的索引
conf.setOpenMode(IndexWriterConfig.OpenMode.CREATE);
// 创建索引的写出工具类。参数:索引的目录和配置信息
IndexWriter indexWriter = new IndexWriter(directory, conf);
// 把文档集合交给IndexWriter
indexWriter.addDocuments(docs);
// 提交
indexWriter.commit();
// 关闭
indexWriter.close();
}
3.索引的基本查询
1):基本流程:
①:创建索引搜索工具
- 指定索引目录
- 创建读取流工具
- 创建搜索工具
②:创建查询条件 - 创建查询解析器
- 解析用户搜索语句,得到查询条件对象
③:搜索并解析结果
2):代码如下:
@Test
public void testSearch() throws Exception {
// 索引目录对象
Directory directory = FSDirectory.open(new File("C:\\lesson\\indexDir"));
// 索引读取工具
IndexReader reader = DirectoryReader.open(directory);
// 索引搜索工具
IndexSearcher searcher = new IndexSearcher(reader);
// 创建查询解析器,两个参数:默认要查询的字段的名称,分词器
QueryParser parser = new QueryParser("title", new IKAnalyzer());
// 创建查询对象
Query query = parser.parse("谷歌地图之父拉斯");
// 搜索数据,两个参数:查询条件对象要查询的最大结果条数
// 返回的结果是 按照匹配度排名得分前N名的文档信息(包含查询到的总条数信息、所有符合条件的文档的编号信息)。
TopDocs topDocs = searcher.search(query, 10);
// 获取总条数
System.out.println("本次搜索共找到" + topDocs.totalHits + "条数据");
// 获取得分文档对象(ScoreDoc)数组.SocreDoc中包含:文档的编号、文档的得分
ScoreDoc[] scoreDocs = topDocs.scoreDocs;
for (ScoreDoc scoreDoc : scoreDocs) {
// 取出文档编号
int docID = scoreDoc.doc;
// 根据编号去找文档
Document doc = searcher.doc(docID);
System.out.println("id: " + doc.get("id"));
System.out.println("title: " + doc.get("title"));
// 取出文档得分
System.out.println("得分: " + scoreDoc.score);
}
}
3):结果
4.索引的特殊查询
1):抽取通用查询方法
①:当我们使用各种不同查询时,其它代码几乎不动,就是查询条件在发生变化,因此我们可以把查询代码进行抽取:
public void search(Query query) throws Exception{
// 索引目录对象
Directory directory = FSDirectory.open(new File("C:\\lesson\\indexDir"));
// 索引读取工具
IndexReader reader = DirectoryReader.open(directory);
// 索引搜索工具
IndexSearcher searcher = new IndexSearcher(reader);
// 搜索数据,两个参数:查询条件对象要查询的最大结果条数
// 返回的结果是 按照匹配度排名得分前N名的文档信息(包含查询到的总条数信息、所有符合条件的文档的编号信息)。
TopDocs topDocs = searcher.search(query, 10);
// 获取总条数
System.out.println("本次搜索共找到" + topDocs.totalHits + "条数据");
// 获取得分文档对象(ScoreDoc)数组.SocreDoc中包含:文档的编号、文档的得分
ScoreDoc[] scoreDocs = topDocs.scoreDocs;
for (ScoreDoc scoreDoc : scoreDocs) {
// 取出文档编号
int docID = scoreDoc.doc;
// 根据编号去找文档
Document doc = searcher.doc(docID);
System.out.println("id: " + doc.get("id"));
System.out.println("title: " + doc.get("title"));
// 取出文档得分
System.out.println("得分: " + scoreDoc.score);
}
}
②:改造之前的查询:(第一种查询方式)
@Test
public void testSearch() throws Exception {
// 创建查询解析器,两个参数:默认要查询的字段的名称,分词器
QueryParser parser = new QueryParser("title", new IKAnalyzer());
// 创建查询对象
Query query = parser.parse("谷歌地图之父拉斯");
// 查询并解析
search(query);
}
2):词条查询(第二种查询方式)
/*
* 注意:Term(词条)是搜索的最小单位,不可再分词。值必须是字符串!
*/
@Test
public void testTermQuery() throws Exception {
// 创建词条查询对象
Query query = new TermQuery(new Term("title", "谷歌地图"));
search(query);
}
3):通配符查询
4):FuzzyQuery(模糊查询)–(第三种查询方式)
/*
* 测试模糊查询
*/
@Test
public void testFuzzyQuery() throws Exception {
// 创建模糊查询对象:允许用户输错。但是要求错误的最大编辑距离不能超过2
// 编辑距离:一个单词到另一个单词最少要修改的次数 facebool --> facebook 需要编辑1次,编辑距离就是1
// Query query = new FuzzyQuery(new Term("title","fscevool"));
// 可以手动指定编辑距离,但是参数必须在0~2之间
Query query = new FuzzyQuery(new Term("title","facevool"),1);
search(query);
}
5):数值范围查询(第四种查询方式)
/*
* 测试:数值范围查询
* 注意:数值范围查询,可以用来对非String类型的ID进行精确的查找
*/
@Test
public void testNumericRangeQuery() throws Exception{
// 数值范围查询对象,参数:字段名称,最小值、最大值、是否包含最小值、是否包含最大值
Query query = NumericRangeQuery.newLongRange("id", 2L, 2L, true, true);
search(query);
}
6):布尔查询(第五种查询方式)–用的最多的一种方式,因为要查询条件组合使用
/*
* 布尔查询:
* 布尔查询本身没有查询条件,可以把其它查询通过逻辑运算进行组合!
* 交集:Occur.MUST + Occur.MUST
* 并集:Occur.SHOULD + Occur.SHOULD
* 非:Occur.MUST_NOT
*/
@Test
public void testBooleanQuery() throws Exception{
Query query1 = NumericRangeQuery.newLongRange("id", 1L, 3L, true, true);
Query query2 = NumericRangeQuery.newLongRange("id", 2L, 4L, true, true);
// 创建布尔查询的对象
BooleanQuery query = new BooleanQuery();
// 组合其它查询
query.add(query1, Occur.MUST_NOT);
query.add(query2, Occur.SHOULD);
search(query);
}
5.修改索引(先删除之前的根据id,然后新增一条文档)
1):基本流程:
①:创建索引写出对象
- 指定目录
- 配置
②:创建文档
③:更新数据
2):代码
/**
* 注意,这里的更新接收的条件时Term,即词条。需要注意两点:
* 1)搜索条件最好唯一,例如ID,否则后果很严重
* 2)之前说过,词条要求必须是字符串类型,那如果我们的id是Long类型怎么办?
* @throws Exception
*/
@Test
public void testUpdate() throws Exception{
// 创建目录对象
Directory directory = FSDirectory.open(new File("indexDir"));
// 创建配置对象
IndexWriterConfig conf = new IndexWriterConfig(Version.LATEST, new IKAnalyzer());
// 创建索引写出工具
IndexWriter writer = new IndexWriter(directory, conf);
// 创建新的文档数据
Document doc = new Document();
doc.add(new StringField("id","1",Store.YES));
doc.add(new TextField("title","谷歌地图之父跳槽facebook 为了加入兴业社区 屌爆了啊",Store.YES));
/* 修改索引。参数:
* 词条:根据这个词条匹配到的所有文档都会被修改
* 文档信息:要修改的新的文档数据
*/
writer.updateDocument(new Term("id","1"), doc);
// 提交
writer.commit();
// 关闭
writer.close();
}
6.删除索引
1):基本流程:
①:创建索引写出工具
②:创建删除条件
③:删除
2):代码
/**
* 删除的方式有多样:
* 1)根据Term删除,需要注意:
* a. 词条的数据类型必须是字符串
* b. 最好根据id进行唯一匹配删除,如果id不是字符串类型怎么办?
* 2)根据Query删除
* @throws Exception
*/
@Test
public void testDelete() throws Exception {
// 创建目录对象
Directory directory = FSDirectory.open(new File("indexDir"));
// 创建配置对象
IndexWriterConfig conf = new IndexWriterConfig(Version.LATEST, new IKAnalyzer());
// 创建索引写出工具
IndexWriter writer = new IndexWriter(directory, conf);
// 根据词条进行删除
// writer.deleteDocuments(new Term("id", "1"));
// 根据query对象删除,如果ID是数值类型,那么我们可以用数值范围查询锁定一个具体的ID
// Query query = NumericRangeQuery.newLongRange("id", 2L, 2L, true, true);
// writer.deleteDocuments(query);
// 删除所有
writer.deleteAll();
// 提交
writer.commit();
// 关闭
writer.close();
}
四:Lucene的高级使用
1.高亮显示
①:后端加标签,前端加样式(高亮)
②:代码
@Test
public void testHighlighter() throws Exception {
// 目录对象
Directory directory = FSDirectory.open(new File("C:\\lesson\\indexDir"));
// 创建读取工具
IndexReader reader = DirectoryReader.open(directory);
// 创建搜索工具
IndexSearcher searcher = new IndexSearcher(reader);
QueryParser parser = new QueryParser("title", new IKAnalyzer());
Query query = parser.parse("谷歌地图");
// 格式化器(start)
Formatter formatter = new SimpleHTMLFormatter("<em>", "</em>");
QueryScorer scorer = new QueryScorer(query);
// 准备高亮工具
Highlighter highlighter = new Highlighter(formatter, scorer);
//(end)
// 搜索
TopDocs topDocs = searcher.search(query, 10);
System.out.println("本次搜索共" + topDocs.totalHits + "条数据");
ScoreDoc[] scoreDocs = topDocs.scoreDocs;
for (ScoreDoc scoreDoc : scoreDocs) {
// 获取文档编号
int docID = scoreDoc.doc;
Document doc = searcher.doc(docID);
System.out.println("id: " + doc.get("id"));
String title = doc.get("title");
//(start)
// 用高亮工具处理普通的查询结果,参数:分词器,要高亮的字段的名称,高亮字段的原始值
String hTitle = highlighter.getBestFragment(new IKAnalyzer(), "title", title); //(end)
System.out.println("title: " + hTitle);
// 获取文档的得分
System.out.println("得分:" + scoreDoc.score);
}
}
2.排序
①:代码
@Test
public void testSortQuery() throws Exception {
// 目录对象
Directory directory = FSDirectory.open(new File("C:\\lesson\\indexDir"));
// 创建读取工具
IndexReader reader = DirectoryReader.open(directory);
// 创建搜索工具
IndexSearcher searcher = new IndexSearcher(reader);
QueryParser parser = new QueryParser("title", new IKAnalyzer());
Query query = parser.parse("谷歌地图");
// 创建排序对象,需要排序字段SortField,参数:字段的名称、字段的类型、是否反转如果是false,升序。true降序
Sort sort = new Sort(new SortField("id", SortField.Type.STRING, true));
// 搜索
TopDocs topDocs = searcher.search(query, 10,sort);
System.out.println("本次搜索共" + topDocs.totalHits + "条数据");
ScoreDoc[] scoreDocs = topDocs.scoreDocs;
for (ScoreDoc scoreDoc : scoreDocs) {
// 获取文档编号
int docID = scoreDoc.doc;
Document doc = searcher.doc(docID);
System.out.println("id: " + doc.get("id"));
System.out.println("title: " + doc.get("title"));
}
}
3.分页(原始自己封装方法)
①:Lucene本身没有分页,分页需要我们自己用代码来模拟实现:
②:int page =1; int size =10;
int start =(page-1)*size;
int end = page * size;
③:获取的数组截取从start到end(或者到size的大小的数据返回)
④:用arrays.spliterator(数组,start,end)–方法
总结:搜索
1.我们原来数据库的查询是:
①:先查询这条记录,然后在这条记录中找对应的搜索的内容
②:Lucene的原理,倒排索引,先根据查询的词语(分词)-然后查询到对应的文档id-然后根据文档id查询到该条记录的全部内容(分词和文档id都是有索引的,所以查询大大加快)
2.新增索引:
①:覆盖或追加
②:Field字段类型
③:分词器(IK分词器,停用词典和扩展词典)
④:批量创建索引
3.索引的基本查询
4.索引的特殊查询
①:抽取通用查询方法
②:词条查询
③:FuzzyQuery(模糊查询)
④:数值范围查询
⑤:布尔查询
5.修改索引
6.删除索引
7.Lucene的高级使用
①:高亮显示
②:排序
③:分页
8.后续使用es会将这些功能再一步进行封装,使用更简单。