注:本文基于Lucene 8.2.0 版本。

上篇文章《Lucene系列(1)——理论介绍》中我们说了搜索的流程分前台用户查询流程后台索引构建流程。本文就借助Lucene(目前最新的8.2.0版本)来实现这两个流程。当然,我们说了Lucene并不负责数据采集和提取,所以为了简单起见,我从网上找了几首精美的英文短诗作为原始数据,你可以认为这就是爬虫从互联网上面爬取并且经过一些初步处理的数据(删除了HTML标签,提取了诗的内容)。

原始数据

原始数据为4首英文短诗,每个诗对应一个文件,文件名为诗名。这里列出内容,方便后面讨论。

Fog(迷雾):

The fog comes
on little cat feet.
It sits looking over harbor and city
on silent haunches
and then, moves on.

Freedom And Love(自由与爱情):

How delicious is the winning
Of a kiss at loves beginning,
When two mutual hearts are sighing
For the knot there's no untying.
Yet remember, 'mist your wooing,
Love is bliss, but love has ruining;
Other smiles may make you fickle,
Tears for charm may tickle.

Love's Secret(爱情的秘密):

Never seek to tell thy love,
Love that never told shall be;
For the gentle wind does move
Silently, invisibly.
I told my love, I told my love,
I told her all my heart,
Trembling, cold, in ghastly fears.
Ah! she did depart!
Soon after she was gone from me,
A traveller came by,
Silently, invisibly:
He took her with a sigh.

On Death(死亡):

Death stands above me, whispering low
I know not what into my ear:
Of his strange language all I know
Is, there is not a word of fear.

后台索引构建

因为原始数据已经是文本格式了,所以我们构建索引的流程如下:

index

其中的分析就是我们之前说的分词。然后先看代码(代码源文件见IndexFilesMinimal.java):

// 省略包等信息,完整文件见源文件

/**
 * Minimal Index Files code.
 **/
public class IndexFilesMinimal {

    public static void main(String[] args) throws Exception {
        // 原数据存放路径
        final String docsPath = "/Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/data/poems";
        // 索引保存目录
        final String indexPath = "/Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/indices/poems-index";

        final Path docDir = Paths.get(docsPath);
        Directory indexDir = FSDirectory.open(Paths.get(indexPath));
        // 使用标准分析器
        Analyzer analyzer = new StandardAnalyzer();

        IndexWriterConfig iwc = new IndexWriterConfig(analyzer);
        // 每次都重新创建索引
        iwc.setOpenMode(IndexWriterConfig.OpenMode.CREATE);
        // 创建IndexWriter用于写索引
        IndexWriter writer = new IndexWriter(indexDir, iwc);

        System.out.println("index start...");
        // 遍历数据目录,对目录下的每个文件进行索引
        Files.walkFileTree(docDir, new SimpleFileVisitor<Path>() {
            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                indexDoc(writer, file);
                return FileVisitResult.CONTINUE;
            }
        });
        writer.close();

        System.out.println("index ends.");
    }

    private static void indexDoc(IndexWriter writer, Path file) throws IOException {
        try (InputStream stream = Files.newInputStream(file)) {
            System.out.println("indexing file " + file);
            // 创建文档对象
            Document doc = new Document();

            // 将文件绝对路径加入到文档中
            Field pathField = new StringField("path", file.toString(), Field.Store.YES);
            doc.add(pathField);
            // 将文件内容加到文档中
            Field contentsField = new TextField("contents", new BufferedReader(new InputStreamReader(stream)));
            doc.add(contentsField);

            // 将文档写入索引中
            writer.addDocument(doc);
        }
    }
}

这段代码的功能是遍历4首诗对应的文件,对其进行分词、索引,最终形成索引文件,供以后检索。里面有几个API比较关键,这里稍作一下介绍:

  • FSDirectory:该类实现了索引文件存储到文件系统的功能。我们无需关注底层文件系统的类型,该类会帮我们处理好。当然还有其它几个类型的Directory,以后再介绍。
  • StandardAnalyzer:Lucene内置的标准分词器,其分词的方法是去掉停用词(stop word),全部转化为小写,根据空白字符分成一个个词/词组。Lucene还支持好几种其它分词器,我们也可以实现自己的分词器,以后再介绍。
  • IndexWriter:该类是索引(此处为动词)文件的核心类,负责索引的创建和维护。

这些API以后还会详细介绍。我们可以这样理解Lucene里面的组织形式:索引(Index)是最顶级的概念,可以理解为MySQL里面的表;索引里面包含很多个Document,一个Document可以理解为MySQL中的一行记录;一个Document里面可以包含很多个Field,每一个Field都是一个类似Map的结构,由字段名和字段内容组成,内容可再嵌套。在MySQL中,表结构是确定的,每一行记录的格式都是一样的,但Lucene没有这个要求,每个Document里面的字段可以完全不一样,即所谓的"flexible schema"。

在上述代码运行完之后,我们就生成了一个名叫poems-index的索引,该索引里面包含4个Document,每个Document对应一首短诗。每个Document由pathcontents两个字段组成,path里面存储的是诗歌文件的绝对路径,contents里面存储的是诗歌的内容。最终生成的索引目录包含如下一些文件:

poems-index

这些文件的含义以后再介绍,现在大家只要知道这些文件里面存储了一些原始文件的信息(可以选择不保存原始文件)以及我们之前介绍的用于高效搜索的倒排索引

