Lucene初步学习及在博客系统中应用Demo

目录

Lucene 简介

简介

Lucene是一套用于全文检索和搜索的开放源码程序库,由Apache软件基金会支持和提供。Lucene提供了一个简单却强大的应用程序接口,能够做全文索引和搜索,在Java开发环境里Lucene是一个成熟的免费开放源代码工具;就其本身而论,Lucene是现在并且是这几年,最受欢迎的免费Java信息检索程序库。

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

现在很流行的SolrElasticsearch,都是基于Lucene开发的.此外,Eclipse的帮助系统的搜索也是基于Lucene实现的.

流程图

使用lucene构建的搜索程序的流程图如下(图源:Lucene In Action书中配图,博主绘制):

2019-05-23-17-13-18

红框中的部分可以由Lucene完成,其余需要自己编码或者借助其他开源框架.

使用准备

简单的使用Lucene,只需要引入Jar包,然后编写对应的生成索引和查找代码即可.

在本文的示例中,我使用Lucene给我的博客建立一个简单的搜索系统,因为之前的搜索系统是在前端完成的,这次学习的Lucene正好可以拿来完成一个后端的搜索系统.

实现思路:

  1. 对博客目录下的所有已md结尾的文件建立索引.并将索引写在硬盘上的某个目录下.
  2. 提供重建索引的API,因为文章可能会修改,以及新增.
  3. 提供根据关键字查找的API.
  4. 根据查找API返回的结果在前端进行渲染.

第四步就不说了,我凑活写了一点JS代码,能够比较丑的渲染出来.

对应于上面的架构图,这里详细写一下博客搜索的实现方法:
首先是建索引的过程,对应图中由下到上.

  1. Row Data为在磁盘上存储的博客文件.
  2. acqure content以及build document为自己编码实现,主要是扫面磁盘文件,并且将其转换成Lucene Document的格式.
  3. Analyzs Document使用了Lucene的分词机制,使用的分词器为IKAnalyzier.
  4. index document使用lucene的索引API.

然后是搜索过程:

  1. search ui是由前端完成,直接传入搜索字符串.
  2. 使用lucene分词机制对搜索词进行分词
  3. 调用lucene查询API,查询文件
  4. 调用lucene解析结果的API拿到结果,封装,返回至前端.
  5. 前端进行渲染.

添加依赖

使用Maven管理项目的话:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
<!-- https://mvnrepository.com/artifact/org.apache.lucene/lucene-core -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>8.0.0</version>
</dependency>

<!-- https://mvnrepository.com/artifact/org.apache.lucene/lucene-queryparser -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-queryparser</artifactId>
<version>8.0.0</version>
</dependency>

<!-- https://mvnrepository.com/artifact/org.apache.lucene/lucene-highlighter -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-highlighter</artifactId>
<version>8.0.0</version>
</dependency>

<!-- https://mvnrepository.com/artifact/org.apache.lucene/lucene-analyzers-common -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analyzers-common</artifactId>
<version>8.0.0</version>
</dependency>

建立索引代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
/**
* 使用IndexWriter对数据创建索引
*/
public void create() throws IOException {

if (deleteAllIndex()) {
logger.info("clear the index.");
} else {
logger.info("cleat the index error, stop create index.");
return;
}

// 索引存放的位置...
Directory d = FSDirectory.open(FileSystems.getDefault().getPath(indexPath));

logger.info("start create index for blog.");
// 索引写入的配置
Analyzer analyzer = new JcsegAnalyzer(1);// 分词器
IndexWriterConfig conf = new IndexWriterConfig(analyzer);
// 构建用于操作索引的类
IndexWriter indexWriter = new IndexWriter(d, conf);
int curFileNum = FILE_NUM.get();
findFile(blogPath, indexWriter);
logger.info("add {} file into index.", FILE_NUM.get() - curFileNum);

indexWriter.close();
}

