17.1 Lucence 和全文检索

Lucene 是 Apache Jakarta 家族中的一个开源项目,它不是一个完整的搜索应用程序,但可为我们的应用程序提供索引和搜索功能。Lucene 也是目前流行的基于 Java 的开源全文检索工具包。

目前已有很多应用程序基于 Lucene 实现了搜索功能,比如 Eclipse 帮助系统的搜索功能。Lucene 能为文本类型的数据建立索引,我们只要能把需要建立索引的数据转化为文本格式,Lucene 就能对该文档建立索引并实现搜索。

17.1.1 全文检索

这里提到了全文检索,我们先来分析一下什么是全文检索,理解了全文检索,再理解 Lucene 的原理就非常简单了。

何为全文检索?举个例子,比如现在要在一个文件中查找某个字符串,最直接的做法就是从头开始检索,直到查找成功。这种做法应用于小数据量的文件上还可以,但应用于大数据量的文件上,就比较吃力了。同样的道理,在一个拥有几十 G 内容的硬盘中,找包含某个字符串的文件,效率可想而知,是很低的。

文件中的数据属于非结构化数据,要提高搜索效率,首先需将非结构化数据中的一部分信息提取出来,重新组织,使其具有一定的结构,然后再对这些结构化的数据进行搜索,从而达到提高搜索效率的目的。这一过程就是全文搜索,即先建立索引,再对索引进行搜索。

17.1.2 Lucene 建立索引的方式

Lucene 是如何建立索引的呢?假设现在有两篇文章,内容如下:

文章 1 的内容为:Tom lives in Guangzhou, I live in Guangzhou too. 文章 2 的内容为:He once lived in Shanghai.

第一步,将文档传给分词组件(Tokenizer),分词组件将文档分成一个个单词,同时去除标点符号和停词。所谓的停词指的是没有特别意义的词,比如英文中的 a、the、too 等。经过分词后,得到词元(Token)。如下:

文章 1 经过分词后的结果:[Tom] [lives] [Guangzhou] [I] [live] [Guangzhou]

文章 2 经过分词后的结果:[He] [lives] [Shanghai]

然后,将词元传给语言处理组件(Linguistic Processor)。对于英语,语言处理组件一般会将字母变为小写,并将单词缩减为词根形式,如“lives”精简为“live”等,或将单词转变为词根形式,如“ drove”转变为“drive” 等。最终得到词(Term),如下:

文章 1 经过处理后的结果:[tom] [live] [guangzhou] [i] [live] [guangzhou]

文章 2 经过处理后的结果:[he] [live] [shanghai]

最后将得到的词传给索引组件(Indexer),经过处理,得到下面的索引结构:

关键词 文章号[出现频率] 出现位置
在这里插入图片描述
以上是 Lucene 索引结构中最核心的部分。它的关键字按字符顺序排列,因此 Lucene 可以用二元搜索算法快速定位关键词。开发时,Lucene 将上面三列分别保存在词典文件(Term Dictionary)、频率文件(Frequencies)位置文件(Positions)中。其中,词典文件不仅保存了每个关键词,还保留了指向频率文件和位置文件的指针,通过指针可以找到该关键字的频率信息和位置信息。

搜索时,先对词典进行二元查找,找到该词后,通过指向频率文件的指针读出所有文章号,并返回结果,之后在具体文章中根据出现位置找到该词。这里注意下,Lucene 首次建立索引会比较慢,好在以后使用 Lucene 时无需再创建索引,运行速度会快很多。

理解了 Lucene 的分词原理,接下来我们在 Spring Boot 中集成 Lucene, 并实现索引和搜索功能。

17.2 Spring Boot 集成 Lucence

17.2.1 依赖导入

首先需要导入 Lucene 的依赖,它的依赖有好几个,如下:

<!-- Lucence核心包 -->
<dependency>
    <groupId>org.apache.lucene</groupId>
    <artifactId>lucene-core</artifactId>
    <version>5.3.1</version>
</dependency>

<!-- Lucene 查询解析包 -->
<dependency>
    <groupId>org.apache.lucene</groupId>
    <artifactId>lucene-queryparser</artifactId>
    <version>5.3.1</version>
