- 什么是搜索引擎
- 项目目标
- 概念解析
- 模块划分
- 创建项目
- 关于分词
- 创建model包
- 实现索引模块
- 实现Index类
- 实现getDocInfo 和 getInverted
- 实现addDoc方法
- 实现buildForward方法
- 实现buildInverted方法
- 关于buildForward和buildInverted为什么有些代码有进行加锁
- 实现save方法
- 实现load方法
- 实现Parser类
- 实现enumFile方法
- 实现parseHTML方法
- 实现parseTitle方法
- 实现parseURL方法
- 实现parseContentByRegex方法
- 实现搜索模块
- 实现loadStopWords方法
- 实现searcher方法
- 实现mergeResult方法
- 实现getDesc方法
- 实现 web模块
像百度,搜狗这种搜索一个关键词,然后在结果页中展示若干条结果,每一个结果中, 又包含了图标, 标题, 描述, 展示url, 时间, 子链, 图片等;当然像这种搜索引擎属于全站搜索,这个项目是要实现站内搜索(只针对某个网站内部进行搜索)。
实现一个 Java API 文档的简单的搜索引擎.
关于Java API 文档的获取我们直接从官网下载离线版本即可
下载版本参见 https://www.oracle.com/technetwork/java/javase/downloads/index.html
下载之后看到一个 docs 目录, 里面存在一个 api 目录. 这里的 html 就和线上版本的文档是一一对应的.
下载的文档假设放在D:doc_searcher_indexjdk-8u361-docs-all 目录中.
- 文档(document):指的是每个待搜索的网页
- 正排索引:指的是通过文档id找到文档内容
- 倒排索引:指的是通过词找到文档id列表
举个例子,1号文档的内容是“小明去买苹果手机”;2号文档的内容是“小明去买4斤苹果”
正排索引:
1:小明去买苹果手机
2:小明去买4斤苹果
倒排索引:
小明:1,2
去:1,2
买:1,2
苹果:1,2
手机:1
4斤:2
- 索引模块
1)扫描下载到的文档,分析文档的内容,构建出正排索引和倒排索引,并把索引内容保存到文件中
2)加载制作好的索引,并提供一些API实现查正排和查倒排这样的功能
- 搜索模块
调用索引模块,实现一个搜索的完整过程
输入:用户的查询词
输出:完整的搜索结果(包含了很多条记录,每个记录就有标题,描述,展示URL,并且能点击页面进行跳转)
- web模块:包含前端和后端(需要实现一个简单的web程序,能够通过网页的形式来和用户进行交互)
使用IDEA创建一个spring boot项目
引入本项目所需要的依赖,列举几个比较重要的依赖:
json依赖
ansj依赖(现成的分词库)
用户输入查询词,我们不可能整个查询词直接拿去搜索,而是将查询词进行分词,然后将分词后的结果,一个一个去查倒排,拿到对应的文档列表,然后展示出来。
这里简单展示个案例,让大家明白如何使用ansj这个库
运行结果:
小明/买/了/苹果/手机/
注意:当 ansj 对英文分词时, 会自动把单词转为小写
首先我们把这个项目所需要的实体类给创建一下
从图中可以看到model这个包主要包含3个类
关于这些类是表示什么意思,注释都有写,现在看不懂没关系,后面会明白的
Index 负责构建索引数据结构. 主要提供以下方法:
- getDocInfo: 根据 docId 查正排.
- getInverted :根据关键词查倒排.
- addDoc :往索引中新增一个文档(正排索引和倒排索引都要新增).
- save :往磁盘中写索引数据
- load :从磁盘加载索引数据
代码的大致框架
只要把这几个方法实现完成,那么Index就算是完成了
实现getDocInfo 和 getInverted
这两个方法比较简单,因为我们构建完的正排索引和倒排索引是由下面这两个数据结构来进行保存的
所以直接返回这两个数据结构的get方法即可
这两个方法相当于,先根据词调用getInverted()方法,然后得到的文档列表再去正排查,然后就能拿到文档的title,url,content;可能现在有点抽象,不过往后看就不难了
实现addDoc方法
当调用addDoc方法时,就会往正排索引(forwardIndex)和倒排索引(invertedIndex)中添加新文档
实现buildForward方法
我们先来实现比较简单的,往正排索引添加一个新文档
步骤1:新建一个DocInfo对象,将对应的属性填入
步骤2:将这个DocInfo对象,add到forwardIndex的末尾
实现buildInverted方法
通过这个方法往invertedIndex这个数据结构新增文档
步骤1:针对文档标题进行分词
步骤2:遍历分词结果,统计每个词出现的次数
步骤3:针对文档内容进行分词
步骤4:遍历分词结果,统计每个词出现的次数
步骤5:将上面结果汇总到一个HashMap里(最终对文档的权重的计算这里提供一个:weight = 词在标题出现次数*10 + 词在正文中出现次数)
步骤6:遍历刚才的hashmap,依次来更新倒排索引的结构
关于buildForward和buildInverted为什么有些代码有进行加锁
因为后面我们是通过多线程来制作索引的,那么多个线程在调用addDoc方法时,就会调用buildForward和buildInverted方法,而这两个方法会去操作存放文档的正排索引(forwardIndex)和倒排索引(invertedIndex),由于这两个数据结构是公共的,所以可能会出现多个线程修改同一个变量的问题。
实现save方法
保存索引到文件中.
生成两个文件: forward.txt 和 inverted.txt. 通过 json 格式来写入
实现load方法
把索引文件的内容加载到内存中,赋值给两个数据结构
通过这个Parser类来完成制作索引的过程
Parser:读取之前下载好的这些文档,然后去解析文档的内容,并完成索引的制作
- 从制定的路径中枚举出所有的文件
- 读取每个文件, 从文件中解析出 HTML 的标题, 正文, URL
代码大致框架
只要把上面的方法实现完成,Parser类就算是完成了,之后只有运行这个Parser类,这样就会将本地的java文档扫描一遍,然后制作索引,将索引的数据保存到本地文件中,这样等需要搜索时,就可以直接从本地读取索引数据,就不需要每次搜索都制作一遍索引。
实现enumFile方法
这个方法会递归遍历我们指定的路径,然后将是html文件的文档加入结果集,比较简单,不难实现,主要涉及到文件操作
实现parseHTML方法
从上面代码可以看出,parseHTML的工作就是解析标题,url,content,然后把解析出来的内容加入到索引当中去,从而构造索引,而addDoc在Index已经实现,所以我们调用就可以了,所以只需要实现这3个方法即可
实现parseTitle方法
首先我们知道html文件的源代码都是由各种标签构成,所以title标签顾名思义就是标题了
但是要一个一个字符读取然后截取出title标签的内容效率太低,所以我们可以用离线文档的文件名作为文档的标题
可以看到文件名和上面的title标签的内容基本一样,所以就选择用文件名来作为文档的标题了
实现parseURL方法
在真实的搜索引擎中,展示URL和跳转URL是不同的URL,但是我们当前就按照一个URL处理即可(因为我们也没有其他业务)
Java API文档存在两份
- 线上文档
- 线下文档(下载到本地的文档)
我们的期望就是用户点击搜索结果时,就能跳转到对应的线上文档页面。
首先下载之后看到一个 docs 目录, 里面存在一个 api 目录. 这里的 html 就和线上版本的文档是一一对应的.
例如, 我们熟悉的 java.util.Collection 类,
在线文档的链接是 https://docs.oracle.com/javase/8/docs/api/java/util/Collection.html
下载的文档的本地目录是 D:doc_searcher_indexjdk-8u361-docs-alldocsapijavaյCollection.html
这两者存在一定的对应关系.
我们可以发现后半部分是相同的:api/java/util/Collection.html
所以我们最终跳转的URL以 https://docs.oracle.com/javase8/docs/api 为固定前缀,然后根据当前本地文档所在的路径去和前缀拼接;也就是说,把本地文档路径后半部分提取出来和前缀拼接,就构成了我们最后能够跳转的URL
实现parseContentByRegex方法
这个方法我们采用正则表达式来实现,首先我们先看html都是什么内容
可以发现html是由各种标签构成的那我们有做到3件事
- 把
到这里就可以点击运行制作索引了
运行结果:
可以看到索引数据被保存到这两个文件,当我们需要搜索时,只需要加载这两个文件即可
搜索模块:调用索引模块,来完成搜索的核心过程
- 分词,针对用户输入的查询词进行分词(用户输入查询词,可能不是一个词,可能是一句话)
- 暂停词过滤:针对分词结果进行暂停词过滤
- 触发,拿着每个分词结果,去倒排索引中查,找到具有相关性的文档
- 合并:针对多个分词结果触发的相同的文档进行合并(将相同文档的权重进行合并)
- 排序,针对上面触发的结果,进行排序(按相关性降序排序)
- 包装结果,根据排序后的结果,依次查正排,获取每个文档的详细信息,包装成一定结果的数据返回出去
所谓暂停词就是没啥意义,但有很高频率出现的词,但是像这样的词按道理来说是不应该被搜索的,举个例子
搜索 array list,按ansj这个库的分词结果是:array 空格 list 那如果我们把空格这样的暂停词也给进行搜索,那几乎所有的文档都有空格,所有我们应该把空格过滤掉。
实际上, 不只空格, 像a, is, have 等高频词语,而且没有意义的词都应该过滤掉,那什么词叫暂停词呢,这种东西, 网上一搜索暂停词表就能搜到.
把搜到的暂停词结果, 放到一个文件中, 每个词占一行. D:doc_searcher_indexstop_word.txt
大概长这样子
合并这一步骤可能不太理解,这里多解释一下,当搜索的查询词包含多个单词的时候, 可能同一个文档中, 会同时包含这多个分词结果.像这样的文档应该要提高权重.
例如 查询词为 “array list”
那么分词结果就是array和list,那么肯定会有文档同时出现array和list这样的词,那么当我们搜索array这个词是会触发一次文档,当我们搜索list又会触发一次文档,这样就会出现相同的文档出现两次,那么我们只要把这两个相同的文档合并成一个,其实就是把它们的权重相加
合并的思路就是多个有序数组合并成一个数组的思路,看图就能理解了
如果读者还不理解合并的思路,后序可以出一篇文章专门详细讲一讲,这里篇幅有限,就不展开了
只要把上面这两个方法实现那么这个类也算是完成了
步骤1:分词:针对query这个查询词进行分词
步骤2:针对分词结果使用暂停词进行过滤
步骤3:触发:针对分词结果进行查倒排,
步骤4:合并:针对多个分词结果触发的相同的文档进行合并
步骤5:排序:针对触发结果按权重排序
步骤6:包装结果:针对排序结果去查正排,构造返回数据
可以看到searcher方法还涉及到mergeResult方法和getDesc方法
mergeResult方法会针对多个分词结果触发的相同文档进行合并
实现mergeResult方法
如果看不懂实现的思路可以在评论区留言,后续可以考虑出一篇文章来进行细讲
实现getDesc方法
我们遍历分词结果的关键词,然后在正文中找到这个关键词之后,把这个关键词往前50个字符作为起点,然后往后150个字符作为终点,截取出来,这截取出来的部分就作为摘要(这部分要提取多少个字符都可以,只要到时前端页面不要太难看都可以)
这个方法还要做一个工作就是当我们把摘要提取出来后,要找到摘要中的关键词然后给它套上一层标签,这样我们在后面可以对关键词进行标红处理
举个例子:
当我们在百度搜索引擎上输入关键词arraylist时,出来的搜索结果的关键词arraylist会被标红处理,那我们也可以模仿这样展示
这里的实现主要涉及到正则表达式
到这里搜索模块也完成了
提供一个web接口,最终以网页的形式把程序呈现给用户
前端(html+css+js)+后端(java,spring boot)
我们约定前端是GET请求,url是/searcher?query=关键词
后端返回json格式数据,当然这里我用spring boot的统一返回数据格式处理,给它包上一层hashmap,然后直接返回
我们先来实现统一返回数据格式处理的代码
先看一下它的目录
对于统一返回数据格式的处理的写法,通过@ControllerAdvice 注解和实现接口 ResponseBodyAdvice 来实现,然后重写supports 方法和beforeBodyWrite即可,这里就不做展开直接展示代码
那么接下来就是web后端处理前端发送过来的http请求的代码
接下来就是前端的代码就直接展示吧
最后运行效果:
源码链接:
https://gitee.com/maze-white/project/tree/master/java_doc_searcher_ssm