Java大数据开发(一)- 搜索引擎 Lucene

Lucene

写在前面:本文中用到的 Apache Lucene 版本号是 4.10.2 截止到文章发布时官方的最新版本是 6.5.1 因不同的版本差异较大,请大家在学习过程中确认下版本号是否一致。本文中的所涉及到的源码分享在在 Gighub 链接地址:Part01_Lucene

1.搜索引擎

1.1 - 概述

  • 概述:根据一定的策略、运用特定的计算机程序从互联网上搜集信息,再对信息进行 组织(分词)处理(添加索引) 后,为用户提供检索服务,将用户检索相关的信息展示给用户的系统。搜索引擎包括 全文索引目录索引元搜索引擎垂直搜索引擎集合式搜索引擎门户搜索引擎免费链接列表等

1.2 - 搜索原理

搜索引擎原理

1.3 - 应用场景

  1. 大型综合搜索网站
  2. 站内搜索
  3. 垂直领域搜索
  4. 软甲内部搜索

1.4 - 搜索技术

  • SQL 进行模糊查询:如果没有前置 % 可以执行索引,如果添加前置 % 则全文检索。
  • Lucene:解决在海量数据的情况下,利用 倒排索引 技术,实现快速的 搜索打分排序 等功能

1.5 - 倒排索引

根据词条查找文档

  • 名词解释:

    • 文档(Document):索引库中的每一条原始数据。
    • 词条(Term):原始数据按照算法进行 分词,得到的每一个词语。
    • 文档列表:Lucene 对原始文档进行编号(DocID),形成的列表就是文档列表。
  • 创建文档列表:Lucene 首先对原始文档数据进行编号(DocId),形成文档列表

文档编号 ID Title
0 1 谷歌地图之父跳槽Facebook
1 2 谷歌地图之父加盟Facebook
2 3 谷歌地图创始人拉斯离开谷歌加盟Facebook
3 4 谷歌地图之父跳槽Facebook与Wave项目取消有关
4 5 谷歌地图之父拉斯加盟社交网站Facebook
  • 创建倒排索引列表:对文档中数据进行分词,得到 词条(Term)。对词条添加编号并创建索引,并在词条中记录包含该词条的所有文档编号及其他信息。
词条ID 词条 倒排列表(包含该词条文档 ID)
1 谷歌 0,1,2,3,4
2 地图 0,1,2,3,4
3 之父 0,1,3,4
4 跳槽 0,3
5 Facebook 0,1,2,3,4
6 加盟 1,2,4
7 创始人 2
8 拉斯 2,4
9 离开 2
10 3
11 wave 3
12 项目 3
13 取消 3
14 有关 3
15 社交 4
16 网站 4
  • 搜索过程:
    1. 获得用户搜索内容,对搜索内容进行分词,得到用户搜索的所有词条。
    2. 将词条在倒排索引列表中进行匹配,得到包含该词条的所有文档编号。

2.Lucene

Apache Lucene

Apache Lucene

2.1 - 概述

  • 用于全文检索和搜寻的开源程序库,由 Apache 软件基金会支持和提供。Lucene 提供了简单强大应用程序接口(API),可以进行全文索引和搜索,可以用来制作搜索引擎产品。

2.2 - 全文检索

  • 计算机索引程序通过扫描文章中的每一个词,对每一个词建立一个索引,指明该词在文章中出现的 次数位置 ,当用户查询时,检索程序就根据事先建立的索引进行查找,并将查找的结果反馈给用户的检索方式。
  • 总结:Lucene 全文检索就是对文档中全部内容进行分词,然后对所有单词建立倒排索引的过程。

3.QuickStart

3.1 - 创建索引流程图

Lucene创建索引流程图
  1. 创建文档对象(Document),并添加索引Field字段(Field)

    • 索引字段:Field
  2. 创建目录对象(Directory)并指定索引在硬盘中存储位置

  3. 创建分词器对象(Analyzer)

  4. 创建索引写出器配置对象(IndexWriterConfig)

    • 索引分词器:Analyzer
    • 版本:Version
  5. 创建索引写出器(IndexWriter)

    • 目录:Directory
    • 索引写出器配置:IndexWriterConfig
  6. 索引写出器,添加文档对象

    • 文档:Document
  7. 提交并关闭索引写出器

