【文档搜索引擎】搜索模块的完整实现
调用索引模块,来完成搜索的核心过程
主要步骤
简化版本的逻辑:
- 分词:针对用户输入的查询词进行分词(用户输入的查询词,可能不是一个词,而是一句话)
- 触发:拿着每个分词结果,去倒排索引中查,找到具有相关性的文档(调用 Index 类里面查倒排的方法)
- 排序:针对上面触发出来的结果,进行排序(按照相关性,降序排序)
- 包装结果:根据排序后的结果,依次去查正排,获取到每个文档的详细信息,包装成一定结构的数据,返回出去
DocSearcher 类
public List<Result> search(String query){}
这个方法用来完成整个搜索的过程。
- 参数就是用户给出的查询词
- 返回值就是搜索结果的集合
// 通过这个类,来完成整个的搜索过程
public class DocSearcher {
// 此处要加上索引对象的实例
// 同时要完成索引加载的工作(这样才能将文件里面的索引加到内存中,不然内存中没有东西查)
private Index index = new Index();
public DocSearcher() {
index.load();
}
// 完成整个搜索过程的方法
// 参数(输入部分)就是用户给出的查询词
// 返回值(输出部分)就是搜索结果的集合
public List<Result> search(String query){
// 1. [分词] 针对 query 这个查询词进行分词
// 2. [触发] 针对分词结果来查倒排
// 3. [排序] 针对触发的结果按照权重降序排序
// 4. [包装结果] 针对排序的结果,去查正排,构造出要返回的数据
return null;
}
}
- 这里要加上索引,并且要将索引加载到内存中,不然搜索没有原数据
- 我们这里直接使用一个构造方法,将
index
加载到内存中即可
1. 分词操作
针对 query
这个查询词进行分词
List<Term> terms = ToAnalysis.parse(query).getTerms();
- 直接使用第三方库,进行分词
- 用 List 接收每一个分词结果
2. 触发
针对分词结果来查倒排
List<Weight> allTermResult = new ArrayList<>();
for(Term term : terms) {
String word = term.getName();
// 虽然倒排索引中有很多的词,但是这里的词一定都是之前的文档中存在的
List<Weight> invertedList = index.getInverted(word);
if(invertedList == null) {
// 说明这个词在所有文档中都不存在
continue;
}
// 对我们的每一个倒排拉链进行汇总
allTermResult.addAll(invertedList);
}
- 循环遍历
Terms
,提出每一个词的名字,然后去查倒排- 首先取到名字
- 然后直接使用
index
里面的查倒排的方法getInverted
方法即可(这里是直接返回term
所对应的value
,若不存在,就返回null
)
- 最后将所有的倒排拉链都加入到
allTermResult
中,进行汇总
3. 排序
针对触发的结果按照权重降序排序。此时待排序的对象是 alltermResult
- 此处我们直接使用线程的排序方法
allTermResult.sort(new Comparator<Weight>() {
@Override
public int compare(Weight o1, Weight o2) {
return o2.getWeight() - o1.getWeight();
}
});
- 这里进行
sort
比较时,由于比较对象不清楚,比较规则不知道,所以我们需要制定一个比较规则 - 创建一个实现
Comparator
接口的类,再去重写里面的方法,最后再去new
出实例- 如果是升序排序:
return o1.getWeight() - o2.getWeight();
- 如果是降序排序:
return o2.getWeight() - o1.getWeight();
- 如果是升序排序:
4. 包装结果
针对排序的结果,去查正排,构造出要返回的数据
List<Result> 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());
results.add(result);
}
- 取到每一个结果
weight
- 拿着
weight
里面的DocId
,去查找文档docInfo
- 将
docInfo
里面的title
和url
信息都设置到result
里面(content
部分我们只需要一部分,所以不能直接通过getContent
获得) - 将描述添加到
result
中 - 将
result
添加到链表results
中即可
生成描述
构造结果的时候,需要生成“描述”
- 描述就是正文的一段摘要,这个摘要源自于正文,同时要包含查询词或者查询词的一部分
生成描述的思路:
我们可以获取到所有的查询词的分词结果。
- 遍历分词结果,看哪个结果在正文中出现
- 针对这个被包含的分词结果,去正文中查找,找到对应的位置
- 以这个词的位置为中心,往前截取 60 个字符,作为描述的开始
- 再从描述开始,一股脑地截取 160 个字符,作为整个描述
针对当前这个文档来说,不一定会包含所有分词结果。只要包含其中一个就能被触发出来
小写转换查找
// 此处需要的是 “全字匹配”,让 word 能够独立成词,才要查找出来,
// 而不是只作为词的一部分(左右加空格)
firstPos = content.toLowerCase().indexOf(" " + word + " ");
word
是通过分词结果得来的,在进行分词的时候,分词库就自动地将word
转换成小写了- 正因如此,我们需要先把正文部分也转换成小写
独立成词
int firstPos = -1;
// 先遍历分词结果,看看哪个结果是在 content 中存在
for(Term term : terms) {
// 分词库直接针对词进行转小写了
// 正因如此,就必须把正文也先转成小写,然后再查询
String word = term.getName();
// 此处需要的是 “全字匹配”,让 word 能够独立成词,才要查找出来,而不是只作为词的一部分(左右加空格)
firstPos = content.toLowerCase().indexOf(" " + word + " ");
if(firstPos >= 0){
// 找到了位置
break;
}
}
- 假设现在的查询词是
List
- 文档正文中包含一个这样的单词——
ArrayList
- 在生成描述的时候,此处拿着这个 List 去正文中
indexOf
,此时是否会把 ArrayList 当做结果呢?(肯定会)- 这就会导致生成的描述,里面就是带
ArrayList
的,而不是带List
的(不科学的)
- 这就会导致生成的描述,里面就是带
- 类似的情况,在查倒排的时候,是否会存在呢?
- 不会的;倒排索引中的
key
都是分词结果,ArrayList
不会被分成Array + List
,就仍然会吧ArrayList
视为是一个单词,所以List
和ArrayList
不能匹配,因此List
这个词不能查出包含ArrayList
的结果(科学的)
- 不会的;倒排索引中的
因此我们希望在生成描述过程中,能够找到整个词都匹配的结果,才算是找到了,而不是知道到词的一部分
截取字符
// 所有的分词结果都不在正文中存在(标题中触发)
if(firstPos == -1) {
// 此时就直接返回一个空的描述,或者也可以直接取正文的前 160 个字符
// return null;
return content.substring(0, 160) + "...";
}
// 从 firstPos 作为基准位置,往前找 60 个字符,作为描述的起始位置
String desc = "";
int descBeg = firstPos < 60 ? 0 : firstPos - 60; // 不足 60 个字符,就直接从 0 开始读
if(descBeg + 160 > content.length()) {
desc = content.substring(descBeg); // 从 descBeg 截取到末尾
}else {
desc = content.substring(descBeg, descBeg + 160) + "...";
}
return desc;
- 当
firstPos
还是-1
的时候,就是分词结果未找到,我们可以直接返回null
或者正文前160
个字符 - 当
firstPos
不是-1
的时候,就是找到分词了- 若
firstPos < 60
,则descBeg
置为0
;若firstPos > 60
,则descBeg
置为firstPos - 60
- 若
descBeg
的长度大于正文的长度了,则直接在正文中从descBeg
的位置截取到文末;若没有,则从descBeg
的位置往后截取160
个字符
- 若
- 最后直接返回
desc
即可
完整代码
private String GenDesc(String content, List<Term> terms) {
int firstPos = -1;
// 先遍历分词结果,看看哪个结果是在 content 中存在
for(Term term : terms) {
// 分词库直接针对词进行转小写了
// 正因如此,就必须把正文也先转成小写,然后再查询
String word = term.getName();
// 此处需要的是 “全字匹配”,让 word 能够独立成词,才要查找出来,而不是只作为词的一部分(左右加空格)
firstPos = content.toLowerCase().indexOf(" " + word + " ");
if(firstPos >= 0){
// 找到了位置
break;
}
}
// 所有的分词结果都不在正文中存在(标题中触发)
if(firstPos == -1) {
// 此时就直接返回一个空的描述,或者也可以直接取正文的前 160 个字符
// return null;
return content.substring(0, 160) + "...";
}
// 从 firstPos 作为基准位置,往前找 60 个字符,作为描述的起始位置
String desc = "";
int descBeg = firstPos < 60 ? 0 : firstPos - 60; // 不足 60 个字符,就直接从 0 开始读
if(descBeg + 160 > content.length()) {
desc = content.substring(descBeg); // 从 descBeg 截取到末尾
}else {
desc = content.substring(descBeg, descBeg + 160) + "...";
}
return desc;
}
测试
public static void main(String[] args) {
DocSearcher docSearcher = new DocSearcher();
Scanner scanner = new Scanner(System.in);
while(true) {
System.out.println("-> ");
String query = scanner.next();
List<Result> results = docSearcher.search(query);
for(Result result : results) {
System.out.println("======================");
System.out.println(result);
}
}
}
- 在验证过程中,发现描述中出现了这种内容,这个内容就是
JavaScript
的代码 - 我们在处理文档的时候,只对正文进行了“去标签”,有的
HTML
里面还包含了script
标签 - 因此就导致去了标签之后,
JS
的代码也被整理到索引里面了 - 这个情况显然是并不科学的,我们需要处理一下
去掉 JS 标签和内容
正则表达式
- 通过一些特殊的字符串,描述了一些匹配的规则
Java
的String
里面的很多方法,都是直接支持正则的(indexOf
,replace
,replaceAll
,spilt
…)
这里我们主要用到的主要有:
.
:表示匹配一个非换行字符(不是\n
或者不是\r
)*
:表示前面的字符可以出现若干次.*
:匹配非换行字符出现若干次
去掉 script
的标签和内容,正则就可以写成这样:<script.*?>(.*?)</script>
- 先去匹配一下有没有
<script>
, 里面可能会包含各种属性, 有的话我们都当成任意字符来匹配
去掉普通的标签(不去掉内容):<.*?>
- 既能匹配到开始标签,也能匹配到结束标签
?
表示“非贪婪匹配”:尽可能短的去匹配,匹配一个符合条件的最短结果- 不带
?
表示“贪婪匹配”:尽可能长的去匹配,匹配一个符合条件的最长结果
假设有一个
content
:<div>aaa</div> <div>bbb</div>
- 如果使用贪婪匹配,
.*
此时就把整个正文都匹配到了。进行替换,自然就把整个正文内容都给替换没了- 如果使用非贪婪匹配,
.*?
此时就是会匹配到四个标签。如果进行替换,也只是替换标签,不会替换内容
代码实现
此时我们就需要重新对 Parser
类的 parserContent
方法进行修改,让其能够去掉 JS
标签和内容
此时我们在 Parser
类中重新写一个方法,实现一个让正文能够去掉 JS
标签和内容的逻辑。
- 这个方法内部就基于正则表达式,实现去标签,以及去除
script
- 先把整个文件都读到
String
里面(然后才好使用正则进行匹配)
这里我们实现一个 readFile
方法,用来读取文件
private String readFile(File f) {
// BufferedReader 设置缓冲区,将 f 中的内容预读到内存中
try(BufferedReader bufferedReader = new BufferedReader(new FileReader(f))){
StringBuilder content = new StringBuilder();
while(true) {
int ret = bufferedReader.read();
if(ret == -1) {
// 读完了
break;
}else {
char c = (char)ret;
if(c == '\n' || c == '\r'){
c = ' ';
}
content.append(c);
}
return content.toString();
}
}catch (IOException e){
e.printStackTrace();
}
return ""; // 抛了异常,就直接返回一个空字符串
}
- 替换掉
script
标签
content = content.replaceAll("<script.*?>(.*?)</script>", " ");
- 替换掉普通的
HTML
标签
content = content.replaceAll("<.*?>", " ");
注意标签替换顺序不能变
- 使用正则,把多个空格合并成一个
content = content.replaceAll("\\s+", " ");
- 正则表达式的空格是
\s
,\\s
是转义字符 +
也是表示这个符号会出现多次,还表示这个符号至少要出现一次*
只表示这个符号会出现多次,但也可以一次都不出现
完整代码:
// 这个方法内部就基于正则表达式,实现去标签,以及去除 script
public String parseContentByRegex(File f) {
//1. 先把整个文件都读到 String 里面
String content = readFile(f);
// 2. 替换掉 script 标签
content = content.replaceAll("<script.*?>(.*?)</script>", " ");
// 3. 替换掉普通的 HTML 标签
content = content.replaceAll("<.*?>", " ");
// 4. 使用正则把多个空格,合并成一个空格
content = content.replaceAll("\\s+", " ");
return content;
}
再次运行 DocSearcher
,可以发现描述中的内容变规范了:
搜索模块总结
实现了 Searcher
类里面的 search
方法
- 分词
- 触发
- 排序
- 包装结果
这里面的很多脏活累活都交给了第三方库和前面模块已经封装好的方法,这里仅仅只是将之前准备好的工作给串起来
这里的搜索模块实现比较简单,主要还是因为当前没有什么“业务逻辑”
- 有的搜索结果要展示不同的搜索样式(图片、子版块、视频…)
- 有的搜索结果会受到地域和时间的影响
- …
- 在实际开发中,技术都是为了业务服务的
- 在公司中除了学习技术之外,也要学习产品的业务