阿粉带你几百行代码手撸搜索引擎

Hello 大家好,我是鸭血粉丝,大家都叫我阿粉,搜索引擎想必大家一定不会默认,我们项目中经常使用的 ElasticSearch 就是一种搜索引擎,在我们的日志系统中必不可少,ELK 作为一个整体,基本上是运维标配了,另外目前的搜索引擎底层都是基于 Lucene 来实现的。

阿粉最近遇到一个需求,因为数据量没有达到需要使用 ElasticSearch 的级别,也不想单独部署一套集群,所以准备自己基于 Lucene 实现一个简易的搜索服务。下面我们一起来看一下吧。

背景

**Lucene **是一套用于全文检索和搜索的开放源码程序库,由 Apache 软件基金会支持和提供。Lucene 提供了一个简单却强大的应用程序接口,能够做全文索引和搜索。Lucene 是现在最受欢迎的免费 Java 信息检索程序库。

上面的解释是来自维基百科,我们只需要知道 Lucene 可以进行全文索引和搜索就行了,这里的索引是动词,意思是我们可以将文档或者文章或者文件等数据进行索引记录下来,索引过后,我们查询起来就会很快。

索引这个词有的时候是动词,表示我们要索引数据,有的时候是名词,我们需要根据上下文场景来判断。新华字典前面的字母表或者书籍前面的目录本质上都是索引。

接入

引入依赖

首先我们创建一个 SpringBoot 项目,然后在 pom 文件中加入如下内容,我这里使用的 lucene 版本是 7.2.1,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<properties>
    <lucene.version>7.2.1</lucene.version>
</properties>

<!-- Lucene核心库 -->
<dependency>
	<groupId>org.apache.lucene</groupId>
	<artifactId>lucene-core</artifactId>
	<version>${lucene.version}</version>
</dependency>
<!-- Lucene解析库 -->
<dependency>
	<groupId>org.apache.lucene</groupId>
	<artifactId>lucene-queryparser</artifactId>
	<version>${lucene.version}</version>
</dependency>
<!-- Lucene附加的分析库 -->
<dependency>
	<groupId>org.apache.lucene</groupId>
	<artifactId>lucene-analyzers-common</artifactId>
	<version>${lucene.version}</version>
</dependency>

索引数据

在使用 Lucene 之前我们需要先索引一些文件,然后再通过关键词查询出来,下面我们来模拟整个过程。为了方便我们这里模拟一些数据,正常的数据应该是从数据库或者文件中加载的,我们的思路是这样的:

  1. 生成多条实体数据;
  2. 将实体数据映射成 Lucene 的文档形式;
  3. 索引文档;
  4. 根据关键词查询文档;

第一步我们先创建一个实体如下:

1
2
3
4
5
6
7
8
import lombok.Data;

@Data
public class ArticleModel {
    private String title;
    private String author;
    private String content;
}

我们再写一个工具类,用来索引数据,代码如下:

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
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
import org.apache.commons.collections.CollectionUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.*;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

public class LuceneIndexUtil {

    private static String INDEX_PATH = "/opt/lucene/demo";
    private static IndexWriter writer;

    public static LuceneIndexUtil getInstance() {
        return SingletonHolder.luceneUtil;
    }

    private static class SingletonHolder {
        public final static LuceneIndexUtil luceneUtil = new LuceneIndexUtil();
    }

    private LuceneIndexUtil() {
        this.initLuceneUtil();
    }

    private void initLuceneUtil() {
        try {
            Directory dir = FSDirectory.open(Paths.get(INDEX_PATH));
            Analyzer analyzer = new StandardAnalyzer();
            IndexWriterConfig iwc = new IndexWriterConfig(analyzer);
            writer = new IndexWriter(dir, iwc);
        } catch (IOException e) {
            log.error("create luceneUtil error");
            if (null != writer) {
                try {
                    writer.close();
                } catch (IOException ioException) {
                    ioException.printStackTrace();
                } finally {
                    writer = null;
                }
            }
        }
    }

    /**
     * 索引单个文档
     *
     * @param doc 文档信息
     * @throws IOException IO 异常
     */
    public void addDoc(Document doc) throws IOException {
        if (null != doc) {
            writer.addDocument(doc);
            writer.commit();
            writer.close();
        }
    }

    /**
     * 索引单个实体
     *
     * @param model 单个实体
     * @throws IOException IO 异常
     */
    public void addModelDoc(Object model) throws IOException {
        Document document = new Document();
        List<Field> fields = luceneField(model.getClass());
        fields.forEach(document::add);
        writer.addDocument(document);
        writer.commit();
        writer.close();
    }

