【项目总结】基于SpringBoot+Ansj分词+正倒排索引的Java文档搜索引擎项目总结
创始人
2024-06-02 03:27:22
0

文章目录

  • 项目介绍(开发背景)
  • 主要用到的技术点
    • 前端
    • 后端
      • Ansj分词
      • 实现索引模块
        • 实现Parser类
        • 实现Index类
        • 完善Parser类
        • 优化制作索引速度
      • 实现搜索模块
        • 实现DocSearcher类
        • 处理暂停词
  • 项目编写过程中遇到的困难点
  • 上传部署
  • 总结

项目介绍(开发背景)

        相信很多小伙伴在学习Java的过程中都会参考Java官方文档,但是这个文档存在一个问题,就是不支持用户对某个关键词进行搜索,只能够通过某个包去找到其中的某个类进行查看,显然这样的效率是比较低的。

在这里插入图片描述
        虽然也有很多是离线的api文档是支持搜索功能的,但是我想做一个类似与百度的搜索界面,点击跳转到详细文档的效果。对此,我参考了百度搜索的页面,初步得到一个结论:搜索引擎是先输入一个查询词,会得到若干个结果,每个结果都会包含标题、部分描述展示、url等,点击标题即可完成页面跳转。

主要用到的技术点

前端

        这个项目前端代码是比较简单的,只有一个页面(因为本项目只做的是搜索引擎实现的逻辑,得到结果后跳转的是官方文档详细页),数据的请求也是使用jQuery来实现的。

后端

        后端部分是整个搜索引擎项目的核心部分,具体开发的流程如下:

Ansj分词

        我们在输入查询词的时候,不仅仅只会是输入一个词,更有可能的是会查询一个句子,那么这时候就需要进行一个分词操作了。
        我们可以选择使用一个开源的分词库,我这里选择的是Ansj,简单来说,其实就是导入一个依赖包即可:

org.ansjansj_seg5.1.6

实现索引模块

实现Parser类

        Parser类负责读取html文件,制作并生成索引数据(输出到文件中),主要的步骤:1. 从制定的路径中枚举出所有文件;2. 读取每一个文件,并从文件中解析出标题、正文内容、url。

//Description: 解析api文档的内容,完成索引的制作
public class Parser {//指定加载文档的路径private static final String INPUT_PATH = "D:/java_code/java-api/java8/docs/api/";private Index index = new Index();public void run(){//根据指定的路径,枚举出该路径下的所有html文件(包括子目录中的所有文件)List fileList = new ArrayList<>();enumFile(INPUT_PATH, fileList);//打开对应的文件,读取文件内容,进行解析并构建索引long begin = System.currentTimeMillis();for(File file : fileList){//解析html文件System.out.println("开始解析:" + file.getAbsolutePath());parseHTML(file);}long end = System.currentTimeMillis();System.out.println("遍历文件加入到索引中消耗的时间:" + (end - begin) + "ms");}private void parseHTML(File file) {//解析标题、正文、urlString title = parseTitle(file);//String content = parseContent(file);String content = parseContentByRegex(file);String url = parseUrl(file);}//解析urlprivate String parseUrl(File file) {String part1 = "https://docs.oracle.com/javase/8/docs/api/";String part2 = file.getAbsolutePath().substring(INPUT_PATH.length());return part1 + part2;}//解析标题private String parseTitle(File file) {String name = file.getName();return name.substring(0, name.length() - ".html".length());}//解析正文private String parseContent(File file) {try {//手动将缓冲区设置成1M大小BufferedReader bufferedReader = new BufferedReader(new FileReader(file), 1024 * 1024);boolean flag = true;StringBuilder content = new StringBuilder();while(true){int t = bufferedReader.read();if(t == -1){break;}char ch = (char)t;if(flag){if(ch == '<'){flag = false;continue;}if(ch == '\n' || ch == '\r'){ch = ' ';}content.append(ch);}else{if(ch == '>'){flag = true;}}}bufferedReader.close();  //记得文件的关闭return content.toString();} catch (IOException e) {e.printStackTrace();}return "";}//递归完成目录的枚举private void enumFile(String inputPath, List fileList) {File rootPath = new File(inputPath);File[] files = rootPath.listFiles();for(File file : files){if(file.isDirectory()){enumFile(file.getAbsolutePath(), fileList);}else{if(file.getAbsolutePath().endsWith(".html")) {fileList.add(file);}}}}public static void main(String[] args) throws InterruptedException {Parser parser = new Parser();parser.runByThread();}
}