</dependency>

<!-- 常规的分词(英文) -->
<dependency>
    <groupId>org.apache.lucene</groupId>
    <artifactId>lucene-analyzers-common</artifactId>
    <version>5.3.1</version>
</dependency>

<!--支持分词高亮  -->
<dependency>
    <groupId>org.apache.lucene</groupId>
    <artifactId>lucene-highlighter</artifactId>
    <version>5.3.1</version>
</dependency>

<!--支持中文分词  -->
<dependency>
    <groupId>org.apache.lucene</groupId>
    <artifactId>lucene-analyzers-smartcn</artifactId>
    <version>5.3.1</version>
</dependency>

Lucene 默认支持英文分词,可以增加最后这个依赖,让它支持中文分词。倒数第二个依赖支持分词高亮,本节课最后将利用它,实现搜索内容的高亮显示,模拟当前互联网上的做法,大家可以运用到实际项目中。

17.2.2 快速入门

根据上文的分析,全文检索有两个步骤,先建立索引,再检索。为了测试这个过程,我新建了两个 Java 类,一个用来建立索引,另一个用来检索。

建立索引

首先,我们可以先在 D:\lucene\data 目录下保存几个文件。

然后,新建一个 Indexer 类,实现建立索引的功能。首先在构造方法中初始化标准分词器和写索引实例。

public class Indexer {

    /**
     * 写索引实例
     */
    private IndexWriter writer;

    /**
     * 构造方法,实例化 IndexWriter
     * @param indexDir
     * @throws Exception
     */
    public Indexer(String indexDir) throws Exception {
        Directory dir = FSDirectory.open(Paths.get(indexDir));
        //标准分词器,会自动去掉空格啊,is a the 等单词
        Analyzer analyzer = new StandardAnalyzer();
        //将标准分词器配到写索引的配置中
        IndexWriterConfig config = new IndexWriterConfig(analyzer);
        //实例化写索引对象
        writer = new IndexWriter(dir, config);
    }
}

如下代码,首先将存放索引的文件夹路径传入构造方法中,并构建标准**分词器(**支持英文),之后利用标准分词器实例化写索引对象。

接下来开始建立索引。大家可以结合代码注释来理解这一构建过程:

/**
 * 索引指定目录下的所有文件
 * @param dataDir
 * @return
 * @throws Exception
 */
public int indexAll(String dataDir) throws Exception {
    // 获取该路径下的所有文件
    File[] files = new File(dataDir).listFiles();
    if (null != files) {
        for (File file : files) {
            //调用下面的 indexFile 方法,对每个文件进行索引
            indexFile(file);
        }
    }
    //返回索引的文件数
    return writer.numDocs();
}

/**
 * 索引指定的文件
 * @param file
 * @throws Exception
 */
private void indexFile(File file) throws Exception {
    System.out.println("索引文件的路径:" + file.getCanonicalPath());
    //调用下面的 getDocument 方法,获取该文件的 Document
    Document doc = getDocument(file);
    //将doc添加到索引中
    writer.addDocument(doc);
}

/**
 * 获取文档,文档里再设置每个字段,就类似于数据库中的一行记录
 * @param file
 * @return
 * @throws Exception
 */
private Document getDocument(File file) throws Exception {
    Document doc = new Document();
    //开始添加字段
    //添加内容
    doc.add(new TextField("contents", new FileReader(file)));
    //添加文件名,并把这个字段存到索引文件里
    doc.add(new TextField("fileName", file.getName(), Field.Store.YES));
    //添加文件路径
    doc.add(new TextField("fullPath", file.getCanonicalPath(), Field.Store.YES));
    return doc;
}

这样索引就建好了,我们在该类中写一个 main 方法测试一下:

