1.1 什么是全文检索?
计算机索引程序通过扫描文章中的每一个词,对每一个词建立一个索引,指明该词在文章中出现的次数和位置,当用户查询时,检索程序就根据事先建立的索引进行查找,并将查找的结果反馈给用户的检索方式
何为全文检索?举个例子,比如现在要在一个文件中查找某个字符串,最直接的想法就是从头开始检索,查到了就OK,这种对于小数据量的文件来说,很实用,但是对于大数据量的文件来说,就有点吃力了。或者说找包含某个字符串的文件,也是这样,如果在一个拥有几十个 G 的硬盘中找那效率可想而知,是很低的。
文件中的数据是属于非结构化数据,也就是说它没有什么结构可言,要解决上面提到的效率问题,首先我们得将非结构化数据中的一部分信息提取出来,重新组织,使其变得有一定结构,然后对这些有一定结构的数据进行搜索,从而达到搜索相对较快的目的。这就叫全文搜索。即先建立索引,再对索引进行搜索的过程。
总结:对文档(数据)中每一个词都做索引。
1.2 Lucene 建立索引的方式
那么 Lucene 中是如何建立索引的呢?假设现在有两篇文章,内容如下:
文章1的内容为:Tom lives in Guangzhou, I live in Guangzhou too.
文章2的内容为:He once lived in Shanghai.
首先第一步是将文档传给分词组件(Tokenizer),分词组件会将文档分成一个个单词,并去除标点符号和停词。所谓的停词指的是没有特别意义的词,比如英文中的 a,the,too 等。经过分词后,得到词元(Token) 。如下:
文章1经过分词后的结果:[Tom] [lives] [Guangzhou] [I] [live] [Guangzhou]
文章2经过分词后的结果:[He] [lives] [Shanghai]
然后将词元传给语言处理组件(Linguistic Processor),对于英语,语言处理组件一般会将字母变为小写,将单词缩减为词根形式,如 ”lives” 到 ”live” 等,将单词转变为词根形式,如 ”drove” 到 ”drive” 等。然后得到词(Term)。如下:
文章1经过处理后的结果:[tom] [live] [guangzhou] [i] [live] [guangzhou]
文章2经过处理后的结果:[he] [live] [shanghai]
最后将得到的词传给索引组件(Indexer),索引组件经过处理,得到下面的索引结构:
关键词 | 文章号[出现频率] | 出现位置 |
---|---|---|
guangzhou | 1[2] | 3,6 |
he | 2[1] | 1 |
i | 1[1] | 4 |
live | 1[2],2[1] | 2,5,2 |
shanghai | 2[1] | 3 |
tom | 1[1] | 1 |
以上就是Lucene 索引结构中最核心的部分。它的关键字是按字符顺序排列的,因此 Lucene 可以用二元搜索算法快速定位关键词。实现时 Lucene 将上面三列分别作为词典文件(Term Dictionary)、频率文件(frequencies)和位置文件(positions)保存。其中词典文件不仅保存有每个关键词,还保留了指向频率文件和位置文件的指针,通过指针可以找到该关键字的频率信息和位置信息。
搜索的过程是先对词典二元查找、找到该词,通过指向频率文件的指针读出所有文章号,然后返回结果,然后就可以在具体的文章中根据出现位置找到该词了。所以 Lucene 在第一次建立索引的时候可能会比较慢,但是以后就不需要每次都建立索引了,就快了。
理解了 Lucene 的分词原理,接下来我们在 Spring Boot 中集成 Lucene 并实现索引和搜索的功能。
1.3 Lucene与Solr的关系
2.1 依赖导入
<!--thymeleaf 模板 这里导入模板是因为后面测试需要用到-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- Lucence核心包 -->
<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>
<!-- 常规的分词(英文) -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analyzers-common</artifactId>
<version>4.10.2</version>
</dependency>
<!--支持分词高亮 -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-highlighter</artifactId>
<version>4.10.2</version>
</dependency>
<!--支持中文分词 -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analyzers-smartcn</artifactId>
<version>4.10.2</version>
</dependency>
<!--引入IK分词器 -->
<dependency>
<groupId>com.janeluo</groupId>
<artifactId>ikanalyzer</artifactId>
<version>2012_u6</version>
</dependency>
2.2 创建索引
/**
* 新建索引库
* @param location 索引库位置
* @throws IOException
*/
public static void createIndex(String location) throws IOException {
long startTime = System.currentTimeMillis();
//创建目录对象 指定索引库的存放位置
Directory dir = FSDirectory.open(new File(location));
//创建默认分词器对象
//Analyzer analyzer = new StandardAnalyzer();
//IK中文分词器
Analyzer analyzer = new IKAnalyzer();
//创建索引配置对象
IndexWriterConfig config = new IndexWriterConfig(Version.LATEST, analyzer);
//设置打开模式,默认为Append(追加索引) Create(覆盖索引)
//config.setOpenMode(IndexWriterConfig.OpenMode.CREATE);
//创建索引写入器对象
IndexWriter indexWriter = new IndexWriter(dir, config);
//创建文档对象
Document document1 = new Document();
//StringField等会创建索引,但是不会被分词。如果不分词,会造成整个字段作为一个词条,除非用户完全匹配,否则搜索不到
document1.add(new StringField("id", "11", Field.Store.YES)); //YES表示存储到文档列表,NO表示不存储
//TextField即创建索引,又会被分词。
document1.add(new TextField("title", "我靠,碉堡了哈", Field.Store.YES));
//向索引库写入文档对象
//indexWriter.addDocument(document);
Document document2 = new Document();
document2.add(new StringField("id", "12", Field.Store.YES)); //YES表示存储到文档列表,NO表示不存储
document2.add(new TextField("title", "hello,传智播客", Field.Store.YES));
//批量写入文档
List<Document> docs = new ArrayList<>();
for (int i = 0; i < 10; i++) {
Document document3 = new Document();
LongField id = new LongField("id", i, Field.Store.YES);//YES表示存储到文档列表,NO表示不存储
TextField title = new TextField("title", "hello,传智播客" + i, Field.Store.YES);
if (i==3){
//为该条数据增加权重,加上权重后查询时就会按权重出现在最前面(类似于百度的竞价排名)
title.setBoost(100f);
}
document3.add(id);
document3.add(title);
docs.add(document3);
}
docs.add(document1);
docs.add(document2);
indexWriter.addDocuments(docs);
//提交
indexWriter.commit();
//关闭
indexWriter.close();
long endTime = System.currentTimeMillis();
log.info("创建索引共耗时{}ms", endTime - startTime);
}
public static void main(String[] args) throws Exception{
//createIndex(LOCATION); //新建索引
queryIndex(LOCATION, "传智播客", "title"); //查询索引数据
//updateIndex(LOCATION); //更新索引
//deleteIndex(LOCATION); //删除索引
}
创建好索引后,就可以直接查看索引了,为了方便,我们这里先直接用索引查看工具查看
2.3 索引查看工具
注: lukeall的版本一定要和maven坐标的版本一致,否则将打不开查看工具,会报错
问题1:如何确定一个字段是否需要存储?
如果一个字段要显示到最终的结果中,那么一定要存储,否则就不存储
问题2:如何确定一个字段是否需要创建索引?
如果要根据这个字段进行搜索,那么这个字段就必须创建索引。
问题3:如何确定一个字段是否需要分词?
前提是这个字段首先要创建索引。然后如果这个字段的值是不可分割的,那么就不需要分词。例如:ID
2.4 Analyzer分词器
因为Lucene默认的分词器是不支持中文分词的,所以我们这里要引入第3方的IK分词器
//创建默认分词器对象
//Analyzer analyzer = new StandardAnalyzer();
//用IK中文分词器替换掉默认分词器即可
Analyzer analyzer = new IKAnalyzer();
扩展词典(新创建词功能):有些词IK分词器不识别例如:“蓝瘦”,“香菇”
停用词典(停用某些词功能):有些词不需要建立索引例如:“哦”,“啊”,“的”
IK分词器的词库有限,新增加的词条可以通过配置文件添加到IK的词库中,也可以把一些不用的词条去除:
xml配置文件:
<properties>
<comment>IK Analyzer 扩展配置comment>
<entry key="ext_dict">IK/ext.dic;entry>
properties>
新建扩展词典ext.dic 和停用词典stopword.dic
这样就加入了我们新的词,被停用的词语没有被分词
2.5. 查询索引数据
/**
* 查询索引
*
* @param location 索引库位置
* @param searchWord 检索词
* @param searchFiled 需要检索的字段
* @throws IOException
*/
public static List<String> queryIndex(String location, String searchWord, String searchFiled) throws IOException,
ParseException, InvalidTokenOffsetsException {
// 实际上Lucene本身不支持分页。因此我们需要自己进行逻辑分页。我们要准备分页参数:
int pageNum = 1;// 当前页码
int pageSize = 1;// 每页条数
int start = (pageNum - 1) * pageSize;// 当前页的起始条数
int end = start + pageSize;// 当前页的结束条数(不能包含)
// 初始化索引库对象
Directory directory = FSDirectory.open(new File(location));
// 索引读取工具
IndexReader indexReader = DirectoryReader.open(directory);
// 索引搜索对象
IndexSearcher indexSearcher = new IndexSearcher(indexReader);
IKAnalyzer analyzer = new IKAnalyzer();
// 1.普通查询解析器对象
QueryParser parser = new QueryParser(searchFiled, analyzer);
//2.多字段查询解析器(可以根据多个字段查询)
//MultiFieldQueryParser parser = new MultiFieldQueryParser(new String[]{"id","title"},new IKAnalyzer());
// 创建查询对象
Query query = parser.parse(searchWord);
// 格式化器
SimpleHTMLFormatter formatter = new SimpleHTMLFormatter("", "");
QueryScorer scorer = new QueryScorer(query);
//根据这个得分计算出一个片段
Fragmenter fragmenter = new SimpleSpanFragmenter(scorer);
// 准备高亮工具
Highlighter highlighter = new Highlighter(formatter, scorer);
//设置一下要显示的片段
highlighter.setTextFragmenter(fragmenter);
//3.词条查询(只能查询一个词,例如可以查询"谷歌",不能查询"谷歌地图",这是2个词)
//Query query = new TermQuery(new Term(searchFiled, searchWord));
//4.通配符
//?:通配一个字符
//*:通配多个字符
//Query query = new WildcardQuery(new Term(searchFiled, "*"+searchWord+"*"));
//5.模糊查询
//Query query = new FuzzyQuery(new Term(searchFiled, searchWord), 2);//允许写错的最大编辑距离
//6.数值范围查询(查询非String类型的数据或者说是一些继承Numeric类的对象的查询)
//Query query = NumericRangeQuery.newLongRange("id", 2L, 9L, true, true);
//7.组合查询
// 交集:Occur.MUST + Occur.MUST
// 并集:Occur.SHOULD + Occur.SHOULD
// 非:Occur.MUST_NOT
//BooleanQuery query = new BooleanQuery();
//Query query1 = NumericRangeQuery.newLongRange("id", 2L, 4L, true, true);
//Query query2 = NumericRangeQuery.newLongRange("id", 0L, 3L, true, true);
//query.add(query1, BooleanClause.Occur.SHOULD);
//query.add(query2, BooleanClause.Occur.SHOULD);
// 创建排序对象,需要排序字段SortField,参数:字段的名称、字段的类型、是否反转如果是false,升序。true降序
Sort sort = new Sort(new SortField("id", SortField.Type.LONG, false));
// 执行搜索操作,返回值topDocs包含命中数,得分文档
TopDocs topDocs = indexSearcher.search(query, end, sort);//MAX_VALUE:返回排名靠前的多少条字段 sort:排序
// 打印命中数
System.out.println("一共命中:" + topDocs.totalHits + "条数据");
// 获得得分文档数组对象,得分文档对象包含得分和文档编号
ScoreDoc[] scoreDocs = topDocs.scoreDocs;
List<String> list=new ArrayList<>();
for (int i = start; i < end; i++) {
ScoreDoc scoreDoc = scoreDocs[i];
System.out.println("得分:" + scoreDoc.score);
// 文档的编号
int doc = scoreDoc.doc;
System.out.println("编号:" + doc);
// 获取文档对象,通过索引读取工具
Document document = indexReader.document(doc);
System.out.println("id:" + document.get("id"));
String title = document.get(searchFiled);
if (title!=null){
//高亮显示
TokenStream tokenStream = analyzer.tokenStream(searchFiled, new StringReader(title));
String heightTitle = highlighter.getBestFragment(tokenStream, title);
//将查询到的数据放入list返回
list.add(heightTitle);
System.out.println(String.format("高亮显示后的{%s}为{%s}", searchFiled, heightTitle));
}
}
indexReader.close();
return list;
}
2.6 修改更新索引
/**
* 更新索引
*
* @param location
* @throws IOException
* @throws ParseException
*/
public static void updateIndex(String location) throws IOException,
ParseException {
// 创建文档对象
Document document = new Document();
document.add(new StringField("id", "9", Field.Store.YES));
document.add(new TextField("title", "谷歌地图之父跳槽FaceBook", Field.Store.YES));
// 索引库对象
Directory directory = FSDirectory.open(new File(location));
// 索引写入器配置对象
IndexWriterConfig conf = new IndexWriterConfig(Version.LATEST, new IKAnalyzer());
// 索引写入器对象
IndexWriter indexWriter = new IndexWriter(directory, conf);
// 执行更新操作(将查询到碉堡所有的数据全部更新为document)
indexWriter.updateDocument(new Term("title", "碉堡"), document);
// 提交
indexWriter.commit();
// 关闭
indexWriter.close();
}
2.7 删除索引
/**
* 删除索引
*
* @param location
* @throws IOException
* @throws ParseException
*/
public static void deleteIndex(String location) throws IOException,
ParseException {
// 创建目录对象
Directory directory = FSDirectory.open(new File(location));
// 创建索引写入器配置对象
IndexWriterConfig conf = new IndexWriterConfig(Version.LATEST, new IKAnalyzer());
// 创建索引写入器对象
IndexWriter indexWriter = new IndexWriter(directory, conf);
// 执行删除操作(根据词条),要求id字段必须是字符串类型
// indexWriter.deleteDocuments(new Term("id", "5"));
// 根据查询条件删除
// indexWriter.deleteDocuments(NumericRangeQuery.newLongRange("id", 2l, 4l, true, false));
// 删除所有
indexWriter.deleteAll();
// 提交
indexWriter.commit();
// 关闭
indexWriter.close();
}
2.8 高亮显示测试
准备thyleaf模板页面result.html
<html lang="en" xmlns:th="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8">
<title>Titletitle>
head>
<body>
<div th:each="desc : ${list}">
<div th:utext="${desc}">div>
div>
body>
html>
准备后端controller
@GetMapping("/lucene")
public String test(Model model) throws ParseException, InvalidTokenOffsetsException, IOException {
String indexDir="D:\\index";
List<String> list = IndexUtil.queryIndex(indexDir, "南京文化", "title");
model.addAttribute("list",list);
return "result";
}
测试结果
注:模板文件必须要放到resources/templates目录下
spring boot项目只有src目录,没有webapp目录,由于我们应用了Web模块,因此产生了 static目录与templates目录,前者用于存放静态资源,如图片、CSS、JavaScript等;后者用于存放Web页面的模板文件。