背景介绍
对于一个网站来说,搜索引擎需要提前预备好很多很多的静态资源。当用户输入查询的关键词的时候根据这些关键词来模糊查询匹配对应的资源,然后将这些资源展示给用户即可。
搜索核心思路
互联网上主要是依赖于爬虫程序,它们可以极大效率的利用互联网获取到海量信息资源。本项目没有用到爬虫,而是根据索引这样的数据结构来实现关键词快速查询指定文档id
- 文档:就是项目中预备的静态资源
- 正排索引:根据文档id查询文档内容
- 倒排索引:根据关键词查询文档id列表
项目目标
站内搜索到技术文档的关键API和简介
区分全站搜索和站内搜索
全站搜索:Google,Bing应,火狐,百度,QQ浏览器,360浏览器。。。等等出名的浏览器在使用过程中会发现可以搜索到任意用户任意输入的关键词
站内搜索:比如在CSDN博客站内搜索某个内容,就会只显示站内的静态资源,站外的资源由于没有存储,所以无法搜索到。也一定程度上实现资源的筛选,得到搜索更准确的目的。
获取java文档
把相关的网页文档获取到,这样才能制作正派索引和倒排索引
官网JDK8在线文档查看步骤
这里对比一下本地的查看效果
至此就已经一步步进入到官网的文档了
下载离线JDK8文档步骤
往下拉,就会有Java8文档
至此就可以下载离线文档
按住ctrl新打开标签页就会出现如下显示
URL | |
---|---|
在线 | https://docs.oracle.com/javase/8/docs/api/java/util/ArrayList.html |
离线线 | file:///D:/Documents/docs/api/java/util/ArrayList.html |
在本地基于离线文档来制作索引,根据关键词实现搜索;当用户在搜索结果页点击具体的搜索内容的时候就自动跳转到在线文档的页面
为什么不用爬虫来准备好这些静态资源呢?
如果用爬虫的话相当于直接拿到了静态资源而不需要我们去实现中间这个过程,这并不是项目的核心技术。也不需要为了爬虫而去学习Python这门语言。只要编程语言能够访问网络,那么就可以实现爬虫。
针对JDK8文档来说,我们选择更简单的方案:直接从官网下载文档的压缩包放在我们的静态资源中。不必通过爬虫来实现了。
项目模块划分
- 索引模块
- 扫描下载到的文档,分析文档内容,构建出正排索引+倒排索引。并把索引内容保存到文件中
- 加载制作好的索引文件,提供一些 API 实现查正排和查倒排这样的功能
- 搜索模块
- 调用搜索模块实现一个完整的搜索过程
输入:关键词
输出:完整的搜索结果【标题,URL并且点击能够跳转,内容】
- 调用搜索模块实现一个完整的搜索过程
- web模块
- 需要实现一个简单的 web 模块,能够通过网页的形式来和用户进行交互。包含前后端。
分词原理
分词是为了后续构造倒排索引,根据关键词查文档id用的。英文分词很简单,但是中文分词就容易造成误解
- 每天膳食,无鸡鸭亦可,无鱼肉亦可,青菜一碟足矣 --> 每天膳食,无鸡,鸭亦可;无鱼,肉亦可;青菜一碟,足矣
- 下雨天留客天留我不留 --> 下雨天,留客天,留我不?留!
- 寄钱三百吊买柴烧孩子小心带和尚田租等我回去收 -->寄钱三百吊买柴烧,孩子小心带,和尚田租等我回去收
分词方法采用的分词第三方库
-
基于词库
尝试把所有的“的”都进行穷举~把这些穷举结果放到词典中。然后就可以一次的取句子中的内容,每隔一个字,再次电力查一下;每隔两个词,查一下。
但是由于互联网带来的一些新词或者说是年度最热的词句是一只变动的,那么就无法正确的分词
-
基于统计
收集到很多很多的“语料库”–>人工标注/直接统计,也就知道了哪些字在一起的概率比较大
分词的实现,就是属于“人工智能”典型的应用场景 -
基于第三方库
这里使用的 ansj
package app;
import org.ansj.domain.Term;
import org.ansj.splitWord.analysis.ToAnalysis;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
@SpringBootTest
class DocSearchSpringApplicationTests {
@Test
void contextLoads() {
}
@Test
void ansj() {
String str = "寄钱三百吊买柴烧孩子小心带和尚田租等我回去收";
List<Term> terms = ToAnalysis.parse(str).getTerms();
for (Term term : terms) {
System.out.println(term.getName());
}
}
}
寄
钱
三百
吊
买柴
烧
孩子
小心
带
和尚
田租
等
我
回去
收
序列化数据
这里使用 jackson
实现索引模块
Parser类的模块划分
- 根据指定的路径,加载并解析本地的html静态资源
- 根据加载的文件列表,读取文件内容解析后构建正排索引和倒排索引
- 制作时把内存中加载好的索引保存到文件中;使用时从文件中读取数据到内存中
加载静态资源流程
- 递归枚举文件
通过ArrayList装填File类,来存储当前路径下的全部html文件,递归处理根目录
/**
* @param inputPath 从哪个目录开始递归遍历
* @param fileList 递归得到的结果
*/
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()) {
// getAbsolutePath:获取绝对路径,getPath获取相对路径,getCanonicalPath获取修饰之后的路径
enumFile(f.getAbsolutePath(), fileList);
} else {
// 排除非html文件
if (f.getAbsolutePath().endsWith(".html")) {
fileList.add(f);
}
}
}
}
- 解析html
解析html就是把html文件的标题,URL,描述内容给展现在网页上
描述来源于正文,所以首先需要解析html文件的内容正文
整个 HTML 的解析通过 parseHTML 函数解决
parseHTML 再由 parseTitle,parseUrl,parseConten 四部分构成
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.把解析出来的这些信息加入到索引当中
}
- 解析标题:html文件名可以作为我们的搜索结果的标题
- 解析URL:选取本地资源路径
D:/Documents/docs/api/
为基准- 在线:https://docs.oracle.com/javase/8/docs/api/java/util/ArrayList.html
- 离线:file:///D:/Documents/docs/api/java/util/ArrayList.html>
做到的效果就是用户点击搜索结果,就能够跳转到对应的线上文档的页面;根据线下文档的后半部分URL和线上文档的前半部分URL进行拼接即可
@Test
void getUrl() {
String INPUT_PATH = "D:\\Documents\\docs\\api\\";
File file = new File("D:\\Documents\\docs\\api\\java\\util\\ArrayList.html");
// 先获得一个固定的前缀
String part1 = "https://docs.oracle.com/javase/8/docs/api/";
String part2 = file.getAbsolutePath().substring(INPUT_PATH.length());
String result = part1 + part2;
// 也可以利用 replaceAll 函数进行替换掉也可以【注意使用正则的时候java语言中需要4个 \\\\ 才能代表 1个 \】
// result = result.replaceAll("\\\\", "/");
System.out.println(result);
}
https://docs.oracle.com/javase/8/docs/api/java/util\ArrayList.html
会发现这里的正斜杠和反斜杠都存在,会不会影响浏览器的正常访问呢?
把地址复制到浏览器中发现,被正常解析了且成功访问。
主流的浏览器都有这样的纠错能力,也就是所谓 “鲁棒性【robot】”
解析正文:一个完整的 HTML文件 由 HTML标签 + 内容(Java文档),接下来,进行的解析正文的核心操作就是去掉 HTML 标签获取到里边的内容【相当于 innerText】
利用正则会太麻烦,因此我们就利用标签的 左右尖括号< > 来判断是否为内容
<div>hello</div>
思路:遇到一个字符 ‘<’ 这个位置开始不进行数据拷贝,接下来读到 “div” 也都不拷贝。读到 ‘>’ 也不拷贝,但是不拷贝状态结束,后续读到 “hello” 就能进行拷贝。读到 ‘<’ 就有结束拷贝。
思考:万一html中有 < 该怎么办
其实这个不用担心,因为 html 中的 ‘<’ 等这些特殊符号是由 & l t 来构成的
private String parseContent(File f) {
StringBuilder content = new StringBuilder();
// 这里需要按照字符来读
try(FileReader fileReader = new FileReader(f)) {
// 加上一个是否拷贝的开关
boolean isCopy = true;
while (true) {
int read = fileReader.read();
if (read == -1) {
break;
}
char c = (char) read;
if (isCopy) {
// 开关打开状态,遇到普通字符就考呗到StringBuilder中
if (c == '<') {// 遇到 '<' 就关闭拷贝
isCopy = false;
continue;
}
if (c == '\n' || c == '\r'){
c = ' ';
}
// 其他字符,直接进行拷贝
content.append(c);
} else {
// 开关关闭状态,暂不拷贝,直到遇到 '>' 打开
if (c == '>') {
isCopy = true;
}
}
}
} catch (IOException e) {
throw new RuntimeException(e);
}
return content.toString();
}
Parser类目前进度小结——解析资源
Parser 类通过 run 方法启动。
- 先扫描指定路径的文件,放入到一个 ArrayList 文件列表中【√】
- 再完善添加文件过程:排除非html文件【√】
- 再通过一个 parseHTML 解析全部的 html文件【√】
有了这些离线文档之后,下一步就是根据解析结果构造索引
构造索引流程
index类 主要用来在内存中构造索引结构。办5件事!
- 通过 docId 查 文档信息【这是正排索引做的事情】
- 给定一个 关键词 查 docId【这是倒排索引做的事情】
- 往索引中增加一个文档【要及时更新正排索引和倒排索引】
- 把内存中的索引结构保存在文件中
- 加载文件中的索引结构到内存中
Index索引类大致框架
import java.util.ArrayList;
import java.util.HashMap;
// 通过这个类在内存中构造出索引结构
public class index {
// 使用数组下标表示 docId
private ArrayList<DocInfo> forwardIndex = new ArrayList<>();
// 使用 哈希表 来表示倒排索引 关键词:一组和这个词关联的文章
private HashMap<String, ArrayList<Weight>> invertedIndex = new HashMap<>();
//这个类提供的方法:
//1.给定一个 docId,在正排索引中查询文档的详细信息
public DocInfo getDocInfo(int docId) {
return forwardIndex.get(docId);
}
//2.给定一个 关键词,在倒排索引中查询文档id【为了使得存储的是相关性,所以使用了 Weight 取代 docId】
public ArrayList<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 DocInfo buildForward(String title, String url, String content) {
}
//4.把内存中的索引结构保存到磁盘中
public void save() {
}
//5.把磁盘中的索引数据加载到内存中
public void load() {
}
}
使用 ArrarList 来创建正派索引;HashMap<String, ArrayList> 来创建倒排索引
注意点
- 正排索引就利用 ArrayList 取下标的方式获取文档,时间复杂度为 O(1)
- 倒排索引可以使用 HashMap<String, ArrayList> 来实现 O(1) 复杂度的获取相关文档 ID
倒排索引为何这样构建
如果使用 HashMap<String, Integer> 是可以实现每个文档的 关键词 与 文档ID 的匹配联系,但是我们需要一个 权重Weight 。类似于搜索引擎中的搜索结果的排名,我们还需要文档和关键词匹配的相关性进行计算权重,因此使用一个数组来装填全部的与此关键词匹配的文档ID
Weight权重类
具体的权重计算先把大致框架搭建好之后再详细设计算法【这里先埋个坑】
// 根据 docId 和 文档与词的相关性 权重 进行一个包裹
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;
}
}
- 正排索引
这里就是简单的通过参数生成指定文档之后加入正排索引
private DocInfo buildForward(String title, String url, String content) {
DocInfo docInfo = new DocInfo();
docInfo.setDocId(forwardIndex.size());
docInfo.setUrl(url);
docInfo.setTitle(title);
docInfo.setContent(content);
forwardIndex.add(docInfo);
return docInfo;
}
- 构建倒排
倒排索引:关键词->文档id 之间的映射关系。正常搜索的时候我们会发现结果相关性高的排名会靠前。因此 HashMap 的 value 需要进行一定的排序,根据 key 来匹配文档分词,如果匹配,就把 DocId 加入到 vlue 中即可。
权重如何设计?
此处我们就通过简单的次数统计来计算。
相关性往往是由部门的算法团队来训练模型的,根据文档中提取的特征,训练模型最终借助机器学习的方式来衡量
private void buildInverted(DocInfo docInfo) {
class WordCnt {
// 这个 关键词 标题出现次数
public int titleCount;
// 这个 关键词 正文出现次数
public int contentCount;
}
// 用这个数据结构进行词频统计
HashMap<String, WordCnt> wordCntHashMap = new HashMap<>();
// 1.统计标题出现次数
List<Term> terms = ToAnalysis.parse(docInfo.getTitle()).getTerms();
// 2.遍历分词结果,统计每个分词出现次数【标题的出现次数意义大于正文】
for (Term t : terms) {
String word = t.getName();
WordCnt wordCnt = wordCntHashMap.get(word);
if (wordCnt == null) {
wordCnt = new WordCnt();
wordCnt.titleCount = 1;
wordCnt.contentCount = 0;
wordCntHashMap.put(word, wordCnt);
} else {
wordCnt.titleCount += 1;
}
}
// 3.统计正文出现次数
terms = ToAnalysis.parse(docInfo.getContent()).getTerms();
// 4.遍历分词结果,统计每个分词出现次数
for (Term t : terms) {
String word = t.getName();
WordCnt wordCnt = wordCntHashMap.get(word);
if (wordCnt == null) {
wordCnt = new WordCnt();
wordCnt.titleCount = 0;
wordCnt.contentCount = 1;
wordCntHashMap.put(word, wordCnt);
} else {
wordCnt.contentCount += 1;
}
}
// 5.把上面的结果汇总到 HashMap 中
// 最终文档的权重就是 标题出现的次数*10 + 正文出现的次数【实际应用的算法会很复杂】
// 6.遍历刚才的 HashMap 依次来更新倒排索引
for (Map.Entry<String, WordCnt> entry : wordCntHashMap.entrySet()) {
ArrayList<Weight> invertedList = invertedIndex.get(entry.getKey());
if (invertedList == null) {
ArrayList<Weight> tmp=new ArrayList<>();
Weight weight = new Weight();
weight.setDocId(docInfo.getDocId());
weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);
tmp.add(weight);
invertedIndex.put(entry.getKey(), tmp);
} else {
Weight weight = new Weight();
weight.setDocId(docInfo.getDocId());
weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);
invertedList.add(weight);
}
}
}
这个倒排索引代码稍长。我们一步一步分析。
我们需要统计标题个数和正文个数,所以创建了一个内部类 WordCnt ,先对标题进行分词并统计,统计结果放在 wordCntHashMap 中。再对正文进行分词统计,统计结果也放在 wordCntHashMap 中。最后遍历整个 wordCntHashMap 将结果保存在类变量 invertedIndex 中。
为何保存加载索引
当前这些索引是保存在内存中的,构建索引过程又是很耗时。因此我们不应该在服务器启动的时候就够建索引(服务器的启动速度会被拖慢很多的)
通常的做法就是这些耗时的操作单独执行,然后让线上的服务器直接加载这个构造好的索引。因此就需要把内存中的 索引这样的数据结构序列化成字符串,然后进行写文件【序列化操作;对应的反序列化就是把字符串反向解析成一些结构化数据(类/对象/基础数据结构)】
对于序列化操作,jdk 自带了就有 Serializable。我们这里使用第三方库 Jackson
下一步操作就是给 index类 添加文件的保存路径变量和Jackson对象来进行文件的读写
private static String INDEX_PATH = "D:/Programme/Java/5.Spring/DocSearch/src/main/resources";
private ObjectMapper mapper = new ObjectMapper();
Parser类目前进度小结——构造索引
Index 类通过 add(title, url, content) 方法制作索引
- 先构建正排索引,放入到一个 ArrayList【√】
- 再构建倒排索引【√】
有了这些内存中的数据之后,下一步就把内存中的索引序列化到文件中,当项目启动的时候也要自动读取文件数据到内存中【服务器制作索引速度太慢】
索引序列化
- 保存内存中的索引到文件中
public void save() {
// 使用两个文件,分别保存正排索引和倒排索引
// 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 {
mapper.writeValue(forwardIndexFile, forwardIndex);
mapper.writeValue(invertedIndexFile, invertedIndex);
} catch (StreamWriteException e) {
throw new RuntimeException(e);
} catch (DatabindException e) {
throw new RuntimeException(e);
} catch (IOException e) {
throw new RuntimeException(e);
}
System.out.println("保存索引结束");
}
- 加载索引文件数据到内存中
public void load() {
long start = System.currentTimeMillis();
// 1.加载索引的文件路径
File forwardIndexFile = new File(INDEX_PATH + "forward.txt");
File invertedIndexFile = new File(INDEX_PATH + "inverted.txt");
try {
forwardIndex = mapper.readValue(forwardIndexFile, new TypeReference<ArrayList<DocInfo>>() {});
invertedIndex = mapper.readValue(invertedIndexFile, new TypeReference<HashMap<String, ArrayList<Weight>>>() {});
} catch (IOException e) {
throw new RuntimeException(e);
}
System.out.println("加载索耗时:" + (System.currentTimeMillis() - start));
}
Parser类目前进度小结——序列化
Index 类通过 save(), load() 方法写和读
- save()【√】
- load()【√】
完善Parser类
- 首先查看功能是否正常
在 Parser类 中创建 index 实例,并在 parserHTML 函数中 调用 index.addDoc(title, url, content) 方法进行保存操作,run函数 中调用 index.save() 方法
制作效果如图所示
- 如何优化
在程序的方法入口 run 中加上一个前后的时间戳。一步一步查看程序的性能瓶颈
先查看整体的耗时,发现内部有很多的文件读写操作如下
2.1. 加载静态资源
2.2. 构造索引
2.3. 索引保存
-
整个流程耗时 16.5s
-
加载静态资源耗时 0.3s
在枚举的代码前后计算耗时,因为用的递归来获取目录中全部的html文件,已经无法优化
-
构造耗时 15.9s
-
序列化索引耗时 0.21s
发现解析文件和索引制作是很耗时的。这些程序都是单线程执行的,且都是 CPU密集型
- 递归加载静态资源【cpu密集型】
- 解析html文件用的是分词库,每个html文件for循环一次就会进行分词解析【cpu密集型】
- 序列化索引【IO密集型】
构造索引整个过程表面上耗时这么多,其实是这个16.8s包含了:枚举0.3s【1.8%】,解析15.9s【94.6%】,保存0.21s【1.3%】。通过大致的计算结果得出:整个过程和磁盘无关而是在CPU的处理上上出现了瓶颈
难道是CPU性能跟不上?我们查看一下CPU的占用率如下所示【截图已经是最高的占用率了,没超过50%以上】
发现这个程序完全没有发挥出CPU的全部性能,因此我们可以考虑一下发挥CPU全部性能
因此我们采用一下线程池的方案来提升速度,重点放在解析HTML文件上
优化方案
实现多线程制作索引
需要注意的是这里的多线程执行问题:可能会在 for 循环执行完毕了但是线程中的 parseHTML 函数没有执行完毕就会执行到后续的 index.save() 就会有错误的
因此需要等待所有线程执行完毕才执行后续代码
CPU密集型:线程数=核心数+1
IO密集型:线程数=核心数/(1-阻塞系数)阻塞系数:阻塞时间/(阻塞时间+cpu运行时间)
这只是网上建议的线程数计算方式,实际情况需要多多测试得出最佳运行效率的核心数才是正道。我的cpu是 4c8t,因此就给5【4+1】个线程
- 使用封装好的线程池ExecutorService
// 多线程制作
public void runByThread() {
//1.枚举出所有文件
ArrayList<File> files = new ArrayList<>();
enumFile(Config.INPUT_PATH, files);
// 2.循环遍历文件:线程池
ExecutorService executorService=Executors.newFixedThreadPool(5);// 固定线程池
//ExecutorService executorService=Executors.newCachedThreadPool();// 自适应线程池
long startFor = System.currentTimeMillis();
for (File f : files) {
executorService.submit(new Runnable() {
@Override
public void run() {
parseHTML(f);
}
});
}
try {
latch.await();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
long endFor = System.currentTimeMillis();
System.out.println("固定线程池耗时:" + (endFor - startFor) + "ms");
// 3.保存索引
index.save();
// 4.手动把所有线程销毁: 并且保证任务需要执行完毕才行
executorService.shutdown();
}
耗时结果三次求平均值得来的
线程池方案 | 线程数 | 耗时 |
---|---|---|
固定线程池 | 5 | 6.8s |
固定线程池 | 8 | 6.3s |
固定线程池 | 10 | 6.3s |
自适应线程池 | 无 | 13.4s |
综上固定线程池的线程数采取8,线程数过多造成的时间片轮转,上下文环境切换也会有开销,属于典型的负优化
到此为止,多线程的框架已经出来。我们主要看 parseHTML
是否会导致线程不安全问题即可
主要观察是否存在多个线程修改同一个对象即可
多线程安全问题
这俩函数的执行只是简单的读取操作,并没有进行一些修改操作。因此再往下看 parseContent
。
这里涉及到文件的读取和拷贝,因为没有任何的修改操作,所以也不用担心多个线程修改同一个变量的问题。
看最后的 addDoc
这里我们发现会出现多个线程同时修改同一个变量的问题,当有很多个线程的时候,对于全局变量的正排索引和倒排索引都会有影响,因此需要加锁。
锁的粒度越小,并发程度越高。
构建正排索引加锁
构建倒排索引加锁
因为需要对圈红的代码进行加锁,所以为了简化,直接对整个 for循环代码块 进行加锁
这样加锁对吗?
我们发现加的都是同样的锁
this
,而构建正排不会影响构建倒排,所以如果是相同的锁,就会出现锁竞争的情况发生,所以我们需要不同的锁。
解决线程不退出的隐患和保证全部线程任务执行完毕
为什么呢?
这有需要理解两个概念:守护线程和非守护线程
- 守护线程【后台线程】:这个线程的运行状态不会影响进程结束。
- 非守护线程:这个线程的运行状态会影响进程结束
我们创建的默认都是 非守护线程 所以我们需要设置成守护线程,进而不影响进程的结束。
如何设置?
- 方案一:线程池的
shutdown
方法直接毁掉线程 - 方案二:线程本身的
setDemeon(true)
设置为守护线程
// 这里使用线程池的 shutdown 方法直接毁掉线程
executorService.shutdown();
// 为了保证每一个线程执行完毕进而保证全部任务执行完毕【相当于运动员到达终点后就撞线一次,裁判要等所有远动员撞线之后才能公布比赛结束】
latch.countDown();
再次验证多线程效果,加锁对此影响的速度还是蛮小的
首次制作索引比较慢的问题
优化了索引制作速度,但还有一个速度是磁盘文件的生成也很慢。我们计算一下文件生成的时间【主要是 parseHTML
函数的执行】
对于标题和URL的速度很快,可以忽略不计,重点放在了html文件内容的读取分词上。
代码准备好之后,IDEA重启,运行run方法来模拟服务器的重启效果。
发现耗时更严重了,明显的第一次加载的时候速度会慢的不是一点半点儿。
这是因为 缓存:操作系统会对经常用的文件进行缓存在内存中方便后续操作的读取 。 IDEA重启后,操作系统里对之前的所有操作的缓存都会清空;而当 IDEA 第一次运行的时候会重新读磁盘、各种文件读取一个一个处理,并缓存一些数据,当第二次继续编译运行的时候就会将内存中的缓存拿出来使用而不是从磁盘中拿出来使用,从而提高IO效率。
为了方便统计更详细的时间外加由于是多线程环境下,所以需要使用原子类【操作都是独立的,不会被干扰】来计算。
private AtomicLong parseContents= new AtomicLong(0);
private AtomicLong addIndexTimes = new AtomicLong(0);
名称 | 简写 | 转换 |
---|---|---|
秒 | s | 1s |
毫秒 | ms【millisecond】 | 1s=103ms |
微秒 | µs【microsecond】 | 1ms=103µs |
纳秒 | ns【nanosecond】 | 1µs=103ns |
皮秒 | ps【picosecond】 | 1ns=103ps |
为什么会出现33.7s?
33s是8个线程累加的构造索引时间,20s是8个线程累加的新增全部文档内时间
现在问题是优化文件的读取速度
优化文件读取速度
从上边的优化和测试后得知:速度的瓶颈在于文件处理的操作上
之前我们是一个一个字符的读取,我们可以利用 BufferedReader 设置一个缓存区来加快读取操作。
public String parseContent(File f) {
// 还得准备一个保存结果的 StringBuilder
StringBuilder content = new StringBuilder();
// 这里需要按照字符来读
try (BufferedReader reader = new BufferedReader(new FileReader(f))) {
// 加上一个是否拷贝的开关
boolean isCopy = true;
while (true) {
int read = reader.read();
if (read == -1) {
break;
}
char c = (char) read;
if (isCopy) {
// 开关打开状态,遇到普通字符就考呗到StringBuilder中
if (c == '<') {// 遇到 '<' 就关闭拷贝
isCopy = false;
continue;
}
if (c == '\n' || c == '\r') {
c = ' ';
}
// 其他字符,直接进行拷贝
content.append(c);
} else {
// 开关关闭状态,暂不拷贝,直到遇到 '>' 打开
if (c == '>') {
isCopy = true;
}
}
}
} catch (IOException e) {
throw new RuntimeException(e);
}
return content.toString();
}
只更改了 读取字符的方式,新增文档内容速度快了11秒;增加索引速度不会改变
索引加载测试
在 Index 类中进行打断点测试
正排索引
倒排索引
索引模块小结
-
实现一个 Parser 类
- 通过递归枚举出全部的 html 文件
- 针对每个 html 文件进行解析
- 解析 Title
- 解析 URL
- 解析 Content:用一个开关控制字符是否拷贝
- 解析后添加正排索引和倒排索引
- 正排索引:使用
ArrayList<DocInfo>
数组长度作为新增DocInfo
的下标,搜索时间复杂度为 O(1) - 倒排索引:使用
HashMap<String, ArrayList<Weight>>
作为访问的数据结构后,时间复杂度也为 O(1)。这里先把要解析的DocInfo
装填入HashMap<String, WordCnt>
中,然后遍历取得的 key 在 倒排中获取,因为如果没有就设为1,如果有就+1。所以把标题*10 + 内容 的出现次数相加作为最后的 权重
- 正排索引:使用
- 保存一个解析后的新增文档到文件中
- 在启动的时候加载本地文件到内存中
-
实现了一个 Index 类
- 查正排:直接取下标
ArrayList<DocInfo>
即可 - 查倒排:按照 key 获取
HashMap<String, ArrayList<Weight>>
中 value 即可 - 添加文档,Parser 类 构建索引的时候调用该方法
- 构建正排:构造
DocInfo
对象,依据ArrayList<DocInfo>
长度作为DocInfo
的ID
进行添加 - 构建倒排:先进行标题,内容的分词。利用一个内部类
WordCnt
统计一个DocInfo
出现的标题和内容的词频,去更新倒排索引 - 整个构建过程中涉及到的 多个线程修改同一个变量是应该注意线程安全问题
- 构建正排:构造
- 查正排:直接取下标
-
优化过程
- 单线程 vs 多线程
- 线程的安全性需要保证
- 线程资源的正常关闭
- 文件读取优化
- 增加一个文件缓存区
- 单线程 vs 多线程
-
保存索引:把数据转换为 JSON 格式存储在文件中
-
加载索引:把 JSON 格式的文件读取出来,加载在内存中
核心部分对比【由于文件数量很大,仪器运行的话会造成GC内存报错只能单独运行对比】
效率大致提升43.8%【
(
21295.2
−
11974.2
)
/
21295.2
≈
0.438
(21295.2 - 11974.2) / 21295.2 \approx 0.438
(21295.2−11974.2)/21295.2≈0.438】
实现搜索模块
DocSearcher搜索模块划分
调用搜索模块,来完成搜索的核心过程
- 分词:针对用户输入的 查询词 进行分词【用输入的可能是一个句子,可能是一个字也可能是一个词】
- 触发:拿着每个分词结果去倒排索引中查询
- 排序:针对上面出发的结果,进行排序【按照相关性,降序排序】
- 包装结果:根据排序后的结果一次去查正排,获取每个文档的详细信息,包装成一定数据结构的返回给页面
创建DocSearcher类
import java.util.ArrayList;
// 通过这个类,来完成整个搜索过程
public class DocSearch {
// 加上对象索引的实例
private Index index = new Index();
// 在运行的时候就开始加载索引到内存中
public DocSearch() {
index.load();
}
// 完成整个搜索过程
// 参数(输入部分)就是用户给出的查询词
// 返回值(输出部分)就是搜索结果的集合
public ArrayList<Result> search(String query) {
// 1.【分词】根据查询词进行分词
// 2.【触发】针对分词结果来查倒排
// 3.【降序】针对触发的结果按照权重降序排序
// 4.【包装结果】针对降序的结果返回
return null;
}
}
创建一个搜索结果 Result类,用来包装搜索后的返回结果
// 这个表示搜索结果
public class Result {
private String title;
private String url;
// 描述是正文的一段摘要
private String desc;
}
实现search方法
注意分词结果全是小写,因此需要现在把 DocInfo 的 Content 全转换为 小写
实现生成描述
取内容的中关键词第一次出现位置的前后各60个单词
先埋一个坑:List关键词能不能只查 List 而排除掉 ArrayList?
这就会导致搜索结果的不准确,类似的情况在查倒排的时候是否会存在呢?倒排索引中的 key 都是分词的结果,我们应该让 List 仅查询出 List,视 ArrayList 为一个单词【独立成词】
简单验证
public static void main(String[] args) {
DocSearch docSearch = new DocSearch();
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.print("-->");
String query = scanner.nextLine();
ArrayList<Result> results=docSearch.search(query);
for (Result r:results ) {
System.out.println(r);
}
}
}
原因
因为 parseContent 仅仅是通过 ‘<>’ 标签进行读写数据的,遇到 js 之后仅仅是把 <script><\script> 给去掉了,而 <script>xxx<script> 内容 xxx 则没被去掉
因此现在问题就是如何去掉 xxx 呢?
就是使用正则表达式来实现效果。
出现jsBUG——使用正则表达式
Java 中的 String 方法很多都是支持正则的【indexOf,replaceAll,replace,split…】
元字符 | 作用 | 举例 |
---|---|---|
. | 匹配非换行【非 \r、\n】 | |
* | 前面的字符可以出现 >=0 次【.*:表示匹配非换行字符若干次】 | |
+ | 前面的字符可以出现 >=1 次 | |
? | 前面的字符可以出现 0 次 或 1 次 | |
() | 匹配一个集合 | (z|f)ood 匹配 zood 或者 food |
{n,m} | 前面的字符可以出现次数大于等于n,小于等于m | o{1,3} 匹配fooooood 前三个o,数字之间只能逗号不能空格 |
[abc] | 匹配任意含有 a,b,c 的字符 | |
[^abc] | 匹配非a,非 b,非c | |
.*? | 非贪婪匹配:匹配到符合条件的最短结果 | <div>aaa</div> <div>bbb</div>:只匹配到4个标签,替换也只是替换标签不替换内容 |
.* | 贪婪匹配,匹配到符合条件最长的结果 | <div>aaa</div> <div>bbb</div>:匹配整个正文,替换也就把正文替换掉了 |
\s | 匹配任意空白字符包含 \r \n \t \v \f | |
\S | 匹配非空字符 | |
\b | 匹配一个单词边界,也就是指单词和空格间的位置 | ‘er\b’ 可以匹配"never" 中的 ‘er’,但不能匹配 “verb” 中的 ‘er’ |
修饰符 | 作用 |
---|---|
i | ignore,表示忽略大小写的匹配 |
替换script标签及其内容
知道了正则表达式后。
去掉 script 标签和内容:<script[^>]*>([^<]|<(?!\/script))*<\/script>
去掉普通标签(不去掉内容)<.*?>既能匹配到开始标签又能匹配到结束标签
可以提前用正则在线测试工具检测自己的正则语句
非贪婪匹配结果:只替换了标签,正文内容被保留下来
贪婪匹配结果:整个正文内容全被替换
知道了正则该如何写之后就可以对 parseContent
进行替换
这里需要注意的是:一定要先替换 script
标签,再替换普通的 html
标签。如果顺序反了之后会导致先去掉 html
标签,script
标签的东西还存在并且后续的 script
,结局就是改了和没改一样。
// 这个解析是基于正则表达式,实现去标签和去script
private String parseContentByRegex(File f) {
// 1.先把整个文件读取到 String 里面
String content = readFile(f);
// 2.换掉 script 标签
content = content.replaceAll("<script[^>]*>([^<]|<(?!\\/script))*<\\/script>", " ");
// 3.换掉 head 标签
content = content.replaceAll("<head.*?>(.*?)</head>", " ");
// 4.换掉普通的 html 标签
content = content.replaceAll("<.*?>", " ");
// 5.合并多个空格
content = content.replaceAll("\\s+", " ");
return content;
}
private String readFile(File f) {
StringBuilder stringBuilder = new StringBuilder();
try (BufferedReader reader = new BufferedReader(new FileReader(f))) {
while (true) {
int ret = reader.read();
if (ret == -1) {
break;
}
char c = (char) ret;
if (c == '\n' || c == '\r') {
c = ' ';
}
stringBuilder.append(c);
}
return stringBuilder.toString();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
检测一下 ArrayList
的去 script
标签效果
ok,对比之前的函数解析正文内容说明已经成功达到目标效果
合并多个空格
问题又来了,我们发现有太多的空格,我们不需要这么多空格,因此需要合并多个空格
还是继续使用正则
\s*:即使没有匹配到空格,我也替换空格,这无中生有不科学
\s+:匹配到了至少1个空格,就开始替换,更合理一些
\s?:也会出现和*一样的bug,如果出现0次也会被替换就会出现bug,如果出现1次就被替换这就是目的结果,因此这样的逻辑也不完善
注意 java 正则规则需要转义
\
,因此多加一个\
public String parseContentByRegex(File f) {
// 1.先把整个文件读取到 String 里面
String content = readFile(f);
// 2.换掉 script 标签
content = content.replaceAll("<script.*?>(.*?)</script>", " ");
// 3.换掉普通的 html 标签
content = content.replaceAll("<.*?>", " ");
content = content.replaceAll("\\s+", " ");
return content;
}
效果已经完善。仔细观察后边的的,会发现还有
这样的 html 中的空白占位符,我们也需要替换掉。因此在合并多个空格之前去掉
代码完善之后就开始最后的替换掉之前使用的 parseContent
在验证一下整个 run
方法能否执行顺利
使用正则之后的优化速度还快了几秒钟的时间【解析正文快了2秒,新增文档快了7秒】
在对 Index
类打断点验证一下是否解析正确
搜索模块小结
到这里,我们已经实现好了搜索模块的需求。这里做一个总结。
- 需求是能够去倒排索引中查询出和关键词分词结果有关的文档Id【√】
- 查询过程中需要将返回结果进行降序排序【重难点√】
- 包装数据返回【√】
剩下的一些是在制作过程中发现的一些需要优化的地方
- 优化文件读取
- 使用正则表达式提升替换效率
- 需要根据独立成词进行查询【合并空格】
在以后的实际开发中,技术都是为了业务服务的,更重要的是也要学习产品的业务
实现web模块
约定前后端交互接口
现在后台的逻辑,数据都有了。现在需要最终以网页的形式把我们的程序呈现给用户
前端(HTML+CSS+JS)+后端(Java,Servlet/Spring)
现在我们需要名的描述出,服务器接受什么样的请求都能返回什么样的响应。此处,我们需要是一个接口,搜索接口即可
请求:GET /search?query=查询词 HTTP/1.1
响应:
HTTP/1.1 200 OK
{
{
title: "标题,
url: "链接",
desc: "描述"
},
{
title: "标题,
url: "链接",
desc: "描述"
},
{
title: "标题,
url: "链接",
desc: "描述"
}
}
Java代码
package api;
import com.fasterxml.jackson.databind.ObjectMapper;
import search.DocSearch;
import search.Result;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
@WebServlet("/search")
public class DocSearchServlet extends HttpServlet {
// 此处的 search.DocSearch 对应也应该是全局唯一的,此处就给一个 static 修饰
private static DocSearch docSearch=new DocSearch();
private ObjectMapper mapper=new ObjectMapper();
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
// 1.先解析请求,拿到用户的查询词
req.setCharacterEncoding("UTF-8");
resp.setContentType("application/json; charset=UTF-8");
String query = req.getParameter("query");
System.out.println(query);
if (query == null || query.isEmpty()) {
String msg = "您的参数非法,没有获取到 query 值";
resp.sendError(404, msg);
return;
}
// 2.打印记录一下 query 值
System.out.println("query:" + query);
// 3.调用搜索模块进行搜索
ArrayList<Result> results=docSearch.search(query);
// 4.把当前的搜索结果进行包装响应给前端
mapper.writeValue(resp.getWriter(), results);
}
}
验证一下,我们需要的数据都在,现在需要把它放在 html 页面上
通过ajax获取搜索结果
到此为止,页面的大概布局已经完成,现在需要获取后端数据来填充网页了。
ajax 前后端交互的常用手段,当用户点击搜索按钮的时候,浏览器就会获取到搜获框内容,基于 ajax 构造 HTTP 请求并发送给服务器,浏览器获取到服务器响应结果后再根据结果的 json 数据 把页面给生成出来
JS 是原生的 ajax,是 XMLHttpRequest,就可以采取其它方式来使用 ajax(借助第三方库),JQuery(js的第三方库,这个库里功能很多,单单是使用 ajax 即可)
如何使用JQuery?
搜索 JQuery,找到 JQuery 的官方然后下载即可
这里选用的压缩版本
根据相应数据构造页面内容
先验证代码是否获取到搜索框内容
如果不验证,后续代码如果拿不到搜索框的内容将会出现获取不到值也就无法搜索
<script>
let button=document.querySelector("#search-btn");
button.onclick = function(){
let input=document.querySelector(".header input");
// js 获取元素的值是 value,不加括号【JQuery 需要加上括号】
let query=input.value;
console.log(query);
}
</script>
验证成功后再构造一个 ajax 请求发给服务器
$:是一个变量名,这个是 JQuery 这个库提供的一个内置的对象的变量名,使用的 JQuery 中的函数/方法,其实就是这个 $ 对象提供的
有的语言允许使用 $ 作为变量名(Java/JS),有的不允许(C/C++)
先测试一下后端服务器是否正常相应前端 ajax 发送的 HTTP 请求
如果不验证,不知道这个 ajax 请求是否正常发送
<script src="./js/jquery-3.6.0.min.js"></script>
<script>
let button=document.querySelector("#search-btn");
button.onclick = function(){
let input=document.querySelector(".header input");
// js 获取元素的值是 value,不加括号【JQuery 需要加上括号】
let query=input.value;
console.log(query);
// 然后构造一个 ajax 请求发给服务器
jQuery.ajax({
type: "GET",
url: "search?query=" + query,
// success:这个函数会在请求成功后调用,data 参数就表示拿到的服务器响应的数据;status 参数表示 HTTP 状态码
success: function(data, status){
console.log(data);
// 利用 DOM API 把数据填充到 html 中
// buildResult(data);
}
});
}
</script>
这一步代码没有问题,前后端的数据交互已经初步完成
细心的朋友会看到 url 中出现的两个 \\
其实这个只是浏览器显示的问题,当初我发现的时候以为前边的
parseUrl
写错了。检查半天也没觉得不妥的地方,后来往下继续完成的时候发现仅仅是浏览器转义字符的问题,数据方面是完全正确的
下一步就是我们利用 DOM API 把数据填充到 html 中
验证页面效果
完善 buildResult(data)
函数
// 通过这个函数构造响应数据或页面内容
function buildResult(data) {
let result = document.querySelector(".result");
// 要做的工作就是便利 data 中的每个数据元素,针对每个元素创建一个 div.item 然后把 title,url,content 都构造成 html 元素,然后再把这个 div.item 给加入到 div.result 中
for (let item of data) {
// 构造 div.item
let itemDiv = document.createElement("div");
itemDiv.className = "item";
// 构造 title
let title = document.createElement("a");
title.innerHTML = item.title;
title.href = item.url;
itemDiv.appendChild(title);
// 构造 url
let url = document.createElement("div");
url.className = "url";
url.innerHTML = item.url;
itemDiv.appendChild(url);
// 构造 desc
let desc = document.createElement("div");
desc.className = "desc";
desc.innerHTML = item.desc;
itemDiv.appendChild(desc);
// 添加 div.item 到 div.result
result.appendChild(itemDiv);
}
}
针对内容太多,超出屏幕问题
可以设置 CSS 的 overflow:auto
属性让超过的部分隐藏
针对点击之后搜索结果停留在当前页的修改
利用 DOM API 设置 title的 taret="_blank"
即可
针对多次搜搜结果重叠在一起
每次点击按钮,都是宝所结果往 .result
中进行累加,没有清理过,更合理的做法应该是在搜索前把之前的的搜搜结果清空掉
实现标红逻辑
想把用户的关键词在搜索的页面中全部标红显示。需要前后端配合
- 修改后端代码,生成搜索结果的时候(
GenDesc
描述)就需要把其中包含关键词的部分你给加上一个<i></i>
标签 - 前端这里针对
<i></i>
标签设置样式进行标红
Java 代码
private String GenDesc(String content, List<Term> terms) {
// 先遍历分词结果,看看哪个结果是在 content 中存在
int firstPos = -1;
for (Term t : terms) {
String word = t.getName();
// 此处需要的是 “全字匹配” 让 word 独立成词,菜肴查找出来而不是只作为词的一部分
firstPos = content.indexOf(" " + word + " ");
if (firstPos >= 0) {
break;
}
}
// 所有的分词结果都不在正文中存在,因此这是属于比较模糊的情况,应该会返回一个 空描述 或者 直接截取正文的 前 160 个字符
if (firstPos == -1) {
return content.substring(0, 160) + "...";
}
// 从 firstPost 作为基准位置,前找 60,后找 160
String desc = "";
int descBeg = firstPos < 60 ? 0 : firstPos - 60;
if (descBeg + 160 > content.length()) {
desc = content.substring(descBeg) + "...";
} else {
desc = content.substring(descBeg, descBeg + 160) + "...";
}
// 在此处加上一个替换操作,把描述中的和分词结果相同的成分加上 <i>标签 可以通过 replace 的方式来实现
for (Term t : terms) {
String word = t.getName();
// 此处应该继续进行全词匹配,由于 word 已经变为小写,这里应该不区分大小写替换所以需要 (?i)
desc = desc.replaceAll("(?i) " + word + " ", "<i> " + word + " </i>");
}
return desc;
}
CSS 代码
.item .desc i{
color: red;
/* 去掉斜体 */
font-style: normal;
}
测试更复杂的查询词
测试一些稀奇古怪的查询词
发现报了 500 错误。查看源头是越界异常的问题。
原来的代码
因此忘记了越界的情况,修改如下
修改效果图,已经完善了。
![在这里插入图片描述](https://img-blog.csdnimg.cn/7ae680be308c47538587508aa5ad3439.png
我们发现搜索 Array List 为何也会出现 一个标红的也没有 呢?
其实这里的原因是因为 Array List 中间的空格导致的,如果以 空格 来查询的话,那查询的数据会很多了。
因为空格就相当于汉语中的 我,的,是,用,好,有… 等等这些常用的词,英语中对应的是 is,a,yes,yeah,ok… 这些常用词。因此就需要使用一个叫做 “暂停词”的词表
处理停用词
这里去搜索关键词 暂停词 就会出现很多。下载保存即可。然后再使用 HashSet
把这些词存储起来,再针对分词结果在停用词表中进行筛选。如果某个词在词表中存在就直接干掉。
完整 Search 类代码
package search;
import org.ansj.domain.Term;
import org.ansj.splitWord.analysis.ToAnalysis;
import java.io.BufferedReader;
import java.io.FileNotFoundException;
import java.io.FileReader;
import java.io.IOException;
import java.util.*;
// 通过这个类,来完成整个搜索过程
public class DocSearch {
// 停用词路径
private static final String STOP_WORDS_PATH = "D:/Programme/Java/5.Spring/DocSearch/src/main/webapp/index/" + "stop_words.txt";
// 使用 HashSet<String> 保存停用词
private static HashSet<String> stopWords = new HashSet<>();
// 加上对象索引的实例
private Index index = new Index();
// 在运行的时候就开始加载索引到内存中
public DocSearch() {
index.load();
loadStopWords();
}
// 完成整个搜索过程
// 参数(输入部分)就是用户给出的查询词
// 返回值(输出部分)就是搜索结果的集合
public ArrayList<Result> search(String query) {
// 1.【分词】根据查询词进行分词
List<Term> oldTerms = ToAnalysis.parse(query).getTerms();
List<Term> terms = new ArrayList<>();
// 针对暂停词进行过滤
for (Term t : oldTerms) {
if (stopWords.contains(t.getName())) {
continue;
}
terms.add(t);
}
// 2.【触发】针对分词结果来查倒排
ArrayList<Weight> allTermResult = new ArrayList<>();
for (Term t : terms) {
String word = t.getName();
ArrayList<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) {
// 大堆升序:o1-o2;小堆降序:o2-o1
return o2.getWeight() - o1.getWeight();
}
});
// 4.【包装结果】针对降序的结果返回
ArrayList<Result> results = new ArrayList<>();
for (Weight w : allTermResult) {
DocInfo docInfo = index.getDocInfo(w.getDocId());
Result result = new Result();
result.setTitle(docInfo.getTitle());
result.setUrl(docInfo.getUrl());
// 描述:正文的一段摘要。关键词往前截取 60,往后截取 160 个字符作为整个描述
result.setDesc(GenDesc(docInfo.getContent().toLowerCase(), terms));
results.add(result);
}
return results;
}
private String GenDesc(String content, List<Term> terms) {
// 先遍历分词结果,看看哪个结果是在 content 中存在
int firstPos = -1;
for (Term t : terms) {
String word = t.getName();
// 此处需要的是 “全字匹配” 让 word 独立成词,菜肴查找出来而不是只作为词的一部分
firstPos = content.indexOf(" " + word + " ");
if (firstPos >= 0) {
break;
}
}
// 所有的分词结果都不在正文中存在,因此这是属于比较模糊的情况,应该会返回一个 空描述 或者 直接截取正文的 前 160 个字符
if (firstPos == -1) {
if (content.length() > 160) {
return content.substring(0, 160) + "...";
}
}
// 从 firstPost 作为基准位置,前找 60,后找 160
String desc = "";
int descBeg = firstPos < 60 ? 0 : firstPos - 60;
if (descBeg + 160 > content.length()) {
desc = content.substring(descBeg) + "...";
} else {
desc = content.substring(descBeg, descBeg + 160) + "...";
}
// 在此处加上一个替换操作,把描述中的和分词结果相同的成分加上 <i>标签 可以通过 replace 的方式来实现
for (Term t : terms) {
String word = t.getName();
// 此处应该继续进行全词匹配,由于 word 已经变为小写,这里应该不区分大小写替换所以需要 (?i)
desc = desc.replaceAll("(?i) " + word + " ", "<i> " + word + " </i>");
}
return desc;
}
public void loadStopWords() {
try (BufferedReader reader = new BufferedReader(new FileReader(STOP_WORDS_PATH))) {
while (true) {
String line = reader.readLine();
if (line == null) {
break;
}
stopWords.add(line);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) {
DocSearch docSearch = new DocSearch();
Scanner scanner = new Scanner(System.in);
while (true) {
System.out.print("-->");
String query = scanner.nextLine();
ArrayList<Result> results = docSearch.search(query);
for (Result r : results) {
System.out.println(r);
}
}
}
}
再搜索 Array List 则不会出现那样的情况
处理生成描述的bug
我们在搜索 ArrayList 会发现还是有:既没有 Array 也没有 List 的情况发生,那么这可能是我们的后端代码的BUG了
我们点标题链接进去查看一下是什么原因
查看网页源代码
发现关键词 Array 是包含在 div
之中的,但是却没有出现在描述中
再看描述的开头
再看线上文档的开头
现在应该知道问题在哪儿了
因为找到了 array 关键字。而在原文档中是
array)
而代码中为了达到全词匹配的效果采用的是firstPos = content.indexOf(" " + word + " ");
所以就找不到,触发下面的代码
return content.substring(0, 160) + "...";
这也就是我们看的一个标红的也没有的原因,但是也查到了 Array
关键词
因此还是需要使用 正则表达式 来去除
正则在线测试工具
使用 \b
来代替空格实现全词匹配,实际效果更好
但是不能全部使用 \b
来代替
因为
indexOf
不支持 正则表达式
解决方案:未知问题转为已知问题
提前先把 关键词周围的标点,符号全部转为空格,在进行之前的全词查找即可【经过转化之后就可以使用了】
查看效果,已经纠正之前的bug了,出现的结果一定会被标红,标红的结果一定会出现。
加上搜索结果的个数
正常搜索引擎都会有一个搜索结果的统计。这里我们也加上一个。
有两个方案
- 直接在服务器这边算好了个数,返回给浏览器【及需要修改前端有需要修改后端】
- 在浏览器这边根据收到的结果的数组的的长度自动地展示出个数【只需要修改前端】
因此我们选择方案2简单
效果如图所示
关于重复文档的问题
我们看一下查询个数
是否可能存在某个文档同时包含 array 和 list 呢?我们就用集合类的接口 Collections
来举例
前面计算权重的时候,都是对 query 进行了分词。举例:
query=array list
则会被分为 array
,空格
,list
三部分。经过暂停词的过滤之后只有 array
和 list
。由于 array
和 list
都实现了 Collections
接口,因此会出现两次相同的结果。
正常来说,对于同一个结果不应该出现两次分重复的搜索结果,像 Collections
这样意的文档味着权重更高,我们提高权重之后相关性也就会更高。
一个简单的办法就是把权重进行相加
要实现这样的效果就需要把触发结果进行合并,把多个分词结果触发的文档按照 dociId
进行去重,同时进行权重的合并。
数据结构中有一个经典的题目就是 合并两个有序链表 此处我们就可以模仿类似的思路进行合并两个相同数组:先把统计结果按照 docId
升序排序,再合并的时候相同 docId
的就可以进行权重相加。此时我们可能还需要和合并 N个 数组。
利用优先级队列,建立大根堆,那么就会存储的是最小的堆顶元素,进行 N 个合并了。
需要改动一下的代码
package search;
import org.ansj.domain.Term;
import org.ansj.splitWord.analysis.ToAnalysis;
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
import java.util.*;
// 通过这个类,来完成整个搜索过程
public class DocSearch {
// 停用词路径
private static final String STOP_WORDS_PATH = "D:/Programme/Java/5.Spring/DocSearch/src/main/webapp/index/stop_words.txt";
// 使用 HashSet<String> 保存停用词
private static HashSet<String> stopWords = new HashSet<>();
// 加上对象索引的实例
private Index index = new Index();
// 在运行的时候就开始加载索引到内存中
public DocSearch() {
index.load();
loadStopWords();
}
// 完成整个搜索过程
// 参数(输入部分)就是用户给出的查询词
// 返回值(输出部分)就是搜索结果的集合
public ArrayList<Result> search(String query) {
// 1.【分词】根据查询词进行分词
List<Term> oldTerms = ToAnalysis.parse(query).getTerms();
List<Term> terms = new ArrayList<>();
// 针对暂停词进行过滤
for (Term t : oldTerms) {
if (!stopWords.contains(t.getName())) {
terms.add(t);
}
}
// 2.【触发】针对分词结果来查倒排
ArrayList<ArrayList<Weight>> termResult = new ArrayList<>();
for (Term t : terms) {
String word = t.getName();
ArrayList<Weight> invertedList = index.getInverted(word);
if (invertedList != null) {
// 说明这个词在所有文档中存在
termResult.add(invertedList);
}
}
// 2.1【合并】针对多个分词结果触发出的相同文档进行权重合并
ArrayList<Weight> allTermResult = mergeResult(termResult);
// 3.【降序】针对触发的结果按照权重降序:小堆,后-前
allTermResult.sort(new Comparator<Weight>() {
@Override
public int compare(Weight o1, Weight o2) {
// 大堆升序:o1-o2;小堆降序:o2-o1
return o2.getWeight() - o1.getWeight();
}
});
// 4.【包装结果】针对降序的结果返回
ArrayList<Result> results = new ArrayList<>();
for (Weight w : allTermResult) {
DocInfo docInfo = index.getDocInfo(w.getDocId());
Result result = new Result();
result.setTitle(docInfo.getTitle());
result.setUrl(docInfo.getUrl());
// 描述:正文的一段摘要。关键词往前截取 60,往后截取 160 个字符作为整个描述
result.setDesc(GenDesc(docInfo.getContent().toLowerCase(), terms));
results.add(result);
}
return results;
}
/**
* 在进行合并的时候,是把多个行合并成一行
* 合并过程中势必使需要操作这个二维数组里面的每个元素
* 操作就涉及到 行,列 这样的概念,就需要明确的知道 行 和 列
*/
static class Pos {
public int row;
public int col;
public Pos(int row, int col) {
this.row = row;
this.col = col;
}
}
private ArrayList<Weight> mergeResult(ArrayList<ArrayList<Weight>> source) {
// 1.针对每一行进行排序(按照 docId 进行升序)
for (ArrayList<Weight> curRow : source) {
curRow.sort(new Comparator<Weight>() {
@Override
public int compare(Weight o1, Weight o2) {
return o1.getDocId() - o2.getDocId();
}
});
}
// 2.用优先队列,针对这些进行合并【升序:大堆;降序:小堆】
PriorityQueue<Pos> minHeap = 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();
}
});
// 初始化优先队列,把每一行的第一个元素放到队列中
ArrayList<Weight> target = new ArrayList<>();
for (int row = 0; row < source.size(); row++) {
minHeap.offer(new Pos(row, 0));
}
// 循环的取队首元素(也就是当前这若干行中最小的元素)
while (!minHeap.isEmpty()) {
Pos minPos = minHeap.poll();
Weight curWeight = source.get(minPos.row).get(minPos.col);
if (target.size() > 0) {
// 去除上次插入的元素
Weight lastWeight = target.get(target.size() - 1);
if (lastWeight.getDocId() == curWeight.getDocId()) {
// 遇到相同的就进行权重合并
lastWeight.setWeight(lastWeight.getWeight() + curWeight.getWeight());
} else {
// 不相同,就把 curWeight 插入到 target 末尾
target.add(curWeight);
}
} else {
// 如果 target 当前是空的,就直接插入即可
target.add(curWeight);
}
// 当前元素处理完之后要处理这一行的下一个元素
Pos newPos = new Pos(minPos.row, minPos.col + 1);
if (newPos.col < source.get(newPos.row).size()){
// 如果移动光标之后,没超出了这一行的列数,继续入队列
minHeap.offer(newPos);
}
}
return target;
}
private String GenDesc(String content, List<Term> terms) {
// 先遍历分词结果,看看哪个结果是在 content 中存在
int firstPos = -1;
for (Term t : terms) {
String word = t.getName();
// 此处需要的是 “全字匹配” 让 word 独立成词,菜肴查找出来而不是只作为词的一部分
content = content.toLowerCase().replaceAll("\\b" + word + "\\b", " " + word + " ");
firstPos = content.indexOf(" " + word + " ");
// firstPos = content.indexOf("\\b" + word + "\\b");
if (firstPos >= 0) {
break;
}
}
// 所有的分词结果都不在正文中存在,因此这是属于比较模糊的情况,应该会返回一个 空描述 或者 直接截取正文的 前 160 个字符
if (firstPos == -1) {
if (content.length() > 160) {
return content.substring(0, 160) + "...";
}
}
// 从 firstPost 作为基准位置,前找 60,后找 160
String desc = "";
int descBeg = firstPos < 60 ? 0 : firstPos - 60;
if (descBeg + 160 > content.length()) {
desc = content.substring(descBeg) + "...";
} else {
desc = content.substring(descBeg, descBeg + 160) + "...";
}
// 在此处加上一个替换操作,把描述中的和分词结果相同的成分加上 <i>标签 可以通过 replace 的方式来实现
for (Term t : terms) {
String word = t.getName();
// 此处应该继续进行全词匹配,由于 word 已经变为小写,这里应该不区分大小写替换所以需要 (?i)
desc = desc.replaceAll("(?i) " + word + " ", "<i> " + word + " </i>");
}
return desc;
}
public void loadStopWords() {
try (BufferedReader reader = new BufferedReader(new FileReader(STOP_WORDS_PATH))) {
while (true) {
String line = reader.readLine();
if (line == null) {
break;
}
stopWords.add(line);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) {
DocSearch docSearch = new DocSearch();
}
}
记得一定要验证权重合并:在此搜索 array list
然后浏览器查询 Collections
关键字就只会有一个标题出现了
部署的准备工作
如果服务器还未购买的可以先看我的博客,介绍了 阿里云服务器的购买及搭建一个博客园的流程
有了 JDK
和 Tomcat
环境之后就把生成的 war
包放在服务器上即可自动解压
利用的 xshell 可以直接把 war
包拖入 tomcat 的 webapps
目录即可
验证一下成功没有
然后我们再放 正倒排索引和暂停词 的文件【注意源代码中修改路径】
更改 Index
代码路径
更改 DocSearch
代码路径
云服务器测试通过,至此为止已经完成项目了。
改成SpringBoot
实现Controller
package App.search.controller;
import App.search.DocSearch;
import App.search.Result;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList;
@RestController
public class DocSearchController {
private static DocSearch search=new DocSearch();
private static ObjectMapper mapper=new ObjectMapper();
@RequestMapping("/search")
public String search(@RequestParam("query") String query) throws JsonProcessingException {
// 参数是查询词,返回值是响应内容
ArrayList<Result> results=search.search(query);
return mapper.writeValueAsString(results);
}
}
线上线下路径切换
当启动 Spring 的时候却发现找不到路径
因此我们设置一个配置文件,在本地运行的时候就设置为本地路径;在服务器上运行的时候就设置为线上路径
先测试本地
发现了一丝不妙的情况
抓包之后发现是数据的格式对不上,如果是 text/plain
则前端会被认为是一个 字符串
,因此我们需要在后端的数据返回的时候设置为 application/json
格式或者前端代码把 data 转为 json格式【这里采用后端修改】
前端修改应该修改 data【可查看JSON.Stringfy()】
修改后段的结果,程序正常显示
部署到云服务器避免检测
修改配置文件,资源加载路径进行切换
利用 Maven 打包却发现没有通过测试,因为路径不存在
添加 pom.xml
代码如下就可以在测试出错的情况下也完成编译【忽略Test单元测试】
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<testFailureIgnore>true</testFailureIgnore>
</configuration>
</plugin>
上传至服务器
挂后台
再启动之前一定要记得,先确定对应的端口号(默认是8080是否已经被占用),一个主机上的一个端口通常情况下只能被一个进程来绑定。
解决方案
- 关闭现有的8080端口
- 修改本程序的启动端口
这里我们选择关闭之前的 8080 端口
// 查看当前服务器哪些进程占用8080端口
netstat -anp | grep 8080
// 关闭 8080 端口的进程
kill -9 1838
java -jar jar包名称
验证效果
部署也已经完成了
但是也有瑕疵,还没完成。
当我们把 xShell 关闭之后就会发现数据无响应
这里涉及到一个概念:前台进程 vs 后台进程
这里和 Java 中的 守护线程 和 非守护线程【isDaemon】没有关系
直接输入一个命令来产生的进程都是前台进程,前台进程会随着终端的关闭而随之被杀死
ps命令
查看后发现 java 产生的进程运行时间为 0
因此需要把这个前台进程转换为后台进程
nohup命令
在这里,我们会多出一个日志文件 nohup.out
终端不显示,而是把内容输出到文件中。我们再关闭 xShell 看看能否成功
已经成功,如果发现上述操作失败的了。可以重新把前台进程转换为后台进程就可以了。可能是服务器卡的原因。
7. 项目总结
- 索引模块
Parser 类完成制作索引的流程;Index类实现索引的数据结构 - 搜索模块
Search 类来完成搜索的整个过程,调用了 Index 类来查正排查倒排。同时也实现了生成描述,关键词标红,重复文档合并等功能
核心内容:通过一些数据结构来完成了一个搜索引擎最小功能的集合
- Web 模块
通过 Servlet/Spring Boot 实现了两个版本的服务器程序;通过 HTML/CSS/JS 做了一个搜索页面
已经设置免费下载,至此完整代码如下 下载
Servlet的修改项
其中 Parser类 并不是一定要设置,因为只是作为一个索引制作的类,制作成功之后的项目启动并不会依赖它
Spring版本的修改