什么是搜索引擎?
搜索引擎的原理
在上述三个步骤中,java要解决的往往是后两个步骤:数据处理和搜索。
那么,我们之前学习的mysql知识也能实现数据的存储和搜索,为什么还要学新的东西呢?
要实现类似百度的复杂搜索,或者京东的商品搜索,如果使用传统的数据库存储数据,那么会存在一系列的问题:
在这里,比较棘手的其实是第二个问题:查询效率低,类似百度和京东这样的网站,对性能要求极高。如果用户点击搜索需要很久才能拿到数据,没人愿意一直等待下去。
那么问题来了:如何才能提高模糊搜索时的效率呢?
答案是:倒排索引技术
倒排索引是一种存储数据的方式,与传统查找有很大区别:
id | title | url |
---|---|---|
1 | 谷歌地图之父跳槽FaceBook | http://www.jd.com/434 |
2 | 谷歌地图之父加盟FaceBook | |
3 | 谷歌地图创始人拉斯离开谷歌加盟Facebook | |
4 | 谷歌地图之父跳槽Facebook与Wave项目取消有关 | |
5 | 谷歌地图之父拉斯加盟社交网站Facebook |
当我们需要把这些数据创建倒排索引时,会分为两步:
1)创建文档列表(Document)
首先给每一条原始的文档数据创建文档编号(docID),创建索引,形成文档列表:
2)创建倒排索引列表
然后对文档中的数据进行分词,得到词条。对词条进行编号,并以词条创建索引。然后记录下包含该词条的所有文档编号(及其它信息)。
搜索的基本流程:
举例:
例如用户要搜索关键词:拉斯跳槽
在java语言中,对倒排索引的实现中最广为人知的就是Lucene了,目前主流的java搜索框架都是依赖Lucene来实现的。
什么是全文检索?
这里有一个陌生的词语:全文检索。
其实全文检索就是利用倒排索引技术对需要搜索的数据进行处理,然后提供快速的全文匹配的技术。
下面我们来看下Lucene对于索引的增(创建索引)、删(删除索引)、改(修改索引)、查(搜索数据)。
<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.0modelVersion>
<groupId>cn.itcast.demogroupId>
<artifactId>lucene-demoartifactId>
<version>1.0.0-SNAPSHOTversion>
<dependencies>
<dependency>
<groupId>junitgroupId>
<artifactId>junitartifactId>
<version>4.12version>
dependency>
<dependency>
<groupId>org.apache.lucenegroupId>
<artifactId>lucene-coreartifactId>
<version>4.10.2version>
dependency>
<dependency>
<groupId>org.apache.lucenegroupId>
<artifactId>lucene-queryparserartifactId>
<version>4.10.2version>
dependency>
<dependency>
<groupId>org.apache.lucenegroupId>
<artifactId>lucene-analyzers-commonartifactId>
<version>4.10.2version>
dependency>
<dependency>
<groupId>org.apache.lucenegroupId>
<artifactId>lucene-highlighterartifactId>
<version>4.10.2version>
dependency>
dependencies>
project>
// 创建索引
@Test
public void testCreate() throws Exception{
// 创建文档对象
Document document = new Document();
// 创建并添加字段信息。参数:字段的名称、字段的值、是否存储,这里选Store.YES代表存储到文档列表。Store.NO代表不存储
document.add(new StringField("id", "1", Store.YES));
// 这里我们title字段需要用TextField,即创建索引又会被分词。StringField会创建索引,但是不会被分词
document.add(new TextField("title", "谷歌地图之父跳槽facebook", Field.Store.YES));
// 索引目录类,指定索引在硬盘中的位置
Directory directory = FSDirectory.open(new File("C:\\lesson\\indexDir"));
// 创建分词器对象
Analyzer analyzer = new StandardAnalyzer();
// 索引写出工具的配置对象
IndexWriterConfig conf = new IndexWriterConfig(Version.LATEST, analyzer);
// 创建索引的写出工具类。参数:索引的目录和配置信息
IndexWriter indexWriter = new IndexWriter(directory, conf);
// 把文档交给IndexWriter
indexWriter.addDocument(document);
// 提交
indexWriter.commit();
// 关闭
indexWriter.close();
}
在课前资料中有工具,帮我们查看生成的索引:
可以看到分词的方式不太正确,一个字作为一个词,这是分词方式的问题,我们后续会解决。
创建索引的API有一些细节需要我们注意。
我们在写索引时,可以在IndexConfigWriter中配置写入模式:覆盖或者追加:
[外链图片转存失败(img-llold7F3-1565249495006)(assets/1540367194859.png)]
可以有3种模式:
刚才创建Document的时候,我们添加了两个字段,StringField和TextField,其实Field还有很多其它实现类:
他们有一些不同的特性:
DoubleField、FloatField、IntField、LongField、StringField、TextField这些子类创建的字段一定会被创建索引。但是不一定会被存储到文档列表。要通过构造函数中的参数Store来指定:
这些字段虽然会创建索引,但是不一定会分词。不分词的字段,会作为一个整体词条存入索引,其中:
TextField即创建索引,又会被分词。其它Field会创建索引,但是不会被分词。
如果不分词,会造成整个字段作为一个词条,除非用户完全匹配,否则搜索不到:
[外链图片转存失败(img-Xl1QxMZz-1565249495009)(assets/wpsE6F8.tmp.jpg)]
上述所有字段都会创建索引,有一个例外:StoreField一定会被存储,但是一定不创建索引
StoredField可以创建各种数据类型的字段:
一般,一些不需要进行搜索的字段我们无需创建索引,就可以使用StoreField类型
到底该使用哪个字段?我们需要思考下面的问题:
问题1:这个字段是否需要创建索引?
问题2:这个字段是否需要存储?
问题3:这个字段是否需要分词?
这个字段首先要需要被搜索,因此剔除了StoreField。然后如果这个字段的值是不可分割的,那么就不需要分词,例如:ID;否则就需要分词
其实,这里最关键的是弄清楚一个字段:是否需要存储、是否需要索引、是否需要分词。弄清楚这个,就能知道怎么选择API了。
刚才的案例中,我们使用了StandardAnalyzer分词器,不过这个分词器对中文的解析能力很差。我们需要使用中文分词器。
这里我们使用IK分词器。
[外链图片转存失败(img-y8zocKh5-1565249495019)(assets/1540365760781.png)]
IK分词器官方版本是不支持Lucene4.X的,有人基于IK的源码做了改造,支持了Lucene4.X,我们可以通过maven引入其依赖:
<dependency>
<groupId>com.janeluogroupId>
<artifactId>ikanalyzerartifactId>
<version>2012_u6version>
dependency>
IK分词器的词库有限,如果是词库中没有出现的词条,不会被正确分词,例如这样一句话:
谷歌地图之父跳槽facebook,加盟啊策策大数据技术社区,屌爆了啊
分词结果:
如图:红色的词条是没有正确分词的;蓝色的词条是没有意义的词语。
我们期待:传智、屌爆了能作为一个完整词条;并且一些无关词语如:了、啊、额、入了可以不被分词。
新增加的词条可以通过配置文件添加到IK的词库中,也可以把一些不用的词条去除。
然后,在classpath下创建一个配置文件,名为:IKAnalyzer.cfg.xml,把刚刚填写的词典配置进去:
IK Analyzer 扩展配置
ext.dic;
stopword.dic;
结构:
再次测试后,查看结果:
我们刚才使用IndexWriter中的addDocument方法来添加文档,然后写出到索引库,是一次添加一个文档。事实上这里也支持批量添加:
可以看到这个API接收的是一个Interable类型,即迭代器类型,完全可以接收一个集合:
// 批量创建索引
@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();
}
基本流程:
代码如下:
@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 = reader.document(docID);
System.out.println("id: " + doc.get("id"));
System.out.println("title: " + doc.get("title"));
// 取出文档得分
System.out.println("得分: " + scoreDoc.score);
}
}
结果:
拓展:
得分的基本规则:
在刚才的基本查询中,我们使用QueryParser来解析并获取查询条件对象Query。事实上,Query有很多的子类,代表各种不同的特殊查询方式:
[外链图片转存失败(img-yrt5oDO4-1565249495033)(assets/1540368558865.png)]
我们创建不同的Query子类,就会实现不同的查询功能。
当我们使用各种不同查询时,其它代码几乎不动,就是查询条件在发生变化,因此我们可以把查询代码进行抽取:选中代码后按 Ctrl + Alt + m
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 = reader.document(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);
}
词条,英文是Term,代表对原始数据进行分词后得到的每一个词语。是搜索匹配时的最小单位,不可再分词。
因此词条查询必须是精确匹配查询。用户输入的查询条件必须是完整的词条。
示例:
/*
* 注意:Term(词条)是搜索的最小单位,不可再分词。值必须是字符串!
*/
@Test
public void testTermQuery() throws Exception {
// 创建词条查询对象
Query query = new TermQuery(new Term("title", "谷歌地图"));
search(query);
}
查询条件可以包含通配符:
/* 通配符查询:
* ? 可以代表任意一个字符
* * 可以任意多个任意字符
*/
@Test
public void testWildCardQuery() throws Exception {
// 创建查询对象
Query query = new WildcardQuery(new Term("title", "*歌*"));
search(query);
}
/*
* 测试模糊查询
*/
@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);
}
/*
* 测试:数值范围查询
* 注意:数值范围查询,可以用来对非String类型的ID进行精确的查找
*/
@Test
public void testNumericRangeQuery() throws Exception{
// 数值范围查询对象,参数:字段名称,最小值、最大值、是否包含最小值、是否包含最大值
Query query = NumericRangeQuery.newLongRange("id", 2L, 2L, true, true);
search(query);
}
/*
* 布尔查询:
* 布尔查询本身没有查询条件,可以把其它查询通过逻辑运算进行组合!
* 交集: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);
}
基本流程:
/**
* 注意,这里的更新接收的条件时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();
}
基本流程:
/**
* 删除的方式有多样:
* 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();
}