    /**
     * 索引实体列表
     *
     * @param objects 实例列表
     * @throws IOException IO 异常
     */
    public void addModelDocs(List<?> objects) throws IOException {
        if (CollectionUtils.isNotEmpty(objects)) {
            List<Document> docs = new ArrayList<>();
            objects.forEach(o -> {
                Document document = new Document();
                List<Field> fields = luceneField(o);
                fields.forEach(document::add);
                docs.add(document);
            });
            writer.addDocuments(docs);
        }
    }

    /**
     * 清除所有文档
     *
     * @throws IOException IO 异常
     */
    public void delAllDocs() throws IOException {
        writer.deleteAll();
    }

    /**
     * 索引文档列表
     *
     * @param docs 文档列表
     * @throws IOException IO 异常
     */
    public void addDocs(List<Document> docs) throws IOException {
        if (CollectionUtils.isNotEmpty(docs)) {
            long startTime = System.currentTimeMillis();
            writer.addDocuments(docs);
            writer.commit();
            log.info("共索引{}个 Document,共耗时{} 毫秒", docs.size(), (System.currentTimeMillis() - startTime));
        } else {
            log.warn("索引列表为空");
        }
    }

    /**
     * 根据实体 class 对象获取字段类型,进行 lucene Field 字段映射
     *
     * @param modelObj 实体 modelObj 对象
     * @return 字段映射列表
     */
    public List<Field> luceneField(Object modelObj) {
        Map<String, Object> classFields = ReflectionUtils.getClassFields(modelObj.getClass());
        Map<String, Object> classFieldsValues = ReflectionUtils.getClassFieldsValues(modelObj);

        List<Field> fields = new ArrayList<>();
        for (String key : classFields.keySet()) {
            Field field;
            String dataType = StringUtils.substringAfterLast(classFields.get(key).toString(), ".");
            switch (dataType) {
                case "Integer":
                    field = new IntPoint(key, (Integer) classFieldsValues.get(key));
                    break;
                case "Long":
                    field = new LongPoint(key, (Long) classFieldsValues.get(key));
                    break;
                case "Float":
                    field = new FloatPoint(key, (Float) classFieldsValues.get(key));
                    break;
                case "Double":
                    field = new DoublePoint(key, (Double) classFieldsValues.get(key));
                    break;
                case "String":
                    String string = (String) classFieldsValues.get(key);
                    if (StringUtils.isNotBlank(string)) {
                        if (string.length() <= 1024) {
                            field = new StringField(key, (String) classFieldsValues.get(key), Field.Store.YES);
                        } else {
                            field = new TextField(key, (String) classFieldsValues.get(key), Field.Store.NO);
                        }
                    } else {
                        field = new StringField(key, StringUtils.EMPTY, Field.Store.NO);
                    }
                    break;
                default:
                    field = new TextField(key, JsonUtils.obj2Json(classFieldsValues.get(key)), Field.Store.YES);
                    break;
            }
            fields.add(field);
        }
        return fields;
    }
    public void close() {
        if (null != writer) {
            try {
                writer.close();
            } catch (IOException e) {
                log.error("close writer error");
            }
            writer = null;
        }
    }

    public void commit() throws IOException {
        if (null != writer) {
            writer.commit();
            writer.close();
        }
    }
}

有了工具类,我们再写一个 demo 来进行数据的索引

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
import java.util.ArrayList;
import java.util.List;

/**
 * <br>
 * <b>Function:</b><br>
 * <b>Author:</b>@author Silence<br>
 * <b>Date:</b>2020-10-17 21:08<br>
 * <b>Desc:</b>无<br>
 */
