站内搜索引擎

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来做一下?

爬虫能否实现一下?

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

阿瞒有我良计15

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值