3.2 - 创建索引

  • 导入依赖 jar:

    <dependency>
        <groupId>org.apache.lucene</groupId>
        <artifactId>lucene-core</artifactId>
        <version>${lucene.version}</version>
    </dependency>
    <dependency>
        <groupId>org.apache.lucene</groupId>
        <artifactId>lucene-analyzers-common</artifactId>
        <version>${lucene.version}</version>
    </dependency>
    
  • 代码示例:

    public class QuickStartTest {
    
        @Test
        public void createTest() throws IOException {
            /* 1.创建文档对象 */
            Document document = new Document();
            /*
            * 添加字段
            *
            * StringField: Field.Store.YES 表示存储到文档列表
            * TestField: 既创建索引 又分词
            * */
            document.add(new StringField("id", "1", Field.Store.YES));
            document.add(new TextField("title", "谷歌地图之父跳槽facebook", Field.Store.YES));
    
            /*
            * 2.创建目录类 指定索引在硬盘中位置
            * */
            Directory directory = FSDirectory.open(new File("/Users/zhangsiyao1/Desktop/indexDir"));
    
            /*
            * 3.创建分词器对象 analyzer
            * */
            Analyzer analyzer = new StandardAnalyzer();
    
            /*
            * 4.索引写出工具配置对象 config
            * */
            IndexWriterConfig config = new IndexWriterConfig(Version.LATEST, analyzer);
    
            /*
            * 5.创建索引写出工具类
            * */
            IndexWriter writer = new IndexWriter(directory, config);
    
            /*
            * 6.将文档添加到写出器工具类中
            * */
            writer.addDocument(document);
    
            /*
            * 7.提交 & 关闭 写出工具
            * */
            writer.commit();
            writer.close();
        }
    }
    

3.3 - 使用 lukeall 工具查看索引

LuceneData

4.创建索引详解

4.1 - Document

Document详解图
  • Document:代表一行数据
  • Field:代表 Document 中的一个字段

4.2 - Field

Field
  1. 存储 :StoreField 支持(byte[]、BytesRef、double、float、int、long、String)
  2. 创建索引 + 可选存储 :DoubleField、FloatField、IntField、LongField、StringField
  3. 创建索引 + 可选存储 + 分词 :TestField
  • 是否存储?:如果字段需要显示到最终结果中,则需要存储。
  • 是否创建索引?:如果根据该字段进行索引,则需要创建索引。
  • 是否分词?:前提需要创建索引,如果字段是不可分割的则不需要分词。

4.3 - Directory

Directory
  • FSDirectory:文件系统目录,将索引库指向本地磁盘。
    • 特点:速度略慢,较安全,节约内存。
  • RAMDirectory:内存目录,将索引保存在内存中。
    • 特点:速度快,不安全,占用内存。

4.4 - Analyzer

Analyzer
  • 没有适合中文的分词器,ChineseAnalyzer(弃用),需要使用第三方分词器

  • maven 导入 IKAnalyzer

    <dependency>
        <groupId>com.janeluo</groupId>
        <artifactId>ikanalyzer</artifactId>
        <version>2012_u6</version>
    </dependency>
    
  • 扩展词典和停用词典:在 resources 下创建 IKAnalyzer.cfg.xml

    <?xml version="1.0" encoding="UTF-8" ?>
    <!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
    <properties>
        <comment>IK Analyzer 扩展配置</comment>
        <!-- 配置扩展字典 -->
        <entry key="ext_dict">ext.dic</entry>
        <!-- 配置扩展停止分词字典 -->
        <entry key="ext_stopwords">stopword.dic</entry>
    </properties>
    

4.5 - IndexWriterConfig

  • 设置写出时,是否先清除索引库中数据:

public IndexWriterConfig setOpenMode(OpenMode openMode)

  • 打开索引库类型:
public static enum OpenMode {
    /** 
    * Creates a new index or overwrites an existing one. 
    */
    CREATE,
        
    /** 
    * Opens an existing index. 
    */
    APPEND,
        
    /** 
    * Creates a new index if one does not exist,
    * otherwise it opens the index and documents will be appended. 
    */
    CREATE_OR_APPEND 
}

4.6 - IndexWriter

  • 批量创建索引:
public void addDocuments(Iterable<? extends Iterable<? extends IndexableField>> docs)

5.查询索引

5.1 - 基本查询

public class QueryTest {

    private static final File INDEX_DIR_FILE = new File("/Users/zhangsiyao1/Desktop/indexDir");

    @Test
    public void baseSearchTest() throws IOException, ParseException {
        /* 索引目录对象 */
        Directory directory = FSDirectory.open(INDEX_DIR_FILE);
        /* 索引读取工具 */
        DirectoryReader directoryReader = DirectoryReader.open(directory);
        /* 索引搜索工具 */
        IndexSearcher indexSearcher = new IndexSearcher(directoryReader);

        /*
        * 创建查询解析器
        * 1.查询字段名称
        * 2.分词解析器
        * */
        QueryParser queryParser = new QueryParser("title", new IKAnalyzer());
        /* 获取查询对象 */
        Query query = queryParser.parse("谷歌地图之父拉斯");

        /*
        * 搜索数据
        * 1.查询解析器解析后的查询结果
        * 2.查询结果的最大条数
        * */
        TopDocs topDocs = indexSearcher.search(query, 10);

        /* 获取总条数 */
        int totalHits = topDocs.totalHits;
        System.out.println("本地搜索共查询到 " + totalHits + " 匹配记录");

        /*
        * 得分文档数组
        * 1.doc 文档编号(ID)
        * 2.score 文档得分数
        * */
        ScoreDoc[] scoreDocs = topDocs.scoreDocs;

        for (ScoreDoc scoreDoc : scoreDocs) {
            /* 文档编号 */
            int docID = scoreDoc.doc;
            /* 通过索引读取器 根据文档编号获取文档 */
            Document document = directoryReader.document(docID);

            System.out.println("DocID: " + document.get("id"));
            System.out.println("Title: " + document.get("title"));
            
            /* 文档得分 */
            System.out.println("Score: " + scoreDoc.score);
        }
    }
}

6.查询索引详解

  • 封装 Lucene 查询工具类 LuceneQueryUtils
public class LuceneQueryUtils {

    private static final File INDEX_DIR_FILE = new File("/Users/zhangsiyao1/Desktop/indexDir");

    public static void queryByQuery(Query query, int maxResult) throws IOException {
        /* 索引目录对象 */
        Directory directory = FSDirectory.open(INDEX_DIR_FILE);
        /* 索引读取工具 */
        DirectoryReader directoryReader = DirectoryReader.open(directory);
        /* 索引搜索工具 */
        IndexSearcher indexSearcher = new IndexSearcher(directoryReader);
        /* 搜索数据 */
        TopDocs topDocs = indexSearcher.search(query, maxResult);

        int totalHits = topDocs.totalHits;
        System.out.println("本地搜索共查询到 " + totalHits + " 匹配记录");
        System.out.println("=======================================");
        /*
        * 得分文档数组
        * */
        ScoreDoc[] scoreDocs = topDocs.scoreDocs;

        for (ScoreDoc scoreDoc : scoreDocs) {
            /* 文档编号 */
            int docID = scoreDoc.doc;
            /* 通过索引读取器 根据文档编号获取文档 */
            Document document = directoryReader.document(docID);
            System.out.println("DocID: " + document.get("id"));
            System.out.println("Title: " + document.get("title"));
            System.out.println("Score: " + scoreDoc.score);
            System.out.println("=======================================");
        }
    }
}

6.1 - MultiFieldQueryParser

  • 根据多字段查询:MultiFieldQueryParser

    MultiFieldQueryParser queryParser = new MultiFieldQueryParser(
            new String[]{"id", "title"},
            new IKAnalyzer()
    );
    

6.2 - Query

  • 方式一:通过 QueryParser 解析关键字,得到查询对象。
  • 方式二:自定义查询对象,通过 Query 子类,创建查询对象,实现高级查询。

6.3 - IndexSearch

  • 功能:快速搜索、排序、打分等功能,其依赖于 IndexReader 对象。

  • 基本创建过程:

    /* 索引目录对象 */
    Directory directory = FSDirectory.open(INDEX_DIR_FILE);
    /* 索引读取工具 */
    DirectoryReader directoryReader = DirectoryReader.open(directory);
    /* 索引搜索工具 */
    IndexSearcher indexSearcher = new IndexSearcher(directoryReader);
    
  • 根据打分排序指定位置结果:

    TopDocs topDocs = indexSearcher.search(query, 10);
    

