Java 文档搜索引擎
文章目录
- Java 文档搜索引擎
- 一、分词
- 二、完成parser 类
- 2.1、排除非html文件
- 2.2、解析html
- 以下是解析 HTML 标题的方法
- 以下是解析 对应的 URL
- 以下是解析 HTML的正文:
- 补充:倒序索引
- 三、实现 index 类
- 3.1、实现索引结构
- 3.2、索引中新增文档 正排索引
- 3.3、索引中倒排
- 四、索引
- 4.1、保存索引
- 4.2、加载索引
- 4.3、在 Parser 中调用 Index
- 4.4、验证索引执行速度
- 4.4.1、使用了多线程
- 4.5、保证所有文档处理完毕
- 4.6、给索引加锁
- 4.7、缓冲区(优化文件读取)
- 4.8、索引模块小结
- 五、搜索模块
- 5.1、创建 DocSearch 类
- 5.2、实现 Search 类
- 5.3、描述的生成
- 5.4、正则表达式
- 5.5、合并空格
- 5.6、搜索模块的小结
- 六、web模块
- 6.1、实现页面结构
- 6.1.1、实现页面样式(1)
- 6.1.2、实现页面样式(2)
- 6.2、ajax 前后端交互
- 6.2.1、根据响应的数据构造页面内容
- 6.2、实现搜索词标红逻辑
- 6.3、测试更复杂的查询词以及停用词
- 6.4、处理生成描述的bug
- 6.5、加载搜索结果个数(前端)
- 6.6、关于搜索出重复文档的问题
- 七、创建 SpringBoot
一、分词
// Term 就表示一个分词的结果,返回的类型是 list
// 英文会转换成小写
List<Term> terms = ToAnalysis.parse(str).getTerms();
二、完成parser 类
读取之前下载好的文档,然后解析文档的内容,并完成索引的制作。
private static final String INPUT_PATH ="E:\\Document java 8\\docs\\api";
java 里面需要两个反斜杠把他转移成一个反斜杠,为了方便后面的服务器建议设置成一个正斜杠
private static final String INPUT_PATH ="E:/Document java 8/docs/api";
设定一个 run()方法,这是Parser 的执行入口
Parser类 的执行入口:
1、根据上面指定的路径,需要枚举出路径中所有的文件(html),这个过程需要把所有子目录中的文件都获取到(递归)
run方法:
ArrayList<File> fileList = new ArrayList<>();
enumFile(INPUT_PATH, fileList); // 这里是递归
// 第一个参数表示从那个路径开始进行递归遍历
// 第二个参数表示递归得到的结果
private void enumFile(String inputPath, ArrayList<File> fileList) {
File rootPath = new File(inputPath); // 因为 inputPath 是字符串需要转换成 File文件对象
// listFiles() 能获取到 rootPath 当前目录下所包含的文件/目录
// 但是 listFiles() 只能看见一级目录,看不到子目录里面的内容,要看到子目录内容就需要递归
File[] files = rootPath.listFiles();
for (File f : files) {
// 根据当前 f 的类型决定是否递归
if (f.isDirectory()) {
enumFile(f.getAbsolutePath(), fileList);
} else {
fileList.add(f);
}
}
}
还有个问题就是递归是递归了所有的文件,而我们需要的是html的文件,代码还需要完善一下是排除非html文件
2.1、排除非html文件
File类里面可以调用 endsWith()的方法来取后缀名;那里面的代码就是在绝对路径里面的取出最后的扩展名。
2.2、解析html
2、针对上面罗列出的文件路径,打开文件,读取文件内容,进行解析,并构建索引
// 2、针对上面罗列出的文件路径,打开文件,读取文件内容,进行解析,并构建索引
for (File f : fileList) {
// 通过这个方法来解析单个的html文件
System.out.println("开始解析:"+f.getAbsolutePath());
parseHTML(f); // 解析 HTML文件的方法
}
private void parseHTML(File f) {
// 1、解析出 HTML 标题
String title = parseTitile(f);
// 2、解析出 HTML 对应的 URL
String url = parseUrl(f);
// 3、解析出 HTML 的正文(有了正文才有后面的描述)
String content = parseContent(f);
}
以下是解析 HTML 标题的方法
:::info
直接取文件名,文件名长度=>总长度(getName取的是文件阶级最后,如…/…/ArrayList.html,最后取出的结果是ArrayList.html)-后半部分的长度(.html)=>前半部分的长度(ArrayList)
:::
f.getName().substring(0,f.getName().length()-“.html”.length()
private String parseTitile(File f) {
String name = f.getName();
return name.substring(0,name.length()-".html".length());
}
java奇怪的规则:
java计算长度,有多种不同的风格:
- 针对数组:.length 属性;
- 针对字符串:.length()方法;
- 针对List等集合类来说:.size()方法
以下是解析 对应的 URL
用户点击搜索结果就可以跳转到对应的线上文档上面
操作:
- 以线上 https://docs.oracle.com/javase/8/docs/api/ 作为固定前缀;
- 根据当前本地文档的路径 java\util\ArrayList.html 与前面固定前缀进行拼接
- part1+part2
private String parseUrl(File f) {
// 先获取到固定前缀
String part1 = "https://docs.oracle.com/javase/8/docs/api/";
String part2 = f.getAbsolutePath().substring(INPUT_PATH.length());
return part1 + part2;
}
在Chrome浏览器中,容错能力相当的强,非法的URL/非法的HTML…都会尽可能的去解析,尽可能的去执行;出现一下代码的时候:
[https://docs.oracle.com/javase/8/docs/api](https://docs.oracle.com/javase/8/docs/api/index.html)/java\util\ArrayList.html
反斜杠和正斜杠混合着使用的时候,浏览器也不会报错。
这种特性也叫做“鲁莽性”(你对他越粗鲁,他就表现的越棒)。
以下是解析 HTML的正文:
思路:需要解析出< div >。。。。< /div > 中间的内容
方法:1、正则表达式:是计算机中进行字符串处理/匹配的常见手法。核心就是通过一些特殊的符号来描述字符串的特征,然后看某个字符串是否符合这些特征。
2、简单粗暴的方法:每个字符去读 当读到 “<" 符号一直到 “>” 为止都不打印,当没有遇到 “<” 的时候,就直接把当前字符拷贝到一个结果中(StringBuilder)。
public String parseContent(File f) {
// 方法是使用一个字符一个字符的读取,设置flag,以 < 和 > 来控制拷贝数据的开关
// file 文件不存在捕获异常
try (FileReader fileReader = new FileReader(f)) { // 文件操作最后要关流,这样写自动执行关闭操作
boolean isCopy = true; // 控制拷贝的开关
StringBuilder content = new StringBuilder(); // 拷贝的内容保存在 StringBulider里面,线程不安全执行的效率会高
while (true) {
// 此处的 read()返回值是一个 int 类型,而不是 char
// 此处的使用 int 作为返回值,主要是为了表示一些非法的情况
// 如果文件读到了末尾,继续读,就会返回 -1
int ret = fileReader.read(); // 输入输出的时候异常
if (ret == -1) {
break; // 文件读到了末尾,读完了
}
char c = (char) ret; // 结果不是 -1 ,就是一个合理的字符,转换char 继续读
// \r 是 回车符,保险起见都要加判断
if(c == '\n' || c == '\r') {
c = ' ';
}
if (isCopy) {
if (c == '<') {
isCopy = false;
continue; // 跳过这次循环,进入下一次循环
}
content.append(c); // 其他字符可以直接拷贝到 StringBulider容器里面
} else {
if (c == '>') {
// 遇到这个符号了就可以 打开开关了,
isCopy = true;
}
}
}
return content.toString();
} catch (IOException e) {
// 都是 IOE 的子类可以合并
e.printStackTrace();
}
return "";
}
思路:
- 首先需要按照字符来读取,使用FileReader;(异常捕获 try…catch)
- 设置一个拷贝开关 IsCopy;
- 把拷贝的内容放入 StringBulider 这个容器里面;
- 开始循环,注意 read()返回值是一个 int,和为什么是返回 int 类型;
- 判断开关;把换行都换成空格
- 最后以字符串的形式打印下俩;
- 文件File类型,如:FileReade…最后需要关闭流(.close());
补充:倒序索引
3、把在内存中构建好的索引数据结构,保存到指定文件中。
三、实现 index 类
通过这个类在内存中来构造出索引结构。
- 给定一个 docId,在正排索引中,查询文档的详细信息;
- 给定一个词,在倒序索引中,查那些文档和这个词关联;
- 往索引中新增一个文档;
- 把内存中的索引结构保存到磁盘中;
- 把磁盘中的索引结构加载到内存中
import java.util.List;
// 通过这个类在内存中来构造出索引结构
public class Index {
//这个类要提供的方法
//1. 给定一个 docId,在正排索引中,查询文档的详细信息;
public DocInfo getDocInfo(int docId) {
// 需要自己创建文件详细信息,获得docId就可以获得对应的详细信息
// TODO
return null;
}
//2. 给定一个词,在倒序索引中,查那些文档和这个词关联;
// trem 是一个分词结果,而不是一句话去查
// 单存返回一个List的整数是不可行的;
// 因为词和文档之间是存在一定的“相关性”的,所以使用“weight"类来表达这种相关性。
// Weight“相关性”会有大和小之分,怎样算之后再说
public List<Weight> getInverted(String term) {
// TODO
return null;
}
//3. 往索引中新增一个文档;
public void addDoc(String title, String url, String content) {
// TODO
}
//4. 把内存中的索引结构保存到磁盘中;
public void saveDisk() {
}
//5. 把磁盘中的索引结构加载到内存中
public void load() {
}
}
这里面需要注意新建的对象 Weight和 DocInfo
3.1、实现索引结构
需要实现 正派索引和倒排索引。
// 使用数组下标表示 docId (正排序)
private ArrayList<DocInfo> forwardIndex = new ArrayList<>();
// 使用 哈希表 表示倒排序
// key 就是词
// value 是一组和这个词有关联得文章
private HashMap<String,ArrayList<Weight>> invertedIndex = new HashMap<>();
3.2、索引中新增文档 正排索引
//3. 往索引中新增一个文档;
public void addDoc(String title, String url, String content) {
// 新增文档操作,需要同时给正排索引和倒排索引新增信息
// 构建正派索引
DocInfo docInfo = bulidForward(title,url,content);
// 构建倒排索引
bulidInverted(docInfo);
}
private DocInfo bulidForward(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;
}
注意正排索引加入文章后的长度就是docId
3.3、索引中倒排
- 针对文档标题进行分词;
- 遍历分词结果,统计每个词出现的次数;
- 针对正文页进行分词;
- 遍历分词结果,统计每个词出现的次数
- 把上面的结果汇总在一个 HashMap 中
最终文档的权重,就设定成标题中出现的次数*10+正文出现的次数;
6、遍历刚刚这个HashMap,依次来更新倒排索引中的结构了
private void bulidInverted(DocInfo docInfo) {
// 使用内部类
class WordCount {
// 表示词在标题中出现的次数
public int titleCount;
// 表示词在正文中出现的次数
public int contentCount;
}
// 用 HashMap 数据结构来统计词频
// String: 出现的分词结果 WordCount:分词结果次数
HashMap<String, WordCount> wordCntHashMap = new HashMap<>();
//1. 针对文档标题进行分词;
List<Term> terms = ToAnalysis.parse(docInfo.getTitle()).getTerms();
//2. 遍历分词结果,统计每个词出现的次数;
for (Term term : terms) {
// 先判定分词是否存在
String word = term.getName();
WordCount wordCount = wordCntHashMap.get(word);
if (wordCount == null) {
// 不存在,创建一个新的键值对,插入进去,titleCount 设为 1;
WordCount newWordCnt = new WordCount();
newWordCnt.titleCount = 1;
newWordCnt.contentCount = 0;
wordCntHashMap.put(word, newWordCnt);
} else {
// 存在,就找到之前的值,然后把对应的 titleCount +=1;
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) {
WordCount newWordCnt = new WordCount();
newWordCnt.contentCount = 1;
newWordCnt.titleCount = 0;
wordCntHashMap.put(word, newWordCnt);
} else {
wordCount.contentCount += 1;
}
}
//5. 把上面的结果汇总在一个 HashMap 中
//6、遍历刚刚这个HashMap,依次来更新倒排索引中的结构了
}
5,6步骤比较抽象,需要仔细理解一下
//5. 把上面的结果汇总在一个 HashMap 中
//6、遍历刚刚这个HashMap,依次来更新倒排索引中的结构了
for (Map.Entry<String, WordCount> entry : wordCntHashMap.entrySet()) { //entrySet()一个整体的打印方式
// 先根据这里的词,去倒排索引中查一查
// 倒排拉链
ArrayList<Weight> invertedList = invertedIndex.get(entry.getKey());
if (invertedList == null) {
// 如果为 null 就插入一个新键值对
// 把新的文档(当前 DocInfo),构造成 Weight 对象,插入进来
ArrayList<Weight> newInvertedList = new ArrayList<>();
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);
}
}
四、索引
4.1、保存索引
通俗来说:保存就是把内存中的数据写道文件里
序列化:把一个对象转为一个字符串
反序列化:把一个字符串转为一个对象
:::success
序列化:把索引的一种结构——》变成字符串;
反序列化:把特定的结构字符串,反向解析成一些结构化数据(类/对象/基础数据结构);
:::
构建索引的过程就是 addDoc,一个文章还好说,当有成千上白个文章的时候加载索引就会很慢,开机就会很慢,没有必要,解决了方法就是把需要单独的执行,执行完后,再让线上服务器直接加载这个构造的索引。
此处使用 Jackson 格式来进行序列化和反序列化。
操作:需要再中央仓库里面系在 Jackson 依赖放入 pom.xml中即可
private ObjectMapper objectMapper = new ObjectMapper();
这个Jackson objectMapper 核心对象就是用来进行序列化和反序列化操作
//4. 把内存中的索引结构保存到磁盘中;
public void saveDisk() {
// 使用两个文件,分别保存正排和倒排 (先指定文件保存的目录)
System.out.println("保存索引开始!");
// 1、先判断以下索引对应的目录是否存在,不存在就创建
File indexPathFile = new File(INPUT_PATH);
if (!indexPathFile.exists()) { // 不存在就创建
indexPathFile.mkdirs(); // 带“s” 可以创建多级目录
}
//创建文件保存正排和倒排
File forwardIndexFile = new File(INPUT_PATH + "forward.txt");
File invertedIndexFile = new File(INPUT_PATH + "inverted.txt");
// objectMapper.writeValue(指定写在哪个文件里,对应那个对象进行写作)
// 这里 writeValue 会抛出异常,try catch 捕获使用 IOException 类即可
try {
objectMapper.writeValue(forwardIndexFile,forwardIndex);
objectMapper.writeValue(invertedIndexFile,invertedIndex);
} catch(IOException e) {
e.printStackTrace();
}
System.out.println("保存索引完成!!");
}
需要注意的就是 objectMapper.writeValue() 里面的参数:
第一个参数:指定写在哪个文件里
第二个参数:对应那个对象进行写作
4.2、加载索引
通俗来说:把文件里的数据写在内存中
//5. 把磁盘中的索引结构加载到内存中
public void load() {
System.out.println("加载索引开始!");
// 1、先设置一下加载索引的路径
File forwardIndexFile = new File(INPUT_PATH + "forward.txt");
File invertedIndexFile = new File(INPUT_PATH + "inverted.txt");
// objectMapper.readValue(从哪个文件读,读到的数据安照什么类型进行解析)
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("加载索引结束!!!");
}
objectMapper.readValue 这里有两个参数:
第一个参数:从哪个文件读
第二个参数:读到的数据安照什么类型进行解析
在上面保存索引的时候就已经把文件里面的信息使用 Jackson 库转换成了 JSON 格式,
[
{docId:0,title:"我是标题“,url:"我是url”,content:"我是正文“},
{............}
]
这里的加载索引就是把文件里面的内容解析成指定的类型
objectMapper.readValue(forwardIndexFile, new TypeReference<ArrayList<DocInfo>>(){});
这是 Jackson 库专门提供的辅助工具类,TypReference<> 但是我们用的时候需要把他实例化,相当于就是把 Json 格式“[ ]"对应为 ArrayList<>数组,数字里面的对象 DocInfo 对应”{ }“ 的对象,里面的就是对应的属性。
给加载保存索引加上时间:
使用 System.currentTimeMillis() 来获取当前的时间。
最后保存开始的时间 begin 和结束的时间 end
(end-begin)就是他们的消耗时间
:::success
衡量代码的执行时间:
1、在开始之前获取一下时间戳(System.currentTimeMillis())
2、在结束之后再获取一下时间戳(System.currentTimeMillis())
:::
4.3、在 Parser 中调用 Index
他两之间的关系,Parser 相当于制作索引的入口(对应到一个“可执行”程序)
Index 相当于实现了索引的数据结构,提供了一些 api
Index类给 Parser 进行调用,才能完成整个制作索引功能。
// 3、把在内存中构建好的索引数据结构,保存到指定文件中。
index.saveDisk();
// 4、把解析出来的信息,假如到索引当中
index.addDoc(title,url,content);
4.4、验证索引执行速度
当开始执行方法的时候,结果为:
:::success
保存索引开始!
保存索引完成!!消耗时间:633ms
:::
在这点文件上没有秒执行完毕,而是用了633ms,说明还是太慢了,接下来通过 测试验证 的手段 ,来找到“性能瓶颈”,做代码优化
:::success
如果抛开测试,谈性能优化,就是刷“流氓”。
:::
public void run() {
long beg = System.currentTimeMillis();
System.out.println("索引制作开始!");
// Parser类 的执行入口
// 1、根据上面指定的路径,需要枚举出路径中所有的文件(html),这个过程需要把所有子目录中的文件都获取到(递归)
ArrayList<File> fileList = new ArrayList<>();
enumFile(INPUT_PATH, fileList);
long endEnumFile = System.currentTimeMillis();
System.out.println("枚举文件完毕!,消耗时间:" + (endEnumFile - beg) + "ms");
// System.out.println(fileList);
// System.out.println(fileList.size());
// 2、针对上面罗列出的文件路径,打开文件,读取文件内容,进行解析,并构建索引
for (File f : fileList) {
// 通过这个方法来解析单个的html文件
System.out.println("开始解析:" + f.getAbsolutePath());
parseHTML(f);
}
long endFor = System.currentTimeMillis();
System.out.println("遍历文件完毕!消耗时间:" + (endFor - endEnumFile) + "ms");
// 3、把在内存中构建好的索引数据结构,保存到指定文件中。
index.saveDisk();
long end = System.currentTimeMillis();
System.out.println("索引制作完毕!消耗时间:" + (end - beg) + "ms");
}
结果:
:::success
遍历文件完毕!消耗时间:19678ms
保存索引开始!
保存索引完成!!消耗时间:676ms
索引制作完毕!消耗时间:20488ms
:::
提一嘴,这里所计算出的时间主要还是电脑上面的 CPU 运算上,一个文件进行解析(读文件+分词+解析内容)
4.4.1、使用了多线程
从结果中看出,最消耗时间的就是循环那个位置,现在问题找到了,就需要解决问题,上面的情况都是单线程,串联执行的任务,我们使用多线程,并联执行就可以优化代码了。
// 这个方法实现“多线程索引制作”
public void runByThread() {
long begin = System.currentTimeMillis();
System.out.println("索引制作开始!");
// 1、枚举出所有的文件
ArrayList<File> files = new ArrayList<>();
enumFile(INPUT_PATH, files);
// 2、循环遍历文件,此处为了能通过多线程制作索引,就直接引入线程池
ExecutorService executorService = Executors.newFixedThreadPool(4);
for (File f : files) {
executorService.submit(new Runnable() {
@Override
public void run() {
System.out.println("开始解析:" + f.getAbsolutePath());
parseHTML(f);
}
});
}
// 3、保存索引
index.saveDisk();
long end = System.currentTimeMillis();
System.out.println("索引制作完毕!消耗时间:" + (end - begin) + "ms");
}
4.5、保证所有文档处理完毕
这里有个问题,到保存索引的时候有可能线程池里面线程还没有执行完,这样就尴尬了。
就好比在公司,公司老板给下属 submit 注册了多个任务,当boss分配完任务后,下属任务是没有做完的,如果boss刚分配完任务就开始检查任务完成度,这是不显示的
success
为了避免这种尴尬的行为,我们需要用到 CountDownLatch 这个类了,CountDownLatch 它的作用是类似于跑步裁判,这要选手都撞线,就认为这场比赛结束;
CountDownLatch 操作步骤:
1、在构造 CountDownLatch 的时候需要指定一下比赛选手的个数;
2、每个选手撞线,都要通知一下 countDown();
3、通过 await() 来等待所有选手撞线完毕。
// 这个方法实现“多线程索引制作”
public void runByThread() throws InterruptedException {
long begin = System.currentTimeMillis();
System.out.println("索引制作开始!");
// 1、枚举出所有的文件
ArrayList<File> files = new ArrayList<>();
enumFile(INPUT_PATH, files);
// 2、循环遍历文件,此处为了能通过多线程制作索引,就直接引入线程池
CountDownLatch latch = new CountDownLatch(files.size());
ExecutorService executorService = Executors.newFixedThreadPool(4);
for (File f : files) {
executorService.submit(new Runnable() {
@Override
public void run() {
System.out.println("开始解析:" + f.getAbsolutePath());
parseHTML(f);
latch.countDown();//撞线都通知一下
}
});
}
latch.await();// 选手都撞线完毕!
// 3、保存索引
index.saveDisk();
long end = System.currentTimeMillis();
System.out.println("索引制作完毕!消耗时间:" + (end - begin) + "ms");
}
4.6、给索引加锁
出现线程安全,就是多个线程随机的调度顺序去执行同一个对象的使用,会出现线程安全问题。
在 forwardIndex 和 invertedIndex 中里面会有多线程来调用 addDoc,addDoc里面就会有 bulidForward 和 bulidInverted ,
bulidForward就会去执行 forwardIndex ,而bulidInverted 就会去执行 invertedIndex。
多个线程执行就出现了锁竞争,然后我们通过加锁的方式来解决线程安全的问题(synchronized);为了达到更高效率的性能,故我们把加锁的粒度尽量搞细一点。
这里的线程不是越多效率越高,线程池中线程的数目,具体设多少,最好是通过实验的方式来确定
//5. 把上面的结果汇总在一个 HashMap 中
//6、遍历刚刚这个HashMap,依次来更新倒排索引中的结构了
for (Map.Entry<String, WordCount> entry : wordCntHashMap.entrySet()) { //entrySet()一个整体的打印方式
// 先根据这里的词,去倒排索引中查一查
// 倒排拉链
synchronized (locker1) {
ArrayList<Weight> invertedList = invertedIndex.get(entry.getKey());
if (invertedList == null) {
// 如果为 null 就插入一个新键值对
// 把新的文档(当前 DocInfo),构造成 Weight 对象,插入进来
ArrayList<Weight> newInvertedList = new ArrayList<>();
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 bulidForward(String title, String url, String content) {
DocInfo docInfo = new DocInfo();
docInfo.setTitle(title);
docInfo.setUrl(url);
docInfo.setContent(content);
synchronized (locker2) {
docInfo.setDocId(forwardIndex.size());
forwardIndex.add(docInfo);
}
return docInfo;
}
本来synchronized(参数)的参数是需要加锁的对象,但是代码多了复杂,容易带错加锁的参数,为了方便可以自定义两个锁,分别用两个锁加锁跟方便。
// 创建两个锁对象
private Object locker1 = new Object();
private Object locker2 = new Object();
4.7、缓冲区(优化文件读取)
在电脑开机的时候,首次制作索引非常的慢,但是再次运行或者说是之后运行就会快了,❓❓❓ 这是为什么?,因为首次开始的时候是从磁盘里面读取的,第二次运行因为第一次已经从磁盘读取系统自动放入了缓冲区,所以就会比第一次来说变快,为了解决首次制作索引快慢的问题,在代码中添加一个缓冲区即可。
:::success
BufferedReader 这个是标准库里面的类,也是FileRead的辅助类,它内部设置了缓冲区,从而可以提前把数据放入缓冲区里面,减少在磁盘里面读入的次数。
:::
public String parseContent(File f) {
// 方法是使用一个字符一个字符的读取,设置flag,以 < 和 > 来控制拷贝数据的开关
// file 文件不存在捕获异常
// BufferedReader 他是 FileRead的辅助类,作用是它内部设置了缓存区,可以把FileRead东西提前放入缓存区中
// 第二个参数是缓存区容量大小
try (BufferedReader bufferedReader = new BufferedReader(new FileReader(f), 1024 * 10224)) { // 文件操作最后要关流,这样写自动执行关闭操作
boolean isCopy = true; // 控制拷贝的开关
StringBuilder content = new StringBuilder(); // 拷贝的内容保存在 StringBulider里面,线程不安全执行的效率会高
while (true) {
// 此处的 read()返回值是一个 int 类型,而不是 char
// 此处的使用 int 作为返回值,主要是为了表示一些非法的情况
// 如果文件读到了末尾,继续读,就会返回 -1
int ret = bufferedReader.read(); // 输入输出的时候异常
......
}
return "";
}
这里为什么是修改内容,是因为经过测试,测试出在 addDoc 和 parseContent 的时候,parseContent 耗时是最大的,所以需要优化 parseContent 。
4.8、索引模块小结
五、搜索模块
调用索引模块,来完成搜索的核心过程。
5.1、创建 DocSearch 类
核心过程,但是也是一个简化的逻辑。
- 1.分词.针对用户输入的查询词进行分词(用户输入的查询词,可能不是一个词,可能是一句话);
- 触发拿着每个分词结果,去倒排索引中查,找到具有相关性的文档(调用ndex 类里面的 查倒排的方法);
- 排序.针对上面触发出来的结果,进行排序.(按照相关性,降序排序);
- 包装结果,根据排序后的的结果,依次去查正排,获取到每人文档的详细信息,包装成一定结构的数据返回出去;
// 这个类是用来完成整个搜索过程
public class DocSearch {
// 此处加上索引对象实例
private Index index =new Index();
// 同时完成索引加载工作(加载到内存中)
public DocSearch(){
index.load();
}
// 这是核心:完成整个搜索的过程的方法
// 参数(query)就是用户查询词
// 返回值(输出部分)就是搜索结果的集合
public List<Result> search (String query) {
// 1、【分词】针对 query这个词进行分词
// 2、【触发】针对分词结果查倒排
// 3、【排序】针对触发结果按照权重来降序排序
// 4、【包装结果】针对排序结果,去查正排,构造出要返回的数据
return null;
}
}
5.2、实现 Search 类
这段代码没有啥好解释的,所有的注解都在这个代码里面了
// 这是核心:完成整个搜索的过程的方法
// 参数(query)就是用户查询词
// 返回值(输出部分)就是搜索结果的集合
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;
}
// addAll是加入一组词,add是一个
allTermResult.addAll(invertedList);
}
// 3、【排序】针对触发结果按照权重来降序排序
allTermResult.sort(new Comparator<Weight>() {
@Override
public int compare(Weight o1, Weight o2) {
// 升序:o1-o2
// 降序:o2-o1
return o2.getWeight() - o1.getWeight();
}
});
// 4、【包装结果】针对排序结果,去查正排,构造出要返回的数据
return null;
}
5.3、描述的生成
注意的点:
- 遍历分词结果,在content中是否存在;
- 匹配的内容都要变成小写才能匹配成功;
- 需要“全字匹配”才更科学,而不是匹配词的一部分。例如“老婆“与”老婆饼“这样的两个词是毫无关系的,故匹配是全字匹配。
- 描述中以firstPose作为基准,前+60字,后加160字,加的字数是可以改变,只是保证美观即可;同时还要边界判断一下
private String GenDesc(String content, List<Term> terms) {
// 1、先遍历分词结果, 看看哪个结果是在 content 中存在.
int firstPose = -1;
for (Term term : terms) {
// 2、别忘了, 分词库直接针对词进行转小写了.
String word = term.getName();
// 正因为如此, 就必须把正文也先转成小写, 然后再查询
// 3、还有个问题,此处需要“全字匹配”,让word成为独立词,才可以查找出来,而不是作为词的一部分
// 此处的全字匹配做的不严谨,更严谨的做法是使用正确的正则表达式
firstPose = content.toLowerCase().indexOf(" " + word + " ");//直接的方法word两边加上空格
if (firstPose >= 0) {
// 找了分词结果的位置
break;
}
if (firstPose == -1) {
// 所有分词结果都不在正文中存在,属于比较极端情况,可以返回空的描述
// 或者也可以直接取正文的前 160 个字符作为描述
if (content.length() > 160) {
return content.substring(0, 160) + "...";
}
}
// 4、从 firstPose 作为基准位置, 往前找 60 个字符, 作为描述的起始位置.
String desc = "";
int descBeg = firstPose < 60 ? 0 : firstPose - 60;
if (descBeg + 160 > content.length()) {
desc = content.substring(descBeg);
} else {
desc = content.substring(descBeg, descBeg + 160) + "...";
}
return desc;
}
}
5.4、正则表达式
在java 中 ,String 类型的”replace/replaceAll“他两都是指出正则表达式的。
正则表达式定义:是计算机中进行字符串处理/匹配的常见手法。核心就是通过一些特殊的符号来描述字符串的特征,然后看某个字符串是否符合这些特征。
代码经过测试检验,出现了 JavaScript 的代码,这不是我们想要的结果,在处理文档的时候,只是对正文进行了“去标签”但是HTML里面是会有 script 标签,就导致去了标签后,JS的代码也被整理进了索引里面,为了取消 JavaScript 代码,我们需要使用这则表达式来解决问题。
正则表达式有很多符号,具体可以看看这个链接 🏷正则表达式 - 元字符🏷
:::success
这里就介绍一下常用的符号:
- “.” 表示匹配一个非换行字符。(不是\n或者不是\r)
- “*”表示前面的字符可以出现若干次。
- “.*”匹配非换行字符出现若干次。
- “?”表示”非贪婪匹配“匹配到一个符合条件的最短结果;反之不带“?” 就是表示“贪婪匹配”匹配到的一个符合条件的最长结果。
- “\s”表示匹配任何空白字符,包括空格、制表符、换页符等等。等价于 [ \f\n\r\t\v]。
:::
举例:以下图片更能清晰体现出正则表达式通过一些特殊的符号来描述字符串的特征,近而开始匹配范围。
故我们是有“<.*?>”的正则表达式来匹配标签,只是替换标签不替换内容。
5.5、合并空格
经过测试发现,中间的间距是相当的大,先不管他为什么间距这么大,我们还是使用正则表达式里面的特殊符号进行匹配替换。
“\s”表示匹配任何空白字符,包括空格、制表符、换页符等等。等价于 [ \f\n\r\t\v]。
content = content.replaceAll("\\s+", " ");
别忘了需要转移字符,要两个“\" 斜杠。
5.6、搜索模块的小结
主要还是 DocSearch 类中的 search 方法:
- 分词;
- 触发;
- 排序;
- 包装结果
最后把这些都串起来
六、web模块
6.1、实现页面结构
1、首先需要一个表示整个页面的容器,这个就是 .container
2、接下来是搜索框+搜索按钮
3、再接下来是搜索结果
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>java 文档搜索</title>
</head>
<body>
<!-- 通过 .container 来表示整个页面元素的容器 -->
<div class="container">
<!-- 1、搜索框+搜索按钮 -->
<div class="header">
<input type="text" />
<button id="search-btn">搜索</button>
</div>
<!-- 2、显示搜索结果 -->
<div class="result">
<!-- 包含了多条记录 -->
<div class="item">
<!-- 每个 .item 表示一条记录 -->
<a href="#">我是标题</a>
<div class="desc">
我是一段描述:Lorem ipsum, dolor sit amet consectetur adipisicing
elit. Dolores tenetur officia quas minima laboriosam atque quia,
magnam perferendis voluptatem iusto explicabo praesentium numquam,
velit magni ullam porro! Voluptatibus, quas odit?
</div>
<div class="url">http://www.baidu.com</div>
</div>
</div>
</body>
</html>
6.1.1、实现页面样式(1)
<style>
/* 这部分代码来写样式 */
/* 先去掉浏览器的默认样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* 给整体页面指定一个高度(和浏览器窗口一样的靠度 */
html,
body {
height: 100%;
/* 设置背景图 */
background-image: url(image/cat.jpg);
/* 设置背景图不平铺 */
background-repeat: no-repeat;
/* 设置背景图的位置 */
background-position: center center;
/* 设置背景图的大小 */
background-size: cover;
}
/* 针对 .container 也设置样式,实现版心效果 */
.container {
/* 此处宽度也可以设置成百分数的形式,当前就使用一个固定宽度 */
width: 1200px;
height: 100%;
/* 设置水平居中 */
margin: 0 auto;
/* 设置背景色,让版心和背景图可以区分 */
background-color: rgba(255, 255, 255, 0.8);
/* 设置圆角矩形 */
border-radius: 10px;
/* 设置内边距,避免文字内容紧贴 */
padding: 20px;
}
</style>
6.1.2、实现页面样式(2)
.header {
width: 100%;
height: 50px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header>input {
width: 1050px;
height: 50px;
font-size: 22px;
line-height: 50px;
padding-left: 10px;
border-radius: 10px;
}
.header>button {
width: 100px;
height: 50px;
background-color: rgb(42, 107, 205);
color: #fff;
font-size: 22px;
line-height: 50px;
border-radius: 10px;
border: none;
}
/* 搜索按钮按下去会给出反应,灰色 */
.header>button:active {
background: gray
}
6.2、ajax 前后端交互
<script src="js/jquery.js"></script>
<script>
// 放置用户自己写的 js 代码
let button = document.querySelector("#search-btn"); // 获取搜索框按钮
button.onclick = function () {
// 先获取到输入框的内容
let input = document.querySelector(".header input");
let query = input.value;
console.log("query: " + query);
// 然后构造一个 ajax 请求发给服务器
$.ajax({
type: "GET",
url: "searcher?query=" + query,
success: function (data, status) {
// success 这个函数会在请求成功后调用.
// data 参数就表示拿到的结果数据
// status 参数就表示 HTTP 状态码
// 根据收到的数据结果, 构造出页面内容.
// console.log(data);
}
});
}
6.2.1、根据响应的数据构造页面内容
// 通过这个函数, 来把响应数据给构造成页面内容.
function buildResult(data) {
// 获取到 .result 这个标签
let result = document.querySelector('.result');
// 1。首先需要遍历 data 中的每个元素
// 这个类似于 java 汇总的 for each
for(let item of data) {
let itemDiv = document.createElement('div');
itemDiv.className = 'item';
// 构造标题
let title = document.createElement('a');
title.href = item.url;
itemDiv.appendChild(title);
// 构造一个描述
let desc = document.createElement("div");
desc.className = 'desc';
desc.innerHTML = item.desc;
itemDiv.appendChild(desc);
// 构造一个 url
let url = document.createElement('div');
url.className = 'url';
url.innerHTML = item.url;
itemDiv.appendChild(url);
// 把itemDiv 假如到 .result 中
result.appendChild(itemDiv);
}
}
6.2、实现搜索词标红逻辑
我们有时候需要把搜索词变成红色,为重点颜色,就可以做一下操作。
- 修改后端的代码,生成搜索结果的时候(生成描述),就需要把其中包含查询词的部分,加上一个标记。例如给搜索词套上一个“" 标签;
// 在此处加上一个替换操作. 把描述中的和分词结果相同的部分, 给加上一层 <i> 标签. 就可以通过 replace 的方式来实现.
for (Term term : terms) {
String word = term.getName();// 注意这里是转换成了小写
// 注意, 此处要进行全字匹配. 也就是当查询词为 List 的时候 不能把 ArrayList 中的 List 给单独标红
desc = desc.replaceAll("(?i) " + word + " ", "<i> " + word + " </i>"); // (?i)就是转换小写的意思
}
return desc;
- 修改前端代码,针对前端“" 标签设置样式,然后浏览器就根据 标签进行显示。(例如 标签加上红色…)
.item .desc i {
color: red;
/* 去掉斜体 */
font-style: normal;
}
6.3、测试更复杂的查询词以及停用词
但在搜索框中搜索 array list 的时候可能在浏览器上面看到一个500的报错,一般500就说明代码再执行过程中抛出了异常。
:::success
在浏览器中找出了异常信息:发现 “array list” ,里面的空格是算进去了的,空格不管是倒排还是正排基本上每一句话可能都会有空格出现,所以可能搜索出来的词语与 “array list”无关也是有可能的。
:::
解决办法:
- 其中有一个概念叫做”停用词“,”停用词“则是:有点单词出现的频率会很高,但是没有啥意义内容(比如:空格,a is have 一 的 是…)这些词是不应该参与触发的;
- 所以我们在网上找到这些停用词的文件,接下来可以让搜索程序加载这些停用词表,加载到内存中,使用 HashSet 把这些词都存在一起,再针对分词结果,在停用词表中进行筛序,如果某个结果在停用词表中存在的,就直接除掉。
6.4、处理生成描述的bug
有时候搜索词会连着一些符号出现,这样看的话也不是单独的“全字匹配”源代码为两边都加上空格的关系,所以有局限性,只能识别空格,为了解决这里的问题,就需要用到正则表达式,正则表达式对于处理 String 是非常灵活也好用的。
firstPose = content.toLowerCase().indexOf(" " + word + " ");//直接的方法word两边加上空格
但是正式使用的话可能会非常的麻烦,一方面可能基于 String 中的一些特定的方法,另一方面可以在标准库中提供 Pattern 和 Matcher 这两个类。
Pattern描述了一个匹配规则
Matcher负责进行具体的匹配规则
:::success
但是这个两个用起来就麻烦,所以使用简单的方式,就是把一个未知的问题转换成已知的问题。
方案:就是把两边不带空格(带标点)情况转换成两边带空格的情况
:::
content = content.toLowerCase().replaceAll("\\b" + word + "\\b"," " + word + " ");
firstPose = content.indexOf(" " + word + " ");//直接的方法word两边加上空格
6.5、加载搜索结果个数(前端)
这里使用到了前端:
1.直接在服务器这边算好了个数,返回给浏览器(既需要修改前端,又需要修改后端
2.在浏览器这边根据收到的结果的数组的长度,自动的展示出个数(只改前端即可)
// 先构造一个 div 用来显示结果的个数
let countDiv = document.createElement("div");
countDiv.innerHTML = "当前找到 " + data.length + "个结果!";
countDiv.className = "count";
result.appendChild(countDiv);
.result .count {
color: gray;
margin-top: 10px;
}
6.6、关于搜索出重复文档的问题
在单独搜索 array 的时候出现的搜索结果:1598
在单独搜索 list 的时候出现的搜索结果:1381
当 array list 一起搜索出现结果:2979
很显然这是前两个加起来的结果,所以这些结果里面很有可能会有重复的文档。
经过实验测试,发现这里就出现了两处一样的文档,只是出现在了不同的位置,按逻辑来说,一个文档不应该在搜索文档出现两次。
之前的代码实现:
:::success
array:触发一组docId,每个文档针对 array 这个词来计算全权重(词频)
list:触发一组docId,每个文档针对 list 这个词来计算全权重(词频)
所以 collections.html中在这两个文档都出现过,所以得出一个结论就是它的文档“相关性”非常的高,因为两个权重都有它,应该排在搜索结果前面的位置!!
:::
实现这样效果的方法:
需要把多个分词结果触发出的文档,按照 docId 进行去重同时进行权重的相加
去重的核心思路:
数据结构,合并两个有序链表,所以要先排序。把分词结果进行排序处理再进行合并,合并的时候可以根据 docid 的值相同情况,进行权重的相加。
对于多路数组归并,核心思路仍然是对比指向多个数组的值大小关系,找到小的插入到结果中(target)。
这个时候就需要使用到了优先级队列了;就是为了能够找出每一行中对应的最小的 docId 的 Weight 对象,再把这个最小的对象插入到 target 中,同时把对应的下标往后移动。
// 通过这个内部类, 来描述一个元素在二维数组中的位置
static class Pos {
public int row;
public int col;
public Pos(int row, int col) {
this.row = row;
this.col = col;
}
}
// 进行合并的时候,是把多个行合并成一行。(二维数组)
// 合并过程中势必是需要操作二维数组(二维list)里面的每一个元素
// 操作元素势必涉及到“行”和“列”这样的概念,要想确定二维数组中的一个元素,就需要明确知道”行“和”列“
private List<Weight> mergeResult(List<List<Weight>> source) {
// 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 {
// 如果文档 id 不同,直接把 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;
}
在DocSearch类中的核心方法 search 中的步骤:
- 【分词】针对 query这个词进行分词
- 【触发】针对分词结果查倒排
- 【合并】针对多个分词结果触发出的文档,进行权重的重合
List<Weight> allTermResults = mergeResult(allTermResult);
- 【排序】针对触发结果按照权重来降序排序
- 【包装结果】针对排序结果,去查正排,构造出要返回的数据
七、创建 SpringBoot
package com.example.java_doc_search.controller;
import com.example.java_doc_search.seacher.DocSearch;
import com.example.java_doc_search.seacher.Result;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import java.util.List;
@Controller
@ResponseBody
public class DocSearcherController {
private static DocSearch searcher = new DocSearch();
// 需要把 json 转成 String类型
private ObjectMapper objectMapper = new ObjectMapper();
// 参数是查询词,返回结果是响应
@RequestMapping("/searcher")
@ResponseBody
public String searcher(@RequestParam("query") String query) throws JsonProcessingException {
// 参数 query 是来自于 ajax 中 url的query String 中 query 这个key的值
List<Result> results = searcher.search(query);
return objectMapper.writeValueAsString(results);// 返回转换成为 String 类型
}
}