搜索引擎(基于java在线文档)

背景:

基于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来进行当亲索引的创建。

java 复制代码
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的合并,下面设计一个算法:

java 复制代码
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字符直接返回所有文章内容即可,否则则按上述约定规则来进行生成描述。

java 复制代码
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查看前端代码。

相关推荐
Elastic 中国社区官方博客1 小时前
使用 Elastic-Agent 或 Beats 将 Journald 中的 syslog 和 auth 日志导入 Elastic Stack
大数据·linux·服务器·elasticsearch·搜索引擎·信息可视化·debian
说私域13 小时前
基于开源AI大模型的精准零售模式创新——融合AI智能名片与S2B2C商城小程序源码的“人工智能 + 线下零售”路径探索
人工智能·搜索引擎·小程序·开源·零售
kngines19 小时前
【实战ES】实战 Elasticsearch:快速上手与深度实践-3.2.3 案例:新闻搜索引擎的相关性优化
大数据·elasticsearch·搜索引擎
天草二十六_简村人1 天前
JPA编程,去重查询ES索引中的字段,对已有数据的去重过滤,而非全部字典数据
java·大数据·spring boot·elasticsearch·搜索引擎·微服务·架构
Elastic 中国社区官方博客1 天前
Elasticsearch:使用 BigQuery 提取数据
大数据·数据库·elasticsearch·搜索引擎·全文检索
FIN66681 天前
通领科技冲刺北交所
大数据·科技·安全·搜索引擎
kngines2 天前
【实战ES】实战 Elasticsearch:快速上手与深度实践-3.1.3高亮与排序的性能陷阱
大数据·elasticsearch·搜索引擎
可涵不会debug2 天前
【文心索引】搜索引擎测试报告
数据库·功能测试·selenium·测试工具·搜索引擎·测试用例·postman
Kale又菜又爱玩2 天前
ElasticSearch 入门教程
大数据·elasticsearch·搜索引擎