public class Demo {
    public static void main(String[] args) {
        LuceneIndexUtil luceneUtil = LuceneIndexUtil.getInstance();
        List<ArticleModel> articles = new ArrayList<>();
        try {
            //索引数据
            ArticleModel article1 = new ArticleModel();
            article1.setTitle("Java 极客技术");
            article1.setAuthor("鸭血粉丝");
            article1.setContent("这是一篇给大家介绍 Lucene 的技术文章,必定点赞评论转发!!!");
            ArticleModel article2 = new ArticleModel();
            article2.setTitle("极客技术");
            article2.setAuthor("鸭血粉丝");
            article2.setContent("此处省略两千字...");
            ArticleModel article3 = new ArticleModel();
            article3.setTitle("Java 极客技术");
            article3.setAuthor("鸭血粉丝");
            article3.setContent("最后邀请你加入我们的知识星球,Today is big day!");
            articles.add(article1);
            articles.add(article2);
            articles.add(article3);
            luceneUtil.addModelDocs(articles);
            luceneUtil.commit();
            
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

上面的 content 内容可以自行进行替换,阿粉这边避免凑字数的嫌疑就不贴了。

展示

运行结束过后,我们用过 Lucene 的可视化工具 luke 来查看下索引的数据内容,下载过后解压我们可以看到有.bat 和 .sh 两个脚本,根据自己的系统进行运行就好了。阿粉这边是 mac 用的是 sh 脚本运行,运行后打开设置的索引目录即可。

luke 在 GitHub 上面有,但是下载很慢,阿粉这边通过国外服务器下载下来,并上传的百度网盘中了,公众号回复【luke】获取。

进入过后,我们可以看到下图显示的内容,选择 content 点击 show top items 可以看到右侧的索引数据,这里根据分词器的不同,索引的结果是不一样的,阿粉这里采用的分词器就是标准的分词器,小伙伴们可以根据自己的要求选择适合自己的分词器即可。

搜索数据

数据已经索引成功了,接下来我们就需要根据条件进行数据的搜索了,我们创建一个 LuceneSearchUtil.java 来操作数据。

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
import org.apache.commons.collections.MapUtils;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.queryparser.classic.QueryParser;
import org.apache.lucene.search.*;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.springframework.beans.factory.annotation.Value;

import java.io.IOException;
import java.nio.file.Paths;
import java.util.Map;


public class LuceneSearchUtil {

    private static String INDEX_PATH = "/opt/lucene/demo";
    private static IndexSearcher searcher;

    public static LuceneSearchUtil getInstance() {
        return LuceneSearchUtil.SingletonHolder.searchUtil;
    }

    private static class SingletonHolder {
        public final static LuceneSearchUtil searchUtil = new LuceneSearchUtil();
    }

    private LuceneSearchUtil() {
        this.initSearcher();
    }

    private void initSearcher() {
        Directory directory;
        try {
            directory = FSDirectory.open(Paths.get(INDEX_PATH));
            DirectoryReader reader = DirectoryReader.open(directory);
            searcher = new IndexSearcher(reader);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    public TopDocs searchByMap(Map<String, Object> queryMap) throws Exception {
        if (null == searcher) {
            this.initSearcher();
        }
        if (MapUtils.isNotEmpty(queryMap)) {
            BooleanQuery.Builder builder = new BooleanQuery.Builder();
            queryMap.forEach((key, value) -> {
                if (value instanceof String) {
                    Query queryString = new PhraseQuery(key, (String) value);
//                    Query queryString = new TermQuery(new Term(key, (String) value));
                    builder.add(queryString, BooleanClause.Occur.MUST);
                }
            });
            return searcher.search(builder.build(), 10);
        }
        return null;
    }

}

在 demo.java 中增加搜索代码如下:

1
2
3
4
5
6
7
8
//查询数据
   Map<String, Object> map = new HashMap<>();
   map.put("title", "Java 极客技术");
//   map.put("title", "极客技术");
//   map.put("content", "最");
   LuceneSearchUtil searchUtil = LuceneSearchUtil.getInstance();
   TopDocs topDocs = searchUtil.searchByMap(map);
   System.out.println(topDocs.totalHits);

运行结果如下,表示搜索到了两条。

通过可视化工具我们可以看到 title 为”Java 极客技术”确实是有两条记录,而且我们也确认只插入了两条数据。注意这里如果根据其他字符去查询可能查询不出来,因为阿粉这里的分词器采用的是默认的分词器,小伙伴可以根据自身的情况采用相应的分词器。

至此我们可以索引和搜索数据了,不过这还是简单的入门操作,对于不同类型的字段,我们需要使用不同的查询方式,而且根据系统的特性我们需要使用特定的分词器,默认的标准分词器不一定符合我们的使用场景。而且我们索引数据的时候也需要根据字段类型进行不同 Field 的设定。上面的案例只是 demo 并不能在生产上使用,搜索引擎在互联网行业是领头羊,很多先进的互联网技术都是从搜索引擎开始发展的。

相关的代码阿粉已经放到 GitHub 上了,公众号回复【源码仓库】即可获得。

写在最后

最后邀请你加入我们的知识星球,这里有 1800+ 优秀的人与你一起进步,如果你是小白那你是稳赚了,很多业内经验和干活分享给你;如果你是大佬,那可以进来我们一起交流分享你的经验,说不定日后我们还可以有合作,给你的人生多一个可能。

Java Geek Tech wechat
欢迎订阅 Java 极客技术,这里分享关于 Java 的一切。