1.什么是搜索引擎?
如图所示:
我用的是谷歌浏览器,但是我的搜索引擎可以跟换 。切换到bing主页
在搜索框中我们输入一段话,跳到一个带有搜索结果的页面如下:
搜索引擎的核心功能:查找用户输入的词/一句话 相关联的网页。
搜索结果页一条记录包含的信息如下:
当我们点击标题,会跳转到落地页,如下图:
相信大家对搜索引擎都有一定的了解了。
2.实现搜索引擎的核心思路:
- 首先我需要很多网页。
- 再根据用户输入的词/一句话,在这些网页中进行查找。
搜索引擎的网页又该如何获取呢?
像百度,搜狗,这样的大型搜索引擎,每天会有很多爬虫去爬取大量网页,在存储起来。
我们这里先直接去官网下载jdk文档(之后抽取时间改进利用爬虫获取网页)。
用户输入查询词之后,如何去让查询词语和当前的这些网页进行匹配呢?
搜索引擎工作原理
利用这些网页(.html文件解析过后,生成的文档DocInfo)构建正排索引和倒排索引。
3.正排索引和倒排索引原理:
正排,倒排原理理解
1.正排索引:文档 id -> 文档内容 (1个id对应1个文档内容)
2.倒排索引:词 -> 和词相关联的文档id (1个词对应一个/多个文档id)
举个例子:
//正排索引
1 我上街买了一份炸鸡
2 我晚饭吃了一份牛肉
//倒排索引(先分词)
我 1,2
上街 1
买 1
了 1,2
一份 1,2
炸鸡 1
晚饭 2
吃 2
牛肉 2
4.我要把搜索引擎做到什么程度?
我们还是先做一个“站内搜索”,类似于哔哩哔哩里的搜索框,搜索站内的东西。我们要做一个java,jdk8文档的搜索引擎,比如输入一个ArrayList,会在页面显示一系列关于ArrayList文档的信息,通过点击标题或者utl跳转到官网jdk8文档。
5.获取文档(网页)
Java Downloads | Oracle
先看看在线文档的内容以及url
https://docs.oracle.com/javase/8/docs/api/java/util/ArrayList.html
在对比看一下离线后的文档路径
file:///C:/Users/Administrator/Desktop/online_search_doc/jdk-8u341-docs-all/docs/api/java/util/ArrayList.html
本地链接和在线网站链接相比得出结论:
我们可以基于离线文档来制作索引,实现搜索 ,当用户在搜索结果页面点击具体的搜索结果的时候,就自动跳转到在线文档的页面。
6.模块粗略划分:
1.索引模块:
- 扫描下载的文档,分析文档内容,构造出正排索引+倒排索引。并且把索引内容保存到文件中
- 加载制作好的索引,并提供一些API实现查正排和查倒排这样的功能。
2.搜索模块:
- 调用索引模块,实现一个搜索的完整过程
- 输入:用户输入词/一句话
- 输出:完整的搜索结果(包含了很多条记录,每个记录都有标题,描述,展示url,并且点击能够跳转)
3.web模块:
- 需要实现一个简单的web程序,能够通过网页的形式来和用户交互~
7.创建项目
用到的工具:
idea专业版2022.2.1
webstorm专业版2022.2.1
8.介绍一下什么是分词
用户在使用时侯,往往会输入一句话,比如:
会出现一下结果页:
用户输入的话被分成好多个词,每个词又对应很多文档id,所以结果页面会有很多条记录,每条记录都包括标题,url,描述这三个基本信息 。
我上街买了一份炸鸡 可分词为:
我
上街
买
了
一份
炸鸡
我们要使用第三方库ansj来进行分词。ansj地址
说一下分词的原理,有助于编写代码。
1.基于词库(跟不上时代发展,每隔一段时间会出现新词)
- 尝试把所有词都进行穷举~把这些穷举结果放到词典文件中。
- 然后就可以依次的取句子中的内容,每隔一个字,在词典里查一下,每隔俩个字,查一下.....
2.基于统计(该方法更加科学,用的多)
- 收集到很多很多的“语料库” => "人工标注/直接统计 => "也就知道了哪些字放在一起的概率比较大~";
- 分词的实现,就是属于“人工智能”典型应用场景~
分词原理
写个测试类用一下ansj这个第三方库
说一下爆红的原因:
在分词的时候,会加载一些词典文件,通过这些词典文件能够加快分词速度和准确率,但是没有这些词典文件ansj仍然能快速分出词。 (可配置词典文件)
我们在来看一下分词结果:
9.实现索引模块Parser类的整体流程
- 指定本地jdk8文档的路径INPUT_PATH
- Parser类作为入口,main函数调用run方法
- run函数包含三个功能:
- 1.根据指定路径,罗列出该文件夹下所有的.html文件,包括子文件夹中的.html的路径
- 2.根据上边罗列的所有html文件路径,打开文件,读取文件,解析文件,构建索引(在内存中)
- 3.把在内存中构造好的索引数据结构,保存到指定的文件中(即序列化,反序列化)
10.递归枚举文件
import java.io.File;
import java.util.ArrayList;
public class Parser {
public static final String INPUT_PATH = "C:/Users/Administrator/Desktop/online_search_doc/jdk-8u341-docs-all/docs/api/";
public void run() {
//1.枚举出所有的.html文件,包括子目录中的文件
ArrayList<File> filelist = new ArrayList<>();
enumFile(INPUT_PATH, filelist);
// System.out.println(filelist);
// System.out.println(filelist.size());
//2.针对上面罗列出的文档路径,打开文件,读取文件内容,进行解析,构建索引
//3.把在内存中构造好的索引数据结构,保存到指定的文件中
}
private void enumFile(String inputPath, ArrayList<File> filelist) {
File rootPath = new File(inputPath);
File[] files = rootPath.listFiles();
for (File f : files) {
if(f.isDirectory()){
enumFile(f.getAbsolutePath(),filelist);
}else {
filelist.add(f);
}
}
}
public static void main(String[] args) {
Parser parser = new Parser();
parser.run();
}
}
现在有一个问题,我要的是html文件,其他文件例如css,js等其他文件是我不想要它出现在结果里边,用户也看不懂,所以filelist里边要去掉这些前端文件。
11.除去不是html的文件
我怎么实现?
我的思路是得到所有文件的目录,它的后缀不是html的就不加到那个filelist里边
private void enumFile(String inputPath, ArrayList<File> filelist) {
File rootPath = new File(inputPath);
File[] files = rootPath.listFiles();
for (File f : files) {
if(f.isDirectory()){
enumFile(f.getAbsolutePath(),filelist);
}else {
if(f.getAbsolutePath().endsWith(".html")){
filelist.add(f);
}
}
}
}
这里我想说的是,后续的开发中会遇到各种各样的问题,但是只要思想不滑坡,办法总比困难多。
浩大的工程,都是由一个一个小小的函数堆积起来的。好了,现在我们把所有的html文件都加载到filelist里边了,现在的问题是,我怎么根据上边的路径打开文件去解析里边的文件呢?解析,我要解析什么?(1.标题,2.url,3.描述(描述是根据正文来的,所以因该是解析正文)),我为什么要这三个东西?我要把这三个东西用一个弄一个实体类来表示,即一个DocInfo,里边有id,title,url,content,然后通过一个ArrayList<DocInfo>构建出一个正排索引,然后根据这个正排索引去构建倒排索引,目前先考虑这么多。
12.解析html
我们打开文件发现html的名字和文件中的title里的名字相似,但是html的名字更加获取,代码实现上更加容易,我们就以html前边的单词作为标题 。
这样的话,我们解析html的标题的功能完成了。我们继续往下做。
13.解析url
我们所期望的结果就是,用户点击搜索结果,就能够跳转到对应的线上文档的页面。
思路:
把本地路径的后半段提取出来作为后缀 java/util/ArrayList.html
以在线路径前半部分为前缀 https://docs.oracle.com/javase/8/docs/api/
拼接后就是完整的url https://docs.oracle.com/javase/8/ docs/api/java/util/ArrayList.html
写一个测试代码:
import java.io.File;
public class TestUrl {
public static final String INPUT_PATH = "C:/Users/Administrator/Desktop/online_search_doc/jdk-8u341-docs-all/docs/api/";
public static void main(String[] args) {
File f = new File("C:/Users/Administrator/Desktop/online_search_doc/jdk-8u341-docs-all/docs/api/java/util/ArrayList.html");
String part1 = "https://docs.oracle.com/javase/8/docs/api/";
String part2 = f.getAbsolutePath().substring(INPUT_PATH.length());
System.out.println(part1+part2);
}
}
这里虽然是反斜杠,但是粘贴在浏览器上还能打开,体现出浏览器的鲁棒性。
我们优化一下吧,把\都替换成/.
具体方法:
注意:
虽然没看懂,但是能用!接下来我们开始解析正文,先讲讲解析正文的思路:
14.解析正文
我们先打开一个html文件观察观察
这里边有script的代码,还有css的代码style,还有html的标签
这里我们使用正则表达式去除一下这些标签
具体代码:
public String parseContent(File f) {
try (FileReader fileReader = new FileReader(f)) {
StringBuilder content = new StringBuilder();
while (true) {
int ret = fileReader.read();
if (ret == -1) {
break;
}
char c = (char) ret;
content.append(c);
}
String scriptRegex = "<script[^>]*?>[\\s\\S]*?<\\/script>";
//定义style的正则表达式,去除style样式,防止css代码过多时只截取到css样式代码
String styleRegex = "<style[^>]*?>[\\s\\S]*?<\\/style>";
//定义HTML标签的正则表达式,去除标签,只提取文字内容
String htmlRegex = "<[^>]+>";
//定义回车,换行符,制表符
String spaceRegex = "\t|\r|\n";
String content_final = content.toString();
content_final = content_final.replaceAll(scriptRegex, "");
content_final = content_final.replaceAll(styleRegex, "");
content_final = content_final.replaceAll(htmlRegex, "");
content_final = content_final.replaceAll(spaceRegex,"");
return content_final;
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
效果展示:
嗯不错不错~(蜜汁自信)
现在的问题是,我们已经把东西解析出来了,我们现在需要一个索引类去把解析出来的东西加入到索引,然后将制作好的索引保存到指定文件中去。
15.实现索引模块
先创建一个Index类,这个类的主要实现方法:
//给定一个docId,在正排索引中,查询文档的详细信息 //给定一个词,在倒排索引中,查哪些文档和这个词相关联 //往索引中新增一个文档 //把内存中的索引结构保存到磁盘中 //把磁盘中的索引数据加载到内存中
然后创建一个DocInfo类,这个实体类来描述docId,title,url,content.
这里有一个问题,返回值是List<Integer>行不行?用户输入一个词/一句话,我返回了文章的id难道不对吗?如过这样做的话,就体现不出文章的相关性了,排在前边的永远是docId小的文章。为了解决这样的问题,修改一下返回值。
创建一个类Weight,权重的意思,Weight里边包含docId,和weight这俩个属性,weight值越大,我们就表示,用户输入的值和文章的相关性越强,在浏览器显示的越靠前。这个Weight类就是把文档id和文档与词的相关性进行一个包裹。
具体实现代码:
import java.util.List;
//通过这个类在内存中构造出索引
public class Index {
//给定一个docId,在正排索引中,查询文档的详细信息
public DocInfo getDocInfo(int docId) {
return null;
}
//给定一个词,在倒排索引中,查哪些文档和这个词相关联
public List<Weight> getInverted(String term) {
return null;
}
//往索引中新增一个文档
public void addDoc(String title, String url, String content) {
}
//把内存中的索引结构保存到磁盘中
public void save(){
}
//把磁盘中的索引数据加载到内存中
public void load(){
}
}
我们现在还需要俩个结构表示正排索引和倒排索引:
现在在想一下,正排索引是通过一个docId返回一个DocInfo,而倒排索引可以通过一个词返回一组docId,为了让词和docId有相关性,所以进行如下编码:
private ArrayList<DocInfo> forwardIndex = new ArrayList<>();
private HashMap<String,ArrayList<Weight>> invertedIndex = new HashMap<>();
现在我有一个问题,查正排,和查倒排效率高吗?时间复杂度是多少?
查正排,我们用的是ArrayList,时间复杂度是O(1)
查倒排,我们用的是HashMap,时间复杂度是O(1)
所以说,查询的很快,我们用这样的结构来保证查询的够快,够高效。
还有就是,我们现在的数据都是放在内存上的,这是查询快的第二个原因。
目前,实现的代码如下:
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
//通过这个类在内存中构造出索引
public class Index {
private ArrayList<DocInfo> forwardIndex = new ArrayList<>();
private HashMap<String,ArrayList<Weight>> invertedIndex = new HashMap<>();
//给定一个docId,在正排索引中,查询文档的详细信息
public DocInfo getDocInfo(int docId) {
return forwardIndex.get(docId);
}
//给定一个词,在倒排索引中,查哪些文档和这个词相关联
public List<Weight> getInverted(String term) {
return invertedIndex.get(term);
}
//往索引中新增一个文档
public void addDoc(String title, String url, String content) {
//构建正排索引
DocInfo docInfo = buildForward(title,url,content);
//构建倒排索引
buildInverted(docInfo);
}
private void buildInverted(DocInfo docInfo) {
}
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;
}
//把内存中的索引结构保存到磁盘中
public void save(){
}
//把磁盘中的索引数据加载到内存中
public void load(){
}
}
目前,我们新增文档的功能还没实现完,正排索引构建好了,现在实现构建倒排索引,怎么构建?
怎么做?倒排索引就是词与docId的映射,我准备,把传入的docInfo,把它的title,和content进行分词,我倒排索引用的是HashMap<String,ArrayList<Weight>>,也就是词和权重的键值对。权重里边包括docId,weight,我们又如何确定这个权重的值, (权重这个值,描述了词个文档之间的相关性,权重值越大,我们认为词和文章id相关性越强,在网页上显示的也就越靠前)。
在真实的搜索引擎中,这里的相关性,是一个非常复杂的逻辑,往往是一个专门的算法团队来进行负责的。根据文档中提取的特征,训练模型,最终借助机器学习的方式来衡量相关性。
现在我对构建倒排索引做如下规划:
1.针对文档标题进行分词
2.遍历分词结果,统计每个词出现的次数
3.针对正文进行分词
4.遍历分词结果,统计每个词出现的次数
5.把上面的结果汇总到一个HashMap里面。
6.遍历刚才这个HashMap,依次来更新倒排索引中的结构。
现在,我们想一想,标题中出现的词,和正文出现的词相比,标题出现的词是不是权重更大一些?
标题的词少,但是这里的词更能表达文章的中心思想,
正文的词多,但是这里的词更不能表达文章的中心思想。
一句话:最终文档的权重,就设定成 : 标题中出现的次数 * 10 + 正文中出现的次数。
这里的权重公式可以改进。如何改进?
点击率 = 点击次数/展示次数
在实际开发中,比如我们的服务器,每天有一亿访问量,然后可以把这一亿访问量拆成若干份,
30% 使用A公式
30% 使用B公式
30% 使用C公式
10% 使用D公式
分别统计,这些情况的点击率如何
公式会越来越复杂,同时也会让点击效果提升
最终我们看看这几个公式哪个更好,就会留下哪个公式继续迭代。
实际的搜索引擎,这里的计算公式非常复杂,并且要持续调整,反复迭代。
画个图理解一下倒排索引的数据结构
具体代码实现:
private void buildInverted(DocInfo docInfo) {
class WordCnt {
public int titleCount;
public int contentCount;
}
HashMap<String, WordCnt> wordCntHashMap = new HashMap<>();
List<Term> terms = ToAnalysis.parse(docInfo.getTitle()).getTerms();
for (Term term : terms) {
String word = term.getName();
WordCnt wordCnt = wordCntHashMap.get(word);
if (wordCnt == null) {
WordCnt newWordCnt = new WordCnt();
newWordCnt.titleCount = 1;
wordCntHashMap.put(word, newWordCnt);
} else {
wordCnt.titleCount += 1;
}
}
terms = ToAnalysis.parse(docInfo.getContent()).getTerms();
for (Term term : terms) {
String word = term.getName();
WordCnt wordCnt = wordCntHashMap.get(word);
if (wordCnt == null) {
WordCnt newWordCnt = new WordCnt();
newWordCnt.contentCount = 1;
wordCntHashMap.put(word, newWordCnt);
} else {
wordCnt.contentCount += 1;
}
}
for (Map.Entry<String, WordCnt> entry : wordCntHashMap.entrySet()) {
//倒排拉链 invertedList
ArrayList<Weight> invertedList = invertedIndex.get(entry.getKey());
if (invertedList == null) {
ArrayList<Weight> newInvertedList = new ArrayList<>();
Weight weight = new Weight();
weight.setDocId(docInfo.getDocId());
weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);
newInvertedList.add(weight);
invertedIndex.put(entry.getKey(), newInvertedList);
} else {
Weight weight = new Weight();
weight.setDocId(docInfo.getDocId());
weight.setWeight(entry.getValue().titleCount * 10 + entry.getValue().contentCount);
invertedList.add(weight);
}
}
}
目前倒排索引构建好了,但这些索引都保存在内存中。而且构建索引的过程是比较耗时间的。我们的文档大概也有一万多条,addDoc这个比较耗时间。
因此,我们不因该在服务器启动的时候才构建索引(启动服务器会被拖慢很多很多)
我的想法是:
把这些耗时间的操作,单独去执行,单独执行完了之后,然后让线上服务器直接加载构造好的索引。
因此才要实现save和load的操作。
我们接下来就去把内存中的索引结构,变成一个字符串,然后写入文件,也就是序列化的过程,当我们加载文档也就是反序列化的过程。
序列化与反序列化也有很多方法,此处我们就用json格式来进行序列化与反序列化
我们先把jackson库引入
具体实现代码:
public void save() {
System.out.println("保存索引开始!");
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();
}
System.out.println("保存索引完成!");
}
现在说一下json的格式:
当我们实现加载功能的时候,jackson库提供了TypeReference这样的类来帮助我们解决,json格式的数据转换成什么类型的数据,由于forwardIndex 的返回值类型是ArrayList<DocInfo>,所以进行以下编码:
public void load() {
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();
}
System.out.println("加载索引结束!");
}
现在我们的索引模块基本完成了,我们看看效果,在看看能否进行一下优化。
16.优化运行时间
先运行一下项目:保存索引大概是560ms这个样子,也就是save的运行时间
文件大小第一个大约是70MB,第二个文件大小大约是64MB
我们前边的解析html也是比较消耗时间的,我们先写个日志打印一下消耗的时间。
public void run() {
long begin = System.currentTimeMillis();
System.out.println("索引制作开始!");
//1.枚举出所有的.html文件,包括子目录中的文件
ArrayList<File> filelist = new ArrayList<>();
enumFile(INPUT_PATH, filelist);
long endEnumFile = System.currentTimeMillis();
System.out.println("枚举文件完成!消耗时间: " + (endEnumFile - begin) + "ms");
// System.out.println(filelist);
// System.out.println(filelist.size());
//2.针对上面罗列出的文档路径,打开文件,读取文件内容,进行解析,构建索引
for (File f : filelist) {
System.out.println("开始解析:" + f.getAbsolutePath());
parseHtml(f);
}
long endFor = System.currentTimeMillis();
System.out.println("遍历文件完成!消耗时间:" + (endFor - endEnumFile) + "ms");
//3.把在内存中构造好的索引数据结构,保存到指定的文件中
index.save();
long end = System.currentTimeMillis();
System.out.println("索引制作完成!消耗时间: " + (end - begin) + " ms");
}
可以清楚的看到制作索引需要消耗18s的时间,还是挺长的。我们该怎么办?
说一下性能优化:
要想优化一段程序的性能,就需要先通过测试的手段找到其中的“性能瓶颈”。现在问题主要出现在
for (File f : filelist) {
System.out.println("开始解析:" + f.getAbsolutePath());
parseHtml(f);
}
这一段代码上,也就是
每次循环都要针对一个文件进行解析,读文件+分词+解析内容(这里面主要还是卡在cpu运算上)
单个线程的情况下,这些任务,都是串行执行的,多个线程,这些任务就可以并发执行了。
我们的速度就会有较大提升。
现在我们修改一下代码,改成多线程制作索引。
什么时候会有线程安全问题?
多个线程同时修改一个对象,就会出现线程安全问题。
目前碰到一个问题,如何保证线程安全?
线程安不安全主要在parseHtml(f);这个函数
parseTitle不涉及多个线程同时修改一个对象,
parseUrl也同样不涉及多个线程同时修改一个对象,
parseContent也不涉及多个线程同时修改一个对象,
上边三个函数都是多个线程玩各自的对象。
问题出现在addDoc这个函数,画个图理解一下。
八个线程同时玩forwardIndex,invertedIndex这俩个对象,势必会出现线程不安全问题。
那你为啥不给addDoc加个锁,这样不就行了吗?
我们引入线程的目的是为了让串行变成并发,你给addDoc加上锁不就又成了串行执行了吗?
通俗点来说,脱裤子放屁,多费手续。
我们的目的是,串行的让它并发,实在并发不了的让它串行。让加锁的粒度在小一些。
所以如果直接把synchronized加到parseHTML或者addDoc上,这样做锁的粒度太粗了,并发程度不高,就是提高的效率有限。
现在发现,buildForward 和 buildInverted其实是在操作不同的对象。也就不存在锁竞争。
新建俩个锁对象,针对俩个锁对象进行加锁。
多线程的效果:
多线程的使用极大的提高了,索引制作时间。
那么线程池中的线程个数设置成几合适呢?我们只能通过实验来进行确定。
不同的代码并发程度是不一样的。
并不是线程数目越多越好,线程多确实可以提升效率,但是太多的话,提升效果就不明显了,在多加线程,白白浪费计算机资源。
目前又出现一个问题:
程序执行完了,进程没有关闭。
这个问题的主要原因就是:
如果要是一个线程是守护线程,此时这个线程的运行状态,不会影响到进程结束。
如果要是一个线程不是守护线程,这个线程的运行状态,就会影响到进程结束。
默认创建出来的都是非守护线程,需要通过setDaemon方法手动设置,才能成为守护线程。
通过线程池创建的线程,并不是守护线程,
当main方法执行完了,这些线程仍然在工作。(仍然在等待新任务的到来)
我们可以使用线程池提供的方法,让线程停止。
目前又碰到一个问题,首次制作索引比较慢,尤其是开机之后,首次制作索引特别慢,后面就会快了,重启机器,第一次制作就会特别慢。
通过排查parseHtml函数定位到,parseContent这个函数有问题,
计算机读取文件,是一个开销比较大的操作。
我为什么选择atomiclong来计时间?
多线程环境下,可以简单使用AtomicXXX 使代码变得线程安全。
atomiclong相当于加了synchronized的long。
开机第一次运行:
第二次运行:
可以明显看到,t1的时间变化,
注意此处是八个线程累加的时间。
(第一次开机运行,t2用了51s ,第二次运行t2用了25s,搞不懂为啥出现这种情况)
这种情况的话,我猜测是因为addDoc之前,你就得把title,url,content解析出来,parsecontent没解析完,adddoc就得等着,t2的时间前后也是2倍关系。
接下来我们可以优化一下parsecontent这个函数。
public String parseContent(File f) {
try (FileReader fileReader = new FileReader(f)) {
StringBuilder content = new StringBuilder();
while (true) {
int ret = fileReader.read();
if (ret == -1) {
break;
}
char c = (char) ret;
content.append(c);
}
我们读取文件用的是FileReader这个方法,fileReader.read()每次从文件中读取一个字符,每次read都是在读磁盘,它的速度就会很慢。
思路:我们可以先让filereader提前把文件读取到内存里面,然后每次调用一次bufferedReader.read() 就可以从内存中读取.
BufferedReader 可以搭配FileReader来使用。
BufferedReader内部就会内置一个缓冲区。就能够自动的把FileReader中的一些内容预读到内存中,从而减少直接访问磁盘的次数。
其实FileReader也有缓冲区,只是BufferedReader对缓冲支持更好。
此刻的心情:优化了个寂寞。
但是,开机第一次运行的效果肯定比没优化前好,我们开机重新试一下。
此时用了19秒 比前开机少用了10来秒,只能说还行。
17.实现搜索模块
首先,搜索模块就是调用索引模块,来完成搜索的核心过程~
1.分词。针对用户输入的词/句进行分词
2.触发,拿着每个分词结果,去倒排索引中查,找到具有相关性的文档,
3.排序,针对上边触发出来的结果,进行排序(按照相关性,降序排序)
4.包装结果。根据排序后的结果,依次去查正排,获取到每个文档的详细信息,包装成一定的数据结构返回出去。
目前遇到一个问题,返回结果中的描述该如何返回?
我们只是得到了正文。
说一下描述包含什么?描述是正文的一段摘要,这个摘要来源于正文,同时要包含查询词或者查询词的一部分。
思路:
目前我们可以获取所有的查询词的分词结果
遍历分词结果,看看哪个结果在正文中出现
for (Weight weight : allTermTesult) {
DocInfo docInfo = index.getDocInfo(weight.getDocId());
Result result = new Result();
result.setTitle(docInfo.getTitle());
result.setUrl(docInfo.getUrl());
result.setDesc(GenDesc(docInfo.getContent(), terms));
results.add(result);
}
这里抽象出了一个方法GenDesc,根据正文和分词结果来生成描述。
针对当前文档来说,不一定包含所有的分词结果
就针对这个被包含的分词结果,去正文中查找,找到对应的位置
就以这个词的位置为中心,往前截取60个字符,作为描述的开始。
然后再从这个描述开始,截取160个字符,作为整个描述。
如果用户查询的时候输入了Hello,我们的分词结果都是小写的,所以用户不管输入什么,都会被转换成小写字母,那么正文中的大写字母也得转换成小写字母,不然就查不到。我们这里统一弄成小写的。
private String GenDesc(String content, List<Term> terms) {
int firstPos = -1;
for (Term term : terms) {
String word = term.getName();
firstPos = content.toLowerCase().indexOf(word);
if (firstPos >= 0) {
//找到了位置
break;
}
}
}
还有一个问题,如果用户输入的查询词是List,而正文中出现的是ArrayList,在生成描述的时候,此处拿着这个List去正文中indexOf,此时是否会把ArrayList当作结果呢?
这就会导致生成的描述,里面就是带ArrayList的,而不是带List的了
我要查List结果描述里边是ArrayList 和 List,这样设计就不科学。
类似这样的情况,在查倒排的时候,是否会存在呢?
倒排索引中的key都是分词结果,ArrayList不会被分成Array + List ,仍然把ArrayList视为是一个单词。List和ArrayList并不能匹配,使用List这个词不能查出包含ArrayList的结果。这是科学的
因此我们希望,在生成描述的过程中能够找到整个词都匹配的结果,才算是找到了。而不是只找到词的一部分。
如何让word独立成词?而不是只作为正文词的一部分。
我的想法是直接在word的前后加个空格,让这个词独立出来。
仅仅是这么一处代码,却需要思考这么多的东西。
验证一下docsearcher类
结果符合我们的预期要求。
18.实现web模块
提供一个web接口,最终以网页的形式,把我们的程序呈现给用户。
前端(html+css+js)后端(servlet/spring)先用servlet实现,在升级成spring
现在约定一下前后端的通信接口。
请求:
GET/searcher?query=[查询词] HTTP/1.1
响应:(json格式的数据)
Http/1.1 200 ok
[
{
title:"这是标题1",
url:"这是url1",
desc:"这是描述1",
},
{
title:"这是标题2",
url:"这是url2",
desc:"这是描述2",
},
]
具体实现:
package api;
import com.fasterxml.jackson.databind.ObjectMapper;
import searcher.DocSearcher;
import searcher.Result;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
public class DocSearcherServlet extends HttpServlet {
//全局唯一,用static修饰一下
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=" + query);
List<Result> results = docSearcher.search(query);
//把结果进行打包
resp.setContentType("application/json;charset=utf-8");
objectMapper.writeValue(resp.getWriter(),results);
}
}
验证一下效果:
后端这边我们已经做好了,我们接下来做一个好看点的前端页面。
19.前端页面的制作
我们利用ajax进行前后端交互,
当用户点击搜索按钮的时候,浏览器就会获取到搜索框的内容,基于ajax构造HTTP请求,然后发给搜索服务器。浏览器获取到搜索结果之后,在根据结果的json数据,把页面生成出来。
目前遇到一个巨坑,呜呜呜~调试了2个小时终于让我给逮住了。
ajax构造请求的路径没有理清楚,造成时间上的浪费。
我把index. html放在了html这个文件夹中,所以ajax构造请求url的路径得修改一下,
如果我放在webapp下,就不用加../了
我是看了这个博客解决的:
ajax关于url路径问题
目前又遇到一个问题:
内容太多,超出了一个屏幕,该怎么解决?
可以通过修改css代码,
你可以定位到代码问题出现在哪里吗?
最大的div类名是container.,就让在这个div内部滚动。
又遇到一个问题:
第一次搜索和第二次搜索结果会累加到一起
出现这个问题的原因是啥?
每次点击按钮,都是把结果往.result里面进行追加,没有清理过内容
更科学的方法,应该是在每次搜索之前,都把之前的旧的结果给清空掉。
现在我们在实现一下标红逻辑。
1.修改后端代码,生成搜索结果的时候,就需要把其中包含查询词的部分,给加上一个标记。
例如,给这个部分套上一层<i>标签~
2.然后在前端这里针对<i>标签设置样式,然后浏览器就可以根据<i>标签的样式来进行显示了,
(比如给<i>标签的字体颜色设置为红色即可)
具体实现:
private String GenDesc(String content, List<Term> terms) {
int firstPos = -1;
for (Term term : terms) {
String word = term.getName();
firstPos = content.toLowerCase().indexOf(" " + word + " ");
if (firstPos >= 0) {
//找到了位置
break;
}
}
if (firstPos == -1) {
//所有的分词结果都不在正文中存在 我们返回空的描述就不合适,所以我们返回正文的前160个字符
return content.substring(0, 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) + "...";
}
for (Term term : terms) {
String word = term.getName();
//此处进行全字匹配,也就是当查询词为list的时候不能把Arraylist中的List给单独标红。(?i) 不区分大小写替换
desc = desc.replaceAll("(?i) " + word + " ", "<i> " + word + " </i>");
}
return desc;
}
现在测试一下更加复杂的情况:
俩次的搜索结果一样,
服务器500,原因是代码执行过程中抛出异常了。
就需要找到
数组越界异常:
排查发现如果正文没有160个字符,还要截取160个字符必然会导致错误。
改正:
又出现一个问题:
下边的itemDiv都没有标红的字
我想是因为分词把空格也看成一个词了,导致代码会拿空格去查找倒排索引,我们想个办法把空格去掉。
这里用停用词表,修改一下代码。
思路:
让搜索程序加载这个停用词表,到内存中,
使用一个HashSet把这些词都存起来,
在针对分词结果,在停用词中进行筛选,
如果某个结果在词表中存在,就直接去掉。
验证结果:
符合预期要求。
又发现一个bug
还是有不标红的情况,出现这种情况的原因
我点进去一个不标红的文档,搜索关键字,结果发现该文档确实存在这个关键字,哪里出现了问题?
前边后边都有空格才去匹配这个firstPos,那要是没有空格就匹配不到了,关键是全文就只有这一个字 ,所以firstPos直接返回的是-1,我们要解决这里的问题,就得使用正则表达式。
成功解决bug。
目前又发现一个问题:
1576 + 1350 = 2926 = 2926
同一个文档即出现了array 又出现了list,也就是说,ArrayList出现了俩次,重复了。
前面算权重,针对分词结果依次进行促发,
array => 触发一组docId
list => 触发一组docId
同时包含多个分词结果,意味着这个文档的相关性更高,
像这种情况,就应该提高这个文档的权重,既然相关性更高,提高权重之后就能排的更靠前。
把权重进行相加。把多个分词结果触发的文档,按照docId进行去重,同时进行权重合并。
去重思路:
合并俩个有序列表,合并俩个数组。
先把分词结果进行一个排序处理(按照docId升序排序)在进行合并,合并的时候就可以根据docId值相同的情况,做权重相加。
可是用户输入的仅仅就是俩个词吗?
所以应该是N个数组合并。
对比多个数组值的大小关系,找到小的,插入到结果中。
谁对应的值越小,就把这个值取出来,插入到结果数组中 ,同时针对这个下标进行++。
我们这里使用堆/优先级队列
end.扩散一下思维,那么一些小型网站的搜索引擎我们是否就会做了呢?
我们是否还能通过es来做一下?文章来源:https://www.toymoban.com/news/detail-410636.html
爬虫能否实现一下?文章来源地址https://www.toymoban.com/news/detail-410636.html
到了这里,关于站内搜索引擎的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!