public static void main(String[] args) {
        //索引保存到的路径
        String indexDir = "D:\\lucene";
        //需要索引的文件数据存放的目录
        String dataDir = "D:\\lucene\\data";
        Indexer indexer = null;
        int indexedNum = 0;
        //记录索引开始时间
        long startTime = System.currentTimeMillis();
        try {
            // 开始构建索引
            indexer = new Indexer(indexDir);
            indexedNum = indexer.indexAll(dataDir);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            try {
                if (null != indexer) {
                    indexer.close();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        //记录索引结束时间
        long endTime = System.currentTimeMillis();
        System.out.println("索引耗时" + (endTime - startTime) + "毫秒");
        System.out.println("共索引了" + indexedNum + "个文件");
    }

我从 Tomcat 的 conf 目录下拷贝了 catalina.properties 和 logging.properties 两个文件,并放在 D:\lucene\data 目录下(这两个文件也可以在源码中获取)。执行上面代码,可以看到控制台输出了以下内容:

索引文件的路径:D:\lucene\data\catalina.properties
索引文件的路径:D:\lucene\data\logging.properties
索引耗时882毫秒
共索引了2个文件

接着,我们打开 D:\lucene\ 目录可以看到一些索引文件,这些文件不能删除,否则需重新构建索引。因为没有索引,便无法检索内容了。

检索内容

这两个文件的索引已经建立好了,接下来就可以写检索程序了,在这两个文件中查找特定的词。

public class Searcher {

    public static void search(String indexDir, String q) throws Exception {

        //获取要查询的路径,也就是索引所在的位置
        Directory dir = FSDirectory.open(Paths.get(indexDir));
        IndexReader reader = DirectoryReader.open(dir);
        //构建 IndexSearcher
        IndexSearcher searcher = new IndexSearcher(reader);
        //标准分词器,会自动去掉空格啊,is a the 等单词
        Analyzer analyzer = new StandardAnalyzer();
        //查询解析器
        QueryParser parser = new QueryParser("contents", analyzer);
        //通过解析要查询的 String,获取查询对象,q 为传进来的待查的字符串
        Query query = parser.parse(q);

        //记录索引开始时间
        long startTime = System.currentTimeMillis();
        //开始查询,查询前 10 条数据,将记录保存在 docs 中
        TopDocs docs = searcher.search(query, 10);
        //记录索引结束时间
        long endTime = System.currentTimeMillis();
        System.out.println("匹配" + q + "共耗时" + (endTime-startTime) + "毫秒");
        System.out.println("查询到" + docs.totalHits + "条记录");

        //取出每条查询结果
        for(ScoreDoc scoreDoc : docs.scoreDocs) {
            //scoreDoc.doc相当于 docID,根据这个 docID 来获取文档
            Document doc = searcher.doc(scoreDoc.doc);
            //fullPath 是刚刚建立索引的时候我们定义的一个字段,表示路径。也可以取其他的内容,只要我们在建立索引时有定义即可。
            System.out.println(doc.get("fullPath"));
        }
        reader.close();
    }
}

OK,检索代码写完了,大家可以结合代码注释来理解这段程序。下面写个 main 方法测试一下:

public static void main(String[] args) {
    String indexDir = "D:\\lucene";
    //查询这个字符串
    String q = "security";
    try {
        search(indexDir, q);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

查找 security 字符串,执行完成后,可以看到控制台打印出如下结果:

匹配security共耗时23毫秒
查询到1条记录
D:\lucene\data\catalina.properties

可以看出,耗时了 23 毫秒后,在其中一个文件中找到了 security 这个字符串,并输出了文件的名称。上面的代码写得也很详细,大家可以应用在生产环境中。

17.2.3 中文分词检索高亮

上文完成了建立索引和检索代码的编写,但在实际项目中,往往需在页面中将查询结果展示出来,比如查某个关键字,查到之后,将相关的信息点展示出来,并将查询的关键字高亮显示等等。这种需求在实际项目中非常常见,大多数网站上基本都有这种效果。本节,我们就使用 Lucene 来实现该效果。

中文分词

首先新建 ChineseIndexer 类,用来建立中文索引。中文索引与英文索引的建立过程一样,不同之处在于这里使用的是中文分词器。上面我们通过读取文件建立索引,这里我们不读取文件,直接对一个字符串建立索引。因为在实际项目中,绝大部分情况下,会获取一些文本字符串(比如从表中查询出来的结果),然后对该文本字符串建立索引。

建立索引时,首先获取 IndexWriter 对象,将相关的内容生成索引。索引的 Key 可以根据项目的情况自定义,内容来自我们处理过的文本,或者从数据库中查询出来的文本。生成时,需要使用中文分词器,代码如下:

public class ChineseIndexer {

    /**
     * 存放索引的位置
     */
    private Directory dir;

    //准备一下用来测试的数据
    //用来标识文档
    private Integer ids[] = {1, 2, 3};
    private String citys[] = {"上海", "南京", "青岛"};
    private String descs[] = {
            "上海是个繁华的城市。",
            "南京是一个文化的城市南京,简称宁,是江苏省会,地处中国东部地区,长江下游,濒江近海。全市下辖11个区,总面积6597平方公里,2013年建成区面积752.83平方公里,常住人口818.78万,其中城镇人口659.1万人。[1-4] “江南佳丽地,金陵帝王州”,南京拥有着6000多年文明史、近2600年建城史和近500年的建都史,是中国四大古都之一,有“六朝古都”、“十朝都会”之称,是中华文明的重要发祥地,历史上曾数次庇佑华夏之正朔,长期是中国南方的政治、经济、文化中心,拥有厚重的文化底蕴和丰富的历史遗存。[5-7] 南京是国家重要的科教中心,自古以来就是一座崇文重教的城市,有“天下文枢”、“东南第一学”的美誉。截至2013年,南京有高等院校75所,其中211高校8所,仅次于北京上海;国家重点实验室25所、国家重点学科169个、两院院士83人,均居中国第三。[8-10]。",
            "青岛是一个美丽的城市。"
    };

    /**
     * 生成索引
     * @param indexDir
     * @throws Exception
     */
    public void index(String indexDir) throws Exception {
        dir = FSDirectory.open(Paths.get(indexDir));
        // 先调用 getWriter 获取 IndexWriter 对象
        IndexWriter writer = getWriter();
        for(int i = 0; i < ids.length; i++) {
            Document doc = new Document();
            // 把上面的数据都生成索引,分别用 id、city 和 desc 来标识
            doc.add(new IntField("id", ids[i], Field.Store.YES));
            doc.add(new StringField("city", citys[i], Field.Store.YES));
            doc.add(new TextField("desc", descs[i], Field.Store.YES));
            //添加文档
            writer.addDocument(doc);
        }
        //close 了才真正写到文档中
        writer.close();
    }

    /**
     * 获取 IndexWriter 实例
     * @return
     * @throws Exception
     */
    private IndexWriter getWriter() throws Exception {
        //使用中文分词器
        SmartChineseAnalyzer analyzer = new SmartChineseAnalyzer();
        //将中文分词器配到写索引的配置中
        IndexWriterConfig config = new IndexWriterConfig(analyzer);
        //实例化写索引对象
        IndexWriter writer = new IndexWriter(dir, config);
        return writer;
    }

    public static void main(String[] args) throws Exception {
        new ChineseIndexer().index("D:\\lucene2");
    }
}

这里我们用 id、city、desc 分别代表 id、城市名称和城市描述,将它们作为关键字来建立索引。后面我们获取的内容主要是城市描述,我故意将南京的城市描述写得长了些,下文检索时,根据不同的关键字会检索到不同部分的信息,这其中涉及到一个权重的概念。

执行 main 方法,索引文件被保存到 D:\lucene2\ 中。

中文分词查询

中文分词查询代码的逻辑和默认查询代码差不多,主要区别在于,我们需要将查询出来的关键字做标红加粗等处理,此时需要计算出一个得分片段。比如分别搜索“南京文化”和“南京文明”,应该返回不同的结果,这个结果根据计算出的得分片段来确定。

举个简单的例子,比如有一段文本:“你好,我的名字叫倪升武,科大讯飞软件开发工程师……,江湖人都叫我武哥,我一直觉得,人与人之间讲得是真诚,而不是套路……”。

如果搜“倪升武”,可能会返回这样结果:“我的名字叫倪升武,科大讯飞软件开发工程师”。

如果搜“武哥”,可能会返回:“江湖人都叫我武哥,我一直觉得”。

Lucene 会根据搜索的关键字,在待搜索文本中计算关键字出现片段的得分,返回最能反映用户搜索意图的一段文本作为结果。明白了这个原理,我们看一下代码和注释:

public class ChineseSearch {

    private static final Logger logger = LoggerFactory.getLogger(ChineseSearch.class);

    public static List<String> search(String indexDir, String q) throws Exception {

        //获取要查询的路径,也就是索引所在的位置
        Directory dir = FSDirectory.open(Paths.get(indexDir));
        IndexReader reader = DirectoryReader.open(dir);
        IndexSearcher searcher = new IndexSearcher(reader);
        //使用中文分词器
        SmartChineseAnalyzer analyzer = new SmartChineseAnalyzer();
        //由中文分词器初始化查询解析器
        QueryParser parser = new QueryParser("desc", analyzer);
        //通过解析要查询的 String,获取查询对象
        Query query = parser.parse(q);

        //记录索引开始时间
        long startTime = System.currentTimeMillis();
        //开始查询,查询前 10 条数据,将记录保存在 docs 中
        TopDocs docs = searcher.search(query, 10);
        //记录索引结束时间
        long endTime = System.currentTimeMillis();
        logger.info("匹配{}共耗时{}毫秒", q, (endTime - startTime));
        logger.info("查询到{}条记录", docs.totalHits);

        //如果不指定参数的话,默认是加粗,即 <b><b/>
        SimpleHTMLFormatter simpleHTMLFormatter = new SimpleHTMLFormatter("<b><font color=red>","</font></b>");
        //根据查询对象计算得分,会初始化一个查询结果最高的得分
        QueryScorer scorer = new QueryScorer(query);
        //根据这个得分计算出一个片段
        Fragmenter fragmenter = new SimpleSpanFragmenter(scorer);
        //将这个片段中的关键字用上面初始化好的高亮格式高亮
        Highlighter highlighter = new Highlighter(simpleHTMLFormatter, scorer);
        //设置一下要显示的片段
        highlighter.setTextFragmenter(fragmenter);

        //取出每条查询结果
        List<String> list = new ArrayList<>();
        for(ScoreDoc scoreDoc : docs.scoreDocs) {
            //scoreDoc.doc 相当于 docID,根据这个 docID 来获取文档
            Document doc = searcher.doc(scoreDoc.doc);
            logger.info("city:{}", doc.get("city"));
            logger.info("desc:{}", doc.get("desc"));
            String desc = doc.get("desc");

            //显示高亮
            if(desc != null) {
                TokenStream tokenStream = analyzer.tokenStream("desc", new StringReader(desc));
                String summary = highlighter.getBestFragment(tokenStream, desc);
                logger.info("高亮后的desc:{}", summary);
                list.add(summary);
            }
        }
        reader.close();
        return list;
    }
}

每一步代码都有注释,就不赘述了。接下来我们测试一下效果。

测试一下

我们使用 Thymeleaf 写个简单的页面来展示获取到的数据,并高亮展示。在 Controller 中,我们指定索引的目录和需要查询的字符串,如下:

@Controller
@RequestMapping("/lucene")
public class IndexController {

    @GetMapping("/test")
    public String test(Model model) {
        // 索引所在的目录
        String indexDir = "D:\\lucene2";
        // 要查询的字符
//        String q = "南京文明";
        String q = "南京文化";
        try {
            List<String> list = ChineseSearch.search(indexDir, q);
            model.addAttribute("list", list);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return "result";
    }
}

直接返回到 result.html 页面,该页面主要用来展示 Model 中的数据。

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div th:each="desc : ${list}">
    <div th:utext="${desc}"></div>
</div>
</body>
</html>

这里注意下,不能使用 th:test,否则字符串中的 html 标签都会被转义,无法完面页面渲染。

启动服务,在浏览器中输入:http://localhost:8080/lucene/test,我们搜索“南京文化”,看一下测试效果。

南京文化

再将 Controller 中的搜索关键字改成“南京文明”,看下命中的效果。

在这里插入图片描述

可以看出,不同的关键词,它会计算一个得分片段,也就是说不同的关键字会命中不同位置的内容,最后将关键字按我们设定的形式高亮显示。从结果中可以看出,Lucene 也可以很智能地将关键字拆分命中,这在实际项目中很好用。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