【文档搜索引擎】在内存中构造出索引结构(下)
文章目录
- 4.保存到磁盘中
- 为什么要保存在磁盘中
- 怎么保存
- 操作步骤
- 1. 前期准备
- 2. 主要操作
- 5. 将磁盘中的数据加载到内存中
- Parser 类完整源码
- Index 类完整源码
4.保存到磁盘中
为什么要保存在磁盘中
索引本来是存储在内存中的,为什么要将其保存在硬盘中?
- 因为创建索引是比较耗时的
因此我们不应该在服务器启动的时候,才构建索引(启动服务器就可能会拖慢很多很多)
- 通常的做法是:把这些耗时的操作,单独去进行执行
- 单独执行完了之后,再让线上服务器直接加载这个构造好的索引
怎么保存
文本实质上就是字符串,我们就可以把字符串直接保存在文件中。我们就需要把内存中的索引结构变成一个“字符串”,然后写文件即可
- 变成字符串的过程就是——序列化
- 对应的特定结构的字符串,反向解析成一些结构化数据(类/对象/基础数据结构)——反序列化
序列化和反序列化有很多现成的通用方法,此处咱们就直接使用 JSON 格式来进行序列化/反序列化——jackson
- 通过
Maven
仓库,引入依赖
<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.18.2</version>
</dependency>
操作步骤
1. 前期准备
引入一个 jackson
里面会用到的核心对象
private ObjectMapper objectMapper = new ObjectMapper();
- 之后就通过这个对象,完成后续的序列化和反序列化操作
创建一个文件指定存放的目录
private static final String INDEX_PATH =
"/Users/yechiel/Desktop/Byte/code_world/Gitee/java_doc_searcher";
2. 主要操作
使用两个文件,分别保存正排和倒排
- 先判定一下索引对应的目录是否存在,不存在就创建
- 然后在索引中分别创建两个文件:
forwardIndexFile
(正排文件)、invertedIndexFile
(倒排文件) - 使用
writeValue
方法,将文件进行写入
public void save(){
// 使用两个文件,分别保存正排和倒排
// 1. 先判断一下,索引对应的目录是否存在,不存在就创建
File indexPathFile = new File(INDEX_PATH);
if(!indexPathFile.exists()){
indexPathFile.mkdirs();
}
File forwardIndexFile = new File(INDEX_PATH + "fordword.txt");
File invertedIndexFile = new File(INDEX_PATH + "inverted.txt");
try {
// 第一个参数:写到哪个文件里 第二个:对哪个对象进行写入
objectMapper.writeValue(forwardIndexFile, forwardIndex);
objectMapper.writeValue(invertedIndexFile, invertedIndex);
}catch (IOException e) {
e.printStackTrace();
}
}
mkdirs()
可以一次嵌套创建多级目录writeValue
方法会报错,要在两个操作外面加上try-catch
。这里调用这个方法就不用我们再将文件变成字符串,然后再写入文件,这里直接进行写入就方便了很多
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("加载索引结束!");
}
readValue
就会直接读取到文件内容,并且把文件内容按照这里指定的类型进行解析- 看见这个类型是
ArrayList<>
,然后就预期文件里面的 jason 也是代大括号的数组 - 然后看到每一个元素又是
DocInfo
,我们的readValue
就期望,我们的数据里面的大括号里面的每一个字段都得和DocInfo
是相对应的- 这个对应关系我们是可以保证的,因为前面存入磁盘的时候,就是用
objectMapper
的writeValue()
来去把对象生成 JSON 然后保存的 - 生成的时候就是按照每一个属性名为
key
来去存的,所以下面解析的时候也是和上面相对应的,根据得到的 JSON 中的每一个 key 的值,来去找到对应对象中的属性,然后给其赋值
- 这个对应关系我们是可以保证的,因为前面存入磁盘的时候,就是用
- 看见这个类型是
这里需要将这个这个结构的字符串,转换成一个 ArrayList<DocInfo>
类型的对象,jakson 专门提供了一个辅助工具类—— TypeReference<>
- 这是一个带有泛型参数的类,我们通过这个类的泛型参数,来指定我们实际要转换的类型
forwardIndex = objectMapper.readValue
(forwardIndexFile, new TypeReference<ArrayList<DocInfo>>() {});
- 这里相当于创建了一个匿名内部类的实例(后面 new 的部分)
- 创建一个匿名内部类,这个类实现了
TypeReference
- 同时再创建一个这个匿名内部类的实例
- 创建这个实例的最主要目的,就是为了把
ArrayList<DocInfo>
这个类型信息,告诉readValue
方法
- 创建一个匿名内部类,这个类实现了
在 java
中,并不能直接把一个类型作为方法的参数,而是必须得传一个具体的对象,正因为这个语法限制,我们就必须得绕一个弯。通过一个专门的泛型类,再搭配泛型参数,才能完成这个过程
Parser 类完整源码
package com.glg.javadoc_searcher;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
public class Parser {
// 先指定一个加载文档的路径
private static final String INPUT_PATH = "/Users/yechiel/Desktop/Byte/code_world/docs";
// 创建一个 Index 实例
private Index index = new Index();
public void run(){
// 整个 Parser 的入口
// 1. 根据指定的路径,枚举出该路径中所有的文件(HTML),这个过程需要把所有子目录中的文件都获取到
ArrayList<File> fileList = new ArrayList<>();
enumFile(INPUT_PATH, fileList);
/*for(File file : fileList){
System.out.println(file);
}
System.out.println(fileList.size());
*/
// 2. 针对上面罗列出的文件路径,打开路径,读取文件内容,进行解析,并构建索引
for(File f : fileList) {
// 通过这个方法来解析单个 HTML 文件
System.out.println("开始解析: "+ f.getAbsolutePath());
parseHTML(f);
}
// 3. 把在内存中构造好的索引数据结构,保存到指定的文件中
index.save();
}
private void parseHTML(File f) {
// 1. 解析出 HTML 的标题
String title = parseTitle(f);
// 2. 解析出 HTML 对应的 URL
String url = parseUrl(f);
// 3. 解析出 HTML 对应的正文(有了正文才有后续的描述)
String content = parseContent(f);
// 4. 将解析出来的这些信息,加入到索引当中
index.addDoc(title,url,content);
}
// 用来解析 HTML 里面的标题信息
private String parseTitle(File f) {
String name = f.getName();
return name.substring(0, name.length() - ".html".length());
}
// 用来解析 HTML 里面的 URL 信息
private String parseUrl(File f) {
String part1 = "https://docs.oracle.com/javase/8/docs/";
String part2 = f.getAbsolutePath().substring(INPUT_PATH.length());
return part1 + part2;
}
// 用来解析 HTML 里面的正文信息
public String parseContent(File f) {
//先按照一个字符一个字符的方式来读取,以 < 和 > 来控制拷贝数据的开关
StringBuilder content = new StringBuilder();
try {
FileReader fileReader = new FileReader(f);
// 加上一个是否要进行拷贝的开关
boolean isCopy = true;
// 还得准备一个保存结果的 StringBuilder
//StringBuilder content = new StringBuilder();
while (true) {
// 注意:此处的 read() 返回值是 int,不是 char
// 按理说,应该是依次读一个字符,返回 char 就够了呀?
// 此处使用 int 作为返回值,主要是为了表示一些非法情况
// 比如说读到了文件末尾,继续读,就会返回 -1
// 我们就可以根据返回的 -1 判断读完了
int ret= fileReader.read();
if(ret == -1) {
// 表示文件读完了
break;
}
// 这个结果不是 -1,那么就是一个合法的字符了
char c = (char)ret;
if(isCopy){
// 开关打开的状态,遇到普通字符就应该拷贝到 StringBuilder 中
if(c == '<'){
// 关闭开关
isCopy = false;
continue;
}
if(c == '\n' || c == '\r'){
// 为了去掉换行,把换行/回车替换成空格
c = ' ';
}
// 其他字符,直接进行拷贝即可,把结果拷贝到最终的 StringBuilder 中
content.append(c);
}else {
// 开关关闭的状态,暂时不拷贝,直到遇到 >
if(c == '>'){
isCopy = true;
}
}
}
fileReader.close();
} catch (IOException e) {
e.printStackTrace();
}
return content.toString();
}
// 第一个参数表示我们从哪个参数开始进行递归遍历
// 第二个参数表示递归得到的结果
private void enumFile(String inputPath, ArrayList<File> fileList) {
File rootPath = new File(inputPath);
// 把当前目录中,所包含的目录名全部获取到
// listFiles 能够获取到 rootPath 当前目录下所包含的文件/目录(一层目录,不会进入子文件)
File[] files = rootPath.listFiles();
for(File f : files) {
// 此时我们就根据当前 f 的类型,来决定是否要进行递归
// 若 f 是一个普通文件,就把 f 加入到 fileList 结果中
// 若 f 是一个目录,就递归调用 enumFile 方法,来进一步地获取子目录中的内容
if(f.isDirectory()) {
enumFile(f.getAbsolutePath(),fileList);
}else {
if (f.getAbsolutePath().endsWith(".html"))
fileList.add(f);
}
}
}
public static void main(String[] args) {
// 通过 main 方法,来实现整个制作索引的过程
Parser parser = new Parser();
parser.run();
}
}
Index 类完整源码
package com.glg.javadoc_searcher;
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 = "/Users/yechiel/Desktop/Byte/code_world/Gitee/java_doc_searcher/";
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. 给定一个词,在倒排索引中,查询哪些文档和这个词关联
// 仔细思考这里的返回值,单纯的返回一个整数的 List 是否可行呢?这样不太好(返回整数是因为 List 里面存的是文档 id)
// 词和文档之间是存在一定的“相关性”的(文档和词的相关性有强有弱),不是单一的依次排列
// 所以我们再创建一个 Weight 类来处理 文档id 和 文档与词 的相关性权重
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 WordCnt {
public int titleCount;
public int contentCount;
}
// 通过一个内部类,将两个数据装到一起了,变成一个 HashMap,更方便遍历
// 这个数据结构用来统计词频
HashMap<String, WordCnt> wordCntHashMap = new HashMap<>();
// 3.1 针对文档标题进行分词
List<Term> terms = ToAnalysis.parse(docInfo.getTitle()).getTerms();
// 3.2 遍历分词结果,统计每个词出现的次数
for(Term term : terms){
// 先判断一下 term 是否存在
String word = term.getName();
WordCnt wordCnt = wordCntHashMap.get(word);
if(wordCnt == null) {
// 如果不存在,就创建一个新的键值对,插入进去,titleCount 设为 1
WordCnt newWordCnt = new WordCnt();
newWordCnt.titleCount = 1;
newWordCnt.contentCount = 0;
wordCntHashMap.put(word, newWordCnt);
}
// 如果存在,就找到之前的值,然后把对应的 titleCount + 1
wordCnt.titleCount++;
}
// 3.3 针对正文页进行分词
terms = ToAnalysis.parse(docInfo.getContent()).getTerms();
// 3.4 遍历分词结果,统计每个词出现的次数
for(Term term : terms) {
String word = term.getName();
WordCnt wordCnt = wordCntHashMap.get(word);
if(wordCnt == null) {
WordCnt newWordCnt = new WordCnt();
newWordCnt.titleCount = 0;
newWordCnt.contentCount = 1;
wordCntHashMap.put(word, newWordCnt);
}else{
wordCnt.contentCount++;
}
}
// 3.5 把上面的结果汇总到一个 HashMap 里面
// 最终文档的权重,就设定成标题中出现的次数 * 10 + 正文中出现的次数
// 3.6 遍历刚才这个 HashMap,依次来更新倒排索引中的结构
// 将 Map 转换成 Set 进行遍历(Map 不能直接进行遍历)
for(Map.Entry<String, WordCnt> 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());
// 权重计算公式:标题中出现的次数 * 10 + 正文中出现的次数
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 beg = System.currentTimeMillis();
// 使用两个文件,分贝保存正排和倒排
System.out.println("保存索引开始!");
// 先判断一下,索引对应的目录是否存在,不存在就创建
File indexPathFile = new File(INDEX_PATH);
if(!indexPathFile.exists()){
indexPathFile.mkdirs();
}
File forwardIndexFile = new File(INDEX_PATH + "fordword.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 - beg) + "ms");
}
// 5. 把磁盘中的索引数据加载到内存中
public void load(){
long beg = System.currentTimeMillis();
System.out.println("加载索引开始!");
// 设置加载索引的路径(和前面保存的路径一样)
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();
}
long end = System.currentTimeMillis();
System.out.println("加载索引结束!消耗时间为:" + (end - beg) + "ms");
}
}