这样后台索引构建的工作就算完成了,接下来我们来看一下如何利用索引进行高效的搜索。

前台查询

还是先上代码(代码源文件见SearchFilesMinimal.java):

// 省略包等信息,完整文件见源文件

/**
 * Minimal Search Files code
 **/
public class SearchFilesMinimal {

    public static void main(String[] args) throws Exception {
        // 索引保存目录
        final String indexPath = "/Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/indices/poems-index";
        // 搜索的字段
        final String searchField = "contents";

        // 从索引目录读取索引信息
        IndexReader indexReader = DirectoryReader.open(FSDirectory.open(Paths.get(indexPath)));
        // 创建索引查询对象
        IndexSearcher searcher = new IndexSearcher(indexReader);
        // 使用标准分词器
        Analyzer analyzer = new StandardAnalyzer();

        // 从终端获取查询语句
        BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
        // 创建查询语句解析对象
        QueryParser queryParser = new QueryParser(searchField, analyzer);
        while (true) {
            System.out.println("Enter query: ");

            String input = in.readLine();
            if (input == null) {
                break;
            }

            input = input.trim();
            if (input.length() == 0) {
                break;
            }

            // 解析用户输入的查询语句:build query
            Query query = queryParser.parse(input);
            System.out.println("searching for: " + query.toString(searchField));
            // 查询
            TopDocs results = searcher.search(query, 10);
            ScoreDoc[] hits = results.scoreDocs;
            if (results.totalHits.value == 0) {
                System.out.println("no result matched!");
                continue;
            }

            // 输出匹配到的结果
            System.out.println(results.totalHits.value + " results matched: ");
            for (ScoreDoc hit : hits) {
                Document doc = searcher.doc(hit.doc);
                System.out.println("doc=" + hit.doc + " score=" + hit.score + " file: " + doc.get("path"));
            }
        }
    }
}

这段代码的核心流程是先从上一步创建的索引目录加载构建好的索引,然后获取用户输入并解析为查询语句(build query),接着运行查询(run query),如果有匹配到的,就输出匹配的结果。这里对查询比较重要的API做下简单说明:

  • IndexReader:打开一个索引;
  • IndexSearcher:搜索IndexReader打开的索引,返回TopDocs对象;
  • QueryParser:该类的parse方法解析用户输入的查询语句,返回一个Query对象;

这些API以后还会详细介绍。下面我们来运行一下程序:

Enter query: 
love
searching for: love
2 results matched: 
doc=0 score=0.48849338 file: /Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/data/poems/Love'sSecret.txt
doc=1 score=0.41322997 file: /Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/data/poems/FreedomAndLove.txt

我们输入关键字"love",搜索出来两个Document,分别对应Love's Secret和Freedom And Love。doc=后面的数字是Document的ID,唯一标识一个Document。score后面的数字是搜索结果与我们搜索的关键字的相关度,这个计算有一个复杂的公式,以后介绍。

然后我们再输入"LOVE"(注意字母都大写了):

Enter query: 
Love
searching for: love
2 results matched: 
doc=0 score=0.48849338 file: /Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/data/poems/Love'sSecret.txt
doc=1 score=0.41322997 file: /Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/data/poems/FreedomAndLove.txt

可以看到搜索结果与之前是一样的,这是因为我们搜索时使用了和构建索引时相同的分词器StandardAnalyzer,该分词器会将所有词转化为小写。然后我们再尝试一下其它搜索:

Enter query: 
fog
searching for: fog
1 results matched: 
doc=2 score=0.67580885 file: /Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/data/poems/Fog.txt

Enter query: 
above
searching for: above
1 results matched: 
doc=3 score=0.6199532 file: /Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/data/poems/OnDeath.txt

Enter query: 
death
searching for: death
1 results matched: 
doc=3 score=0.6199532 file: /Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/data/poems/OnDeath.txt

Enter query: 
abc
searching for: abc
no result matched!

都工作正常,最后一个关键字"abc"没有搜到,因为原文中也没有这个词。我们再来看一个复杂点的查询:

Enter query: 
+love -seek
searching for: +love -seek
1 results matched: 
doc=1 score=0.41322997 file: /Users/allan/Git/allan/github/CodeSnippet/Java/lucene-learning/data/poems/FreedomAndLove.txt

这里我们输入的关键字为"+love -seek",这是一个高级一点的查询,含义是“包含love但不包含seek”,于是就只搜出来Freedom And Love一首诗了。Lucene还支持很多类似的高级查询方式,以后再介绍。

也许要没有最后一个高级一点的搜索,你会觉得这种搜索我在MySQL里面用like也能做到,或者ctrl+f在很多编辑器里面也能找到。是的,对于简单的关键字搜索,在数据量不大的时候的确可以做到。但是如果给你100GB的文本数据,还可以吗?100TB、100PB呢?RDBMS连存储都存储不了,编辑器打都打不开。想想Google后台存储着互联网上的很多数据,但他的查询依旧可以做到秒级甚至毫秒级的返回,就是使用了类似Lucene这样的技术(当然还有很多其他技术支撑)。

本文用两段代码对构建索引和查询的流程进行了介绍,很多API及概念都没有太细介绍。因为任何一个点都包含很多知识点,而本文的目标就是从代码角度对索引构建和查询有一个整体的认识,那些细节让我们以后一个一个介绍吧。