1. 认识搜索引擎:
在搜狗搜索的搜索结果页中, 包含了若干条结果, 每一个结果包含了图标, 标题, 描述, 展示URL等
搜索引擎的本质:输入一个查询词, 得到若干个搜索结果, 每个搜索结果包含了标题, 描述, 展示URL和点击URL
2. 搜索引擎思路:
2.1 搜索的核心思路:
当前我们有很多的网页(假设上亿个), 每个网页我们称为是一个文档
如何高效进行检索? 查找出有哪些网页是和查询词具有一定的相关性呢?
我们可以认为, 网页中包含了查询词(或者查询词的一部分), 就认为具有相关性.
那么我们就有了一个直观的解决思路
方案一 -- 暴力搜索
每次处理搜索请求的时候, 拿着查询词去所有的网页中搜索一遍, 检查每个网页是否包含查询词字符串.
这个方法是否可行?
显然, 这个方案的开销非常大. 并且随着文档数量的增多, 这样的开销会线性增长. 而搜索引擎往往对于效率的要求非常高.
方案二 -- 倒排索引
这是一种专门针对搜索引擎场景而设计的数据结构.
文档(doc): 被检索的html页面(经过预处理)
正排索引: "一个文档包含了哪些词". 描述一个文档的基本信息, 包括文档标题, 文档正文, 文档标题和正文的分词 /断句结果
倒排索引: "一个词被哪些文档引用了". 描述了一个词的基本信息, 包括这个词都被哪些文档引用, 这个词在该文档 中的重要程度, 以及这个词的出现位置等.
2.2 项目目标:
- 实现一个 Java API 文档的简单的搜索引擎.
- 最终效果
2.3 核心流程
- 索引模块: 扫描下载到的文档, 分析数据内容构建正排+倒排索引, 并保存到文件中.
- 搜索模块: 加载索引. 根据输入的查询词, 基于正排+倒排索引进行检索, 得到检索结果.
- web模块: 编写一个简单的页面, 展示搜索结果. 点击其中的搜索结果能跳转到对应的 Java API 文档页面.
3. 实现搜索引擎:
3.. Weight, Result, DocInfo类
/** * 这个类就是把 文档id 和 文档与词的相关性 权重 进行一个包裹 */ @Data public class Weight { private int docId; // 这个 weight 就表示 文档 和 词 之间的"相关性" // 这个值越大, 就认为相关性越强 private int weight; } /** * 这个类来表示一个搜索结果 */ @Data public class Result { private String title; private String url; // 描述是正文的一段摘要 private String desc; } /** * 表示一个文档对象(HTML对象) * 根据这些内容后面才能制作索引, 完成搜索过程. */ @Data public class DocInfo { // docId 文档的唯一身份标识(不能重复) private int docId; // 该文档的标题. 简单粗暴的使用文件名来表示. // Collection.html => Collection private String title; // 该文档对应的线上文档的 URL. 根据本地文件路径可以构造出线上文档的 URL private String url; // 该文档的正文. 把 html 文件中的 html 标签去掉, 留下的内容 private String content; }
3.1 分词:
分词是搜索中的一个核心操作. 尤其是中文分词, 比较复杂(当然, 咱们此处暂不涉及中文分词)
我们可以使用现成的分词库 ansj.
注意: 当 ansj 对英文分词时, 会自动把单词转为小写.
- 导入依赖:
<dependency> <groupId>org.ansj</groupId> <artifactId>ansj_seg</artifactId> <version>5.1.6</version> </dependency>
- 实例代码:
public static void main(String[] args) { // 准备一个比较长的话, 用来分词 String str = "小明毕业于清华大学"; // Term 就表示一个分词结果 List<Term> terms = ToAnalysis.parse(str).getTerms(); for (Term term : terms){ System.out.println(term.getName()); } }
3.2 实现 Parser 类:
Parser 构建一个可执行程序, 负责读取 html 文档, 制作并生成索引数据(输出到文件中)
- 从制定的路径中枚举出所有的文件
- 读取每个文件, 从文件中解析出 HTML 的标题, 正文, URL
- 先指定一个加载文档的路径
private static final String INPUT_PATH = "C:/Users/LEO/Desktop/jdk-8u361-docs-all/docs/api";
- 创建一个 index 实例
1. 2. private Index index = new Index();
(1) run 方法
public void run(){ long beg = System.currentTimeMillis(); System.out.println("*** 索引制作开始! ***"); // 整个 searcher.Parser 类的入口 // 1. 根据上面指定的路径, 枚举出该路径中所有的文件(html), 这个过程需要把所有子目录中的文件都能获取到 ArrayList<File> fileList = new ArrayList<>(); enumFile(INPUT_PATH,fileList); /** * 获取到 INPUT_PATH 下的所有文件 * System.out.println(fileList); * System.out.println(fileList.size()); */ // 2. 针对上面罗列出的文件的路径, 打开文件, 读取文件内容, 并进行解析, 并构建索引 for (File f : fileList){ // 通过这个方法来解析单个的html文件 System.out.println("开始解析: " + f.getAbsolutePath()); parseHTML(f); } // 3. 把在内存中构造好的索引数据结构, 保存到指定的文件中 index.save(); long end = System.currentTimeMillis(); System.out.println("**** 索引制作完成! " + (end - beg) + "ms ****"); }
(2) enumFile() 枚举出该路径中所有的文件
<code class="language-plaintext hljs">// 第一个参数表示: 从哪个目录开始进行递归遍历 // 第二个参数表示: 递归得到的结果 // inputPath: C:/Users/LEO/Desktop/jdk-8u361-docs-all/docs/api private void enumFile(String inputPath, ArrayList<File> fileList) { File rootPath = new File(inputPath); // listFiles 能够获取到 rootPath 当前目录下所包含的文件/目录 // 使用 listFiles 只能看到一级目录, 看不到子目录里的内容 // 要想看到子目录中的内容, 还需要进行递归 File[] files = rootPath.listFiles(); for (File f : files){ // 根据当前 f 的类型, 来决定是否要递归 // 如果 f 是一个普通文件, 就把 f 加入到 fileList 结果中 // 如果 f 是一个目录, 就递归的调用 enumFile 方法, 进一步的获取子目录中的内容 if(f.isDirectory()){ enumFile(f.getAbsolutePath(), fileList); } else { // 排除非html文件 // endsWith是String类的方法 if(f.getAbsolutePath().endsWith(".html")){ fileList.add(f); } } } }</code>
(3) parseHTML() 通过这个方法来解析单个的html文件
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. 把解析出来的这些信息加入到索引当中 index.addDoc(title, url, content); }
(4) parseTitle() 解析出html文件的标题
private String parseTitle(File f) { String name = f.getName(); return name.substring(0, name.length() - ".html".length()); }
(5) parseUrl() 解析出html文件的URL
private String parseUrl(File f) { // String part1 = "file:///C:/Users/LEO/Desktop/jdk-8u361-docs-all/docs/api/"; String part11 = "https://docs.oracle.com/javase/8/docs/api"; String part2 = f.getAbsolutePath().substring(INPUT_PATH.length()); return part11 + part2; }
(6) parseContent() 解析出html文件的正文
// (边读边判断) public String parseContent(File f) { // 先按照一个字符一个字符的方式来读取, 以<和>来控制拷贝数据的开关 // BufferedReader bufferedReader = new BufferedReader(new FileReader(f),1024 * 1024); try { FileReader fileReader = new FileReader(f); // 加上一个是否要进行拷贝, 开关 boolean isCopy = true; // 还得准备一个保存结果的 StringBuilder StringBuilder content = new StringBuilder(); while(true){ // 注意, 此处的 read 返回值是一个 int , 不是 char // 此处使用 int 作为返回值, 主要是为了表示一些非法情况 // 如果读到了文件末尾, 继续读, 就会返回 -1 int ret = fileReader.read(); if(ret == -1){ // 表示文件读完了 break; } // 如果这个结果不是 -1, 那么就是一个合法的字符 char c = (char) ret; if(isCopy) { // 开关打开的状态, 遇到普通字符就应该拷贝到 Stringbuilder 中 if(c == '<'){ // 关闭开关 isCopy = false; continue; } if(c == '\n' || c == '\r'){ // 目的是为了去掉换行, 把换行符替换成空格 c = ' '; } // 其他字符, 直接进行拷贝即可, 把结果给拷贝到最终的 StringBuilder 中 content.append(c); } else { // 开关关闭的状态, 就暂时不拷贝, 直到遇到 > if(c == '>'){ isCopy = true; } } } fileReader.close(); return content.toString(); } catch (IOException e) { e.printStackTrace(); } return ""; }
(7) parseContentByRegex() 使用正则获取html的正文
基于正则表达式去除 script 标签的内容
// (先全部读取完, 然后替换) readFile 是 parseContentByRegex 需要的读取文件的方法 private String readFile(File f){ try(BufferedReader bufferedReader = new BufferedReader(new FileReader(f))){ StringBuilder content = new StringBuilder(); while(true){ int ret = bufferedReader.read(); if(ret == -1){ break; } char c = (char) ret; if(c == '\n' || c == '\r'){ c = ' '; } content.append(c); } return content.toString(); } catch (IOException e){ e.printStackTrace(); } return ""; } // 这个方法内部就基于正则表达式, 实现去标签, 以及去除 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; }
(8) 通过这个main方法实现整个制作索引的过程
要先将api文档扫描完并保存到磁盘上, 然后再启动tomcat
public static void main(String[] args) throws InterruptedException { // 通过main方法来实现整个制作索引的过程 Parser parser = new Parser(); // parser.run(); parser.runByThread(); }