private boolean deleteAllIndex() {
File indexDir = new File(indexPath);
if (indexDir.isDirectory()) {
File[] files = indexDir.listFiles();
if (files == null || files.length == 0) {
logger.info("index dir is em[ty.");
return true;
}
for (File file : files) {
if (!file.delete()) {
logger.info("delete some index error, file = {}", file.getAbsolutePath());
return false;
}
}
return true;
}
logger.info("delete index error, because the file is not a dir.");
return false;
}

private void findFile(String blogPath, IndexWriter indexWriter) throws IOException {
File file = new File(blogPath);
if (file.exists()) {
File[] files = file.listFiles();
if (files == null || files.length == 0) {
logger.info("this file is empty, path = {}", file.getAbsolutePath());
} else {
for (File file2 : files) {
if (file2.isDirectory()) {
logger.info(" {} is a directory, find in it.", file2.getAbsolutePath());
findFile(file2.getAbsolutePath(), indexWriter);
} else {
if (file2.getName().endsWith(".md")) {
logger.info("find a md file = {} , make index", file2.getAbsolutePath());
FILE_NUM.incrementAndGet();
addFile(indexWriter, file2);
}
}
}
}
} else {
logger.warn("file is not exist");
}
}

private void addFile(IndexWriter indexWriter, File file) throws IOException {
// 通过IndexWriter来创建索引
// 索引库里面的数据 要遵守一定的结构(索引结构,document)
Document doc = new Document();
String titleValue = file.getName().replace(".md", "");
IndexableField title = new TextField("title", titleValue, Field.Store.YES);

String contentValue = readToString(file);
IndexableField content = new TextField(
"content", contentValue, Field.Store.YES);

Map<String, List<String>> profiles = getTagsAndCategoriesFromContent(file, contentValue);

List<String> tagList = profiles.get(TAGS_KEY);
String tagStr = String.join(",", tagList);
IndexableField tags = new TextField("tags", tagStr, Field.Store.YES);

List<String> categoriesList = profiles.get(CATEGORIES_KEY);
String categoriesStr = String.join(",", categoriesList);
IndexableField categories = new TextField("categories", categoriesStr, Field.Store.YES);


doc.add(title);
doc.add(content);
doc.add(tags);
doc.add(categories);
// document里面也有很多字段
indexWriter.addDocument(doc);
}

这里面主要实现了四个方法:

  1. 创建索引的入口
  2. 删除当前的所有索引
  3. 遍历查找文件
  4. 将查找到的文件读取并调用Lucene API建立索引.

查找API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
public List<SearchArticleVO> search(String target) throws IOException {

List<SearchArticleVO> result = new ArrayList<>();
String[] fields = {"title", "tags", "content"};
for (String field : fields) {
paddingResultByField(target, result, field);
if (result.size() >= 10) {
break;
}
}

return result;
}

private void paddingResultByField(String target, List<SearchArticleVO> result, String field) throws IOException {
// 索引存放的位置...
Directory d = FSDirectory.open(FileSystems.getDefault().getPath(indexPath));

// 通过indexSearcher去检索索引目录
IndexReader indexReader = DirectoryReader.open(d);
IndexSearcher indexSearcher = new IndexSearcher(indexReader);

// 这是一个搜索条件,根据这个搜索条件我们来进行查找
// term是根据哪个字段进行检索,以及字段对应值
//================================================
Query query = new TermQuery(new Term(field, target));

// 搜索先搜索索引目录
// 找到符合条件的前100条数据
TopDocs topDocs = indexSearcher.search(query, 100);
logger.info("search keyword = {}, getNum = {}", target, topDocs.totalHits);
ScoreDoc[] scoreDocs = topDocs.scoreDocs;
for (ScoreDoc scoreDoc : scoreDocs) {
//得分采用的是VSM算法
logger.info("score = {}", scoreDoc.score);
//获取查询结果的文档的惟一编号,只有获取惟一编号,才能获取该编号对应的数据
int doc = scoreDoc.doc;
//使用编号,获取真正的数据
Document document = indexSearcher.doc(doc);

String title = document.get("title").substring(11);
logger.info("get article name = {}", title);

String tagStr = document.get("tags");
String content = document.get("content");
String categoriesStr = document.get("categories");

List<String> tagList = Stream.of(tagStr.split(",")).collect(Collectors.toList());
List<String> caList = Stream.of(categoriesStr.split(",")).collect(Collectors.toList());


String cateUrl = String.join("/", caList) + "/";


String dateUrl = document.get("title").substring(0, 11).replace("-", "/");


String url = "http://huyan.couplecoders.tech/" + cateUrl.toLowerCase() + dateUrl + title;

int firstIndex = content.indexOf(target);

String targetStr = content.substring(firstIndex - 15 < 0 ? 0 : firstIndex - 15, firstIndex + 15 > content.length() ? content.length() - 1 : firstIndex + 15).replaceAll("\n", "");

result.add(SearchArticleVO.builder()
.title(title).content(content)
.tags(tagList)
.url(url)
.categories(caList)
.targetStr(targetStr)
.build());

}
}

