基于 Java 文档的搜索引擎

391 阅读30分钟

搜索引擎的基本组成

搜索的核心思路

对于一个搜索引擎来说,首先需要获取到很多的网页,然后再根据用户输入的查询词,在这些网页中进行查询。

但是有如下的问题需要解决:

  1. 搜索引擎的网页是怎么获取的?

此处主要涉及到 “爬虫”这样的程序。

  1. 用户输入了查询词之后,如何去让查询词和当前的这些网页进行匹配呢?

此处需要设计一个特殊的数据结构,叫 倒排索引。(此数据结构能够让用户非常高效的获取到结果)

倒排索引

在介绍倒排索引之前,先介绍几个概念:

  1. 文档(document) :指的是每个待搜索的页面(这些页面通过爬虫获取并保存的)。
  2. 正排索引:指的是 文档 id --> 文档内容(一般文档内容包含:查询词、段落、标题等等)。
  3. 倒排索引:指的是 词 --> 文档 id 列表。(通过查询词来获取跟词有关的文档列表)。

举个例子:

现在有如下文档列表:

文档 id文档内容
1乔布斯发布了苹果手机
2乔布斯买了四斤苹果

正排索引:

1 乔布斯发布了苹果手机

2 乔布斯买了四斤苹果

倒排索引:

乔布斯 1,2

发布 1

....

项目目标

实现一个针对 Java 文档的搜索引擎。


像百度、搜狗、必应 这种搜索引擎,都是属于 “全站搜索”,搜索整个互联网上所有的网站。

还有一类搜索引擎,称为“站内搜索”,只针对某个网站内部的内容进行搜索的。

我们要实现的搜索引擎就是站内搜索。

获取 Java 文档

我们需要把相关的网页文档获取到,这样才能够制作正排索引和倒排索引。

问题:通过爬虫技术,能否把这些文档给获取到呢?

完全可以的!爬虫就是一个 HTTP 客户端(和浏览器类似)。

爬虫,是获取到一个网站页面的一种通用手段。但是针对 Java 文档来说,我们有更简单的办法!

可以直接从官方网站上下载文档的压缩包。

官方网站的地址为:官网

进入网站后,下载压缩包

并在本地进行解压缩:

java 文档的搜索界面在 docs 文件夹下的 api 目录中:

随便点开一个网页,发现离线文档和在线文档的网址后面都是一样的:

我们可以利用这一点:

在本地基于离线文档来制作索引,实现搜索。当用户在搜索结果页点击具体的搜索结果的时候,就自动跳转到在线文档的页面。

模块划分

  1. 索引模块
    1. 扫描下载到的文档,分析文档的内容,构建出正排索引+倒排索引,并且把索引内容保存到文件中。
    2. 加载制作好的索引,并提供一些 API 实现查正排和查倒排的功能。
  1. 搜索模块
    1. 调用索引模块,实现一个搜索的完整过程。
    2. 输入:用户的查询词。
    3. 输出:完整的搜索结果(包含很多条记录,每个记录有标题、描述、展示 URL,并且能够跳转)。
  1. web 模块: 需要实现一个简单的 web 程序,能够通过网页的形式来和用户进行交互。

开始项目

创建 Maven 项目,并引入第三方分词库Ansj

<dependency>
  <groupId>org.ansj</groupId>
  <artifactId>ansj_seg</artifactId>
  <version>5.1.6</version>
</dependency>

测试下 Ansj 的分词效果:

import org.ansj.domain.Term;
import org.ansj.splitWord.analysis.ToAnalysis;

import java.util.List;

public class TestAnsj {
    public static void main(String[] args) {
        String str = "小米比白米有更多的微量营养,小米取代白米不但有助控制血糖,而且可以有助逆转糖尿病,降低血脂,改善血压和降低心血管风险。";

        List<Term> terms = ToAnalysis.parse(str).getTerms();
        for (Term term : terms) {
            System.out.println(term.getName());
        }
    }
}

结果:

这些警告是要我们引入词典,不过没必要,只是简单测试下:

分词结果:

注意:如果对英文进行分词,Ansj 会默认把单词转为小写进行分词。

一、实现索引模块

创建新类,命名为 Parser

Parser 类的整体流程

public class Parser {

    // 指定一个加载文档的路径
    private static final String INPUT_PATH = "E:\Coding\Java\java-code-exercise\A-MyProjects\docs\api";

    public void run() {
        // 整个 Parser 类的入口
        // 1. 根据上面指定的路径,枚举出所有的文件(html),这个过程需要把所有子目录中的文件都能获取到
        // 2. 针对上面罗列出的文件路径,打开文件,读取文件内容,并进行解析,并构建索引
        // 3. 把在内存中构造好的索引数据结构,保存到指定的文件中。
    }

    public static void main(String[] args) {
        // 通过 main 方法来实现制作索引的过程
        Parser parser = new Parser();
        parser.run();
    }
}

递归枚举文件

public void run() {
    // 整个 Parser 类的入口
    // 1. 根据上面指定的路径,枚举出所有的文件(html),这个过程需要把所有子目录中的文件都能获取到
    ArrayList<File> filelist = new ArrayList<>();
    enumFile(INPUT_PATH,filelist);
    
    // 2. 针对上面罗列出的文件路径,打开文件,读取文件内容,并进行解析,并构建索引
    // 3. 把在内存中构造好的索引数据结构,保存到指定的文件中。
}

private void enumFile(String inputPath, ArrayList<File> filelist) {
    File rootPath = new File(inputPath);
    // 使用 listFiles 只能看到一级目录,看不到子目录
    File[] files = rootPath.listFiles();
    for (File file : files) {
        if (file.isDirectory()) {
            enumFile(file.getAbsolutePath(),filelist);
        } else {
            filelist.add(file);
        }
    }
}

我们来测试下 run 方法打印的东西:

发现除了 html 文件,还有别的文件类型:

所以接下来我们要排除 除html 文件之外的文件了。

排除非 HTML 文件

修改下 enumFile 方法的 else 语句块:

先将文件转换为路径字符串,然后在筛查出以 .html 为后缀的文件。

private void enumFile(String inputPath, ArrayList<File> filelist) {
    File rootPath = new File(inputPath);
    // 使用 listFiles 只能看到一级目录,看不到子目录
    File[] files = rootPath.listFiles();
    for (File file : files) {
        if (file.isDirectory()) {
            enumFile(file.getAbsolutePath(),filelist);
        } else {
            if (file.getAbsolutePath().endsWith(".html")) {
                filelist.add(file);
            }
        }
    }
}

查看结果:

获取到了 HTML 文件,接下来就要,解析 HTML 了。

解析 HTML