实现Index类

        Index类负责构建索引数据结构,其中需要包含的方法有:getDocInfo(根据docId查正排)、getInverted(根据关键词查倒排)、addDoc(往索引中新增一个文档,构建正排索引和倒排索引)、save(往磁盘中写索引数据)、load(从磁盘中加载索引数据)。

        整个搜索的核心思路其实就在这个类中,对于搜索的思路主要有两种:一种是直接进行暴力搜索,每次处理搜索请求的时候,拿着查询词到所有网页中都搜索一遍,并检查每个网页中是否包含查询词字符串,但是随着文档数量不断的增加,这种方法所需要的开销是非常大的,而对于搜索引擎来说,效率往往都是放在第一位的,因此该方法是不可行的;另外一种是建立倒排索引,倒排索引是一种专门针对搜索引擎场景所设计的数据结构,可以通过文档的信息来制定排序规则。

        从构建倒排索引和根据关键词查倒排这两个步骤中可以看出一个问题,那就是倒排索引需要使用什么规则来构建呢?直接按照插入文档的顺序来构建显然是不行的,我们在百度搜索的时候会看到排在前面的都是比较重要的或者是搜索次数比较多的,因此,我们在构建倒排索引的时候不能仅仅按照插入顺序,而是应该自定义一个权重,我在这个项目中定义的权重是按照搜索词在文档中出现的次数(标题出现+10,正文出现+1的规则)来实行的。以下是定义权重的类:

public class Weight {private int docId;private int weight;public int getDocId() {return docId;}public void setDocId(int docId) {this.docId = docId;}public int getWeight() {return weight;}public void setWeight(int weight) {this.weight = weight;}
}

        构建出Index类的基本骨架:

public class Index {private static String INDEX_PATH = "D:/java_code/java-api/java8/";private ObjectMapper objectMapper = new ObjectMapper();private List forwardIndex = new ArrayList<>();  //正排索引private Map> invertedIndex = new HashMap<>();  //倒排索引//给定一个docId,通过正排索引查询到文档的详细信息public DocInfo getDocInfo(int docId){//TODO}//给定一个词,通过倒排索引查询哪些文档与这个词相关联public List getInverted(String term){//TODO}//往索引中新增一个文档public void addDoc(String title, String url, String content){//TODO}//把内存中的索引结构保存到磁盘中public void save(){//TODO}//把磁盘中的索引数据加载到内存中public void load(){//TODO}
}

        查正排&查倒排:

    //给定一个docId,通过正排索引查询到文档的详细信息public DocInfo getDocInfo(int docId){return forwardIndex.get(docId);}//给定一个词,通过倒排索引查询哪些文档与这个词相关联public List getInverted(String term){return invertedIndex.get(term);}

        实现addDoc(构建正排索引和倒排索引):

    //往索引中新增一个文档public void addDoc(String title, String url, String content){DocInfo docInfo = buildForward(title, url, content);buildInverted(docInfo);}

        正排索引的构建是比较简单的,直接按照插入顺序进行构建即可:

    private DocInfo buildForward(String title, String url, String content) {DocInfo docInfo = new DocInfo();docInfo.setTitle(title);docInfo.setUrl(url);docInfo.setContent(content);docInfo.setDocId(forwardIndex.size());forwardIndex.add(docInfo);return docInfo;}

        倒排索引需要计算每个词的权重,我们这里可以使用哈希表来进行存储。