6.4 - TopDocs

  • 获取对象:

    TopDocs topDocs = indexSearcher.search(query, 10);
    
  • 包含内容:

    • int totalHints:查询的总条数
    • ScoreDoc[] scoreDocs:得分文档对象数组

6.5 - ScoreDoc

  • 包含内容:
    • int doc:文档编号(ID),根据文档 ID 获取指定文档

      Document document = directoryReader.document(docID);
      
    • float score:文档得分

7.高级查询

7.1 - 词条查询

  • 概述:词条是搜索的最小单位 不可再分割 且值必须是字符串
public void termQueryTest() throws IOException {
   TermQuery termQuery = new TermQuery(new Term("title", "谷歌地图"));
   LuceneQueryUtils.queryByQuery(termQuery, 10);
}

7.2 - 通配符查询

  • ?:任意 1 个字符
  • *:任意 n 字符
public void wildcardQuery() throws IOException {
   WildcardQuery query = new WildcardQuery(new Term("title", "*歌"));
   LuceneQueryUtils.queryByQuery(query, 10);
}

7.3 - 模糊查询

  • maxEdits:最大编辑距离 一个单词到另一个单词最少修改次数 [0,2]
public void fuzzyQueryTest() throws IOException {
   FuzzyQuery fuzzyQuery = new FuzzyQuery(new Term("title", "facebool"), 1);
   LuceneQueryUtils.queryByQuery(fuzzyQuery, 10);
}

7.4 - 数值范围查询

  • 应用:id[2L,2L] 对非 String 类型的 ID 进行精确查找
public void numericRangeQueryTest() throws IOException {
   NumericRangeQuery<Long> numericRangeQuery = NumericRangeQuery.newLongRange("id", 2L, 2L, true, true);
   LuceneQueryUtils.queryByQuery(numericRangeQuery, 10);
}

7.5 - 组合查询

  • 交集: Occur.MUST + Occur.MUST
  • 并集: Occur.SHOULD + Occur.SHOULD
  • 补集: Occur.MUST_NOT
public void booleanQueryTest() throws IOException {
   NumericRangeQuery<Long> numericRangeQuery1 = NumericRangeQuery.newLongRange("id", 1L, 3L, true, true);
   NumericRangeQuery<Long> numericRangeQuery2 = NumericRangeQuery.newLongRange("id", 2L, 4L, true, true);
   BooleanQuery booleanQuery = new BooleanQuery();
   booleanQuery.add(numericRangeQuery1, BooleanClause.Occur.MUST_NOT);
   booleanQuery.add(numericRangeQuery2, BooleanClause.Occur.SHOULD);
   LuceneQueryUtils.queryByQuery(booleanQuery, 10);
}

8.修改索引

  • 问题:修改索引时,只能指定词条(Term)进行更新,词条只能是 String 类型,如果 id 为数值类型怎么更新?
  • 答案:先删除,再更新
public class UpdateIndexTest {

    private static final File INDEX_DIR_FILE = new File("/Users/zhangsiyao1/Desktop/indexDir");

    /*
    * 1.Lucene 底层先删除所有匹配的索引 再添加新文档
    * 2.一般修改功能会根据 Term 词条进行匹配
    * 3.根据一个唯一不重复字段进行匹配(ID)
    *
    * 问题: update 时 Term 词条搜索 要求 ID 必须是字符串 如果不是则不能使用这个方法
    * 解决: 先删除该词条 再添加更新后的词条
    * */
    @Test
    public void updateTest() throws IOException {
        /* 创建目录对象 */
        FSDirectory directory = FSDirectory.open(INDEX_DIR_FILE);
        /* 创建索引写出器配置对象 */
        IndexWriterConfig config = new IndexWriterConfig(Version.LATEST, new IKAnalyzer());
        /* 创建索引写出器 */
        IndexWriter writer = new IndexWriter(directory, config);

        Document document = new Document();
        document.add(new StringField("id", "1", Field.Store.YES));
        document.add(new TextField("title", "谷歌地图之父跳槽facebook为了加入Amazon", Field.Store.YES));
        writer.updateDocument(new Term("id", "1"), document);

        writer.commit();
        writer.close();
    }
}