一条搜索结果,包含了 标题、描述、展示 URL,这些信息就来自于解析的 HTML。

因此当前的解析 HTML 操作,就是要把这个 HTML 文件的标题、描述 和 URL 给获取到。(其中,描述可以视为正文的一段摘要,因此要得到描述,就要先得到整个正文)

public void run() {
    // 整个 Parser 类的入口
    // 1. 根据上面指定的路径,枚举出所有的文件(html),这个过程需要把所有子目录中的文件都能获取到
    ArrayList<File> filelist = new ArrayList<>();
    enumFile(INPUT_PATH, filelist);

    // 2. 针对上面罗列出的文件路径,打开文件,读取文件内容,并进行解析,并构建索引
    for (File f : filelist) {
        System.out.println("开始解析:" + f.getAbsolutePath());
        // 通过这个方法来解析单个 HTML 文件
        parserHTML(f);
    }

    // 3. 把在内存中构造好的索引数据结构,保存到指定的文件中。
}
private void parserHTML(File f) {
    // 1. 解析出 HTML 的标题
    String title = parserTitle(f);
    // 2. 解析出 HTML 对应的 URL
    String url = parserURL(f);
    // 3. 解析出 HTML 对应的正文(有了正文才有后续的描述)
    String content = parserContent(f);
}

解析标题

因为 Java 文档的标题跟他的文件名是相同的,只不过我们需要去掉 .html 后缀:

private String parserTitle(File f) {
    String name = f.getName();
	return name.substring(0, name.length() - ".html".length());
}

解析 URL

我们期望的结果是,用户点击搜索结果,就能够跳转到对应的线上文档的页面。

根据本地文档路径和线上文档路径的对比

我们最终跳转的 URL 以 为固定前缀,然后根据当前本地文档所在的路径,去和前缀进行拼接。

我们可以获取到本地文档路径的,只需要把后半部分提取出来:,再和前面的固定前缀进行拼接。

private String parserURL(File f) {
    String part1 = "https://docs.oracle.com/javase/8/docs/api/";
    String part2 = f.getAbsolutePath().substring(INPUT_PATH.length());
    return part1 + part2;
}

测试此功能:

代码结果返回一个字符串:

我们使用此字符串进行访问:

可以访问,功能正常。

解析正文

比如我们打开一个本地的java文档:

我们需要从中读取正文,也就是标签里面的内容。

由于 HTML 的标签都是<>,我们可以根据这个特点去依次读取 HTML 中的每个字符。然后针对取出的每个字符进行判定,看结果是否是 <,如果是 <,那么从这个位置开始,直到遇到 >,都不把这些字符放到结果中。

换句话说,如果遇到的字符不是 <,就直接把当前的字符拷贝到一个结果中(StringBuilder)。

举个例子

<div>这是一段内容</div>

先读到第一个字符:<,就从这个位置开始不进行数据拷贝。往后继续读的字符为 d,i,v,也都不进行拷贝,读到 >的时候仍不进行拷贝,但是不拷贝状态结束。

可以搞一个标志位 flag,为 true 就拷贝,为 false 就不拷贝。

遇到 <就把 flag 设为 false,遇到 > 就把 flag 设为 true。

后续读到“这是一段内容”就进行拷贝。

再后续读到 <又把 flag 设为 false,/div也就不进行拷贝,读到 >又把 flag 设为 true。

问题:如果 html 的内容中存在 < 或 > 怎么办?

html 就要求,内容中的 < 使用 &lt; 来替换,> 使用 &gt; 来替换。

读取文件的时候,有时候按照“字节”来读取,有时候按照“字符”来读取。

在 Java 标准库中,既提供了能够按照字节读取的类(FileInputStream),也提供了能按照字符读取的类(FileReader)。

public String parserContent(File f) {
    // 先按照一个字符一个字符的方式来读取,以 < 和 > 来控制拷贝数据的开关
    try (FileReader fileReader = new FileReader(f)) {
        // 加上一个是否要进行拷贝的开关
        boolean isCopy = true;
        // content 用来存储结果
        StringBuilder content = new StringBuilder();

        while (true) {
            // 注意,此处的 read,返回值是一个 int,而不是 char !
            // 此处使用 int 作为返回值,主要为了表示一些非法情况!
            // 如果读到文件末尾,继续读,就会返回 -1
            int ret = fileReader.read();
            if (ret == -1) {
                // 表示文件读完了
                break;
            }
            // 如果 ret 不为 -1,则为一个合法的字符
            char c = (char)ret;
            // 开关打开的状态,遇到普通字符拷贝到 StringBuilder 中
            if (isCopy) {
                if (c == '<') {
                    // 关闭开关
                    isCopy = false;
                    continue;
                }

                if (c == '\n' || c == '\r') {
                    // 为了去掉换行,把换行替换成空格
                    c = ' ';
                }
                content.append(c);
            } else {
                if (c == '>') {
                    isCopy = true;
                }
            }
        }
        return content.toString();
    } catch (IOException e) {
        e.printStackTrace();
    }
    return "";
}

测试代码:

代码结果:

结果都变为一行了,因为把换行替换成了空格。

Parser 类小结总代码:

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;

public class Parser {

    // 指定一个加载文档的路径
    private static final String INPUT_PATH = "E:\Coding\Java\java-code-exercise\A-MyProjects\docs\api";

    public void run() {
        // 整个 Parser 类的入口
        // 1. 根据上面指定的路径,枚举出所有的文件(html),这个过程需要把所有子目录中的文件都能获取到
        ArrayList<File> filelist = new ArrayList<>();
        enumFile(INPUT_PATH, filelist);

        // 2. 针对上面罗列出的文件路径,打开文件,读取文件内容,并进行解析,并构建索引
        for (File f : filelist) {
            System.out.println("开始解析:" + f.getAbsolutePath());
            // 通过这个方法来解析单个 HTML 文件
            parserHTML(f);
        }

        // 3. TODO:把在内存中构造好的索引数据结构,保存到指定的文件中。
    }

    private void parserHTML(File f) {
        // 1. 解析出 HTML 的标题
        String title = parserTitle(f);
        // 2. 解析出 HTML 对应的 URL
        String url = parserURL(f);
        // 3. 解析出 HTML 对应的正文(有了正文才有后续的描述)
        String content = parserContent(f);
        // 4. TODO:把解析出来的这些信息,加入到索引当中
    }