public class WordCnt {private int titleCount;private int contentCount;public int getTitleCount() {return titleCount;}public void setTitleCount(int titleCount) {this.titleCount = titleCount;}public int getContentCount() {return contentCount;}public void setContentCount(int contentCount) {this.contentCount = contentCount;}
}
    private void buildInverted(DocInfo docInfo) {//针对标题和正文进行分词(需要分开计算),并统计每个词出现的次数Map wordCntMap = new HashMap<>();List terms = ToAnalysis.parse(docInfo.getTitle()).getTerms();for(Term term : terms){String word = term.getName();if(!wordCntMap.containsKey(word)){WordCnt wordCnt = new WordCnt();wordCnt.setTitleCount(1);wordCnt.setContentCount(0);wordCntMap.put(word, wordCnt);}else{WordCnt wordCnt = wordCntMap.get(word);wordCnt.setTitleCount(wordCnt.getTitleCount() + 1);}}terms = ToAnalysis.parse(docInfo.getContent()).getTerms();for(Term term : terms){String word = term.getName();if(!wordCntMap.containsKey(word)){WordCnt wordCnt = new WordCnt();wordCnt.setTitleCount(0);wordCnt.setContentCount(1);wordCntMap.put(word, wordCnt);}else{WordCnt wordCnt = wordCntMap.get(word);wordCnt.setContentCount(wordCnt.getContentCount() + 1);}}for(Map.Entry entry : wordCntMap.entrySet()){List invertedList = invertedIndex.get(entry.getKey());if(invertedList == null){List list = new ArrayList<>();Weight weight = new Weight();weight.setDocId(docInfo.getDocId());weight.setWeight(entry.getValue().getTitleCount() * 10 + entry.getValue().getContentCount());list.add(weight);invertedIndex.put(entry.getKey(), list);}else{Weight weight = new Weight();weight.setDocId(docInfo.getDocId());weight.setWeight(entry.getValue().getTitleCount() * 10 + entry.getValue().getContentCount());invertedList.add(weight);}}}

        单看这段代码可以会有点绕,可以先看下图:
在这里插入图片描述
        也就是说都有其对应的文章(可以多篇),这其实就类似于是一个哈希桶,当查询词是某个词的时候,就可以快速列出对应词下的文章,这就是倒排索引,当然,每一个节点中都包含了文章的信息(如词出现的次数等),方便后面对这些权重进行自定义排序。

        实现save往磁盘中写索引数据(通过json格式来生成正排索引和倒排索引两个文件):

    //把内存中的索引结构保存到磁盘中public void save(){//使用两个文件分别表示正排和倒排//判断索引对应的文件是否存在,不存在就创建long begin = System.currentTimeMillis();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();}long end = System.currentTimeMillis();System.out.println("保存索引成功. 消耗时间:" + (end - begin) + "ms");}

        实现load将索引文件中的内容加载到内存中:

    //把磁盘中的索引数据加载到内存中public void load(){long begin = System.currentTimeMillis();File forwardIndexFile = new File(INDEX_PATH + "forward.txt");File invertedIndexFile = new File(INDEX_PATH + "inverted.txt");try {forwardIndex = objectMapper.readValue(forwardIndexFile, new TypeReference>() {});invertedIndex = objectMapper.readValue(invertedIndexFile, new TypeReference>>() {});} catch (IOException e) {e.printStackTrace();}long end = System.currentTimeMillis();System.out.println("数据加载成功. 消耗时间:" + (end - begin) + "ms");}

完善Parser类

        接下来,我们可以在Parser类中实例化Index对象,在每次解析出信息的时候就调用Index对象添加到索引中,同时将内存中的索引数据保存到指定文件中:

	private Index index = new Index();
    private void parseHTML(File file) {//解析标题、正文、urlString title = parseTitle(file);//String content = parseContent(file);String content = parseContentByRegex(file);String url = parseUrl(file);//将解析出的信息加入到索引中index.addDoc(title, url, content);}
    public void run(){//根据指定的路径,枚举出该路径下的所有html文件(包括子目录中的所有文件)List fileList = new ArrayList<>();enumFile(INPUT_PATH, fileList);//打开对应的文件,读取文件内容,进行解析并构建索引long begin = System.currentTimeMillis();for(File file : fileList){//解析html文件System.out.println("开始解析:" + file.getAbsolutePath());parseHTML(file);}long end = System.currentTimeMillis();System.out.println("遍历文件加入到索引中消耗的时间:" + (end - begin) + "ms");//内存中构造的索引数据结构保存到指定文件中index.save();}

优化制作索引速度

        索引的制作是会比较耗时间的,特别是数据量大的时候,那么需要如何优化这个过程呢?显然,我们可以通过多线程来进行优化:

    //实现多线程制作索引public void runByThread() throws InterruptedException {//根据指定的路径,枚举出该路径下的所有html文件(包括子目录中的所有文件)List fileList = new ArrayList<>();enumFile(INPUT_PATH, fileList);//打开对应的文件,读取文件内容,进行解析并构建索引(引入线程池)CountDownLatch countDownLatch = new CountDownLatch(fileList.size());ExecutorService executorService = Executors.newFixedThreadPool(4);long begin = System.currentTimeMillis();for(File file : fileList){executorService.submit(new Runnable() {@Overridepublic void run() {//解析html文件System.out.println("开始解析:" + file.getAbsolutePath());parseHTML(file);countDownLatch.countDown();}});}countDownLatch.await();long end = System.currentTimeMillis();System.out.println("遍历文件加入到索引中消耗的时间:" + (end - begin) + "ms");executorService.shutdown();  //手动销毁线程池中的线程//内存中构造的索引数据结构保存到指定文件中index.save();}

        我这里创建的是4个线程(已经可以提升很高的效率了),需要注意的是:在使用多线程的时候需要检查代码中是否存在写操作,如果存在,需要对这个操作整体进行加锁操作。我们在检查代码的时候就发现有几个地方是需要修改的:

(1)在建立倒排索引的时候,我们需要不断地对出现词次数进行修改,所以在出现修改操作部分的代码都需要加上锁:

    private void buildInverted(DocInfo docInfo) {//针对标题和正文进行分词(需要分开计算),并统计每个词出现的次数Map wordCntMap = new HashMap<>();List terms = ToAnalysis.parse(docInfo.getTitle()).getTerms();for(Term term : terms){synchronized (locker1){String word = term.getName();if(!wordCntMap.containsKey(word)){WordCnt wordCnt = new WordCnt();wordCnt.setTitleCount(1);wordCnt.setContentCount(0);wordCntMap.put(word, wordCnt);}else{WordCnt wordCnt = wordCntMap.get(word);wordCnt.setTitleCount(wordCnt.getTitleCount() + 1);}}}terms = ToAnalysis.parse(docInfo.getContent()).getTerms();for(Term term : terms){synchronized (locker2){String word = term.getName();if(!wordCntMap.containsKey(word)){WordCnt wordCnt = new WordCnt();wordCnt.setTitleCount(0);wordCnt.setContentCount(1);wordCntMap.put(word, wordCnt);}else{WordCnt wordCnt = wordCntMap.get(word);wordCnt.setContentCount(wordCnt.getContentCount() + 1);}}}for(Map.Entry entry : wordCntMap.entrySet()){synchronized (locker3){List invertedList = invertedIndex.get(entry.getKey());if(invertedList == null){List list = new ArrayList<>();Weight weight = new Weight();weight.setDocId(docInfo.getDocId());weight.setWeight(entry.getValue().getTitleCount() * 10 + entry.getValue().getContentCount());list.add(weight);invertedIndex.put(entry.getKey(), list);}else{Weight weight = new Weight();weight.setDocId(docInfo.getDocId());weight.setWeight(entry.getValue().getTitleCount() * 10 + entry.getValue().getContentCount());invertedList.add(weight);}}}}

(1)在建立正排索引的时候,我们也需要不断地对id进行修改,所以在出现修改操作部分的代码都需要加上锁:

    private DocInfo buildForward(String title, String url, String content) {DocInfo docInfo = new DocInfo();docInfo.setTitle(title);docInfo.setUrl(url);docInfo.setContent(content);synchronized (locker4){docInfo.setDocId(forwardIndex.size());forwardIndex.add(docInfo);}return docInfo;}

实现搜索模块

实现DocSearcher类

        简单介绍一下这个类需要实现的功能,这个类主要负责实现搜索功能,得到用户输入的查询词后,对查询词进行分词处理,之后对每一个分词都查找一下之前存的倒排索引,得到一个倒排哈希桶,将这些结构合并到一个结果集中,并对其中的这些结果按照指定的权重降序排序后返回结果即可。

    public List search(String query){//分词List oldTerms = ToAnalysis.parse(query).getTerms();List terms = new ArrayList<>();//过滤for(Term term : oldTerms){terms.add(term);}//触发List> termResult = new ArrayList<>();for(Term term : terms){String word = term.getName();List invertedList =  index.getInverted(word);if(invertedList == null){continue;}termResult.add(invertedList);}//合并List allTermResult = mergeResult(termResult);//排序(降序)allTermResult.sort((o1, o2) -> {return o2.getWeight() - o1.getWeight();});//包装List results = 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));results.add(result);}return results;}

        对于其中的合并操作,我们可以使用优先级队列来辅助实现(其实这就涉及到多路归并算法的思想):

    private List mergeResult(List> source) {//针对每一行进行排序(按照id升序排序)for(List curRow : source){curRow.sort(new Comparator(){@Overridepublic int compare(Weight o1, Weight o2) {return o1.getDocId() - o2.getDocId();}});}//使用优先级队列对每一行进行合并List target = new ArrayList<>();PriorityQueue queue = new PriorityQueue<>(new Comparator() {@Overridepublic int compare(Pos o1, Pos o2) {return source.get(o1.row).get(o1.col).getDocId() - source.get(o2.row).get(o2.col).getDocId();}});for(int row = 0; row < source.size(); row++){queue.offer(new Pos(row, 0));}while(!queue.isEmpty()){Pos minPos = queue.poll();Weight curWeight = source.get(minPos.row).get(minPos.col);if(target.size() > 0){Weight lastWeight = target.get(target.size() - 1);if(lastWeight.getDocId() == curWeight.getDocId()){lastWeight.setWeight(lastWeight.getWeight() + curWeight.getWeight());}else{target.add(curWeight);}}else{target.add(curWeight);}Pos newPos = new Pos(minPos.row, minPos.col + 1);if(newPos.col >= source.get(newPos.row).size()){continue;}queue.offer(newPos);}return target;}

        以下这段代码表示需要截取内容的部分简介显示到页面上:

    private String GenDesc(String content, List terms) {int firstPos = -1;for(Term term : terms){String word = term.getName();content = content.toLowerCase().replaceAll("\\b" + word + "\\b", " " + word + " ");firstPos = content.indexOf(" " + word + " ");if(firstPos >= 0){break;}}if(firstPos == -1){return content.substring(0, Math.min(60, content.length())) + "...";}String desc = "";int descBeg = firstPos < 30 ? 0 : firstPos - 30;desc = content.substring(descBeg, Math.min(descBeg + 100, content.length())) + "...";for(Term term : terms){String word = term.getName();desc = desc.replaceAll("(?i) " + word + " ", "" + " " + word + " " + "");}return desc;}

处理暂停词

        我们在网上搜索到一些常见的暂停词后将它们保存到一个文件夹中,每次从文件中获取这些暂停词,看是否有存在于查询词中的,若有,直接跳过即可。

    private static String STOP_WORD_PATH = "D:/java_code/java-api/java8/stop_word.txt";private Set stopWords = new HashSet<>();

        将暂停词从磁盘加载到内存中:

    public void loadStopWords(){try {BufferedReader bufferedReader = new BufferedReader(new FileReader(STOP_WORD_PATH));while(true){String line = bufferedReader.readLine();if(line == null){break;}stopWords.add(line);}bufferedReader.close();} catch (IOException e) {e.printStackTrace();}}

        之后就是补充上面search方法中的代码,在查询词中发现暂停词时,直接进行过滤操作:

		//过滤for(Term term : oldTerms){if(stopWords.contains(term.getName())){continue;}terms.add(term);}

项目编写过程中遇到的困难点

        这个项目是基础版的搜索引擎,所以总体来说是中规中矩的,没有特别难解决的问题,唯一一个比较难编写的是最后的使用多路归并算法来实现权重的合并。

上传部署

        将打包后的jar包以及正排、倒排索引和暂停词放在同一个目录底下,输入命令:java -jar [jar包名]就可以打开SpringBoot项目;持久运行需要输入命令:nohup java -jar [jar包名] &
在这里插入图片描述

总结

        简单总结一下这个Java文档搜索引擎项目:
        有点:使用了主流的SpringBoot框架进行开发,运用多线程来提高代码的运行效率,包含了基本搜索引擎中所需要的数据结构和算法。
        缺点:扩展性弱,这个搜索引擎只能够针对Java文档进行搜索,比较合理的实现是需要搭配爬虫实现对页面数据的获取,当然,这个难度也是比较大的。
        关于本项目的全部代码我都放在了我的个人Gitee账户下,有需要的可以点击查看:Java文档搜索引擎项目存放代码。

相关内容

热门资讯

常用商务英语口语   商务英语是以适应职场生活的语言要求为目的,内容涉及到商务活动的方方面面。下面是小编收集的常用商务...
六年级上册英语第一单元练习题   一、根据要求写单词。  1.dry(反义词)__________________  2.writ...
复活节英文怎么说 复活节英文怎么说?复活节的英语翻译是什么?复活节:Easter;"Easter,anniversar...
2008年北京奥运会主题曲 2008年北京奥运会(第29届夏季奥林匹克运动会),2008年8月8日到2008年8月24日在中华人...
英语道歉信 英语道歉信15篇  在日常生活中,道歉信的使用频率越来越高,通过道歉信,我们可以更好地解释事情发生的...
六年级英语专题训练(连词成句... 六年级英语专题训练(连词成句30题)  1. have,playhouse,many,I,toy,i...
上班迟到情况说明英语   每个人都或多或少的迟到过那么几次,因为各种原因,可能生病,可能因为交通堵车,可能是因为天气冷,有...
小学英语教学论文 小学英语教学论文范文  引导语:英语教育一直都是每个家长所器重的,那么有关小学英语教学论文要怎么写呢...
英语口语学习必看的方法技巧 英语口语学习必看的方法技巧如何才能说流利的英语? 说外语时,我们主要应做到四件事:理解、回答、提问、...
四级英语作文选:Birth ... 四级英语作文范文选:Birth controlSince the Chinese Governmen...
金融专业英语面试自我介绍 金融专业英语面试自我介绍3篇  金融专业的学生面试时,面试官要求用英语做自我介绍该怎么说。下面是小编...
我的李老师走了四年级英语日记... 我的李老师走了四年级英语日记带翻译  我上了五个学期的小学却换了六任老师,李老师是带我们班最长的语文...
小学三年级英语日记带翻译捡玉... 小学三年级英语日记带翻译捡玉米  今天,我和妈妈去外婆家,外婆家有刚剥的`玉米棒上带有玉米籽,好大的...
七年级英语优秀教学设计 七年级英语优秀教学设计  作为一位兢兢业业的人民教师,常常要写一份优秀的教学设计,教学设计是把教学原...
我的英语老师作文 我的英语老师作文(通用21篇)  在日常生活或是工作学习中,大家都有写作文的经历,对作文很是熟悉吧,...
英语老师教学经验总结 英语老师教学经验总结(通用19篇)  总结是指社会团体、企业单位和个人对某一阶段的学习、工作或其完成...
初一英语暑假作业答案 初一英语暑假作业答案  英语练习一(基础训练)第一题1.D2.H3.E4.F5.I6.A7.J8.C...
大学生的英语演讲稿 大学生的英语演讲稿范文(精选10篇)  使用正确的写作思路书写演讲稿会更加事半功倍。在现实社会中,越...
VOA美国之音英语学习网址 VOA美国之音英语学习推荐网址 美国之音网站已经成为语言学习最重要的资源站点,在互联网上还有若干网站...
商务英语期末试卷 Part I Term Translation (20%)Section A: Translate ...