9.删除索引

  • 方式一:根据 Term 删除,只能根据 String 类型的词条进行匹配删除。
  • 方式二:根据 Query 删除,可以是任意类型的词条进行匹配(更新 ID 非 String 类型文档的解决方案)。
  • 方式三:删除所有。
public class UpdateIndexTest {

    /*
    * 删除索引
    * */
    @Test
    public void deleteTest() throws IOException {
        /* 创建目录对象 */
        FSDirectory directory = FSDirectory.open(INDEX_DIR_FILE);
        /* 创建索引写出器配置对象 */
        IndexWriterConfig config = new IndexWriterConfig(Version.LATEST, new IKAnalyzer());
        /* 创建索引写出器 */
        IndexWriter writer = new IndexWriter(directory, config);

        /*
        * 1.根据词条 Term 进行删除 只能匹配 字符串类型 字段
        * */
        writer.deleteDocuments(new Term("id", "1"));

        /*
        * 2.根据 Query 删除 可以匹配任何类型的字段
        * */
        NumericRangeQuery<Long> numericRangeQuery = NumericRangeQuery.newLongRange("id", 2L, 2L, true, true);
        writer.deleteDocuments(numericRangeQuery);

        /* 3.删除所有 */
        writer.deleteAll();

        writer.commit();
        writer.close();
    }
}

10.Lucene 高级使用

10.1 - 高亮显示

  1. SimpleHTMLFormatter:HTML 格式化工具
  2. Highlighter:高亮工具
@Test
public void highLightTest() throws IOException, ParseException, InvalidTokenOffsetsException {
    /* 目录对象 */
    FSDirectory directory = FSDirectory.open(INDEX_DIR_FILE);
    /* 读取工具 */
    DirectoryReader reader = DirectoryReader.open(directory);
    /* 搜索工具 */
    IndexSearcher searcher = new IndexSearcher(reader);

    /* parse 方式获得 Query 对象 */
    QueryParser queryParser = new QueryParser("title", new IKAnalyzer());
    Query query = queryParser.parse("谷歌地图");

    /* HTML 格式化器 */
    Formatter formatter = new SimpleHTMLFormatter("<em>", "</em>");
    QueryScorer queryScorer = new QueryScorer(query);
    /* 准备高亮工具 */
    Highlighter highlighter = new Highlighter(formatter, queryScorer);

    /* 搜索 */
    TopDocs topDocs = searcher.search(query, 10);
    System.out.println("TotalHits: " + topDocs.totalHits);

    for (ScoreDoc scoreDoc : topDocs.scoreDocs) {
        Document document = reader.document(scoreDoc.doc);
        /*
        * 高亮工具处理普通查询结果
        * 参数一: 分词器
        * 参数二: 高亮字段名
        * 参数三: 高亮字段原始值
        * */
        String highLightTitle = highlighter.getBestFragment(new IKAnalyzer(), "title", document.get("title"));
        System.out.println(highLightTitle);
    }
}
  • 导入依赖 jar:
<dependency>
    <groupId>org.apache.lucene</groupId>
    <artifactId>lucene-highlighter</artifactId>
    <version>${lucene.version}</version>
</dependency>

10.2 - 排序

Sort sortArray = new Sort(new SortField("id", SortField.Type.LONG, true));
TopDocs topDocs = searcher.search(query, 10, sortArray);

悄悄话 🌈

  • 最近项目进度比较紧,基本是有时间学习技术,没时间写出来的样子,这两天趁着休息时间将之前的一些学习内容按照先后顺序陆续整理一下与大家分享。

彩蛋 🐣

  • 最近开通了文集的同名专题 《Java大数据开发》 并会从大数据开发的基础技术向下延伸至云服务,有兴趣的朋友可以来一同交流进步。

如果你觉得我的分享对你有帮助的话,请在下面👇随手点个喜欢 💖,你的肯定才是我最大的动力,感谢。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,393评论 5 467
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,790评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,391评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,703评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,613评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,003评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,507评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,158评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,300评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,256评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,274评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,984评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,569评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,662评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,899评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,268评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,840评论 2 339

推荐阅读更多精彩内容