    public String parserContent(File f) {
        // 先按照一个字符一个字符的方式来读取,以 < 和 > 来控制拷贝数据的开关
        try (FileReader fileReader = new FileReader(f)) {
            // 加上一个是否要进行拷贝的开关
            boolean isCopy = true;
            // content 用来存储结果
            StringBuilder content = new StringBuilder();

            while (true) {
                // 注意,此处的 read,返回值是一个 int,而不是 char !
                // 此处使用 int 作为返回值,主要为了表示一些非法情况!
                // 如果读到文件末尾,继续读,就会返回 -1
                int ret = fileReader.read();
                if (ret == -1) {
                    // 表示文件读完了
                    break;
                }
                // 如果 ret 不为 -1,则为一个合法的字符
                char c = (char)ret;
                // 开关打开的状态,遇到普通字符拷贝到 StringBuilder 中
                if (isCopy) {
                    if (c == '<') {
                        // 关闭开关
                        isCopy = false;
                        continue;
                    }

                    if (c == '\n' || c == '\r') {
                        // 为了去掉换行,把换行替换成空格
                        c = ' ';
                    }
                    content.append(c);
                } else {
                    if (c == '>') {
                        isCopy = true;
                    }
                }
            }
            return content.toString();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return "";
    }

    private String parserURL(File f) {
        String part1 = "https://docs.oracle.com/javase/8/docs/api/";
        String part2 = f.getAbsolutePath().substring(INPUT_PATH.length());
        return part1 + part2;
    }

    private String parserTitle(File f) {
        String name = f.getName();
        return name.substring(0, name.length() - ".html".length());
    }

    private void enumFile(String inputPath, ArrayList<File> filelist) {
        File rootPath = new File(inputPath);
        // 使用 listFiles 只能看到一级目录,看不到子目录
        File[] files = rootPath.listFiles();
        for (File file : files) {
            if (file.isDirectory()) {
                enumFile(file.getAbsolutePath(), filelist);
            } else {
                if (file.getAbsolutePath().endsWith(".html")) {
                    filelist.add(file);
                }
            }
        }
    }
}

在此类中,还有两个功能需要做:将信息加入到索引中 和 将索引的数据结果保存到文件中。

接下来就创建 Index 类来在内存中构造出索引结构:

Index 类构造索引结构

创建 新类 DocInfo用来表示文档的信息:

public class DocInfo {

    private int docId;
    private String title;
    private String url;
    private String content;

    public int getDocId() {
        return docId;
    }

    public void setDocId(int docId) {
        this.docId = docId;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getUrl() {
        return url;
    }

    public void setUrl(String url) {
        this.url = url;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }
}

创建 新类 Weight用来表示查询词与文档之间的关联性:

public class Weight {

    private int docId;

    // 表示查询词和文档之间的关联性
    // weight 越大,代表关联性越强
    private int weight;

    public int getDocId() {
        return docId;
    }

    public void setDocId(int docId) {
        this.docId = docId;
    }

    public int getWeight() {
        return weight;
    }

    public void setWeight(int weight) {
        this.weight = weight;
    }
}

创建新类 Index用来创建索引:

import java.util.List;

public class Index {

    // 1. 给定一个 docId,在正排索引中,查询文档的详细信息
    public DocInfo getDocInfo(int docId) {
        // TODO
        return null;
    }

    // 2. 给定一个词,在倒排索引中,查哪些文档和这个词关联
    // 因为 文档和词之间存在一定的相关性,所以返回值应和权重有关
    public List<Weight> getInverted(String term) {
        //TODO
        return null;
    }

    // 3. 往索引中新增一个文档
    public void addDoc(String title, String url, String content) {
        // TODO
    }

    // 4. 把内存中的索引结构保存到磁盘中
    public void save() {
        // TODO
    }

    // 5. 把磁盘中的索引结构加载到内存
    public void load() {
        // TODO

    }
}

实现索引结构

新增文档 -- 正排索引的构建

新增文档 -- 倒排索引的构建

倒排索引,词 --> 文档 id 之间的映射关系。

首先就需要先知道当前这个文档,里面有哪些词。

因此需要针对当前文档也进行分词:

  1. 针对标题。
  2. 针对正文。

然后就可以结合这个分词的结果,就知道当前这个文档 id 应该要加入到哪个倒排索引的 key 中了。

倒排索引是一个键值对结构(HashMap),key 分词结果(term),value 是一组和这个分词结果相关的文档 id 列表。

因此可以针对当前文档进行分词,然后根据每个分词结果,去倒排索引中,找到对应的 value,然后把当前文档 id 给加入到对应的 value 列表中即可。

如何来确定这个权重的值?

这个值描述了 词 和 文档 之间的“相关性”。

此处我们单纯的通过 词 出现的次数,来表示相关性。

代码的步骤如下:

// 1. 针对文档标题进行分词
// 2. 遍历分词结果,统计每个词出现的次数
// 3. 针对正文进行分词
// 4. 遍历分词结果,统计每个词出现的次数
// 5. 把上面的结果汇总到一个 HashMap 里面
//      最终文档的权重,就设定成 标题中出现的次数*10 + 正文中出现的次数
// 6. 遍历刚才的 HashMap,依次来更新倒排索引中的结构了
实现词频的统计

首先在方法 buildInverted 中,创建一个类,用来存储在标题和在正文出现的次数:

定义一个 HashMap,用来存放词和词频:

  1. 对文档标题进行统计

  1. 对文档正文进行统计

构造倒排索引逻辑梳理

比如现在有两个文档(已经分好词了):

