Lucene是apache软件基金会4 jakarta项目组的一个子项目,是一个开放源代码的全文检索引擎工具包,但它不是一个完整的全文检索引擎,而是一个全文检索引擎的架构,提供了完整的查询引擎和索引引擎,部分文本分析引擎(英文与德文两种西方语言)。Lucene的目的是为软件开发人员提供一个简单易用的工具包,以方便的在目标系统中实现全文检索的功能,或者是以此为基础建立起完整的全文检索引擎。
上面这段是百度百科的解释,简单的说就是一个实现全文检索引擎的工具包,里面提供了一些api接口供使用者调用。
需要注意的是lucene并不是一个搜索引擎,Lucene仅仅是一个工具包,它不能独立运行,不能单独对外提供服务。搜索引擎可以独立运行对外提供搜索服务,如百度,搜狐等。
搜索引擎是一种软件,能够为文本建立索引,能够根据索引搜索文本信息。如
web搜索:百度,google
桌面搜索:开始,运行里面的搜索。
企业搜索:站内搜索,企业知识库搜索。
全文检索是计算机程序通过扫描文章中的每一个词,对必要的词建立一个索引,指明该词在文章中出现的次数和位置。当用户查询时根据建立的索引查找,类似于通过字典的检索字表查字的过程。
全文检索(Full-Text Retrieval)是指以文本作为检索对象,找出含有指定词汇的文本。全面、准确和快速是衡量全文检索系统的关键指标。
关于全文检索,我们要知道:1,只处理文本。2,不处理语义。3,搜索时英文不区分大小写。4,结果列表有相关度排序。5,并且可以对结果具有过滤高亮的功能
在信息检索工具中,全文检索是最具通用性和实用性的。
全文检索的流程:索引流程、搜索流程
索引流程:采集数据(读取数据库,读取文件数据)—》文档处理(存储到索引库中,索引库文件可以存在内存也可以存在磁盘)
搜索流程:输入查询条件—》通过lucene的查询器查询索引—》从索引库中取出结—》视图渲染
上面已经讲解了lucene相关的一些基本知识,下面结合案例来讲解下lucene的使用,涉及到使用Lucene的API来实现对索引的增(创建索引)、删(删除索引)、改(修改索引)、查(搜索数据)。本案例是spring boot项目,从页面浏览器上输入要搜索的关键字,然后在控制里打印出搜索的数据信息,对于工程的创建和其他的一些无关代码就不粘贴了,主要粘贴一些主要的代码
4.0.0
org.springframework.boot
spring-boot-starter-parent
2.3.3.RELEASE
com.tp
lucene
0.0.1-SNAPSHOT
lucene
Demo project for lucene study
1.8
4.10.2
org.springframework.boot
spring-boot-starter
org.projectlombok
lombok
true
org.springframework.boot
spring-boot-starter-test
test
org.junit.vintage
junit-vintage-engine
org.springframework.boot
spring-boot-starter-web
junit
junit
4.12
org.apache.lucene
lucene-core
${lunece.version}
org.apache.lucene
lucene-queryparser
${lunece.version}
org.apache.lucene
lucene-analyzers-common
${lunece.version}
org.apache.lucene
lucene-highlighter
${lunece.version}
org.springframework.boot
spring-boot-devtools
true
org.springframework.boot
spring-boot-maven-plugin
package com.tp.lucene;
import lombok.Data;
/**
* @Package: com.tp.lucene
* @ClassName: Product
* @Author: tanp
* @Description: 产品实体类
* @Date: 2020/9/17 15:34
*/
@Data
public class Product {
int id;
String name;
String category;
float price;
String place;
String code;
Product(int id,String name,String category,float price,String place,String code){
this.id = id;
this.name = name;
this.category = category;
this.place = place;
this.code = code;
this.price = price;
}
Product(){}
@Override
public String toString() {
return "Product [id=" + id + ", name=" + name + ", category=" + category + ", price=" + price + ", place="
+ place + ", code=" + code + "]";
}
}
package com.tp.lucene;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.*;
import org.apache.lucene.queryparser.classic.QueryParser;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.ScoreDoc;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.search.highlight.Highlighter;
import org.apache.lucene.search.highlight.InvalidTokenOffsetsException;
import org.apache.lucene.search.highlight.QueryScorer;
import org.apache.lucene.search.highlight.SimpleHTMLFormatter;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.RAMDirectory;
import org.apache.lucene.util.Version;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.io.*;
import java.util.ArrayList;
import java.util.List;
/**
* 做 Lucene的思路。
* 1. 首先搜集数据
* 数据可以是文件系统,数据库,网络上,手工输入的,或者像本例直接写在内存上的
* 2. 通过数据创建索引
* 3. 用户输入关键字
* 4. 通过关键字创建查询器
* 5. 根据查询器到索引里获取数据
* 6. 然后把查询结果展示在用户面前
*/
/**
* @Package: com.tp.lucene
* @ClassName: TestLucene
* @Author: tanp
* @Description: ${description}
* @Date: 2020/9/17 10:34
*/
@RestController
@RequestMapping(value = "/lucene")
public class TestLucene {
@RequestMapping("lucene01")
public void Luncene01(String keyWord) throws Exception {
//1.创建分词器对象
Analyzer analyzer = new StandardAnalyzer();
//2.创建目录索引
Directory directory = createIndex(analyzer);
//3.创建查询器
Query query = new QueryParser("name", analyzer).parse(keyWord);
//4.获取搜索工具
IndexSearcher searcher = getSearch(directory);
//5.查询数据
ScoreDoc[] hits = getResults(searcher, query);
//6.展示数据
showSearchResults(hits, searcher, query, analyzer);
}
/**
* @Description 获取查询数据, 按照匹配度排名得分前N名的文档信息(包含查询到的总条数信息、所有符合条件的文档的编号信息)。
* @Date 2020/9/17 16:50
* @Author tanp
*/
private ScoreDoc[] getResults(IndexSearcher searcher, Query query) throws IOException {
//比如要查询第10页,每页10条数据。
//Lucene 分页通常来讲有两种方式:
//第一种是把100条数据查出来,然后取最后10条。 优点是快,缺点是对内存消耗大。
//第二种是把第90条查询出来,然后基于这一条,通过searchAfter方法查询10条数据。 优点是内存消耗小,缺点是比第一种更慢
int pageSize = 50;
int pageNum = 1;
//方式1
//查询出数据
// TopDocs topDocs = searcher.search(query, pageNum * pageSize);
// System.out.println("查询到的总条数\t" + topDocs.totalHits);
// //获取得分文档对象(ScoreDoc)数组.SocreDoc中包含:文档的编号、文档的得分
// ScoreDoc[] alllScores = topDocs.scoreDocs;
// List hitScores = new ArrayList<>();
// int start = (pageNum - 1) * pageSize;
// int end = pageSize * pageNum;
// for (int i = start; i < end; i++) {
// hitScores.add(alllScores[i]);
// }
// ScoreDoc[] hits = hitScores.toArray(new ScoreDoc[]{});
// return hits;
//方式2
int start = (pageNum - 1) * pageSize;
if(0==start){
TopDocs topDocs = searcher.search(query, pageNum*pageSize);
return topDocs.scoreDocs;
}
// 查询数据, 结束页面自前的数据都会查询到,但是只取本页的数据
TopDocs topDocs = searcher.search(query, start);
//获取到上一页最后一条
ScoreDoc preScore= topDocs.scoreDocs[start-1];
//查询最后一条后的数据的一页数据
topDocs = searcher.searchAfter(preScore, query, pageSize);
return topDocs.scoreDocs;
}
/**
* @Description 展示查询到的数据
* @Date 2020/9/17 14:34
* @Author tanp
*/
private void showSearchResults(ScoreDoc[] hits, IndexSearcher searcher, Query query, Analyzer analyzer) throws IOException, InvalidTokenOffsetsException {
System.out.println("找到 " + hits.length + " 个命中.");
System.out.println("序号\t匹配度得分\t结果");
// 查询高亮,对于name字段中符合keyWord的高亮
QueryScorer score = new QueryScorer(query);
// 定制高亮标签
SimpleHTMLFormatter simpleHTMLFormatter = new SimpleHTMLFormatter("", "");
// 高亮分析器
Highlighter highlighter = new Highlighter(simpleHTMLFormatter, score);
for (int i = 0; i < hits.length; i++) {
ScoreDoc scoreDoc = hits[i];
// 取出文档编号
int docId = scoreDoc.doc;
// 根据编号去找文档
Document document = searcher.doc(docId);
System.out.print((i + 1));
System.out.print("\t" + scoreDoc.score);
List fields = document.getFields();
for (IndexableField f : fields) {
if ("name".equals(f.name())) {
//name字段中,符合搜索keyWord的高亮展示
TokenStream tokenStream = analyzer.tokenStream(f.name(), new StringReader(document.get(f.name())));
// 获取高亮的片段
String fieldContent = highlighter.getBestFragment(tokenStream, document.get(f.name()));
System.out.print("\t" + fieldContent);
} else {
System.out.print("\t" + document.get(f.name()));
}
}
System.out.println();
}
}
/**
* @Description 获取索引搜索工具
* @Date 2020/9/17 11:43
* @Author tanp
*/
private IndexSearcher getSearch(Directory directory) throws Exception {
if (directory == null) {
//获取内存目录索引
directory = new RAMDirectory();
//磁盘目录索引,要么使用内存目录索引,要么使用自磁盘目录索引,二选一
//directory = FSDirectory.open(new File("d:\\indexDir"));
}
//创建索引读取工具
IndexReader indexReader = DirectoryReader.open(directory);
//创建索引搜索工具
IndexSearcher searcher = new IndexSearcher(indexReader);
return searcher;
}
/**
* @Description 创建索引
* @Date 2020/9/17 10:43
* @Author tanp
*/
private Directory createIndex(Analyzer analyzer) throws Exception {
//1.创建内存目录索引
Directory directory = new RAMDirectory();
//磁盘目录索引,要么使用内存目录索引,要么使用自磁盘目录索引,二选一
//Directory directory = FSDirectory.open(new File("d:\\indexDir"));
//2.根据分词器创建写出工具配置对象
IndexWriterConfig config = new IndexWriterConfig(Version.LATEST, analyzer);
//设置打开方式,OpenMode.CREATE会先清空原来的索引,再添加新的索引,如果不设置,多运行几次则会发现同样的id的数据在索引中存储了好几份
config.setOpenMode(IndexWriterConfig.OpenMode.CREATE);
//3.创建索引的写出工具类。参数:索引的目录和配置信息
IndexWriter indexWriter = new IndexWriter(directory, config);
//4.将数据写入索引
addDoc(indexWriter);
//额外功能:删除id=51173的数据
//indexWriter.deleteDocuments(new Term("id", "51173"));
//删除全部数据
//indexWriter.deleteAll();
//额外功能:更新
Document doc = new Document();
doc.add(new TextField("id", "141769", Field.Store.YES));
doc.add(new TextField("name", "神鞭,鞭没了,神还在", Field.Store.YES));
doc.add(new TextField("category", "道具", Field.Store.YES));
doc.add(new TextField("price", "998", Field.Store.YES));
doc.add(new TextField("place", "南海群岛", Field.Store.YES));
doc.add(new TextField("code", "888888", Field.Store.YES));
indexWriter.updateDocument(new Term("id", "141769"), doc );
indexWriter.commit();
indexWriter.close();
return directory;
}
/**
* @Description 将数据写入索引
* @Date 2020/9/17 10:53
* @Author tanp
*/
private void addDoc(IndexWriter indexWriter) throws Exception {
//1.获取数据,本次直接模拟10条数据,还可从文件中获取,数据库中获取
//List products = getProducts();
//从文本中获取数据
List products = getProducts1();
for (Product p : products) {
//每条数据创建一个Document,并把这个Document放进索引里。
Document doc = new Document();
// 创建并添加字段信息。参数:字段的名称、字段的值、是否存储,这里选Store.YES代表存储到文档列表。Store.NO代表不存储
// 这里我们name字段需要用TextField,即创建索引又会被分词。StringField会创建索引,但是不会被分词
doc.add(new TextField("id", String.valueOf(p.getId()), Field.Store.YES));
doc.add(new TextField("name", p.getName(), Field.Store.YES));
doc.add(new TextField("category", p.getCategory(), Field.Store.YES));
doc.add(new TextField("price", String.valueOf(p.getPrice()), Field.Store.YES));
doc.add(new TextField("place", p.getPlace(), Field.Store.YES));
doc.add(new TextField("code", p.getCode(), Field.Store.YES));
indexWriter.addDocument(doc);
}
}
/**
* @Description 从文件中获取模拟产品数据
* @Date 2020/9/17 16:25
* @Author tanp
*/
private List getProducts1() throws Exception {
String fileName = "140k_products.txt";
File file = new File(fileName);
FileInputStream fileInputStream = new FileInputStream(file);
InputStreamReader isr = new InputStreamReader(fileInputStream, "UTF-8");
BufferedReader br = new BufferedReader(isr);
String line;
List products = new ArrayList<>();
while ((line = br.readLine()) != null) {
Product p = new Product();
String[] fields = line.split(",");
p.setId(Integer.parseInt(fields[0]));
p.setName(fields[1]);
p.setCategory(fields[2]);
p.setPrice(Float.parseFloat(fields[3]));
p.setPlace(fields[4]);
p.setCode(fields[5]);
products.add(p);
}
br.close();
isr.close();
fileInputStream.close();
return products;
}
/**
* @Description 手动输入产品信息模拟数据
* @Date 2020/9/17 15:21
* @Author tanp
*/
private List getProducts() {
List products = new ArrayList<>();
products.add(new Product(0, "飞利浦led灯泡e27螺口暖白球泡灯家用照明超亮节能灯泡转色温灯泡", "照明用品", 23.01F, "湖南长沙", "000"));
products.add(new Product(1, "飞利浦led灯泡e14螺口蜡烛灯泡3W尖泡拉尾节能灯泡暖黄光源Lamp", "照明用品", 23.01F, "湖南长沙", "001"));
products.add(new Product(2, "雷士照明 LED灯泡 e27大螺口节能灯3W球泡灯 Lamp led节能灯泡", "照明用品", 23.01F, "湖南长沙", "002"));
products.add(new Product(3, "飞利浦 led灯泡 e27螺口家用3w暖白球泡灯节能灯5W灯泡LED单灯7w", "照明用品", 23.01F, "湖南长沙", "003"));
products.add(new Product(4, "飞利浦led小球泡e14螺口4.5w透明款led节能灯泡照明光源lamp单灯", "照明用品", 23.01F, "湖南长沙", "004"));
products.add(new Product(5, "飞利浦蒲公英护眼台灯工作学习阅读节能灯具30508带光源", "照明用品", 23.01F, "湖南长沙", "005"));
return products;
}
}
首先我们可以看到在Luncene01方法中一共分了6步,我们也根据这六大步来讲解一下知识点
提供分词算法,可以把文档中的数据按照算法分词,如我们搜索护眼光源的关键字,我们首先看下假设查询数据库数据,我们会写下如下sql
select * from tableA where nameA like %护眼光源% (tableA为表名,nameA为字段名),无论如何写sql,数据库里存储的数据一定是护眼光源几个字连在一起的,而 Analyze分词器则可以把护眼光源几个字数据分开来
Directory是目录类描述了索引的存储位置,底层封装了I/O操作,负责对索引进行存储。它是一个抽象类,它的子类常用的包括FSDirectory(在文件系统存储索引)、RAMDirectory(在内存存储索引)。
FSDirectoryRAMDirectory是内存目录,会把索引库保存在内存,特点是速度快,但不是很安全。
RAMDirectory是磁盘文件系统目录,会把索引库指向本地磁盘,特点是速度稍慢,但比较安全。
索引写出器配置类,可以设置最大缓存文档数,对documents建立索引的线程池,索引段的合并策略等。这个类里面最重要的还是它里面的三个枚举变量CREATE,APPEND,CREATE_OR_APPEND,如代码案例中设置的就是create模式
CREATE模式:这个模式下,每次新建的索引都会先清空上次索引的目录,然后在新建当前的索引,注意可以不用事先创建索引目录,这个模式一般是测试时候用的。
APPEND模式:这个模式下,每次新添加的索引,会被追加到原来的索引里,有一点需要注意的是,如果这个索引路径不存在的话,这个操作,将会导致报出一个异常,所以,使用此模式前,务必确定你有一个已经创建好的索引。
CREATE_OR_APPEND模式:这个模式就是我们默认 的模式,也是比较安全或者比较通用的模式,如果这个索引不存在,那么在此模式下就会新建一个索引目录,如果已存在,那么在添加文档的时候,直接会以Append的方式追加到索引里,所以此模式下,并不会出现一些意外的情况,所以大多数时候,建议使用此方式,进行构建索引。
IndexWriter是索引过程的核心组件,通过IndexWriter可以创建新索引、更新索引、删除索引操作。IndexWriter需要通过Directory对索引进行存储操作。
一个Document文档对象,是一条原始的数据,就相当于数据库中一条数据
一个Field就类似于数据库中的每个字段。一个Document中可以有很多个不同的字段,每一个字段都是一个Field类的对象。
一个Document中的字段其类型是不确定的,因此Field类就提供了各种不同的子类,来对应这些不同类型的字段。下边列出了开发中常用 的Filed类型,注意Field的属性,根据需求选择:
Field类 | 数据类型 | Analyzed是否分词 | Indexed是否索引 | Stored是否存储 | 说明 |
---|---|---|---|---|---|
StringField(FieldName, FieldValue,Store.YES)) | 字符串 | N | Y | Y或N | 这个Field用来构建一个字符串Field,但是不会进行分词,会将整个串存储在索引中,比如(订单号,身份证号等)是否存储在文档中用Store.YES或Store.NO决定 |
LongField(FieldName, FieldValue,Store.YES) | Long型 | Y | N | Y或N | 这个Field用来构建一个Long数字型Field,进行分词和索引,比如(价格)是否存储在文档中用Store.YES或Store.NO决定 |
StoredField(FieldName, FieldValue) | 重载方法,支持多种类型 | N | N | Y | 这个Field用来构建不同类型Field不分析,不索引,但要Field存储在文档中 |
TextField(FieldName, FieldValue, Store.NO)或TextField(FieldName, reader) | 字符串或流 | Y | Y | Y或N | 如果是一个Reader, lucene猜测内容比较多,会采用Unstored的策略. |
DoubleField、FloatField、IntField、LongField、StringField、TextField这些子类一定会被创建索引,但是不会被分词,而且不一定会被存储到文档列表。要通过构造函数中的参数Store来指定:如果Store.YES代表存储,Store.NO代表不存储
TextField即创建索引,又会被分词。StringField会创建索引,但是不会被分词。如果不分词,会造成整个字段作为一个词条,除非用户完全匹配,否则搜索不到:
判断一个字段是否需要存储的前提是,该字段是否要显示到最终的结果中,如果要那么一定要存储,否则就不存储
判断一个字段是否需要创建索引的前提是,是否要要根据这个字段进行搜索
包含要查询的关键词信息,可通过QueryParser来创建查询对象(常用的方法),也可通过Query子类来创建查询对象,Query子类常用的如下:
TermQuery:精确的词项查询,如 Query query = new TermQuery(new Term("name", "小王"));
NumericRangeQuery:数值范围查询,如 Query query = NumericRangeQuery.newLongRange("id", 2L, 2L, true, true);
BooleanQuery:组合查询 ,身没有查询条件,可以把其它查询通过逻辑运算进行组合
// 交集:Occur.MUST + Occur.MUST
// 并集:Occur.SHOULD + Occur.SHOULD
// 非:Occur.MUST_NOT
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, BooleanClause.Occur.MUST_NOT);
query.add(query2, BooleanClause.Occur.SHOULD);
QueryParser(单一字段的查询解析器),MultiFieldQueryParser(多字段的查询解析器)
索引搜索对象,执行搜索功能,实现快速搜索、排序、打分等功能。需要依赖IndexReader类
索引读取对象
scoreDoc是得分文档对象,包含两部分数据。文档的唯一编号和文档的得分信息
查询结果对象,包含两部分数据,查询到的总条数信息、所有符合条件的得分文档数组
高亮展示如代码中所展示
如我在浏览器输入灯光
控制台打印结果如下
本博客中涉及到解析文件数据,格式要求如下