基于正倒排索引的Java文档搜索引擎3-实现Index类-实现搜索模块-实现DocSearcher类

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档

文章目录

  • 前言
  • [1. 实现Index类](#1. 实现Index类)
    • [1.13 给制作索引模块加锁](#1.13 给制作索引模块加锁)
    • [1.14 验证](#1.14 验证)
    • [1.15 非守护线程问题](#1.15 非守护线程问题)
    • [1.15 首次制作索引比较慢问题](#1.15 首次制作索引比较慢问题)
    • [1.16 优化文件读取速度](#1.16 优化文件读取速度)
    • [1.17 验证索引加载逻辑](#1.17 验证索引加载逻辑)
  • [2. 实现搜索模块](#2. 实现搜索模块)
  • [3. 实现DocSearcher类](#3. 实现DocSearcher类)
    • [3.1 实现search方法](#3.1 实现search方法)
    • [3.2 实现生成描述](#3.2 实现生成描述)
    • [3.3 验证](#3.3 验证)
    • [3.4 去除script标签](#3.4 去除script标签)
    • [3.5 合并多个空格为一个空格](#3.5 合并多个空格为一个空格)
  • 总结

前言

1. 实现Index类

1.13 给制作索引模块加锁

java 复制代码
    private void parseHTML(File file) {
        //1.解析出标题
        String title  = parseTitle(file);
        System.out.println(title);
        //2.解析出html对应url
        String url  = parseUrl(file);
        //3.解析出html正文
        String content = parseContent(file);
//        System.out.println(content);
        index.addDoc(title,url,content);
    }

像parseTitle,parseUrl,parseContent这种都不涉及线程安全问题,因为不涉及多个线程修改同一个对象的问题

而index.addDoc(title,url,content);这个的话,是多个线程都会操作同一个Index对象,操作同一个正排索引,操作同一个倒排索引

所以就会有线程安全问题了,所以需要加锁来解决

可以直接给addDoc这个方法加锁synchronized

但是给这个方法加锁的话,那么在这个方法上,线程就是串行的了,parseTitle,parseUrl,parseContent这三个方法才是并发的,addDoc是串行的

所以加锁要在细一点,小一些

java 复制代码
    public void addDoc(String title,String url, String content){
        //新增文档操作,需要同时给正派索引,和倒排索引添加
        DocInfo docInfo = buildForward(title,url,content);
        buildInverted(docInfo);
    }
java 复制代码
    private DocInfo buildForward(String title, String url, String content) {
        DocInfo docInfo = new DocInfo();
        docInfo.setTitle(title);
        docInfo.setUrl(url);
        docInfo.setContent(content);
        forwardIndex.add(docInfo);//因为是加在最后的,所以下标就是数组长度,就是docId
        docInfo.setDocId(forwardIndex.size()-1);
        return docInfo;
    }

主要是 forwardIndex.add(docInfo);//因为是加在最后的,所以下标就是数组长度,就是docId

docInfo.setDocId(forwardIndex.size()-1);这两个代码可能会有线程安全,所以给这两行加锁就可以了

因为这两行访问了公共对象forwardIndex正排索引,所以会有线程安全问题

java 复制代码
    private DocInfo buildForward(String title, String url, String content) {
        DocInfo docInfo = new DocInfo();
        docInfo.setTitle(title);
        docInfo.setUrl(url);
        docInfo.setContent(content);
        synchronized (this){
            forwardIndex.add(docInfo);//因为是加在最后的,所以下标就是数组长度,就是docId
            docInfo.setDocId(forwardIndex.size()-1);
        }
        return docInfo;
    }

因为indx这个类的实例只有一份,所以可以对这个加锁了

在buildInverted方法中也是一样的

涉及invertedIndex的地方都要加锁

java 复制代码
        for(Map.Entry<String,WordCnt> entry : wordCntHashMap.entrySet()){
            synchronized (this){
                //先根据词去倒排索引中查
                ArrayList<Weight> invertedList = invertedIndex.get(entry.getKey());
                if(invertedList == null){
                    //插入一个新的键值对
                    invertedList = new ArrayList<>();
                    Weight weight = new Weight();
                    weight.setDocId(docInfo.getDocId());
                    weight.setWeight(entry.getValue().titleCount*10+entry.getValue().contentCount);
                    invertedList.add(weight);
                    invertedIndex.put(entry.getKey(),invertedList);
                }else{
                    Weight weight = new Weight();
                    weight.setDocId(docInfo.getDocId());
                    weight.setWeight(entry.getValue().titleCount*10+entry.getValue().contentCount);
                    invertedList.add(weight);
                }
            }
        }

buildInverted方法中只有for循环这里是涉及倒排索引的,所以这里加锁就可以了

但是这里加锁的锁和正派索引的锁是同一个锁this,按理说,这两个可以为不同锁,因为它们不是竞争的同一个资源,它们是操作的不同对象,所以应该是不同的锁才行

意思就是不同线程的正排索引和倒排索引不应该有锁竞争

java 复制代码
    private DocInfo buildForward(String title, String url, String content) {
        DocInfo docInfo = new DocInfo();
        docInfo.setTitle(title);
        docInfo.setUrl(url);
        docInfo.setContent(content);
        synchronized (forwardIndex){
            forwardIndex.add(docInfo);//因为是加在最后的,所以下标就是数组长度,就是docId
            docInfo.setDocId(forwardIndex.size()-1);
        }
        return docInfo;
    }
java 复制代码
        for(Map.Entry<String,WordCnt> entry : wordCntHashMap.entrySet()){
            synchronized (invertedIndex){
                //先根据词去倒排索引中查
                ArrayList<Weight> invertedList = invertedIndex.get(entry.getKey());
                if(invertedList == null){
                    //插入一个新的键值对
                    invertedList = new ArrayList<>();
                    Weight weight = new Weight();
                    weight.setDocId(docInfo.getDocId());
                    weight.setWeight(entry.getValue().titleCount*10+entry.getValue().contentCount);
                    invertedList.add(weight);
                    invertedIndex.put(entry.getKey(),invertedList);
                }else{
                    Weight weight = new Weight();
                    weight.setDocId(docInfo.getDocId());
                    weight.setWeight(entry.getValue().titleCount*10+entry.getValue().contentCount);
                    invertedList.add(weight);
                }
            }
        }
    }

所以我们把锁改为两个索引,或者直接创建两个新的对象弄为新的锁也是可以的

java 复制代码
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();
java 复制代码
        synchronized (lock2){
            forwardIndex.add(docInfo);//因为是加在最后的,所以下标就是数组长度,就是docId
            docInfo.setDocId(forwardIndex.size()-1);
        }

另一个方法同理

1.14 验证

java 复制代码
    public static void main(String[] args) throws InterruptedException {
        Parser p = new Parser();
        p.runByThread();
    }

线程池的线程不是设置越多就越好,具体设置为多少---》用实验的方式来· ·

1.15 非守护线程问题

这里进程没有结束

因为守护线程

如果一个线程是守护线程(后台线程)---》线程的运行状态不会影响进程结束

如果是非守护线程---》这个线程运行状态就会影响进程结束

默认创建的都是非守护线程,需要设置setDaemon才能成为守护线程

线程池创建的线程默认都是非守护线程,所以main执行完了,这些线程还在等待新任务到来

要想使线程随着main方法结束而结束,要么变为守护线程,要么干掉线程

java 复制代码
    public void runByThread() throws InterruptedException {
        System.out.println("索引制作开始");
        long begin = System.currentTimeMillis();
        ArrayList<File> fileList = new ArrayList<>();
        //1.根据上面指定路径,枚举出所有的文件(html),包括所有的子目录
        enumFile(INPUT_PATH,fileList);
        //2.循环遍历文件,线程池
        CountDownLatch countDownLatch = new CountDownLatch(fileList.size());
        ExecutorService executorService = Executors.newFixedThreadPool(4);
        for (File file : fileList) {
            executorService.submit(new Runnable() {
                @Override
                public void run() {
                    System.out.println("解析"+file.getAbsolutePath());
                    parseHTML(file);//解析html文件
                    countDownLatch.countDown();//解析完成之后,资源数减一
                }
            });
        }
        //3.保存索引,要等线程池执行完成之后才可以,submit只是把任务放入阻塞队列中,执行完毕还要等等
        //怎么等待呢,使用CountDownLatch,先指定任务个数,每完成一个任务parseHTML就减一,用await来等待CountDownLatch所有任务数都没有
        countDownLatch.await();//会阻塞,直到所有的任务都完成
        executorService.shutdown();
        index.save();
        long end = System.currentTimeMillis();
        System.out.println("索引制作完毕:"+(end-begin)+"ms");
    }

executorService.shutdown();就是干掉所有线程了

1.15 首次制作索引比较慢问题

我们第一次制作索引的时候很慢,9秒多

后面变快了,变为6秒了

如果重启机器,又变慢了

问题主要是parseContent,这里是读取文件的操作,是一个开销比较大的操作

首次开机-----》读取文件速度很慢

我们要得出一下parseContent这个耗费的时间总长

java 复制代码
    private AtomicLong t1 = new AtomicLong(0);
    private AtomicLong t2 = new AtomicLong(0);

这两个都是原子类,都是线程安全的类,可以不用加锁了,基于cas,比较快

java 复制代码
    private void parseHTML(File file) {
        //1.解析出标题
        String title  = parseTitle(file);
        System.out.println(title);
        //2.解析出html对应url
        String url  = parseUrl(file);
        //3.解析出html正文
        long begin = System.nanoTime();//更加细腻度,为纳秒,因为一个文件的parseContent用ms来计算,不准确
        //如果是所有文件的parseContent用ms来计算还可以接受
        String content = parseContent(file);
        long mid = System.nanoTime();
//        System.out.println(content);
        index.addDoc(title,url,content);
        long end = System.nanoTime();
        t1.addAndGet(mid-begin);
        t2.addAndGet(end-mid);//原子相加,很快
        //注意因为单次对文件的操作时间本来就很短了。如果这里还打印的话---》时间就变长了
    }
java 复制代码
        System.out.println("t1:"+t1);
        System.out.println("t2:"+t2);

在runByThread方法末尾加上这个打印

这是第一次开机之后执行的时间

这里是线程累积时间,所以超过总时间很正常

发现解析正文的时间比addDoc的时间长很多

第二次运行之后发现,t2变化不大,主要是t1变化很大,之间变为7s了

直接从几百秒变为几秒了

为什么呢

parseContent这个操作主要是读取文件----》操作系统会对进程读取的文件进行缓存,首次运行的时候,这些java文档都没有在内存上缓存

所以第一次读取的时候,只能从磁盘上读取

后面再读取的时候,已经在内存中有缓存了,所以直接读缓存了,不读磁盘了

1.16 优化文件读取速度

上面的是操作系统的优化,我们怎么优化呢

fileReader.read()这个方法就是从文件中读取一个字符,每次都是读磁盘一个字符,比较慢,我们可以直接把所有内容从磁盘中先读到内存中,然后在挨个读取

BufferredReader可以搭配fileReader使用

BufferredReader内部就会内置一个缓冲区,就能够自动把fileReader中的一些内容预读到内存中,从而减少我们直接访问磁盘的次数

java 复制代码
    private String parseContent(File file) {
        //先按照一个字符一个字符来读取,字符流
        //手动设置缓冲区为1M
        try(BufferedReader bufferedReader = new BufferedReader(new FileReader(file),1024*1024)) {
            //加上一个是否要进行拷贝的开关
            boolean isCopy= true;
            StringBuilder content = new StringBuilder();
            while (true){
                int read = bufferedReader.read();//一次读取一个字符,返回值是一个int
                if (read == -1){
                    break;//表示文件读完了
                }
                char c = (char) read;
                if (isCopy){
                    if(c=='<'){
                        isCopy=false;
                        continue;
                    }
                    if(c=='\n' || c == '\r'){//\r表示回车符
                        c=' ';
                    }
                    content.append(c);
                }else if (c=='>'){
                    isCopy=true;
                }
            }
            return content.toString();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
java 复制代码
    private static int defaultCharBufferSize = 8192;

BufferredReader提供的缓冲区大小默认是8K

但是html比较大,所以8k太小了

但是我们系统第二次运行本身就有缓存了,所以可能不会提升多少

但是第一次访问的时候还是有效的

1.17 验证索引加载逻辑

就是把文件还原为数据结构了

java 复制代码
    public static void main(String[] args) {
        Index index = new Index();
        index.load();
        System.out.println("索引加载完成");
    }


我们调试一下,发现好像没有什么问题

2. 实现搜索模块

索引模块我们就实现完了

搜索模块就是调用索引模块来完成搜索

先对用户输入的查询词进行分词(因为可能是一句话)

然后拿着每个分词去倒排索引中查

针对倒排索引中查询出来的结果,然后按照相关性进行排序,降序

拿着排序后的结果,去查正排,拿到每个文档的详细信息,包装为一定的数据结构后返回

3. 实现DocSearcher类

java 复制代码
@Data
//表示一个搜索结果
public class Result {
    private String title;
    private String url;
    private String desc;//这是正文的摘要
}
java 复制代码
public class DocSearcher {
    //引入index实例,然后加载好
    private Index index = new Index();
    
    public DocSearcher(){
        index.load();
    }

    //完成整个搜索过程
    //query就是用户输入查询词
    public List<Result> search(String query){
        //1.针对query分词
        //2.查倒排
        //3.排序
        //4.包装:查正排
        return null;
    }
}

3.1 实现search方法

描述是正文中的一段摘要

怎么生成呢

这个描述还要包含查询词

遍历分词结果,看哪个结果在正文中出现

针对这个文档来说,不一定会包含所有的分词结果

就针对这个分词结果去正文中查找。找到对应的位置

就以这个词的位置为中心,往前截取60个字符,作为描述的开始

然后再从描述开始,一股脑截取160个字符,作为整个描述,体现出相关性就可以了,就不深究了

java 复制代码
    //完成整个搜索过程
    //query就是用户输入查询词
    public List<Result> search(String query){
        //1.针对query分词
        List<Term> terms = ToAnalysis.parse(query).getTerms();
        //2.查倒排
        List<Weight> allTermResult = new ArrayList<>();
        for (Term term : terms){
            String word  = term.getName();
            List<Weight> invertedList = index.getInverted(word);
            //如果分词term是一个生僻的词,那么倒排索引可能查不出来
            if (invertedList==null){
                //说明这个词不存在
                continue;
            }
            allTermResult.addAll(invertedList);//批量追加一组元素,支追加一个元素用add
        }
        //3.排序,针对权重来降序
        allTermResult.sort(new Comparator<Weight>() {
            @Override
            public int compare(Weight o1, Weight o2) {
                //如果是升序排序,就写o1.getWeight()-o2.getWeight()
                return o2.getWeight()-o1.getWeight();//可以先随便写一种,后面测试来修改
            }
        });
        //4.包装:查正排
        List<Result> resultList = new ArrayList<>();
        for (Weight weight : allTermResult){
            DocInfo docInfo = index.getDocInfo(weight.getDocId());
            Result result = new Result();
            result.setTitle(docInfo.getTitle());
            result.setUrl(docInfo.getUrl());
            result.setDesc(GenDesc(docInfo.getContent(),terms));
            resultList.add(result);
        }
        return resultList;
    }

3.2 实现生成描述

java 复制代码
    private String GenDesc(String content, List<Term> terms) {
        //先遍历分词结果
        int firstPos = -1;
        for (Term term : terms){
            //别忘了,分词都是小写的,所以要把正文变为小写toLowerCase再去查询,不然可能会漏
            //还有一个问题,正文为ArrayList,分词为List,这样也会查询到---》生成的描述不准确,但是在查倒排的时候,就不会这样。因为ArrayLIst就不会被分成Array和List
            //我们搞的是原词匹配,不是近义词匹配。所以正文为ArrayList不应该被查出来,必须查出来整个词都匹配才好
            String word = term.getName();
            //全字匹配--》加空格就可以了,让word独立成词
            //此处不太严谨,严谨的话可以使用正则表达式,因为万一List在开头呢
            firstPos = content.toLowerCase().indexOf(" "+word+" ");
            if(firstPos>=0){
                //找到了位置
                break;
            }
        }
        if (firstPos==-1){
            //所有分词结果都不在正文中存在----》在标题中,也可以返回正文前160字符
            return content.substring(0,160)+".......";
        }
        //从firstPos往前60开始截取
        String desc = "";
        int descBeg = firstPos < 60 ? 0 : firstPos-60;
        if(descBeg+160>content.length()){
            desc = content.substring(descBeg);//从descBeg位置截取到末尾
        }else {
            desc = content.substring(descBeg,descBeg+160)+"......";
        }
        return desc;
    }

3.3 验证

java 复制代码
    public static void main(String[] args) {
        DocSearcher searcher = new DocSearcher();
        Scanner scanner = new Scanner(System.in);
        while (true){
            System.out.print("->");
            String query = scanner.next();
            List<Result> resultList = searcher.search(query);
            for (Result result : resultList){
                System.out.println(result);
            }
        }
    }

这个主要是JavaScript中的代码

有些html中有script标签,去除这个以后----》js中的代码也被整理到索引里面去了,这个并不科学

所以js的代码并不需要

3.4 去除script标签

我们这里使用正则表达式,

Java中的String里面的很多方法都是直接支持正则的,比如IndexOf,replace,replaceAll,split

正则里面就是很多符号

java 复制代码
.表示匹配一个非换行字符
*表示前面的字符可以出现若干次
.*表示匹配非换行字符出现若干次

去除script标签和内容

java 复制代码
<script.*>(.*)</script>
java 复制代码
<script.*>

这个表示匹配这个标签,里面还有点和星主要是因为script可能还有属性

括号表示它们是一个整体

去掉普通标签,不去掉内容

java 复制代码
<.*>

这个既能匹配开始标签,也可以匹配结束标签

java 复制代码
<script.*?>(.*?)</script>
java 复制代码
<.*?>

此处问号表示非贪婪匹配---》匹配到符合条件的最短结果

不带问号表示贪婪匹配,就是尽可能长的匹配,尽可能匹配到满足条件的最长字符串





所以最好使用非贪婪匹配,如果是贪婪匹配,可能就把所有都匹配到了,非贪婪的话,就比较细了

java 复制代码
    private String readFile(File file) {
        try (BufferedReader bufferedReader = new BufferedReader(new FileReader(file), 1024 * 1024)) {
            StringBuilder content = new StringBuilder();
            while (true) {
                int read = bufferedReader.read();//一次读取一个字符,返回值是一个int
                if (read == -1) {
                    break;//表示文件读完了
                }
                char c = (char) read;
                if (c == '\n' || c == '\r') {//\r表示回车符
                    c = ' ';
                }
                content.append(c);
            }
            return content.toString();
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    //这个方法基于正则表达式,实现去标签,去除script
    private String parseContentByRegex(File file) {
        //1.先把整个文件都读到String中
        String content = readFile(file);
        //2.替换掉script标签
        content = content.replaceAll("<script.*?>(.*?)</script>"," ");//直接把满足"<script.*?>(.*?)</script>"条件的替换为空格
        //3.替换掉普通标签
        content = content.replaceAll("<.*?>"," ");
        return content;
    }

这样的话,就没有js的代码了

3.5 合并多个空格为一个空格

java 复制代码
    //这个方法基于正则表达式,实现去标签,去除script
    private String parseContentByRegex(File file) {
        //1.先把整个文件都读到String中
        String content = readFile(file);
        //2.替换掉script标签
        content = content.replaceAll("<script.*?>(.*?)</script>"," ");//直接把满足"<script.*>(.*?)</script>"条件的替换为空格
        //3.替换掉普通标签
        content = content.replaceAll("<.*?>"," ");
        //多个空格合并为一个空格
        content = content.replaceAll("\\s+"," ");
        //\s+加号表示至少出现一次>=1次,*的话就是表示出现>=0次,,,,,\s就是空格,\是转义字符,这里就是要使用字符\s才行
        return content;
    }

然后重新制作索引

这样里面就没有js文件了

总结

相关推荐
l***46681 小时前
SSM与Springboot是什么关系? -----区别与联系
java·spring boot·后端
稚辉君.MCA_P8_Java1 小时前
Gemini永久会员 快速排序(Quick Sort) 基于分治思想的高效排序算法
java·linux·数据结构·spring·排序算法
I***t7161 小时前
【MyBatis】spring整合mybatis教程(详细易懂)
java·spring·mybatis
YA3331 小时前
mcp-grafana mcp 使用stdio报错
java·开发语言
z***02601 小时前
SpringBoot创建动态定时任务的几种方式
java·spring boot·spring
w***95491 小时前
VScode 开发 Springboot 程序
java·spring boot·后端
MOMO陌染1 小时前
Python 饼图入门:3 行代码展示数据占比
后端·python
兔子撩架构2 小时前
Dubbo 的同步服务调用
java·后端·spring cloud
vvoennvv2 小时前
【Python TensorFlow】 TCN-GRU时间序列卷积门控循环神经网络时序预测算法(附代码)
python·rnn·神经网络·机器学习·gru·tensorflow·tcn