  1. 乔布斯 发布 了 苹果 手机
  2. 乔布斯 买 了 四斤 苹果

则构建逻辑如下:

首先有个 HashMap 来存储索引结构:

一开始 HashMap 是为空的。

先对 1 号文档构建倒排索引:

第一个词为 乔布斯,发现 HashMap 中没有此 key,则把 乔布斯作为 key。value 是 Weight 类型的链表,而 Weight 有两个属性:docId 和 weight。乔布斯在 1 号文档出现了一次,所以key-value如下:

1号文档后续的词也是同样操作:

接着对 2 号文档进行构建倒排:(画红框的都是2号文档新增的)

如果词在HashMap中已存在,则在此key的value部分,添加 Weight。

构造倒排索引代码实现

其中,invertedIndex 的结构为:

保存索引到磁盘中

如何将内存中的索引结构保存到磁盘呢?

这就需要 “序列化”了,将内存中的索引结构转为“字符串”,然后写进文件即可。

对应的,把特定结构的字符串,反向解析成一些结构化数据(类/对象/基础数据结构),称为反序列化。

我们使用 JSON 格式来进行序列化和反序列化。

首先引入 Jason 库:

<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.14.1</version>
</dependency>

定义一个 ObjectMapper 用于序列化:

定义一个保存索引结构的路径:

编写代码:

加载索引结构到内存中

在保存的文件中,JSON 的格式大概如下:

把这个结构的字符串,转成啥样的对象呢?就需要指定一个具体对象的类型!

JackSon 专门提供了一个辅助的工具类:TypeReference<>

创建一个匿名内部类,这个类实现了 TypeReference,同时再创建一个这个匿名内部类的实例。

创建这个实例最主要的目的,就是为了把 ArrayList这个类型信息,告诉 readValue 方法。


关于 TypeReference<> 可查看此博客:Java泛型 | Jackson TypeReference获取泛型类型信息

代码实现:

Index 类总代码

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.ansj.domain.Term;
import org.ansj.splitWord.analysis.ToAnalysis;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public class Index {

    // 设置索引结构保存到磁盘的路径
    private static final String INDEX_PATH = "E:\Coding\Java\java-code-exercise\A-MyProjects\";

    private ObjectMapper objectMapper = new ObjectMapper();

    // 使用数组下标表示 docId
    private ArrayList<DocInfo> forwardIndex = new ArrayList<>();

    // 使用哈希表来表示倒排索引
    // key 为 词
    // value 为一组和这个词关联的文档
    private HashMap<String, ArrayList<Weight>> invertedIndex = new HashMap<>();

    // 1. 给定一个 docId,在正排索引中,查询文档的详细信息
    public DocInfo getDocInfo(int docId) {
        return forwardIndex.get(docId);
    }

    // 2. 给定一个词,在倒排索引中,查哪些文档和这个词关联
    // 因为 文档和词之间存在一定的相关性,所以返回值应和权重有关
    public List<Weight> getInverted(String term) {
        return invertedIndex.get(term);
    }

    // 3. 往索引中新增一个文档
    public void addDoc(String title, String url, String content) {
        // 新增文档操作,需要同时给正排索引和倒排索引新增信息
        // 构建正排索引
        DocInfo docInfo = buildForward(title, url, content);
        // 构建倒排索引
        buildInverted(docInfo);
    }

    private void buildInverted(DocInfo docInfo) {
        class WordCount {
            // 词在标题中出现的次数
            public int titleCount;
            // 词在正文中出现的次数
            public int contentCount;
        }

        HashMap<String, WordCount> wordCntHashMap = new HashMap<>();

        // 1. 针对文档标题进行分词
        List<Term> terms = ToAnalysis.parse(docInfo.getTitle()).getTerms();

        // 2. 遍历分词结果,统计每个词出现的次数
        for (Term term : terms) {
            // 判断 term 是否存在
            String word = term.getName();
            WordCount wordCount = wordCntHashMap.get(word);
            if (wordCount == null) {
                // 如果不存在,就创建一个新的键值对,插入进去,titleCount设为 1
                WordCount wordCount1 = new WordCount();
                wordCount1.titleCount = 1;
                wordCount1.contentCount = 0;
                wordCntHashMap.put(word, wordCount1);
            } else {
                wordCount.titleCount += 1;
            }
        }

        // 3. 针对正文进行分词
        terms = ToAnalysis.parse(docInfo.getContent()).getTerms();
        // 4. 遍历分词结果,统计每个词出现的次数
        for (Term term : terms) {
            String word = term.getName();
            WordCount wordCount = wordCntHashMap.get(word);
            if (wordCount == null) {
                // 如果不存在,就创建一个新的键值对,插入进去,contentCount设为 1
                WordCount wordCount1 = new WordCount();
                wordCount1.titleCount = 0;
                wordCount1.contentCount = 1;
                wordCntHashMap.put(word, wordCount1);
            } else {
                wordCount.contentCount += 1;
            }
        }
        // 5. 把上面的结果汇总到一个 HashMap 里面
        //      最终文档的权重,就设定成 标题中出现的次数*10 + 正文中出现的次数
        // 6. 遍历刚才的 HashMap,依次来更新倒排索引中的结构了
        for (Map.Entry<String, WordCount> entry : wordCntHashMap.entrySet()) {
            // 先根据这里的词,去倒排索引中查一查
            // 倒排拉链
            List<Weight> invertedList = invertedIndex.get(entry.getKey());
            if (invertedList == null) {
                // 如果为空,就插入一个新的键值对
                ArrayList<Weight> newInvertedList = new ArrayList<>();
                // 把新的文档(当前的 DocInfo),构造成 Weight 对象,插入进来
                Weight weight = new Weight();
                weight.setDocId(docInfo.getDocId());
                // 权重计算公式,标题中出现的次数*10 + 正文中出现的次数
                weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);
                newInvertedList.add(weight);
                invertedIndex.put(entry.getKey(),newInvertedList);
            } else {
                // 如果非空,就把当前这个文档,构造出一个 Weight 对象,插入到倒排拉链的后面
                Weight weight = new Weight();
                weight.setDocId(docInfo.getDocId());
                weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);
                invertedList.add(weight);
            }
        }
    }

    private DocInfo buildForward(String title, String url, String content) {
        DocInfo docInfo = new DocInfo();
        docInfo.setDocId(forwardIndex.size());
        docInfo.setTitle(title);
        docInfo.setUrl(url);
        docInfo.setContent(content);
        forwardIndex.add(docInfo);
        return docInfo;
    }

    // 4. 把内存中的索引结构保存到磁盘中
    public void save() {
        long start = System.currentTimeMillis();
        System.out.println("开始保存索引!");
        // 1. 判定索引对应的目录是否存在,不存在就创建
        File indexPathFile = new File(INDEX_PATH);
        if (!indexPathFile.exists()) {
            indexPathFile.mkdirs();
        }
        File forwardIndexFile = new File(INDEX_PATH + "forward.txt");
        File invertedIndexFile = new File(INDEX_PATH + "inverted.txt");
        try {
            objectMapper.writeValue(forwardIndexFile,forwardIndex); // 保存正排索引
            objectMapper.writeValue(invertedIndexFile,invertedIndex); // 保存倒排索引
        } catch (IOException e) {
            e.printStackTrace();
        }
        long end = System.currentTimeMillis();
        System.out.println("保存索引完成!耗时:" + (end-start) + " ms");
    }

    // 5. 把磁盘中的索引结构加载到内存
    public void load() {
        System.out.println("加载索引开始!");
        // 1. 设置加载索引的路径
        File forwardIndexFile = new File(INDEX_PATH + "forward.txt");
        File invertedIndexFile = new File(INDEX_PATH + "inverted.txt");
        try {
            forwardIndex = objectMapper.readValue(forwardIndexFile, new TypeReference<ArrayList<DocInfo>>() {});
            invertedIndex = objectMapper.readValue(invertedIndexFile, new TypeReference<HashMap<String, ArrayList<Weight>>>() {});
        } catch (IOException e) {
            e.printStackTrace();
        }
        System.out.println("加载索引结束!");
    }
}

在Parser中调用Index

