在线体验 : http://43.139.1.94:9090/index.html
项目链接 : https://gitee.com/xiaobite_hl/JAVA/tree/master/Big-work/java_doc_searcher_springboot
我们平时查百度, 搜狗的时候, 结果页会显示若干条相关结果 , 每个结果几乎都包含图片, 标题, 描述, 展示 URL以及时间等等.
输入一个查询词, 得到若干个结果, 每个搜索结果包含了标题, 描述, 展示 URL , 以及点击 URL...
实现过程中, 我们主要考虑的问题有两个 :
查询出哪些网页和查询词具有一定的相关性.
如何高效的进行搜索.
==============
解决思路 >>
方案一 : 暴力检索
每当用户输入一个查询词, 就拿着这个查询词去所有的网页中遍历一次, 查找出包含该查询词的所有文档.
显然这个方案不满足高效检索, 随着文档数量的增多, 搜索的开销会线性增长.
方案二 : 倒排索引 + 正排索引
倒排索引是一种专门针对搜索引擎场景而设计的数据结构.
因为当前这个项目只是针对 Java 官方文档设计的一个搜索引擎, 总共也就一万多条记录, 所以我们可以提前将 Java官方文档下载到本地, 然后对其进行预处理. 此处的预处理就是构建正排, 倒排索引.
1. 正排索引 : 一个文档包含了哪些词, 描述了 一个文档的基本信息, 包括文档标题, 文档正文, 文档 URL 以及文档标题和正文的分词结果.
2. 倒排索引 : 查询词在哪些文档中出现, 描述了 一个词的基本信息, 包括这个词分别被哪些文档引用, 这个词在该文档中的相关性/权重, 以及这个词出现的位置等.
Java 官方文档线上链接 : https://docs.oracle.com/javase/8/docs/api/index.html
Java 官方文档下载链接 : https://www.oracle.com/technetwork/java/javase/downloads/index.html
索引模块 : 扫描下载到的文档, 分析数据内容构建正排+倒排索引, 并保存到文件中.
搜索模块: 加载索引. 根据输入的查询词, 基于正排+倒排索引进行检索, 得到检索结果.
web模块: 编写一个简单的页面, 展示搜索结果. 点击搜索结果能跳转到对应的 Java API 文档页面.
创建项目具体流程就不再赘述. 引入常用的依赖 : devtools, lombok, Spring Web 以及项目中需要使用到的分词依赖 : ansj.
分词操作是项目中的一个核心操作, 此处仅针对 Java官方文档进行分词, 就只需要考虑英文分词即可. 项目中使用现成的分词库 ansj.
分词库的简单使用示例 : https://blog.csdn.net/weixin_44112790/article/details/86756741
org.ansj
ansj_seg
5.1.6
代码示例 :
public class TestAnsj {
public static void main(String[] args) {
String str = "Write a search engine blog today";
List terms = ToAnalysis.parse(str).getTerms();
for (Term term : terms) {
System.out.print(term.getName() + "/");
}
}
}
分词结果 (分词库会将所有单词都转成小写):
write/ /a/ /search/ /engine/ /blog/ /today/
parser 主要做的工作有 :
构建一个可执行程序.
读取 HTML 文档, 制作并生成索引数据.
public class Parser {
// 指定一个加载文档的路径
private static final String INPUT_PATH = "D:/software-download/Java-Official-document/jdk-8u351-docs-all/docs/api";
// 整个 Parser 类的入口
public void run() {
long beg = System.currentTimeMillis();
System.out.println("索引制作开始!");
// 1. 根据指定的本地文档路径, 枚举出该路径中所有的 html 文件.
ArrayList fileList = new ArrayList<>();
enumFile(INPUT_PATH, fileList);
// 2. 针对这些 html 文件的路径, 打开文件, 读取文件内容, 并解析, 并构建索引.
for(File f : fileList) {
System.out.println("开始解析: " + f.getAbsolutePath());
// 解析每个 HTML 文件
parseHTML(f);
}
// 3. TODO 将在内存中构造好的索引数据结构, 保存到指定文件中.(涉及 Index 类)
long end = System.currentTimeMillis();
System.out.println("索引制作完成! 消耗时间: " + (end - beg));
}
/**
* 枚举指定路径下的所有文件
* @param rootPath 从哪个目录开始进行递归遍历
* @param fileList 递归得到的结果集
*/
private void enumFile(String rootPath, ArrayList fileList) {
File rootFile = new File(rootPath);
// 获取到 rootPath 当前目录下所包含的文件/目录
File[] files = rootFile.listFiles();
for(File f : files) {
// 如果 f 是一个目录, 就进行递归
// 如果 f 是一个普通文件, 就加入到 fileList 中
if(f.isDirectory()) {
enumFile(f.getAbsolutePath(), fileList);
} else {
// endsWith() 表示字符串以什么结尾
if(f.getAbsolutePath().endsWith(".html")) {
fileList.add(f);
}
}
}
}
private void parseHTML(File f) {
// 1. 解析出 HTML 文件的标题
String title = parseTitle(f);
// 2. 解析出 HTML 文件的 URL
String url = parseUrl(f);
// 3. 解析出 HTML 文件的正文(后续再处理描述)
String content = parseContent(f);
// String content = parseContentByRegex(f); 后续记得要将其改成正则表达式的版本.
// 4. TODO 把解析出来的 HTML 文件的具体信息加入到索引中.(涉及 Index 类)
}
private String parseTitle(File f) {
// 获取标题不必读取到 标签中的内容, 只需拿文件名即可
String name = f.getName();
return name.substring(0, name.length() - ".html".length());
}
//Java API 文档存在两份 : 线上文档,本地文档
// 这两文件路径的后缀很相似, 因此就可以拿着这两个路径的关键部分进行拼接, 就可以得到线上文档的网络路径了
private String parseUrl(File f) {
// 线上文档
String part1 = "https://docs.oracle.com/javase/8/docs/api";
// 线下文档
String part2 = f.getAbsolutePath().substring(INPUT_PATH.length());
String result = (part1 + part2).replaceAll("\\\\", "/");
return result;
}
private String parseContent(File f) {
// 按照字符来读取, 以正文中标签的 < 和 > 作为控制拷贝数据的开关
try(FileReader fileReader = new FileReader(f)) {
// 拷贝数据的开关
boolean isCopy = true;
// 用来保存结果
StringBuilder content = new StringBuilder();
while(true) {
// 一次读一个字符, 返回值为 int 是为了处理一些非法情况, 如果读到了末尾, 就会返回 -1
int ret = fileReader.read();
if (ret == -1) {
// 文件读完了
break;
}
char c = (char) ret;
if(isCopy) {
// 开关处于打开状态
if(c == '<') {
// 遇到 <, 就关闭开关
isCopy = false;
continue;
}
// 优化 : 将换行符转换成空格
if(c == '\n' || c == '\r') {
c = ' ';
}
// 其他字符, 就进行拷贝
content.append(c);
} else {
// 开关处于关闭状态
if(c == '>') {
// 遇到 >, 就关闭开关
isCopy = true;
}
}
}
return content.toString();
} catch (IOException e) {
e.printStackTrace();
}
return "";
}
}
【Parser 类的一些实现细节】
parseTitle() 方法
为什么直接拿文件名作为标题 >>>
当我们以记事本的方式打开每一个 html 文件时, 发现title 标签中的内容其实和文件名没啥区别, 只不过多了一个 (Java Platform SE 8), 我们只需要标题保持一致即可, 其他的都不重要.
parseContent() 方法
1. 对于标签中间的正文, 有一个特点, 以 < 开头, 以 > 结尾, 于是就可以拿 < > 作为控制拷贝的开关, 当然不排除会拷贝到 ", " "); // 3. 替换普通的 HTML 标签 content = content.replaceAll("<.*?>", " "); // 4. 将多个空格合并成一个空格 content = content.replaceAll("\\s+", " "); return content; }1. 此处的 readFile 方法和之前的 parseContent 方法很相似, 只是少了开关这一操作. 先把正文所有内容都读到一个字符串中.
2. 其次要注意的是再使用正则表达式的时候, 前面的 2,3 步骤顺序不能颠倒, 否则就和之前的没区别了.
3. 在合并多个空格时, 使用正则表达式 \s 搭配 + 号来使用, 而不是搭配 * 号来使用, 因为搭配 + 号表示至少匹配一个空格, 而搭配 * 号可以匹配一个空格都没有的情况, 就不科学.此时我们还要回过头把 Parse 类中调用 parseContent() 的地方改成调用 parseContentByRegex(), 并再次测试 Parse 类中制作索引的速度, 看看改成正则表达式之后, 有没有拖慢索引制作的速度. (大家可以自己测试一下, 答案是并没有拖慢).
代码改进后的搜索结果 >>
此时搜索结果看起来就科学很多了.
2.6 实现 web 模块
仅仅在控制台进行搜索, 这样用户体验不友好, 于是可以提供一个 web 接口, 最终以网页的形式, 把程序呈现给用户. 而实现 web 模块就需要前端 + 后端, 此处前端就使用 (HTML + CSS + JS), 后端基于 SpringBoot 来实现.
2.6.1 准备前端页面
此处没有细节可言, 因为我对前端非常陌生, 所以做出的页面也非常丑陋, 凑合着使用即可.
Java 文档搜索 页面样式 >>
后续还会遇到很多问题,此处先了解页面的基本结构>>
2.6.2 搜索模块的前后端交互
约定前后端交互接口
此处只需要提供一个接口 - 搜索接口
请求 >>
GET / searcher?query=[查询词] HTTP/1.1响应 >>
HTTP/1.1 200 OK
返回的数据 : JSON 格式 >> [{title:"ArrayList", url:"http:xxxx.araylist",desc:"arraylist....."}, {.....}]前端代码
前后端交互流程, 前端使用 ajax 来构造 HTTP 请求, 此处不使用 JS原生的 ajax, 此处借助第三方库 jquery 提供的 ajax.第三库 jquery 依赖下载链接 : https://code.jquery.com/jquery-3.6.3.min.js
进入链接后, 只需要 ctrl A, 然后复制, 然后在前端新创建 js文件, 将刚刚复制的代码粘贴进去即可, 然后哪些地方使用到了第三方库提供的 ajax , 就直接引用即可.
【注意事项】
此处拿到响应数据, 在构造页面之前务必要进行一次清空操作, 否则每次搜索的结果都冉到一起了.这就不科学了.后端代码
创建一个 controller 包, 创建 DocSearcherController 类.
@RestController public class DocSearcherController { @Autowired private DocSearcher docSearcher; private ObjectMapper objectMapper = new ObjectMapper(); @RequestMapping(value = "/searcher", produces = "application/json;charset=utf-8") @ResponseBody public String search(@RequestParam("query") String query) throws JsonProcessingException { List
results = docSearcher.searcher(query); return objectMapper.writeValueAsString(results); } } 2.6.3 验证 web 模块
先搜索一个 arraylist >>
再搜索一个 Map >>
搜索结果看上去没啥问题, 但是相较于浏览器来说, 还有很大的区别, 对比搜狗搜出来的结果, 发现它会将关键词进行标红处理, 用户体验非常友好, 于是我们也实现一下标红处理 >>
2.7 实现标红逻辑
实现标红逻辑, 需要前后端配合 >>
后端处理 >>
后端针对生成搜索结果的描述部分, 把其中包含查询词的部分, 加上一个标记. 例如给这部分套上一层 标签.前端处理 >>
前端针对响应中的数据, 给 标签设置样式即可.2.7.1 修改 GenDesc() 方法
private String GenDesc(String content, List
terms) { // .... 省略相同代码 // 后端标红逻辑, 给描述中和分词结果相同的部分, 套上一层 标签 for(Term term : terms) { String word = term.getName(); // 1.注意全字匹配 // 2.replace 的操作是生成一个新的 String, 记得拿 desc 接收 // 3. (?i) 表示忽略大小写匹配, 因为分词库天然将 word 转成小写了. desc = desc.replaceAll("(?i)" + " " + word + " ", " " + word + " "); } return desc; } 2.7.2 前端添加标红样式
.item .desc i { color: red; /* 去掉斜体 */ font-style: normal; }
2.7.3 验证标红逻辑
输入查询词 arraylist >> (符合预期)
2.8 测试一些复杂的查询词
当我们输入 array list 查询词时 >> (中间多了一个空格)
此时搜索结果竟然比中间不带空格的 arraylist 多了一万多条, 而且搜出来的结果还有和 array, list 都毫不相关的结果, 这就很不科学.
【为什么会出现这样的原因】
1. 当查询词为中间带空格的 array list 时, 分词库会将空格也分出来, 那么拿着空格去倒排索引中查倒排拉链, 就会把所有文档给查出来, 毕竟哪个文档不带空格.
2. 而且并不只是空格这样的词会导致这样的情况, 像 a, is, have, 一, 是, 的, 有....这样词都属于高频词, 但又没啥意义. 这一类词也叫作暂停词.2.8.1 处理暂停词
首先去网上搜索英文暂停词表, 然后使用记事本保存起来, 再把这个 txt 文件保存到一个路径下面.
此处我是直接将其保存到了索引文件的目录下面 >>
2.8.1.1 修改 DocSearcher 类中的代码
引入暂停词表的文件路径, 并且创建一个 HashSet 来保存暂停词
// 停用词表的文件路径 private static final String STOP_WORD_PATH = "D:/software-download/Java-Official-document/stop_word.txt"; // 使用 HashSet 来保存停用词 private HashSet
stopWordsSet = new HashSet<>();
编写加载暂停词到内存的方法
// 加载暂停词表到内存 public void loadStopWords() { try(BufferedReader bufferedReader = new BufferedReader(new FileReader(STOP_WORD_PATH))) { while(true) { String stop_word = bufferedReader.readLine(); if(stop_word == null) { // 读取文件完毕 break; } stopWordsSet.add(stop_word); } } catch(IOException e) { e.printStackTrace(); } }
修改 searcher 方法
public List
searcher(String query) { // 1. [分词] 针对 query 查询词进行分词 List oldTerms = ToAnalysis.parse(query).getTerms(); List terms = new ArrayList<>(); // 针对分词结果, 使用暂停词表进行过滤 for(Term term : oldTerms) { if(stopWordsSet.contains(term.getName())) { continue; } terms.add(term); } // .... 省略相同代码 return results; }
在 DocSearcher 类的构造方法中调用 loadStopWords() 方法
public DocSearcher() { // 内存中有索引结构才能根据查询词查处结果, 所以要调用 load() 方法 index.load(); // 加载暂停词表 loadStopWords(); }
2.8.1.2 验证处理暂停词效果
此时搜索结果就正常多了, 并且往下滑几乎就看不到与 array, list 不相关的搜索结果了 >>
2.8.2 使用正则表达式处理生成描述时出现的 Bug
通过 array list 的搜索结果往下滑, 发现还是有个别搜索结果出现既与 array 不相关, 也与 list 不相关的查询结果.
将其点开, 并查看网页源代码 >>
Ctrl f , 搜索 array, 出现了这样的结果
说明在生成描述的时候 (GenDesc()), 出现了问题, 于是找到相应的代码 >>
firstPos = content.toLowerCase().indexOf(" " + word + " "); // [并不严谨]
我们前面写成 indexOf(" " + word + " ") 是为了防止出现查询词为 list, 搜索出与 arraylist 相关的结果. 却忽略了正文中会出现 " aaa array)", " aaa array.", " aaa array," 这样的搜索结果.于是此处可以使用正则表达式进行处理 >>
\b -> 匹配一个单词的边界, 也就是只单词和空格, 以及各种标点符号, 括号间的位置.使用在线正则在线工具进行示范 >>
实现思路
此处由于 indexOf 函数不支持正则表达式, 所以采取一种 曲线救国的方式, 我们可以 将单词与标点间的位置, 单词与括号间的位置, 转换成单词与空格间的位置, 于是就可以使用 replaceAll 方法来实现, 因为它是支持正则表达式的.代码改进
private String GenDesc(String content, List
terms) { // 遍历分词结果, 只要找到一个在 content 中出现的查询词, 就按照一定的规则截取一段内容 int firstPos = -1; for(Term term : terms) { // 此处分词库已经将 word 转小写了 String word = term.getName(); // 此处从正文查询分词结果需要按照全字匹配的规则, 不涉及同义词, 近义词 // [改进代码] - 使用正则表达式 \b 处理单词与空格, 标点符号间的位置 content = content.toLowerCase().replaceAll("\\b" + word + "\\b"," " + word + " "); firstPos = content.indexOf(" " + word + " "); if(firstPos >= 0) { // 找到一个词在正文中出现的位置了 break; } } // ... 省略相同代码 return desc; } 2.8.2.1 验证描述中是否还存在 Bug
输入查询词 array list >>
Ctrl f 找到刚才的 package-use :
此时搜索结果中的描述就变得正常了.
2.9 实现权重合并
查询词为 array >> 搜索结果有 1598 条
查询词为 list >> 搜索结果有 1381 条
查询词为 array list >> 搜索结果有 2979 条
两次搜索结果的总次数为 2979 条, 和查询词为 array list 的搜索结果条数竟然一样多.
那么这样的结果是否正确 ??
通过滑动搜出来的结果, 仔细一思考就可以知道这里面有些文档是既包含 array , 又包含 list 的, 如果分开搜索和一起搜索查询出来的结果条数一样多的话, 那么当查询词为 array list 的时候, 查询出来的文档就一定会将同时包含 array 和 list 的文档查询出来两次, 这显然不合理.为什么会出现这样的情况 >>
前面我们算权重, 是依次针对分词结果进行触发, 也就是说 array 会触发出一组 docId, list 会触发出一组 docId, 而这两组 docId 中可能就会出现相同的 docId 的情况, 如下图 >> 而我们前面是直接将所有文档触发出来的 Weight 对象都混合在一个一维数组中了, 所以才会出现这样的问题. (正确的做法是搞一个二维数组)当前要解决的问题 >>
1. 一个文档不应该展示两次.
2. 像 Collections 这样的文档, 同时包含多个分词结果, 其实就意味着该文档的 "相关性" 更高!!
于是我们就需要提高这个文档的权重, 如何提高, 那么就需要进行多路归并.此处多路归并的思路就和合并 k 个已排序链表的思路一样, 如果你会做下面这道算法题, 那么你就能想明白合并权重的思路. -- 合并 k 个已排序链表
2.9.1 改进 searcher 方法
public List
searcher(String query) { // .... 省略相同代码 // 2. [触发] 针对分词结果来查倒排 [改进代码 - 合并权重] // [改进] 使用二维数组将每个分词结果查询出来的倒排拉链给保存起来 List > termResult = new ArrayList<>(); for(Term term : terms) { String word = term.getName(); List
invertedList = index.getInverted(word); if(invertedList == null) { // 说明这个词在所有文档中都不存在 continue; } // 查询出来的文档都装在一个 List 中 termResult.add(invertedList); } // 3. [合并] 针对多个分词结果, 触发出的相同文档, 进行权重合并 List allTermResult = mergeResult(termResult); // .... 省略相同代码 } 2.9.2 实现多路归并的核心方法
static class Pos { public int row; public int col; public Pos(int row, int col) { this.row = row; this.col = col; } } private List
mergeResult(List > source) { // 在进行多路归并的时候, 是需要操作二维 list 里面的每一个元素的 // 而操作元素就涉及到 "行" 和 "列" 这样的概念, 为了方便确定每个元素, 于是提供一个内部类 Pos // 1. 先针对每一个一维 list 按照 docId 进行升序排序 for(List
curRow : source) { curRow.sort(new Comparator () { @Override public int compare(Weight o1, Weight o2) { return o1.getDocId() - o2.getDocId(); } }); } // 2. 使用优先级队列, 针对所有有序的一维 list 进行合并 List target = new ArrayList<>(); // -- target 表示合并的结果 PriorityQueue queue = new PriorityQueue<>(new Comparator () { @Override public int compare(Pos o1, Pos o2) { // 拿到数组中的一个 Weight 对象 Weight weight1 = source.get(o1.row).get(o1.col); Weight weight2 = source.get(o2.row).get(o2.col); // 指定排序规则 return weight1.getDocId() - weight2.getDocId(); } }); // 初始化优先级队列 for(int row = 0; row < source.size(); row++) { // 初始插入队列中元素的列为 0 queue.offer(new Pos(row, 0)); } // 循环取队首元素 while(!queue.isEmpty()) { Pos minPos = queue.poll(); Weight curWeight = source.get(minPos.row).get(minPos.col); // 判断当前取出来的元素和前一个插入 target 中的元素的 docId 是否相同, 如果是就合并权重 if(target.size() > 0) { // 取出上一次插入到 target 中的元素 Weight lastWeight = target.get(target.size() - 1); // docId 相同, 合并权重 if(lastWeight.getDocId() == curWeight.getDocId()) { // 合并后的权重 int newWeight = curWeight.getWeight() + lastWeight.getWeight(); lastWeight.setWeight(newWeight); } else { // docId 不同, 插入到 target target.add(curWeight); } } else { // target 为空, 直接插入 target.add(curWeight); } // 当前最小 docId 处理完之后, 就将该行的列往后移, 如果位置合法就入队列, 继续进行下一次循环 Pos newPos = new Pos(minPos.row, minPos.col + 1); // 判断当前最小 docId 这一行 , 往后移动 1 格后, 下标是否合法 if(newPos.col >= source.get(newPos.row).size()) { // 下标不合法, 这一行已经合并完了, 继续对剩下的行进行合并 continue; } queue.offer(newPos); } return target; } 此处的实现确实要合并 k 个有序链表稍微难以理解一些, 其实只要想到创建一个内部类来操作二维数组中的元素, 后面的逻辑就和合并 k 个有序链表差不多了.2.9.3 验证权重合并的效果
查询词为 array list, 搜索结果 >>
此时的搜搜结果就从原来的 2979 变成了 2478, 说明查询出两次的文档还是比较多的.
2.10 设置控制线上运行与线下运行的开关
此处省略了部署操作, 提供一个控制线上运行和线下运行的开关, 方便修改代码, 而不是每次都修改路径.
创建 Config 类 >>
public class Config { // 变量为 true, 表示在云服务器上运行, 为 false 表示在本地运行 public static boolean isOnline = false; }
修改 Index 类中的索引路径 >>
// 保存和加载索引文件的路径 private static String INDEX_PATH = null; static { if(Config.isOnline) { INDEX_PATH = "/root/install/doc_searcher_index/"; } else { INDEX_PATH = "D:/software-download/Java-Official-document/"; } }
修改 DocSearcher 类中的暂停词加载路径 >>
// 停用词表的文件路径 private static String STOP_WORD_PATH = null; static { if(Config.isOnline) { STOP_WORD_PATH = "/root/install/doc_searcher_index/stop_word.txt"; } else { STOP_WORD_PATH = "D:/software-download/Java-Official-document/stop_word.txt"; } }
这里面的线上线下路径根据自己的实际情况来定.
实现到这里, 基本就完成了整个 SpringBoot 项目!!