文章目录
前言
前段时间由于工作需要,用到ES做数据索引框架,便学习了一阵子。最近想想忘得也差不多了,于是便决定写下文章帮助自己复习一下~
讲Elasticsearch分布式检索框架前,我们先重点学习一下elasticsearch中的核心——lucene框架。
一、搜索是什么?
简单来说,就是通过关键词,去搜索位置的数据源,得到自己想要的相关信息
普通搜索
比如:
程序猿老王,掏出自己一堆银行卡和数十把钥匙,来到相亲大会。这个不速之客,立马引来一堆妹纸簇拥而上。
但是,面对这么多的妹纸,老王慌了(他必须选出最满意的那个姑娘~)。
如何在一群姑娘们的信息里,找到自己想要的那个呢?
于是老王根据肤白貌美大长腿,对手里的妹纸信息筛选,淘汰一批。然后越发膨胀,身高低于170不要,结果就剩几张了。最后选择了年龄最小的那个妹纸,刚拉上小手,该死的闹钟响了~
上面的搜索其实就是遍历所有信息后,做匹配查询
非普通搜索
生活中很多场景下,我们搜索的对象并非都是关系型结构化的信息。我们无法像数据库模糊查询那样模糊匹配,跟不能遍历所有内容做匹配,毕竟查询是为快速找到想要的信息。
举个栗子:
我们在百度中搜索关键字,结果出现内容却是来自不同网站,不同内容体的结果,并标红显示关键词。
而且还有更重要的一点就是 非常非常的快~
那么问题来了,网上的数据各式各样并非结构化的,而面对这样的业务查询,请问该如何匹配呢?
显然不可能一片一片的内容做全局字符匹配吧~
想知道,就接者看下去吧~
倒排索引
上面说到,我们无法根据常见的搜索,快速匹配到不同的文档,并显示出来。那么接下来,我们提供一个新的实现思路来解决这个问题。
举个栗子:
原文档:
doc1:小明说他看见他的女朋友了
doc2:纳尼~他哪里来的女朋友?
doc3:在梦里~
doc4:哦,那没事了
文档分词
ID | 文档 | 内容 | 分词 |
---|---|---|---|
1 | doc1 | 小明说他看见他的女朋友了 | [小明、看见、女朋友] |
2 | doc2 | 纳尼~他哪里来的女朋友? | [纳尼、哪里、女朋友] |
3 | doc3 | 在梦里看见的~ | [梦里、看见] |
4 | doc4 | 哦,那没事了 | [没事] |
建立倒排索引
关键词 | ID |
---|---|
小明 | 1 |
看见 | 1,3 |
女朋友 | 1 ,2 |
纳尼 | 2 |
哪里 | 2 |
梦里 | 3 |
没事 | 3 |
比较分析
- 建立完毕后,我再来分析下这种方式的查询实现。
假设:现在需求是搜索“女朋友”
普通方式:
- 遍历四个文档,分别对文档中的内容进行关键词匹配。如果匹配上了,就输出。
倒排索引:
- 根据关键词,搜索建立好的索引库。我们可以直接拿到“女朋友”出现在文档1和文档2中。于是我们直接去文档1和2 ,找到有这个关键字的部分。
显然倒排的方式要明显优于普通的方式。
二、Lucene介绍
什么是lucene
-
Lucene就是一个开源的全文检索的工具包,可以帮助实现索引搜索。
什么是全文检索
luncene 通过对文档内容,全部进行分词,并对单词建立倒排索引的过程。
工作流程
下面这幅图来自《Lucene in action》,但却不仅仅描述了Lucene的检索过程,而是描述了全文检索的一般过程。
根据上图显示可知:
- 各种数据源的数据(结构的或非结构的),创建索引文档
- 通过分词、倒排索引创建索引,存入索引库中。
- 用户查询,通过查询索引,找到文档,进而找到数据源信息。
部分组成介绍
1. Field
//1 创建文档对象
Document document1 = new Document();
// 创建并添加字段信息。参数:字段的名称、字段的值、是否存储,这里选Store.YES代表存储到文档列表。Store.NO代表不存储
document1.add(new StringField("id", "1", Field.Store.YES));
- Document文档中有很多字段组成,每个字段就是一个Field,且有不同的类型。
- 子类: DoubleField、FloatField、IntField、LongField、StringField、TextField等
- TextField即创建索引,又会被分词。
- StringField会创建索引,但是不会被分词。
- StoreField一定会被存储,但是一定不创建索引。
2. Directory
//2 索引目录类,指定索引在硬盘中的位置
Directory directory = FSDirectory.open(new File("D:\\work\\index"));
- 索引存储的地方
- 子类:FSDirectory 和 RAMDirectory两种
- FSDirectory :文件系统目录,会把索引库指向本地磁盘。速度慢,但是数据安全,程序挂了索引不会消息。
- RAMDirectory:内存目录,会把索引库保存在内存。索引数据没有持久化,挂死后,数据丢失。
3. Analyzer
//3 创建分词器对象--使用IK分词器
Analyzer analyzer = new IKAnalyzer();
- 分词解析器,给文档数据分词使用
- 默认的分词,只能对英文进行分词,无法分词中文。
- 我们使用的IK分词器
三、基本使用
1 添加依赖
<!-- 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>
<!--中文分词器-->
<dependency>
<groupId>com.janeluo</groupId>
<artifactId>ikanalyzer</artifactId>
<version>2012_u6</version>
</dependency>
2. 创建索引
创建索引的流程
- 创建文档对象
- 指定索引存放目录
- 创建分词对象
- 索引写出配置
- 创建索引的写出工具类
- 添加文档到索引类里
- 提交
- 关闭
结合代码理解一下流程~
// 创建索引
@Test
public void testCreateIndex() throws Exception{
// 创建文档的集合
Collection<Document> docs = new ArrayList<>();
//1 创建文档对象
Document document1 = new Document();
// 创建并添加字段信息。参数:字段的名称、字段的值、是否存储,这里选Store.YES代表存储到文档列表。Store.NO代表不存储
document1.add(new StringField("id", "1", Field.Store.YES));
// 这里我们title字段需要用TextField,即创建索引又会被分词。StringField会创建索引,但是不会被分词
document1.add(new TextField("title", "小明说他看见他的女朋友了", Field.Store.YES));
docs.add(document1);
Document document2 = new Document();
document2.add(new StringField("id", "1", Field.Store.YES));
document2.add(new TextField("title", "纳尼~他哪里来的女朋友?", Field.Store.YES));
docs.add(document2);
Document document3 = new Document();
document3.add(new StringField("id", "1", Field.Store.YES));
document3.add(new TextField("title", "在梦里看到的~", Field.Store.YES));
docs.add(document3);
Document document4 = new Document();
document4.add(new StringField("id", "1", Field.Store.YES));
document4.add(new TextField("title", "哦,那没事了", Field.Store.YES));
docs.add(document4);
//2 索引目录类,指定索引在硬盘中的位置
Directory directory = FSDirectory.open(new File("D:\\work\\index"));
//3 创建分词器对象--使用IK分词器
Analyzer analyzer = new IKAnalyzer();
//4 索引写出工具的配置对象
IndexWriterConfig conf = new IndexWriterConfig(Version.LATEST, analyzer);
//设置每次索引都是重新创建---还有其他集中模式 append 追加
conf.setOpenMode(IndexWriterConfig.OpenMode.CREATE);
//5 创建索引的写出工具类。参数:索引的目录和配置信息
IndexWriter indexWriter = new IndexWriter(directory, conf);
//6 把文档交给IndexWriter
indexWriter.addDocuments(docs);
//7 提交
indexWriter.commit();
//8 关闭
indexWriter.close();
}
3.查询索引
3.1 流程
- 获取索引目录,建立对象
- 读取索引目录对象,建立索引读取工具
- 根据索引读取工具,创建索引搜索工具
- 最重要的,创建查询解析器
- 创建查询对象
- 根据查询对象,搜索数据
- 获取查询结果
3.2 测试代码
@Test
public void testIndexSearch() throws Exception {
// 1.索引目录对象
Directory directory = FSDirectory.open(new File("D:\\work\\index"));
// 2.索引读取工具
IndexReader reader = DirectoryReader.open(directory);
// 3.索引搜索工具
IndexSearcher searcher = new IndexSearcher(reader);
// 4.创建查询解析器,两个参数:默认要查询的字段的名称,分词器
QueryParser parser = new QueryParser("title", new IKAnalyzer());
// 5.创建查询对象---女朋友
Query query = parser.parse("女朋友");
// 6.搜索数据,两个参数:查询条件对象要查询的最大结果条数
// 返回的结果是 按照匹配度排名得分前N名的文档信息(包含查询到的总条数信息、所有符合条件的文档的编号信息)。
TopDocs topDocs = searcher.search(query, 10);
// 获取总条数
System.out.println("本次搜索共找到" + topDocs.totalHits + "条数据");
// 7.获取得分文档对象(ScoreDoc)数组.SocreDoc中包含:文档的编号、文档的得分
ScoreDoc[] scoreDocs = topDocs.scoreDocs;
for (ScoreDoc scoreDoc : scoreDocs) {
// 取出文档编号
int docID = scoreDoc.doc;
// 根据编号去找文档
Document doc = reader.document(docID);
System.out.println("id: " + doc.get("id"));
System.out.println("title: " + doc.get("title"));
// 取出文档得分
System.out.println("得分: " + scoreDoc.score);
}
}
3.3 扩展查询
方式一:通过QueryParser 解析关键字,得到查询对象(会使用分词)
// 4.创建查询解析器,两个参数:默认要查询的字段的名称,分词器
QueryParser parser = new QueryParser("title", new IKAnalyzer());
// 5.创建查询对象---女朋友
Query query = parser.parse("女朋友");
方式二:最小Trem单元查询(不可再分词。值必须是字符串!)
Query query = new TermQuery(new Term("title", "女朋友"));
方式三:匹配查询( ? 可以代表任意一个字符 * 可以任意多个任意字符)
Query query = new WildcardQuery(new Term("title", "*朋*"));
方式四:错误匹配(允许查询的key有一定偏差,会帮你查询正确的结果。)
Query query = new FuzzyQuery(new Term("title","facevool"),1);
方式五: 数值范围查询(查询文档id 在2-4的结果)
// 数值范围查询对象,参数:字段名称,最小值、最大值、是否包含最小值、是否包含最大值
Query query = NumericRangeQuery.newLongRange("id", 2L, 4L, true, true);
方式六:组合查询(多个条件组合)
Query query1 = NumericRangeQuery.newLongRange("id", 1L, 3L, true, true);
Query query2 = NumericRangeQuery.newLongRange("id", 2L, 4L, true, true);
// 创建布尔查询的对象
BooleanQuery query = new BooleanQuery();
// 组合其它查询
//且:Occur.MUST
//或:Occur.SHOULD
//非:Occur.MUST_NOT
query.add(query1, BooleanClause.Occur.MUST_NOT);
query.add(query2, BooleanClause.Occur.SHOULD);
4.修改索引
流程:
- 根据ID 查找到文档
- 删除要修改的ID 文档
- 添加此ID修改后的文档
- 生成索引
代码:
@Test
public void testUpdateIndex() throws Exception{
// 创建目录对象
Directory directory = FSDirectory.open(new File("D:\\work\\index"));
// 创建配置对象
IndexWriterConfig conf = new IndexWriterConfig(Version.LATEST, new IKAnalyzer());
// 创建索引写出工具
IndexWriter writer = new IndexWriter(directory, conf);
// 创建新的文档数据
Document doc = new Document();
doc.add(new StringField("id","1", Field.Store.YES));
doc.add(new TextField("title","我不信他会有女朋友~", Field.Store.YES));
/* 修改索引。参数:
* 词条:根据这个词条匹配到的所有文档都会被修改
* 文档信息:要修改的新的文档数据
*/
writer.updateDocument(new Term("id","1"), doc);
// 提交
writer.commit();
// 关闭
writer.close();
}
注意事项:
- Lucene修改功能底层会先删除,再把新的文档添加。
- 修改功能会根据Term进行匹配,所有匹配到的都会被删除。这样不好
- 因此,一般我们修改时,都会根据一个唯一不重复字段进行匹配修改。例如ID
- 但是词条搜索,要求ID必须是字符串。如果不是,这个方法就不能用。
- 如果ID是数值类型,我们不能直接去修改。可以先手动删除deleteDocuments(数值范围查询锁定ID),再添加。
5.删除索引
删除方式:
- 根据文档id找到文档,然后删除
- 根据Term查询,把查询的结果删除
- 删除所有
代码:
@Test
public void testDelete() throws Exception {
// 创建目录对象
Directory directory = FSDirectory.open(new File("D:\\work\\index"));
// 创建配置对象
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, 4L, true, true);
// writer.deleteDocuments(query);
// 删除所有
writer.deleteAll();
// 提交
writer.commit();
// 关闭
writer.close();
}
6. 高亮显示:
/**
* 高亮显示
*/
@Test
public void testHighlighter() throws Exception {
// 目录对象
Directory directory = FSDirectory.open(new File("D:\\work\\index"));
// 创建读取工具
IndexReader reader = DirectoryReader.open(directory);
// 创建搜索工具
IndexSearcher searcher = new IndexSearcher(reader);
QueryParser parser = new QueryParser("title", new IKAnalyzer());
Query query = parser.parse("女朋友");
// 格式化器
Formatter formatter = new SimpleHTMLFormatter("<em>", "</em>");
QueryScorer scorer = new QueryScorer(query);
// 准备高亮工具
Highlighter highlighter = new Highlighter(formatter, scorer);
// 搜索
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 = reader.document(docID);
System.out.println("id: " + doc.get("id"));
String title = doc.get("title");
// 用高亮工具处理普通的查询结果,参数:分词器,要高亮的字段的名称,高亮字段的原始值
String hTitle = highlighter.getBestFragment(new IKAnalyzer(), "title", title);
System.out.println("title: " + hTitle);
// 获取文档的得分
System.out.println("得分:" + scoreDoc.score);
}
}
6.排序
/**
* 排序
* @throws Exception
*/
@Test
public void testSortQuery() throws Exception {
// 目录对象
Directory directory = FSDirectory.open(new File("D:\\work\\index"));
// 创建读取工具
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.LONG, 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 = reader.document(docID);
System.out.println("id: " + doc.get("id"));
System.out.println("title: " + doc.get("title"));
}
}
7.分页
/**
* 分页
* @throws Exception
*/
@Test
public void testPageQuery() throws Exception {
// 实际上Lucene本身不支持分页。因此我们需要自己进行逻辑分页。我们要准备分页参数:
int pageSize = 1;// 每页条数
int pageNum = 1;// 当前页码
int start = (pageNum - 1) * pageSize;// 当前页的起始条数
int end = start + pageSize;// 当前页的结束条数(不能包含)
// 目录对象
Directory directory = FSDirectory.open(new File("D:\\work\\index"));
// 创建读取工具
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.LONG, false));
// 搜索数据,查询0~end条
TopDocs topDocs = searcher.search(query, end,sort);
System.out.println("本次搜索共" + topDocs.totalHits + "条数据");
ScoreDoc[] scoreDocs = topDocs.scoreDocs;
for (int i = start; i < end; i++) {
ScoreDoc scoreDoc = scoreDocs[i];
// 获取文档编号
int docID = scoreDoc.doc;
Document doc = reader.document(docID);
System.out.println("id: " + doc.get("id"));
System.out.println("title: " + doc.get("title"));
}
}
8.得分算法
Lucene会对搜索结果打分,用来表示文档数据与词条关联性的强弱,得分越高,表示查询的匹配度就越高,排名就越靠前!
打分公式有些复杂,这里我也没弄太明白。这里就不写了,待日后有时间沉下心来仔细研究一番。
有兴趣请参考另一方大佬写的博客:
https://www.cnblogs.com/forfuture1978/archive/2010/03/07/1680007.html
总结
本章主要内容:
- 理解倒排索引的实现
- 明白lucene的基本组成和工作流程
- 熟练使用lucene的方法 对索引操作