关于 Parser 类 和 Index 类之间的关系:

  • Parser 相当于制作索引的入口 --> 对应到一个“可执行”程序。
  • Index 相当于实现了索引的数据结构,提供了一些 api。

Index 类就要给 Parser 进行调用,才能完成整个制作索引的功能。

在 Parser 类中创建 Index 实例:

然后在 parserHTML 方法中使用 Index 类的添加文档的操作,将信息加入到索引:

在 run 方法中,使用 Index 类的 save 方法进行索引结构的保存:

Parser类 总代码

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;

public class Parser {

    // 指定一个加载文档的路径
    private static final String INPUT_PATH = "E:\Coding\Java\java-code-exercise\A-MyProjects\docs\api";

    private Index index = new Index();

    public void run() {
        // 整个 Parser 类的入口
        // 1. 根据上面指定的路径,枚举出所有的文件(html),这个过程需要把所有子目录中的文件都能获取到
        ArrayList<File> filelist = new ArrayList<>();
        enumFile(INPUT_PATH, filelist);

        // 2. 针对上面罗列出的文件路径,打开文件,读取文件内容,并进行解析,并构建索引
        for (File f : filelist) {
            System.out.println("开始解析:" + f.getAbsolutePath());
            // 通过这个方法来解析单个 HTML 文件
            parserHTML(f);
        }

        // 3.把在内存中构造好的索引数据结构,保存到指定的文件中。
        index.save();
    }

    private void parserHTML(File f) {
        // 1. 解析出 HTML 的标题
        String title = parserTitle(f);
        // 2. 解析出 HTML 对应的 URL
        String url = parserURL(f);
        // 3. 解析出 HTML 对应的正文(有了正文才有后续的描述)
        String content = parserContent(f);
        // 4. 把解析出来的这些信息,加入到索引当中
        index.addDoc(title,url,content);
    }

    public String parserContent(File f) {
        // 先按照一个字符一个字符的方式来读取,以 < 和 > 来控制拷贝数据的开关
        try (FileReader fileReader = new FileReader(f)) {
            // 加上一个是否要进行拷贝的开关
            boolean isCopy = true;
            // content 用来存储结果
            StringBuilder content = new StringBuilder();

            while (true) {
                // 注意,此处的 read,返回值是一个 int,而不是 char !
                // 此处使用 int 作为返回值,主要为了表示一些非法情况!
                // 如果读到文件末尾,继续读,就会返回 -1
                int ret = fileReader.read();
                if (ret == -1) {
                    // 表示文件读完了
                    break;
                }
                // 如果 ret 不为 -1,则为一个合法的字符
                char c = (char)ret;
                // 开关打开的状态,遇到普通字符拷贝到 StringBuilder 中
                if (isCopy) {
                    if (c == '<') {
                        // 关闭开关
                        isCopy = false;
                        continue;
                    }

                    if (c == '\n' || c == '\r') {
                        // 为了去掉换行,把换行替换成空格
                        c = ' ';
                    }
                    content.append(c);
                } else {
                    if (c == '>') {
                        isCopy = true;
                    }
                }
            }
            return content.toString();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return "";
    }

    private String parserURL(File f) {
        String part1 = "https://docs.oracle.com/javase/8/docs/api/";
        String part2 = f.getAbsolutePath().substring(INPUT_PATH.length());
        return part1 + part2;
    }

    private String parserTitle(File f) {
        String name = f.getName();
        return name.substring(0, name.length() - ".html".length());
    }

    private void enumFile(String inputPath, ArrayList<File> filelist) {
        File rootPath = new File(inputPath);
        // 使用 listFiles 只能看到一级目录,看不到子目录
        File[] files = rootPath.listFiles();
        for (File file : files) {
            if (file.isDirectory()) {
                enumFile(file.getAbsolutePath(), filelist);
            } else {
                if (file.getAbsolutePath().endsWith(".html")) {
                    filelist.add(file);
                }
            }
        }
    }
}

实现多线程制作索引

通过下面的代码,我们来进行各个部分的耗时测试:

测试结果:

可以发现:解析 HTML 文件是最耗时的。这也是我们要优化的点!

因此我们要引入多线程,让多个线程去执行 解析 HTML 的任务,减少耗时。

下面为代码:

但是呢,有个问题在图中的橙色框里:

index.save 操作一定在每个线程都解析完 HTML 操作后才会执行吗?

答案是不一定的。

submit 操作是往线程池中提交任务,这个操作本身极快(只是把 Runnable 对象给放到阻塞队列中)。因此很大可能存在:比如 1 w多次循环 submit 已经结束了,但是线程池里这 1 w多个文档还没有解析完,然后你就是执行 save 操作了。

所以,必须要等待所有线程把所有文档任务都处理完,才能进行 save 索引操作!

使用 CountDownLatch 协调线程同步

我们引入一个工具类:CountDownLatch

CountDownLatch 概念:

CountDownLatch是一个同步工具类,用来协调多个线程之间的同步,或者说起到线程之间的通信(而不是用作互斥的作用)。

CountDownLatch能够使一个线程在等待另外一些线程完成各自工作之后,再继续执行。使用一个计数器进行实现。计数器初始值为线程的数量。当每一个线程完成自己任务后,计数器的值就会减一。当计数器的值为0时,表示所有的线程都已经完成一些任务,然后在CountDownLatch上等待的线程就可以恢复执行接下来的任务。

下面为代码实现:

给制作索引代码加锁

我们要考虑同步安全问题。那什么时候会产生这种问题呢:那就是多个线程去修改了同一个对象!

我们来看看代码里,有哪些地方涉及多个线程去修改了同一个对象:

画个简图:

代码实现如下:

由于 方法buildInverted 和 bulidForward 操作的对象分别为:HashMap<String, ArrayList> invertedIndex 和 ArrayList forwardIndex,所以在 Index 类中创建两个锁对象来分别加锁:

然后加锁:

方法 buildInverted

方法bulidForward

验证多线程效果

测试代码:

运行结果对比:

解决进程不退出问题

我们发现引入多线程后,运行程序后,发现程序结束的时候进程并没退出:

这就是涉及到 守护线程 的概念了:

  • 如果一个线程是守护线程(后台线程),此时这个线程的运行状态,不会印象到进程结束。
  • 如果一个线程是非守护线程,此时这个线程的运行状态,就会影响到进程结束。

前面学过的线程创建手段:链接默认创建出来的都是非守护线程,需要通过 setDaemon方法手动设置,才能成为守护线程。

这些通过线程池创建的线程,并不是守护线程。当 main 方法执行完,这些线程仍然在工作(等待新任务的到来)

因此,当线程的任务完成之后,我们需要手动干掉线程池里的线程:

再次运行程序:

优化文件读取速度

方法 parserContent 的核心操作就是读取文件,从磁盘进行访问!操作系统就会对“经常读取的文件”进行缓存!

首次运行的时候,当前的这些 Java 文档,都没有在内存中缓存,因此读取的时候只能直接从硬盘上读取(相对耗时)。

后面再运行的时候,由于前面已经读取过这些文档了,这些文档在操作系统中其实已经有一份缓存(在内存中的),这次的读取不必直接读硬盘,而是直接读取内存中的缓存(读取速度会快很多)

BufferedReader可以搭配 FileReader来使用。

BufferedReader 内部就会内置一个缓冲区,就能够自动的把 FileReader 中的一些内容预读到内存中,从而减少直接访问磁盘的次数。

二、搜索模块

搜索模块简要流程

搜索模块:调用索引模块,来完成搜索的核心过程。

  1. 分词:针对用户输入的 查询词 进行分词(用户输入的查询词,可能不是一个词,可能是一句话)
  2. 触发:拿着每个分词结果,去倒排索引中查,找到具有相关性的文档(调用 Index 类里面的 查倒排 的方法)
  3. 排序:针对上面触发的结果,进行排序(按照相关性,降序排序)
  4. 包装结果:根据排序后的结果,依次去查正排,获取到每个文档的详细信息,包装成一定结构的数据返回出去。

搜索模块入口:创建DocSearcher类

需要创建一个新类 Result,用来存储搜索结果的信息:

package Searcher;

public class Result {
    private String title;
    private String url;
    // 描述为正文的一部分摘要
    private String desc;

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getUrl() {
        return url;
    }

    public void setUrl(String url) {
        this.url = url;
    }

    public String getDesc() {
        return desc;
    }

    public void setDesc(String desc) {
        this.desc = desc;
    }
}

整个搜索模块的入口:

package Searcher;

import Index.Index;

import java.util.List;

public class DocSearcher {

    private Index index = new Index();

    public DocSearcher() {
        index.load();
    }

    // 完成整个搜索过程的方法
    // 参数(输入部分)用户给出的查询词
    // 返回值(输出部分)搜索结果的集合
    public List<Result> search(String query) {
        // 1.【分词】针对 query 查询词进行分词
        // 2.【触发】针对分词结果来查倒排
        // 3.【排序】针对触发结果按照权重降序排序
        // 4.【包装结果】针对排序的结果,查正排,构造出要返回的数据
        return null;
    }
}

实现search方法

// 完成整个搜索过程的方法
// 参数(输入部分)用户给出的查询词
// 返回值(输出部分)搜索结果的集合
public List<Result> search(String query) {
    // 1.【分词】针对 query 查询词进行分词
    List<Term> terms = ToAnalysis.parse(query).getTerms();
    // 2.【触发】针对分词结果来查倒排
    List<Weight> allTermResult = new ArrayList<>();
    for (Term term : terms) {
        String word = term.getName();
        List<Weight> invertedList = index.getInverted(word);
        if (invertedList == null) {
            // 说明这个查询词在所有文档中都不存在
            continue;
        }
        allTermResult.addAll(invertedList);
    }
    // 3.【排序】针对触发结果按照权重降序排序
    allTermResult.sort(new Comparator<Weight>() {
        @Override
        public int compare(Weight o1, Weight o2) {
            return o2.getWeight() - o1.getWeight();
        }
    });
    // 4.【包装结果】针对排序的结果,查正排,构造出要返回的数据
    List<Result> results = new ArrayList<>();
    for (Weight weight:allTermResult) {
        DocInfo docInfo = index.getDocInfo(weight.getDocId());
        Result result = new Result();
        result.setTitle(docInfo.getTitle());
        result.setUrl(docInfo.getUrl());
        result.setDesc(GenDesc(docInfo.getContent(),terms));
    }
    return results;
}

方法 GenDesc 用于找到在正文中和查询词相关的一段语句:

private String GenDesc(String content, List<Term> terms) {
    // 先遍历分词结果,看看哪个结果在 content 中存在
    int firstPos = -1;
    for (Term term:terms) {
        // 分词库直接针对词进行转小写了
        // 正因为如此,就必须把正文内容也先转为小写,然后再查询
        String word = term.getName();
        // 此处需要的是 “全字匹配” ,让 word 能够独立成词,才要查找出来,而不是只作为词的一部分
        // 此处的 “全字匹配” 的实现不算特别严谨,更严谨的做法,可以使用正则表达式
        firstPos = content.toLowerCase().indexOf(" " + word + " ");
        if (firstPos >= 0) {
            // 找到了位置
            break;
        }
    }
    if (firstPos == -1) {
        // 所有的分词结果都不在正文中
        // 属于比较极端的情况
        // 可以直接读取正文的前160字符作为描述
        if (content.length() > 160) {
            return content.substring(0,160) + "...";
        }
        return content;
    }
    // 从 firstPos 作为基准,往前找 60 个字符,作为描述的起始位置
    String desc = "";
    int descBeg = firstPos > 60 ? firstPos-60:0;
    if (descBeg+160 > content.length()) {
        desc = content.substring(descBeg);
    } else {
        desc = content.substring(descBeg,descBeg+160) + "...";
    }
    return desc;
}

验证 search 方法:

结果:

但是发现一个问题:在有些搜索结果里,desc 里的内容为 HTML 文件里

这个内容就是 JavaScript 的代码。我们在处理文档的时候,只是对正文,进行了“去标签”,有的 HTML 里面包含了 script 标签,就导致去了标签之后,JS 的代码也被整理到索引里了。

下面通过正则表达式来解决这个问题。

使用正则表达式去除

简单介绍下要使用的正则表达式:

. 表示匹配一个非换行字符(不是\n 或者不是 \r)

* 表示前面的字符可以出现若干次。

.* 匹配非换行字符出现若干次。

去掉 script 标签和内容: <script.*?>(.*?)</script>

去掉 普通的标签(不去掉内容): <.*?> ,既能匹配到开始标签,也能匹配到结束标签。

? 表示 “非贪婪匹配”,匹配到一个符合条件的最短结果。

不带 ? .*表示“贪婪匹配”,匹配到一个符合条件的最长结果。

使用在线正则表达式网站进行测试:

使用 <.*>

<.*> 把整个正文都匹配到了,进行替换,也就自然把整个正文内容给替换没了

进行替换:

使用 <.*?>

<.*?> 此时的匹配就是会分别匹配到四个标签,如果替换,只是替换标签,不替换内容。

使用替换:

在 Parser 类中添加方法 parserContentByRegex:

// 此方法基于正则表达式,实现去标签,就是去除 script
private String parserContentByRegex(File f) {
    // 1. 把整个文件都读到 String 里面
    String content = readFile(f);
    // 2. 替换掉 script 标签
    content = content.replaceAll("<script.*?>(.*?)</script>"," ");
    // 3. 替换掉普通的 html 标签
    content = content.replaceAll("<.*?>"," ");
	// 4. 使用正则把多个空格合成一个
    content = content.replaceAll("\s+"," ");
    return content;
}

private String readFile(File f) {
    try (BufferedReader bufferedReader = new BufferedReader(new FileReader(f))) {
        StringBuilder content = new StringBuilder();
        while (true) {
            int ret = bufferedReader.read();
            if (ret == -1) {
                break;
            }
            char c = (char) ret;
            if (c == '\n' || c == '\r') {
                c = ' ';
            }
            content.append(c);
        }
        return content.toString();
    } catch (IOException e) {
        e.printStackTrace();
    }
    return "";
}

进行测试:

结果:

别忘了,在 Parser 类中,修改下 parserHTML 方法:

三、web 模块

基于Servlet的方式实现后端

约定前后端交互形式:

创建新类:DocSearcherServlet

@WebServlet("/searcher")
public class DocSearcherServlet extends HttpServlet {

