背景:
基于java文档的搜索引擎,可以输入搜索词,然后就可以查询出与搜索词相关的文档。该项目的最主要的工作是要构建索引,就是正排和倒排索引。正排索引:根据文档id获取到文档;倒排索引:根据搜索词分词结果获取到相关文档。
此项目主要有3个板块:解析板块(Parser),索引板块(Index),服务板块(DocSearchService)
完整代码
1.解析板块
此版块的主要作用就是从本地文档(java在线文档)中加载文件到内存中,然后解析加载到内存中的文件(主要就是解析标题,Url,正文),解析完成后,构造索引结构,最后在进行硬盘的保存(以便于下一次启动时更快,不需要重新构建索引结构)
1.1 加载文件(enumFile)
此处是采用了一个递归的写法,首先找到本地文件路径,然后打开文件,文件中肯定不止一个文件,肯定会有很多子文件,所以需要递归来找出后缀名为html的所有文件,最终放入一个list集合。
1.2 解析文件(pathFile)
此处主要的完成工作是解析标题,解析Url,解析文章内容,然后添加文档(这一步就是构建索引结构,这一步放在索引模块来仔细说明)
1.2.1 解析标题(pathTitle)
搜索结果标题可以从文件名上获取,一般文件名都是文件内存最相关的内容,所以可以直接用字符串的截取获取即可。
1.2.2 解析Url(pathUrl)
解析Url就相当于拼接字符串,在线路径+本地路径的拼接,java在线文档的路径,对于前面来说都是固定的路径:,而后面就是就是本地文档路径了。
1.2.3 解析content(pathContent)
解析内容主要完成的是去掉标签,这里去标签,一是去script标签(标签和内容都不需要),二是去普通标签 。
去标签这里使用了正则表达式进行匹配,去之前还得将文件中的内容读到字符串中。
ps:去script标签必须在去普通标签之前。如果先取普通标签的话,那么就会将script标签给去掉,但是script标签中的内容还在,那后进行去script标签的时候就找不到script,从而script标签中的内容就去不掉了。
1.2.4 添加文档(addDoc)
主要包括构建正排和倒排索引,这部分放到后面索引板块来讲。
1.3 硬盘保存(save)
上述步骤完成之后,正排,倒排索引已经构建完毕了,用ObjectMapper类进行Json字符串的转化然后写入硬盘文件中,这部分放到索引板块细讲。
上述的所有方法都是在run方法中实现的,Parser是一个启动类入口,对于run方法固然有可以用单线程的方式来写,但是这里还实现了多线程的版本:
首先定义一个线程池(newFixedThreadPool:需要设置线程数量,队列是无限大的),队列无限大意味着任务可以一直添加,也就是任务添加完毕过后,但是任务(指的是解析文件:pathFile这一步)可能还没处理完就直接保存了,所以还需要一个计数器(CountDownLatch)来进行计数。设置当前CountDownLatch的大小为文件的数量,每当一个文件被解析完毕后,就减1,直到减为0以后才进行硬盘上的保存。
既然已经使用了多线程,那么在操作集合的时候就需要注意,否则会出现线程安全问题,这部分在后面的索引板块会体现出来。
2.索引板块
此模块的主要作用是创建正排,倒排索引,硬盘保存索引结构,从硬盘上加载索引数据。
2.1 创建正排索引(buildForward)
正排索引就是根据文档id获取文档,那么可以构造一个数组,数组的下标就是文档id,根据文档id就能获取对应文档。
由于上面在进行构造正排索引的时候使用了多线程,所以这里在操作集合的时候,要格外注意,所以在进行修改的时候,应该加锁。
2.2 创建倒排索引(buildInverted)
倒排索引就是根据搜索词寻找出相关文档,相关二字就涉及到权重问题(当前搜索词和文档的关联程度),由于是根据词来搜索文档,所以可以使用一个Map来进行当亲索引的创建。
private void buildInverted(DocInfo docInfo) {class WordCnt {public int titleCount;public int contentCount;}synchronized (object2) {//当前map用来统计分词结果HashMap<String,WordCnt> wordCntHashMap = new HashMap<>();//1.针对文档标题进行分词//已经对大写进行转换成小写//仔细观察,HEllo和hello搜索出来的结果一样,所以遇到这两个单词,应该记两次List<Term> titleTerms = ToAnalysis.parse(docInfo.getTitle()).getTerms();//2.遍历分词结果,统计出每个单词出现的次数for(Term term : titleTerms) {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;wordCntHashMap.put(word,wordCnt);}}//3.针对正文页进行分词List<Term> contentTerms = ToAnalysis.parse(docInfo.getContent()).getTerms();//4.遍历分词结果,统计每个词出现的次数for(Term term : contentTerms) {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;wordCntHashMap.put(word,wordCnt);}}//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> 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());weight.setWeight(entry.getValue().titleCount*10 + entry.getValue().contentCount);invertedList.add(weight);}}}
}
注意点:
(1)这个方法中有一个内部类WordCnt,有两个属性(一个用来统计在标题中出现的次数,一个用来统计在文档中出现的次数),并且用标题中出现的次数*10+文档中出现的次数(这里的权重计算比较简单,如果有更好的自行更换)。
(2)在操作map集合的时候,如果是在多线程环境下,没有进行线程安全问题的考虑的话是可能会出现问题的,所以这里也进行了加锁,保障了线程安全。可以看见构建正排和倒排的索引的锁是不相同的,如果使用同一把锁,由于是多线程的,那么正排和倒排索引是不能同时进行的,但如果是使用不同的锁,是可以同时进行的。
2.3 保存索引(save)
这一步主要是使用ObjectMapper类,进行对象到json字符串的转化,然后进行写硬盘。
2.4 加载硬盘(load)
也是使用ObjectMapper类进行,json字符串到对象的转换,然后进行读硬盘。
2.5 新增文档(addDoc)
主要完成的就是构建正排和倒排索引,也就是由buildForward和buildInverted组成。
3.服务板块
该板块主要完成分词,去停用词,多个分词触发相同文档进行权重合并,结果构造。
3.1 分词
主要使用分词类ToAnalysis对搜索词进行分词,然后去掉停用词。
去停用词的必要性:
进行去分词结果中的停用词,是为了避免用户输入带有空格,换行符等比较通用的词语,一旦这样的搜索词在被分词后,就当相于没有搜索了,因为只要是一篇比较长的文档都会带有这些符号,那不是全都会被查询出来,所以在进行搜索之前应该要这些停用词(网络上有文档可以自行下载)给去掉。
3.2 触发
针对分词结果进行倒排索引的查询。
3.3 合并
该步是很重要的一步,如果不行合并,那么对于一个文档中出现了多个分词结果,那么在最终返回的结果里面是会有重复的文档的,所以要进行权重的合并。
这里的合并就是多个List的合并,下面设计一个算法:
private List<Weight> mergeResult(List<List<Weight>> source) {//在进行合并的时候,是把多个行合并成一行了//合并的过程中势必是要操作这个二维数组里面的每个元素//操作元素就涉及到“行”“列”这样的概念,要想确定二维数组中的一个元素,就需要明确知道行和列//1.先针对每一行进行排序(按照id进行升序排序),因为必须是有序的,才可以进行下面的权重合并for(List<Weight> weights : source) {weights.sort((o1,o2)->{return o1.getDocId().compareTo(o2.getDocId());});}//2.借助一个优先级队列,进行合并//target表示合并的结果List<Weight> target = new ArrayList<>();//创建优先级队列,按照docId创建小根堆PriorityQueue<Pos> posPriorityQueue = new PriorityQueue<>((o1,o2)->{Weight w1 = source.get(o1.row).get(o1.col);Weight w2 = source.get(o2.row).get(o2.col);return w1.getDocId().compareTo(w2.getDocId());});//初始化队列,把每一行的第一个元素放到优先级队列中for(int i = 0; i < source.size(); i++) {posPriorityQueue.offer(new Pos(i,0));}//循环的取队首元素(也就是相当于这若干行中的最小元素)while (!posPriorityQueue.isEmpty()) {Pos minPos = posPriorityQueue.poll();Weight minWeight = source.get(minPos.row).get(minPos.col);//看看这个取到的weight是否和前一个插入到target中的结果是相同的docId//如果是就合并if(target.size() > 0) {Weight lastWeight = target.get(target.size()-1);if(lastWeight.getDocId().equals(minWeight.getDocId())) {//此时代表是同一个文档,应该进行合并lastWeight.setWeight(lastWeight.getWeight()+minWeight.getWeight());}else {target.add(minWeight);}}else {//当前结果集中没有对应的文档,直接插入即可target.add(minWeight);}//把当前元素处理完成之后,就需要进行把这个元素的对应光标位置往后移,去取当前位置(当前行)的下一个元素Pos newPos = new Pos(minPos.row,minPos.col+1);if(newPos.col >= source.get(newPos.row).size()) {//如果光标移动到超出了这一行的列数的话,就说明到末尾了//到达末尾之后说明当前行已经处理完毕continue;}posPriorityQueue.offer(newPos);}return target;
}
解析:
(1)先对每一个list进行文档id降序排序。
(2)创建一个优先级队列,按照文档id构建小根堆。
(3)把每一个list的第一个weight添加进队列中,然后取出队首的,与结果集合中的最后一个元素进行比较(因为里面结果集合中的每一个元素都是按照文档id进行排序的,所以只需要和结果集合中的最后一个元素比较即可),如果结果集合为空或者是最后一个元素的文档id与当前文档不同的话,说明不同合并直接添加进去即可,否则如果相同就进行权重的合并。
ps:这里的Pos类是为了更好的操作为List<List<Weight>>,这就相当于一个二维数组,并且也方便后面进行下一个元素的比较。
3.4 包装结果
根据查询出来的Weight(带有docId,weight两个属性)权重集合去查正排索引,查询出现的结果保存在最终返回的结果集合中。
可以看到这里还有一个方法GenDesc(生存描述),对于描述可以参考一些网站的搜索结果,搜索发现这些结果的描述中都是包含有关键词的,所以我们可以在正文中先找到关键词,从当前词向后150字符,向前60字符。如果当前位置是小于150字符直接返回所有文章内容即可,否则则按上述约定规则来进行生成描述。
private String GenDesc(String content, List<Term> terms) {int firstPos = 0;//拿分词结果去正文中查看是否存在当前分词for(Term term : terms) {String word = term.getName();//这里的匹配如果只是word的话,进行匹配list的话,会将ArrayList也进行匹配出来,而我们只是想要匹配出list//所以加上空格在两边//注意此处要进行小写转换,否则匹配会出现问题//因为分词的时候就已经进行了小写转换//这里只进行了了单个单词的匹配,如果现在有单词list),如果还是想搜索list就会搜索失败//所以可以采用如下方法进行替换://法一:由于indexOf不支持正则匹配的,所以可以采用第三方库进行正则匹配,但是比较复杂//法二:使用正则表达式将上述形式替换成旁边带有两个空格的形式content = content.replaceAll("\\b" + word +"\\b"," " + word + " ");firstPos= content.toLowerCase().indexOf(" " + word + " ");if(firstPos > 0) {//找到了break;}}if(firstPos == -1) {//代表当前没有找到//此时就直接返回一个空的描述或者也可以取正文的前150字符返回if(content.length() <= 150) {return content;}return content.substring(0,150) + "...";}//找到的第一个字符往前60字符,然后从当前位置往后150个字符为描述int begIndex = firstPos < 50 ? 0 : firstPos - 50;int endIndex = begIndex + 150 < content.length() ? (begIndex + 150) : content.length();String desc = content.substring(begIndex,endIndex) + "...";for (Term term : terms) {String word = term.getName();//当查询词为list的时候,不能把arraylist标红,所以加上空格//(?i)代表不区分大小写进行匹配desc = desc.replaceAll("(?i) " + word + " ","<i>" + word + "</i>");}return desc;
}
这里我们首先使用\b+word+\b进行正则匹配成 空格+word+空格的形式,是防止一旦出现word,的形式,使用空格+word+空格的形式就匹配不到了;使用空格+word+空格的时候,是为了避免想要匹配出list而匹配出Arraylist的情况。
最后还进行了word两边的替换成了i标签,是为了让前面能够选中i标签,从而给这个关键词设置一些形式,比如:标红。
前端略,重点在后端的描述,可以自行前往gitee查看前端代码。