private Map<String, List<String>> getTagsAndCategoriesFromContent(File file, String content) {
Map<String, List<String>> map = new HashMap<>();

Pattern r = Pattern.compile("---\n.*---\n", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
Matcher m = r.matcher(content);
if (m.find()) {
String s = m.group();

//tag
List<String> tags = new ArrayList<>();
Pattern p1 = Pattern.compile("- .*\n");
Matcher m1 = p1.matcher(s);
while (m1.find()) {
tags.add(m1.group(0).replace("\n", "").replace("- ", ""));
}
map.put(TAGS_KEY, tags);
logger.info("load {} tags from {} done.", tags.size(), file.getAbsolutePath() + file.getName());

//category
List<String> categories = new ArrayList<>();
Pattern p2 = Pattern.compile("category:.*\n");
Matcher m2 = p2.matcher(s);
while (m2.find()) {
List<String> tmp = Stream.of(m2.group()
.replace("category: ", "")
.replace("]", "")
.replace("\n", "")
.replace("[", "")
.split(",")).collect(Collectors.toList());
categories.addAll(tmp);
}
map.put(CATEGORIES_KEY, categories);
logger.info("load {} categories from {} done.", categories.size(), file.getAbsolutePath() + file.getName());

} else {
logger.warn("not match tags or categories from {}, warning.", file.getAbsolutePath() + file.getName());
}


return map;
}

这里主要实现的是:

  • 根据传入嗯关键字进行查找,然后获取真正的内容进行返回.
  • 返回的时候需要拼装多个字段,在上面的代码中有很大一部分进行了这个操作.

实现的效果

这个是纯后端的一个项目,实现的效果较为难以量化.

体验地址

在博客的SEARCH页面中添加了入口,可以输入关键字进行搜索.

搜索效率比较高,我在后台实际测试在毫秒级.

关于重建索引的说明

Lucene其实是支持增量的添加索引的,否则在数据量极其大的情况下,每次都全量的重建索引是不科学的.

但是在本文中,我采用的是每次清空索引,全部重建,原因主要有以下:

  1. 我的数据量很小,1k篇文章最多了.
  2. 我只是写个Demo,增量添加索引打算后续研究以下.
  3. 每次不止是添加文章,还可能对已有的文章进行了一些修改,所以在这个情况下的增量添加索引我没整明白.

存在的问题

  1. 就像上面写的,需要解决增量添加索引的问题,全量更新不是长久之计.
  2. 我测试的效率还不错,但是远没有达到预期,因为在我的数据量下需要100ms,那么在真正的应用场景中,这个延迟肯定是不能接受的,所以还有优化的空间.
  3. Lucene的Collector功能十分丰富,应该是有提供更科学一点的方式来进行结果数据的组装,我现在的手动组装太愚蠢了.
  4. Lucene应该支持目标内容高亮,目前我在前端实现的目标高亮算法实在是…,所以需要切换一下高亮内容的获取方法.

这些问题将在后续的文章中一一解决.

参考文章

Lucene-维基百科

基于Java的全文检索引擎简介

完。





ChangeLog

2019-04-07 完成

以上皆为个人所思所得,如有错误欢迎评论区指正。

欢迎转载,烦请署名并保留原文链接。

联系邮箱:huyanshi2580@gmail.com

更多学习笔记见个人博客——>呼延十