    private static DocSearcher docSearcher = new DocSearcher();
    private ObjectMapper objectMapper = new ObjectMapper();

    @Override
    protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        String query = req.getParameter("query");
        if (query == null || query.equals("")) {
            String msg = "参数缺失,未获取到 query 参数";
            System.out.println(msg);
            resp.sendError(404,msg);
            return;
        }
        System.out.println("查询词为:" + query);
        List<Result> results = docSearcher.search(query);
        resp.setContentType("application/json; charset=utf-8");
        objectMapper.writeValue(resp.getWriter(),results);
    }
}

实现页面结构

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Java 文档搜索</title>
    <link rel="stylesheet" type="text/css" href="common.css">
</head>
<body>
    <div class="container">

        <!-- 搜索界面 -->
        <div class="header">
            <input type="text">
            <button type="submit" id="search_btn">搜索</button>
        </div>

        <!-- 搜索结果 -->
        <div class="result">
            <div class="item">
                <a href="#">标题</a>
                <div class="desc">描述</div>
                <div class="url">www.baidu.com</div>
            </div>
        </div>
    </div>
</body>
</html>
/*去除浏览器默认样式*/
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

/*给整体页面指定一个高度*/
html, body {
    height: 100%;
    /*设置背景图*/
    background-image: url(img/m.jpg);
    /*设置背景图不平铺*/
    background-repeat: no-repeat;
    /*设置图片位置*/
    background-position: center center; 
    /*设置背景图大小*/
    background-size: cover;
}

/*设置版心样式*/
.container {
    width: 70%;
    height: 100%;
    margin: 0 auto;
    /*设置背景色,让版心和背景图能够区分开*/
    background-color: rgba(255, 255, 255, 0.8);
    /*设置圆角*/
    border-radius: 20px;
    /*设置内边距,避免文字内容紧贴边界*/
    padding: 20px;

    /*加上这个属性,超出元素的部分会自动生成一个滚动条*/
    overflow: auto;
}

.header {
    width: 100%;
    height: 50px;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

/*设置输入框样式*/
.header>input {
    width: 77%;
    height: 50px;
    border-radius: 10px;
    font-size: 22px;
    line-height: 50px;
    padding-left: 10px;
}

.header>button {
    width: 20%;
    height: 50px;
    border-radius: 10px;
    background-color: rgba(34,139,34, 0.9);
    color: #fff;
    font-size: 22px;
    line-height: 50px;
    border: none;
}

.header>button:active {
    background: gray;
}

.item {
    width: 100%;
    margin-top: 20px;
}

.item a {
    display: block;
    height: 40px;
    font-size: 22px;
    line-height: 40px;
    font-weight: 700;
    color: #4007A2;
}

.item .desc {
    font-size: 14px;
}

.item .url {
    font-size: 10px;
    color: rgb(0, 128, 0);
}

效果:

通过 Ajax 获取搜索结果

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Java 文档搜索</title>
    <link rel="stylesheet" type="text/css" href="common.css">
    <script src="http://code.jquery.com/jquery-2.1.1.min.js"></script>
</head>
<body>
    <div class="container">

        <!-- 搜索界面 -->
        <div class="header">
            <input type="text">
            <button type="submit" id="search_btn">搜索</button>
        </div>

        <!-- 搜索结果 -->
        <div class="result">
            <!-- <div class="item">
                <a href="#">标题</a>
                <div class="desc">描述</div>
                <div class="url">www.baidu.com</div>
            </div> -->
        </div>
    </div>

    <script>
        let button = document.querySelector("#search_btn");
        button.onclick = function() {
            let input = document.querySelector(".header input");
            let query = input.value;
            jQuery.ajax({
                type: "GET",
                url: "searcher?query=" + query,
                success: function(data,status) {
                    buildResult(data);
                }
            });
        }

        function buildResult(data) {
            let result = document.querySelector(".result");
            // 清空上次的结果
            result.innerHTML = "";

            for (let item of data) {
                let itemDiv = document.createElement("div");
                itemDiv.className = "item";

                let title = document.createElement("a");
                title.innerHTML = item.title;
                title.href = item.url;
                /*页面跳转,让原页面不会消失*/
                title.target = '_blank';
                itemDiv.appendChild(title);

                let desc = document.createElement("div");
                desc.className = "desc";
                desc.innerHTML = item.desc;
                itemDiv.appendChild(desc);

                let url = document.createElement("div");
                url.className = "url";
                url.innerHTML = item.url;
                itemDiv.appendChild(url);
                
                result.appendChild(itemDiv);

            }
        }
    </script>
</body>
</html>

注意:在代码第 47 行使用了 result.innerHTML = "";是为了防止将每次的搜索结果都存放在一个result中,然后一起展现出来的问题。

结果展示:

实现标红逻辑

可以发现在搜索结果的描述里,没有把查询词进行标红(对比必应搜索)

实现标红逻辑,需要前后端配合:

  1. 修改后端代码,生成搜索结果的时候(生成描述),就需要把其中包含查询词的部分,给加上一个标记。例如,给这个部分加上 标签。
  2. 然后给前端这里针对 标签设置样式,然后浏览器就可以根据 标签的样式来进行显示了,比如给 标签的字体颜色设为红色。

在 DocSearcher 类中,生成描述的 GenDesc 方法加一些东西:

在 css 文件中针对这个标签进行字体颜色的设置:

查看效果:

可以看到,查询词被标红了,并且带有斜体( 标签的作用)。

处理更复杂的查询词

当我们在搜索框中输入:array list的时候发现会搜索到一些不相关的文档:

这是因为,当我们的查询词为 array list的时候,此时的分词结果,不仅仅是 array 和 list,而是 array,空格,list。

因此代码中就会拿 空格 来查询索引。

此时会涉及到一个概念:暂停词(停用词):没啥意义的高频词,如 a,is,have 等。这些词是不应该参与触发的。


从网上下载英文停用词保存在本地:

首先在 DocSearcher 类中,添加停用词文件路径,并创建一个HashSet用来存放停用词:

创建方法 loadStopWord 用来将文件中的停用词加载到内存中:

记得在 DocSearcher 的构造函数中调用此方法:

最后在方法 searcher 中,调用此方法,将停用词排除在分词结果中:

测试:

发现此结果的描述中并没有出现查询词。为什么呢?我们接下来进行分析

处理生成描述的bug

我们进去有问题的搜索结果,查看他的源代码:

发现一个问题:这个地方是 array)

