搜索模块
查询关键字文章子模块
查询语句
上述我们所做的工作都是将文档进行分词后保存到我们数据库的过程,下面我们将实现搜索功能
我们先来看最基本的一个搜索逻辑:
我们在前端输入一个词,然后根据词去倒排+正排索引中去搜索,然后就可以获得文档列表(并针对这个文档可以做分页)
假设此时我们搜索list这个词:使用单表查询:就需要写两条语句,而且还不能保证搜索出来的文档一定是按照权重降序排列的,如下:
select docid, weight from inverted_indexes where word = 'list' order by weight desc; select * from forward_indexes where docid in (...);//无序
所以此时我门就需要使用联表查询进行处理来保证我们最终查询的结果是有序的:
这里的join代表的是内连接(inner join),也可以只写JOIN。只有进行连接的两个表中,都存在与连接标准相匹配的数据才会被保留下来,相当于两个表的交集。如果前后连接同一张表,也叫自连接。
select ii.docid, title, url, content from inverted_indexes ii join forward_indexes fi on ii.docid = fi.docid where word = 'list' order by weight desc; //有序
获取拆分关键字子模块
这里主要就是对前端输入的查询条件进行分词操作,同样会使用到Ansj分词库进行分词
核心代码如下:
// 参数的合法性检查 + 处理 if (query == null) { log.debug("query 为 null,重定向到首页"); return "redirect:/"; } query = query.trim().toLowerCase(); if (query.isEmpty()) { log.debug("query 为空字符串,重定向到首页"); return "redirect:/"; } // 分词 List<String> queryList = ToAnalysis.parse(query) .getTerms() .stream() .map(Term::getName) .collect(Collectors.toList()); if (queryList.isEmpty()) { log.debug("query 分词后一个词都没有,重定向到首页"); return "redirect:/"; }
关键字建立综合权重子模块
核心代码:
int limit = 20; int offset = 0; int page = 1; if (pageString != null) { pageString = pageString.trim(); try { page = Integer.parseInt(pageString); if (page <= 0) { page = 1; } limit = page * 20; } catch (NumberFormatException ignored) {} } log.debug("limit = {}, offset = {}, page = {}", limit, offset, page); // 分别搜索 -> 聚合 -> 排序 -> 区间 List<DocumentWightWeight> totalList = new ArrayList<>(); for (String s : queryList) { List<DocumentWightWeight> documentList = mapper.queryWithWeight(s, limit, offset); totalList.addAll(documentList); } // 针对所有文档列表,做权重聚合工作 // 维护: // docId -> document 的 map Map<Integer, DocumentWightWeight> documentMap = new HashMap<>(); for (DocumentWightWeight documentWightWeight : totalList) { int docId = documentWightWeight.getDocId(); if (documentMap.containsKey(docId)) { DocumentWightWeight item = documentMap.get(docId); item.weight += documentWightWeight.weight; continue; } DocumentWightWeight item = new DocumentWightWeight(documentWightWeight); documentMap.put(docId, item); } Collection<DocumentWightWeight> values = documentMap.values(); // Collection 没有排序这个概念(只有线性结构才有排序的概念),所以我们需要一个 List List<DocumentWightWeight> list = new ArrayList<>(values); // 按照 weight 的从大到小排序了 Collections.sort(list, (item1, item2) -> { return item2.weight - item1.weight; }); int from = (page - 1) * 20; int to = Integer.min(from + 20, list.size()); // 从 list 中把分页区间取出来 List<DocumentWightWeight> subList = list.subList(from, to); List<Document> documentList = subList.stream() .map(DocumentWightWeight::toDocument) .collect(Collectors.toList());
前端展示子模块
前端采用相关的thymeleaf模版技术来进行的渲染操作。
model.addAttribute("query", query); model.addAttribute("docList", documentList); model.addAttribute("page", page); return "search";
因为使用到了thymeleaf模板,所以我们要在templates目录下撰写我们的搜索页面searth.html
<!DOCTYPE html> <html lang="zh-hans" xmlns:th="https://www.thymeleaf.org"> <head> <meta charset="UTF-8"> <title th:text="${query} + ' - 智能搜索'"></title> <link rel="stylesheet" href="/query.css"> </head> <body> <!-- th:xxx 是 thymeleaf 的语法 --> <!-- <div th:text="'你好 ' + ${name} + ' 世界'"></div>--> <div class="header"> <div class="brand"><a href="/">智能搜索</a></div> <form class="input-shell" method="get" action="/web"> <input type="text" name="query" th:value="${query}"> <button>智能搜索</button> </form> </div> <div class="result"> <!-- th:utext 和 th:text 的区别:要不要进行 HTML 转义 --> <!-- <div th:text="'<span>你好 th:text</span>'"></div>--> <!-- <div th:utext="'<span>你好 th:utext</span>'"></div>--> <div class="result-item" th:each="doc : ${docList}"> <a th:href="${doc.url}" th:text="${doc.title}"></a> <div class="desc" th:utext="${doc.desc}"></div> <div class="url" th:text="${doc.url}"></div> </div> </div> <!-- 一直上一页可能走到 page <= 0 的情况 --> <!-- 一直下一页可能走到 page > 上限的情况 --> <div class="pagination"> <a th:href="'/web?query=' + ${query} + '&page=' + ${page - 1}">上一页</a> <a th:href="'/web?query=' + ${query} + '&page=' + ${page + 1}">下一页</a> </div> </body> </html>
对搜索出的内容中关键字高亮展示
这里的高亮展示只针对我们query参数分词后的第一个词,当然后期的高亮展示也可根据业务需求进行调整
package com.peixinchen.searcher.web; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import java.util.List; @Slf4j @Component public class DescBuilder { public Document build(List<String> queryList, Document doc) { // 找到 content 中包含关键字的位置 // query = "list" // content = "..... hello list go come do ...." // desc = "hello <i>list</i> go com..." String content = doc.getContent().toLowerCase(); String word = ""; int i = -1; for (String query : queryList) { i = content.indexOf(query); if (i != -1) { word = query; break; } } if (i == -1) { // 这里中情况如果出现了,说明咱的倒排索引建立的有问题 log.error("docId = {} 中不包含 {}", doc.getDocId(), queryList); throw new RuntimeException(); } // 前面截 120 个字,后边截 120 个字 int from = i - 120; if (from < 0) { // 说明前面不够 120 个字了 from = 0; } int to = i + 120; if (to > content.length()) { // 说明后面不够 120 个字了 to = content.length(); } String desc = content.substring(from, to); desc = desc.replace(word, "<i>" + word + "</i>"); doc.setDesc(desc); return doc; } }
controller层的代码:
// lambda 中无法使用非 final 变量 List<String> wordList = queryList; documentList = documentList.stream() .map(doc -> descBuilder.build(wordList, doc)) .collect(Collectors.toList());
项目开发重难点
索引构建模块
倒排索引与正排索引构建
正则表达式
在正排索引构建的过程中,我们使用了正则表达式,这里当时也是查询了一下如何使用的
批量插入 + 多线程
如果一条一条的执行索引插入数据库的操作,其实是非常耗时的,也是非常低效的
所以我们将保存索引数据插入表的操作改成批量插入操作
那么什么是批量插入?
如果要实现批量插入的话:我们就要使用到mybatis中的动态sql的特性了:因为前面我们保存文档对象使用的是list集合,所以最终我们使用foreach来对list集合进行遍历
同时我们可以将批量插入的操纵并行化,所以引入了线程池,将我们的插批量插入操作并行化
搜索模块
联表查询优化
搜索模块的查询语句为:
select ii.docid, title, url, content from inverted_indexes ii join forward_indexes fi on ii.docid = fi.docid where word = 'list' order by weight desc; //有序
但是这条语句时有缺陷的:
例如我们搜索单条语句的时候:
select docid, weight from inverted_indexes where word = 'list' order by weight desc;
这样的一条查询语句所需要的时间为1.8秒左右,在之前我们了解到inverted_indexes中的数据大概有200多w条,可想而知最后的查询会非常慢
查询很慢,应该怎么办?
答:向表中新建索引(针对 word列去建索引)
建索引的过程,就是把 word 列作为key,docid 作为value,新建一棵搜索树(B+树)
从key查找value,则时间复杂度变成O(log(n)),如果是200w条只需要执行21次,远远小于O(n)的时间复杂度执行的次数,这个时间复杂度相当于要执行200w次
建索引的速度很慢,而且会导致数据插入很慢,所以,表中的数据已经插入完成的情况下,添加索引
(1)未使用索引:
(2)给word字段添加一个普通查询索引,名字叫做INDEX_word
选中数据表,单击右键
找到索引
名字可以随便取
字段我选择的是word,类型我选的是NORMAL,方法用的是BTREE
执行以下sql语句:
使用explain查看索引的建立情况:
explain select docid, weight from inverted_indexes where word = 'list' order by weight desc;
建立索引情况:
当然除了可以给word列建立索引的方式以外,我们还可以给word和weight按照先后顺序来建议索引,给word加索引是为了保证检索的时候提升检索速度,而我们给weight添加索引的时候是为了增加排序速度,因为索引本质上是搜索树,搜索数有序,所以它直接可以用索引排序了,所以速度就提升非常明显啊
可以看到查询的速率上来了:没有用到filesort
所以最终我们在查询的时候,要为我门表中的字段增添索引
多词查询时的权重聚合
(1)首先思考一个问题,假设我现在搜索的词语为list map string,,那么我要取搜索到的结果的第三页的结果,请问我该怎么找到?先给出一个想法:
答案:不正确
那么该怎么取呢?
所以我们每次取某一页的结果,都要对结果从第一条开始到这一页的最后一条语句进行聚合权重,然后重新排序,虽然这样时间复杂度高,但是一般来说啊,就是用户不会选,特别后面的,实际中呢,就是基本上是针对前几页做热点数据优化,后面的就是不会太管他,或者是说慢一点就慢一点,现实中也是这样的,就是针对用户经常访问的商品,你放的离用户近一点,如果用户很少用就慢就慢一点嘛,他愿意访问这点东西,他是愿意等的,其实是。
(2)假设此时搜索的关键词为list string map三个关键字的时候,会出现很多个文档,那么让这些文档如何排序进行展示
思路
先将分词后的结果保存到一个list数组里面去:
List<DocumentWightWeight> totalList = new ArrayList<>(); for (String s : queryList) { List<DocumentWightWeight> documentList = mapper.queryWithWeight(s, limit, offset); totalList.addAll(documentList); }
接下来做权重的重新聚合工作
// 针对所有文档列表,做权重聚合工作 // 维护: // docId -> document 的 map Map<Integer, DocumentWightWeight> documentMap = new HashMap<>(); for (DocumentWightWeight documentWightWeight : totalList) { int docId = documentWightWeight.getDocId(); if (documentMap.containsKey(docId)) { DocumentWightWeight item = documentMap.get(docId); item.weight += documentWightWeight.weight; continue; } DocumentWightWeight item = new DocumentWightWeight(documentWightWeight); documentMap.put(docId, item); }
假设
list这个词出现再了docid为1的文档里,权重为13,同时list这个词出现再了docid为2的文档里,权重为22
String出现在了docid为1的文档里、权重为7,
map出现在了docid为1的文档里、权重为1,
那么同时搜索list String map的时候,它会将docid和DocumentWightWeight存成一个map集合,然后不同的词相同的docid号中的weight进行累加
在重新聚合的结果中根据weight进行由大到小的排序
Collection<DocumentWightWeight> values = documentMap.values(); // Collection 没有排序这个概念(只有线性结构才有排序的概念),所以我们需要一个 List List<DocumentWightWeight> list = new ArrayList<>(values); // 按照 weight 的从大到小排序了 Collections.sort(list, (item1, item2) -> { return item2.weight - item1.weight; });
对排序后的结果进行分页展示(截取区间):
int from = (page - 1) * 20; int to = Integer.min(from + 20, list.size()); // 从 list 中把分页区间取出来 List<DocumentWightWeight> subList = list.subList(from, to); List<Document> documentList = subList.stream() .map(DocumentWightWeight::toDocument) .collect(Collectors.toList());
项目总结
1.为什么要做这个项目?
同时由于搜索引擎的应用非常的广泛,为了了解这个搜索引擎背后的原理以及对于spring的熟悉程度,做了一个基于springboot的搜索引擎项目来锻炼自己。同时,也是为了在以后的生活以及工作中能够应用到这个项目中的一些知识点等。
2.本项目的难点是什么?
本项目的难点在于正排索引以及倒排索引的设计,首先需要记录每一篇文章的标题以及各个文章之间的分词,文章利用正则表达式进行单词的挑选,在设计正则表达式时是挺不容易的,最后通过查询搞清了正则表达式的一些相关用法,其次就是后来的调优阶段,在首先插入的时候需要半个小时之久,插入两百多万条数据确实不是一个小的数目,最后通过批量的插入以及相关的线程池的多线程解决了此问题
同时在搜索模块中使用索引加快查询的速度,并对多词查询的结果的展示进行了权重的聚合排序展示
3.本次项目对你最大的提升是什么?
通过本次项目,最大的提升莫过于技术方面的提升,使得自己对于springboot框架更加清楚基本流程是什么,以及对于每一个数据他背后所存在的意义是什么,每个注解的意义是什么,该怎么使用,以及对于spring框架的熟悉,在中间碰到了许许多多的错误,通过一点点的摸索,解决相关的问题,同时,对于自己处理能力的方法以及手段有了进一步的提升。