回顾下我们写的后端代码:DocSearcher 类的GenDesc 方法中:

由于是 array)不符合 array ,所以就没找到,直接以文档内容的前160个字符作为返回,没有包含查询词。因此我们要修改这个地方。

针对这个问题,我们使用正则表达式来解决:

使用在线正则表达式来测试下:在查询词前后都加上\b就是全字匹配

但是在后端代码中,indexOf方法不支持正则表达式:

Java 中使用正则,一方面可以基于 String 中的一些特定方法,另一方面标准库里提供了 Pattern 和 Matcher 这两个类(Pattern 描述了一个匹配规则,Matcher 负责进行具体的匹配操作),由于这两个类使用复杂,因此不做考虑。

换一种思路:把两边不带空格(带标点)的情况转换成两边带空格的情况。使用方法 replace / replaceAll(这两个方法支持正则)

这样就可以支持 indexOf(" " + word + " ")的查找了。

测试:

可以看到被标记呈现出来了。

加上搜索结果的个数

在 html 中,构造 Result 的方法中,加入如下功能:

测试:

重复文档的问题

我们进行下测试:

发现 list 的搜索结果个数 + array 搜索结果的个数 = array list 的搜索结果个数。

也就是同一个页面在 array list 的搜索结果里被多次展示了:

前面我们计算权重的时候,先对分词结果依次进行触发。

  1. 一个文档不应该被展示两次
  2. 像 Collections 这样的文档,同时包含多个分词结果,其实意味着这个文档的”相关性“更高!

像这种情况,就应该提高这个文档的权重!既然相关性更高,提高权重之后就能排的更靠前了!

一个简单粗暴的方法,就是把权重进行相加

要实现这样的效果,就需要把触发结果进行合并!!

就需要把多个分词结果触发出的文档,按照 docId 进行去重,同时进行权重的合并。

去重的核心思路:

链表可以合并两个有序链表,利用类似的思路来合并两个数组。

先把分词结果进行一个排序处理(按照 docId 升序排序),再进行合并。合并的时候就可以根据 docId 值相同的情况,进行权重相加。

注意:当前的分词结果,不一定就只是分成两个部分,很可能是多个部分。此时就变成了 N 个数组合并了。


针对每个分词结果构建数组

在 DocSearcher 类中的 search 方法进行修改:

这块代码就是在构建如图所示的结构:

每一行是每个分词结果触发出的结果,每一列就是一个一个的 Weight 对象。

权重合并

【合并】针对多个分词结果触发出的相同文档,进行权重合并:权重的合并通过一个 mergeResult 自定义方法来进行:

此处的操作是针对每一行进行排序:

使用优先队列进行排序

接着,借助 优先队列 找出每一行最小的 docId 的 Weight 对象,把这个最小的对象给插入到结果 target 中,同时把对应行的下标往后移动。

最后整个函数返回 target:

mergeResult 方法总代码:

private List<Weight> mergeResult(List<List<Weight>> source) {
    // 在进行合并的时候,把多个行合并成一行
    // 合并过程中势必需要操作这个二维数组(二维 List)里面的每个元素
    // 操作元素涉及到“行”“列”这样的概念

    // 1. 先针对每一行进行排序,按照 id 进行升序排序
    for (List<Weight> curRow : source) {
        curRow.sort(new Comparator<Weight>() {
            @Override
            public int compare(Weight o1, Weight o2) {
                return o1.getDocId() - o2.getDocId();
            }
        });
    }

    // 2. 借助优先队列,针对这些行进行合并
    //  target 表示合并的结果
    List<Weight> target = new ArrayList<>();
    // 2.1 创建一个优先级队列,并指定比较规则(按照 Weight 的 docId,取小的更优先)
    PriorityQueue<Pos> queue = new PriorityQueue<>(new Comparator<Pos>() {
        @Override
        public int compare(Pos o1, Pos o2) {
            // 先根据 pos 找到对应的 Weight 对象,再根据 Weight 的 docId 来排序
            Weight w1 = source.get(o1.row).get(o1.col);
            Weight w2 = source.get(o2.row).get(o2.col);
            return w1.getDocId() - w2.getDocId();
        }
    });
    // 2.2 初始化队列,把每一行的第一个元素放到队列中
    for (int row = 0; row < source.size(); row++) {
        // 初始插入的元素的 col 就是 0
        queue.offer(new Pos(row, 0));
    }
    // 2.3 循环的队列取队首元素(也就是当前这若干行中最小的元素)
    while (!queue.isEmpty()) {
        Pos minPos = queue.poll();
        Weight curWeight = source.get(minPos.row).get(minPos.col);
        // 2.4 看看这个取到的 Weight 是否和前一个插入到 target 中的结果是相同的 docId
        // 如果相同,就合并
        if (target.size() > 0) {
            // 取出上次插入的元素
            Weight lastWeight = target.get(target.size() - 1);
            if (lastWeight.getDocId() == curWeight.getDocId()) {
                // 说明遇到了相同的文档
                // 合并权重
                lastWeight.setWeight(lastWeight.getWeight() + curWeight.getWeight());
            } else {
                // 如果 docId 不同,就直接把 curWeight 插入到 target 的末尾
                target.add(curWeight);
            }
        } else {
            // 如果 target 当前是空的,就直接插入即可
            target.add(curWeight);
        }
        // 2.5 把当前元素处理完之后,要把对应这个元素的光标往后移动
        // 去取这个一行的下一个元素
        Pos newPos = new Pos(minPos.row, minPos.col + 1);
        if (newPos.col >= source.get(newPos.row).size()) {
            // 如果移动光标之后,超出这一行的列数,就说明达到末尾了
            // 到达末尾之后说明这一行就处理完毕了
            continue;
        }
        queue.offer(newPos);
    }
    return target;
}