> 作者:დ旧言~
> 座右铭:松树千年终是朽,槿花一日自为荣。> 目标:能自己实现 Boost 搜索引擎。
> 毒鸡汤:有些事情,总是不明白,所以我不会坚持。早安!
> 专栏选自:实战项目_დ旧言~的博客-****博客
> 望小伙伴们点赞????收藏✨加关注哟????????
一、前言
我们平时在用浏览器搜索时,服务器给我们返回的分别是跟搜索关键字相关的一个个网站信息,网站信息分别包括网站的标题,网站内容的简述,和该网站的url。在点击标题后,会跳转到对应链接的页面。平时我们用的搜索引擎,比如说百度,谷歌等等,他们都是搜索全网的信息的,我们项目做的是一个小范围的搜索引擎,一个用 boost库 实现的 boost站内搜索。
二、项目的相关背景
2.1 什么是Boost库
Boost库:
是C++的准标准库, 它提供了很多C++没有的功能,可以称之为是C++的后备力量。早期的开发者多为C++标准委员会的成员,一些Boost库也被纳入了C++11中(如:哈希、智能指针);这里大家可以去百度百科上搜索,一看便知。
下面是boost的官网:
Boost C++ Libraries
2.2 什么是搜索引擎
概念:
对于搜索引擎,相信大家一定不陌生,如:百度、360、搜狗等,都是我们常用的搜索引擎。但是你想自己实现出一个和百度、360、搜狗一模一样哪怕是类似的搜索引擎,是非常非常困难的。我们可以看一下这些搜索引擎在搜索关键字的时候,给我们展示了哪些信息:
我们可以看到,基本上搜索引擎根据我们所给的关键字,搜出来的结果展示都是以,网页标题、网页内容摘要和跳转的网址组成的。但是它可能还有相应的照片、视频、广告,这些我们在设计基于Boost库的搜索引擎项目的时候,不考虑这些,它们属于扩展内容 。
2.3 为什么要做Boost库搜索引擎
刚刚我们看到了Boost的官网界面,我们可以对比一下cplusplus官网,看看有什么区别:
说明:
我们可以看到,Boost库是没有站内搜索框的,如果我们可以对boost库做一个站内搜索,向cplusplus一样,搜索一个关键字,就能够跳转到指定的网页,并显示出来。那么这个项目还是具有一定意义的。这也就是项目的背景。其次,站内搜索的数据更加垂直,数据量其实更小。
三、搜索引擎的宏观原理
说明:
刚刚我们介绍完了基于Boost库的搜索引擎的项目背景后,相信大家有了一定的了解,大致上知道了这个项目是什么意思。但是我们还需要了解一下搜索引擎的宏观原理。接下来以下面的图为例,介绍一下其宏观原理。
图解:
原理图分析:
我们要实现出boost库的站内搜索引擎,红色虚线框内就是我们要实现的内容,总的分为客户端和服务器,详细分析如下:
- 客户端:想要获取到大学生的相关信息(呈现在网页上的样子就是:网页的标题+摘要+网址),首先我们构建的服务器就要有对应的数据存在,这些数据从何而来,我们可以进行全网的一个爬虫,将数据爬到我们的服务器的磁盘上,但是我们这个项目是不涉及任何爬虫程序的,我们可以直接将boost库对应版本的数据直接解压到我们对应文件里。
- 现在数据已经被我们放到了磁盘中了,接下来客户端要访问服务器,那么服务器首先要运行起来,服务器一旦运行起来,它首先要做的工作就是对磁盘中的这些数据进行去标签和数据清洗的动作,因为我们从boost库拿的数据其实就是对应文档html网页,但是我们需要的只是每个网页的标题+网页内容摘要+跳转的网址,所以才有了去标签和数据清洗(只拿我们想要的)。这样就可以直接跳过网址跳转到boost库相应文档的位置。
- 服务器完成了去标签和数据清洗之后,就需要对这些清洗后的数据建立索引(方便客户端快速查找);
- 当服务器所以的工作都完成之后,客户端就发起http请求,通过GET方法,上传搜索关键,服务器收到了会进行解析,通过客户端发来的关键字去检索已经构建好的索引,找到了相关的html后,就会将逐个的将每个网页的标题、摘要和网址拼接起来,构建出一个新的网页,响应给客户端;至此,客户就看到了相应的内容,点击网址就可以跳转到boost库相应的文档位置。
四、搜索引擎技术栈和项目环境
技术栈:
- C/C++ C++11, STL, 准标准库 Boost , Jsoncpp , cppjieba , cpp-httplib
选学:
- html5、css、js、jQuery、Ajax
项目环境:
- Centos 7 云服务器、vim/gcc(g++)/Makefile、vs2019 or vs code
相关库:
- cppjieba: 提供分词的相关接口
- boost: 提供在当前目录下遍历所有子目录文件的迭代器
- Jsoncpp: 提供可以将格式化的数据和 json 字符串相互转换的接口
- cpp-httplib: 提供http相关接口
五、正排索引 VS 倒排索引 —— 搜索引擎的具体原理
解释 正排索引 和 倒排索引:
5.1 正排索引(forword index)
正排索引:
- 就是从【文档ID】找到【文档内容】(文档内的关键字)
拓展:
- 正排索引:是创建倒排索引的基础,有了正排索引之后,如何构建倒排索引呢? -- 分词
- 分词的目的:方便建立倒排索引和查找
我们要对目标文档进行【分词】,我们来进行分词演示:
- 文档1:雷军、买、四斤、小米、四斤小米
- 文档2:雷军、发布、小米、汽车、小米汽车
进行分词之后,就能够方便的建立倒排索引和查找:
我们可以看到,在文档1、2中,其中的 “了” 子被我们省去了,这是因为像:了,呢,吗 ,a,the 等都是属于停止词,一般我们在分词的时候可以不考虑。那么什么是停止词呢?
停止词: 它是搜索引擎分词的一项技术,停止词就是没有意义的词。如:在一篇文章中,你可以发现有很多类似于了,呢,吗 ,a,the等(中文或英文中)都是停止词,因为频繁出现,如果我们在进行分词操作的时候,如果把这些停止词也算上,不仅会建立索引麻烦,而且会增加精确搜索的难度。
5.2 倒排索引(inverted index)
倒排索引:
就是根据文档内容的分词,整理不重复的各个关键字,对应联系到文档ID的方案。
模拟一次查找的过程:
用户输入:小米 ---> 去倒排索引中查找关键字“小米” ---> 提取出文档ID【1,2】---> 去正排索引中,根据文档ID【1,2】找到文档内容 ---> 通过 [ 标题 + 内容摘要 + 网址 ] 的形式,构建响应结果 ---> 返回给用户小米相关的网页信息。
5.3 搜索引擎的具体原理
搜索引擎的具体步骤:
- 我们需要去boost官网下载boost库,这个库里面包含boost官网的所有文档的html文件。
- 我们写一个解析程序从一个个html文件的源码中提取标题、内容和url,将他们保存到硬盘的一个data.txt文件中。
- 读取data.txt文件,建立正排和倒排索引,提供索引的接口来获取正排和倒排数据
- 写一个html页面,提供给用户一个搜索功能。
一次访问过程:
当用户通过浏览器向服务器发送搜索信息时,服务器会根据搜索的关键字获取对应倒排数据,然后通过倒排数据找到正排ID,从而找到正排的文档内容。然后构建出网页的标题,简述(内容的一部分),url,通过json字符串响应回去,然后在用户的浏览器显示出一个个网页信息。
六、编写数据去除标签和数据清洗模块 Parser(解析器)
在编写Parser模块的之前,我们先将数据准备好,去boost官网下载最新版本的库,解压到 Linux下,操作方法如下:
6.1 数据准备
boost官网:Boost C++ Libraries
进入官网后,如下点击:
进入之后,你可以选择最新版本的下载,不管是那个版本其实都是可以用的:
点击Download之后,我们选择这个进行下载:
下载好之后,我们先在 linux下,创建一个名为 Boost_Searcher 目录,以后将会在这个目录下进行各种代码模块的编写以及存放各种数据,下面是创建过程:
接下来我们将下载好的1.85.0版本的boost库解压到Linux下,使用 rz 命令(用于文件传输),输入rz -E 命令后直接回车,找到 boost,点击打开即可,你也可以直接将压缩包拖拽到命令行中:
效果如下:
此时,我们使用 tar xzf boost_1_85_0.tar.gz 进行解压,解压好后,我们进行查看 :
可以看到,解压好的boost,里面有这么多文件,但这么多文件并不是我们都需要的,我们需要的就是boost_1_85_0/doc/html目录下的html。为什么呢?结合下面的图:
上面的图就是boost库的操作方法,我们可以看到右下角的两个网页的网址,他们都是在doc/html目录下的文件,都是 .html。我们只要这个就可以了。后期通过地址进行拼接,达到跳转,就能来到这个网页。
我们进入到Linux下的 doc/html 目录,看看里面有哪些东西:
可以看到,里面除了 html为后缀的文件外,还有一些目录,但是我们只需要html文件,所以我们要进行数据清洗。只拿html文件。
我们了解了大概的情况之后,我们来将我们所需要的数据源 拷贝到 data目录下的intput目录下:
6.2 编写parser模块
6.2.1 基本结构设计
这里我是在 vscode 下进行代码编写的,但是需要连接一下云服务器,与 Linux进行同步。 你也可以选择 vim。基本框架主要完成的工作如下:
- 将 data/input/ 所有后缀为 html 的文件筛选出来 ---- 清洗数据
- 然后对筛选好的html文件进行解析(去标签),拆分出标题、内容、网址 ---- 去标签
- 最后将去标签后的所有html文件的标题、内容、网址按照 \3 作为分割符,每个文件再按照 \n 进行区分。写入到 data/raw_html/raw.txt 下
什么是清洗数据:
我们进入到Linux下的 doc/html 目录,看看里面有哪些东西:
可以看到,里面除了 html为后缀的文件外,还有一些目录,但是我们只需要html文件,所以我们要进行数据清洗。只拿html文件。
最后,我们在 data目录 下的 raw_html目录下 创建有一个 raw.txt文件,用来存储干净的数据文档:
什么是去标签:
- 对数据清洗之后,拿到的全都是 html 文件,此时还需要对 html 文件进行去标签处理,我们这里随便看一个html文件:
- 进入 input 这个目录下,随便打开一个 .html 的文件
<> : html的标签,这个标签对我们进行搜索是没有价值的,需要去掉这些标签,一般标签都是成对出现的!但是也有单独出现的,我们也是不需要的。
我们的目标:
把每个文档都去标签,然后写入到同一个文件中!
采用下面的方案:
- 写入文件中,一定要考虑下一次在读取的时候,也要方便操作!
- 类似:title\3content\3url \n title\3content\3url \n title\3content\3url \n ...
- 方便我们getline(ifsream, line),直接获取文档的全部内容:title\3content\3url
接下来 我们根据上面分析的 基本结构设计 来写出一个 parser 模块的框架:
在 Boost_Searcher 目录下创建 parser.cpp 文件开始编写框架:
parser.cpp 框架如下:
代码如下:
#include <iostream>
#include <string>
#include <vector>
// 首先我们肯定会读取文件,所以先将文件的路径名 罗列出来
// 将 数据源的路径 和 清理后干净文档的路径 定义好
const std::string src_path = "data/input"; // 数据源的路径
const std::string output = "data/raw_html/raw.txt"; // 清理后干净文档的路径
//DocInfo --- 文件信息结构体
typedef struct DocInfo
{
std::string title; //文档的标题
std::string content; //文档的内容
std::string url; //该文档在官网当中的url
}DocInfo_t;
// 命名规则
// const & ---> 输入
// * ---> 输出
// & ---> 输入输出
//把每个html文件名带路径,保存到files_list中
bool EnumFile(const std::string &src_path, std::vector<std::string> *files_list);
//按照files_list读取每个文件的内容,并进行解析
bool ParseHtml(const std::vector<std::string> &files_list, std::vector<DocInfo_t> *results);
//把解析完毕的各个文件的内容写入到output
bool SaveHtml(const std::vector<DocInfo_t> &results, const std::string &output);
int main()
{
std::vector<std::string> files_list; // 将所有的 html文件名保存在 files_list 中
// 第一步:递归式的把每个html文件名带路径,
// 保存到files_list中,方便后期进行一个一个的文件读取
// 从 src_path 这个路径中提取 html文件,将提取出来的文件存放在 string 类型的 files_list 中
if(!EnumFile(src_path, &files_list)) //EnumFile--枚举文件
{
std::cerr << "enum file name error! " << std::endl;
return 1;
}
return 0;
// 第二步:从 files_list 文件中读取每个.html的内容,并进行解析
std::vector<DocInfo_t> results;
// 从 file_list 中进行解析,将解析出来的内容存放在 DocInfo 类型的 results 中
if(!ParseHtml(files_list, &results))//ParseHtml--解析html
{
std::cerr << "parse html error! " << std::endl;
return 2;
}
// 第三部:把解析完毕的各个文件的内容写入到output,按照 \3 作为每个文档的分隔符
// 将 results 解析好的内容,直接放入 output 路径下
if(!SaveHtml(results, output))//SaveHtml--保存html
{
std::cerr << "save html error! " << std::endl;
return 3;
}
return 0;
}
6.2.2 EnumFile接口实现
主要实现:
- 枚举文件、解析html文件、保存html文件三个工作。
这三个工作完成是需要我们使用boost库当中的方法的,我们需要安装一下boost的开发库:
命令:sudo yum install -y boost-devel
下图就是我们接下来编写代码需要用到的 boost库 当中的 filesystem方法:
枚举文件代码:
//在原有的基础上添加这个头文件
#include <boost/filesystem.hpp>
//把每个html文件名带路径,保存到files_list中
bool EnumFile(const std::string &src_path, std::vector<std::string> *files_list)
{
// 简化作用域的书写
namespace fs = boost::filesystem;
fs::path root_path(src_path); // 定义一个path对象,枚举文件就从这个路径下开始
// 判断路径是否存在
if(!fs::exists(root_path))
{
std::cerr << src_path << " not exists" << std::endl;
return false;
}
// 对文件进行递归遍历
fs::recursive_directory_iterator end; // 定义了一个空的迭代器,用来进行判断递归结束 -- 相当于 NULL
for(fs::recursive_directory_iterator iter(root_path); iter != end; iter++)
{
// 判断指定路径是不是常规文件,如果指定路径是目录或图片直接跳过
if(!fs::is_regular_file(*iter))
{
continue;
}
// 如果满足了是普通文件,还需满足是.html结尾的
// 如果不满足也是需要跳过的
// ---通过iter这个迭代器(理解为指针)的一个path方法(提取出这个路径)
// ---然后通过extension()函数获取到路径的后缀
if(iter->path().extension() != ".html")
{
continue;
}
//std::cout << "debug: " << iter->path().string() << std::endl; // 测试代码
// 走到这里一定是一个合法的路径,以.html结尾的普通网页文件
files_list->push_back(iter->path().string()); // 将所有带路径的html保存在files_list中,方便后续进行文本分析
}
return true;
}
代码编写到这里我们就可以进行测试了,使用上述代码中注释掉的代码进行测试,首先编写Makefile:
- 注意:boost库 并不是C++语言的标准库,我们需要指明我们需要链接那个库
- 链接: -lboost_system -lboost_filesystem
cpp=g++
parser:parser.cpp
$(cpp) -o $@ $^ -lboost_system -lboost_filesystem -std=c++11
.PHONY:clean
clean:
rm -f parser
6.2.3 ParseHtml接口实现
解析html文件:
- 读取刚刚枚举好的文件
- 解析html文件中的title
- 解析html文件中的content
- 解析html文件中的路径,构建url
ParseHtml()解析函数
代码的整体框架如下:
该函数主要完成 4件事:
①根据路径名依次读取文件内容,②提取title,③提取content,④构建url。
函数架构:
bool ParseHtml(const std::vector<std::string> &files_list, std::vector<DocInfo_t> *results)
{
for(const std::string &file : files_list)
{
// 1.读取文件,Read()
std::string result;
if(!ns_util::FileUtil::ReadFile(file, &result))
{
continue;
}
// 2.解析指定的文件,提取title
DocInfo_t doc;
if(!ParseTitle(result, &doc.title))
{
continue;
}
// 3.解析指定的文件,提取content
if(!ParseContent(result, &doc.content))
{
continue;
}
// 4.解析指定的文件路径,构建url
if(!ParseUrl(file, &doc.url))
{
continue;
}
// 到这里,一定是完成了解析任务,当前文档的相关结果都保存在了doc里面
results->push_back(std::move(doc)); // 本质会发生拷贝,效率肯能会比较低,这里我们使用move后的左值变成了右值,去调用push_back的右值引用版本
}
return true;
}
读取文件:
遍历 files_list 中存储的文件名,从中读取文件内容到 result 中,由函数 ReadFile() 完成该功能。该函数定义于头文件 util.hpp 的类 FileUtil中。
#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <vector>
namespace ns_util
{
class FileUtil
{
public:
//输入文件名,将文件内容读取到out中
static bool ReadFile(const std::string &file_path, std::string *out)
{
// 读取 file_path(一个.html文件) 中的内容 -- 打开文件
std::ifstream in(file_path, std::ios::in);
//文件打开失败检查
if(!in.is_open())
{
std::cerr << "open file " << file_path << " error" << std::endl;
return false;
}
//读取文件内容
std::string line;
//while(bool),getline的返回值istream会重载操作符bool,读到文件尾eofset被设置并返回false
//如何理解getline读取到文件结束呢??getline的返回值是一个&,while(bool), 本质是因为重载了强制类型转化
while(std::getline(in, line)) // 每循环一次,读取的是文件的一行内容
{
*out += line; // 将文件内容保存在 *out 里面
}
in.close(); // 关掉文件
return true;
}
};
}
提取title ——
ParseTitle():
随意打开一个html文件,可以看到我们要提取的title部分是被title标签包围起来的部分。
- 上图显示标题的时候,是以 <title>标题</title> 构成的,我们只需要find(<title>)就能找到这个标签的左尖括号的位置,然后加上<title>的长度,此时就指向了标题的起始位置,同理,再去找到</title>的左尖括号,最后截取子串;
- 这里需要依赖函数 —— bool ParseTitle(const std::string& file,&doc.title),来帮助完成这一工作,函数就定义在 parse.cpp 中。
//解析title
static bool ParseTitle(const std::string& file,std::string* title)
{
// 查找 <title> 位置
std::size_t begin = file.find("<title>");
if(begin == std::string::npos)
{
return false;
}
// 查找 </title> 位置
std::size_t end = file.find("</title>");
if(end == std::string::npos)
{
return false;
}
// 计算中间的距离,截取中间的内容
begin += std::string("<title>").size();
if(begin > end)
{
return false;
}
*title = file.substr(begin, end - begin);
return true;
}
提取content,实际上是去除标签 ——
ParseContent():
- 随意打开一个html文件,即 把所有尖括号及尖括号包含的部分全部去除,只保留尖括号以外有价值的内容。
- 解析内容的时候,我们采用一个简易的状态机来完成,状态机包括两种状态:LABLE(标签)和CONTENT(内容)。
- html的代码中标签都是这样的<>;起始肯定是标签,我们追个字符进行遍历判断,如果遇到“>”,表明下一个即将是内容了,我们将状态机置为CONTENT,接着将内容保存起来,如果此时遇到了“<”,表明到了标签了,我们再将状态机置为LABLE;不断的循环,知道遍历结束。
- 这里需要依赖函数 —— bool ParseContent(const std::string& file,&doc.content),来帮助完成这一工作,函数就定义在parse.cpp中。
//去标签 -- 数据清洗
static bool ParseContent(const std::string& file,std::string* content)
{
//去标签,基于一个简易的状态机
enum status // 枚举两种状态
{
LABLE, // 标签
CONTENT // 内容
};
enum status s = LABLE; // 刚开始肯定会碰到 "<" 默认状态为 LABLE
for(char c : file)
{
// 检测状态
switch(s)
{
case LABLE:
if(c == '>') s = CONTENT;
break;
case CONTENT:
if(c == '<') s = LABLE;
else
{
// 我们不想保留原始文件中的\n,因为我们想用\n作为html解析之后的文本的分隔符
if(c == '\n') c = ' ';
content->push_back(c);
}
break;
default:
break;
}
}
return true;
}
解析 html 的 url:
boost库的官方文档,和我们下载下来的文档,是有路径对应关系的:
官网URL样例:https://www.boost.org/doc/libs/1_78_0/doc/html/accumulators.html
我们下载下来的url样例:boost_1_78_0/doc/html/accumulators.html
我们拷贝到我们项目中的样例:data/input/accumulators.html //我们把下载下来的boost库 doc/html/* copy data/input/
url_head = "https://www.boost.org/doc/libs/1_78_0/doc/html";
url_tail = [data/input](删除) /accumulators.html -> url_tail = /accumulators.html
url = url_head + url_tail ; 相当于形成了一个官网链接
const std::string src_path = "data/input";
bool ParseUrl(const std::string &filepath, std::string* url)
{
std::string url_head = "https://www.boost.org/doc/libs/1_84_0/doc/html";
std::string url_tail = filepath.substr(src_path.size());
*url = url_head + url_tail;
return true;
}
测试:
//for debug
static void ShowDoc(const DocInfo_t &doc)
{
std::cout << "title: " << doc.title << std::endl;
std::cout << "content: " << doc.content << std::endl;
std::cout << "url: " << doc.url << std::endl;
}
bool PraseHtml(const std::vector<std::string> &file_list,std::vector<DocInfo_t> *results)
{
for(const std::string &file:file_list)
{
//1.读取文件,Read();
std::string result;
if(!ns_util::FileUtil::ReadFile(file, &result)){
continue;
}
DocInfo_t doc;
//2.解析指定的文件,提取title
if(!ParseTitle(result,&doc.title)){
continue;
}
//3. 解析指定的文件,提取content,就是去标签
if(!ParseContent(result,&doc.content)){
continue;
}
//4. 解析指定的文件路径,构建url
if(!ParseUrl(file,&doc.url)){
continue;
}
//5. 完成了解析任务,当前文档的相关结果都保存在了doc里面
results->push_back(doc);//bug,push_back本质会发生拷贝,效率可能会比较低
//for debug
ShowDoc(doc);
break;//打印一份测试就行
}
return true;
}
6.2.4 SaveHtml()接口实现
SaveHtml()接口实现将解析结果写入到文件当中:目标:将results,写入到output中
写入文件中,一定要考虑下一次在读取的时候,也要方便操作:方案2
- 类似:title\3content\3url \n title\3content\3url \n title\3content\3url \n ...
- 方便我们getline(ifsream, line),直接获取文档的全部内容:title\3content\3url
bool Savehtml(const std::vector<DocInfo_t> &results,const std::string& output)
{
#define SEP '\3'
//我们要将结果写入到output路径下的文件,首先打开output路径下的文件
std::ofstream out(output, std::ios::out | std::ios::binary);//按照二进制方式进行写入
if(!out.is_open()){
std::cerr << "open " << output << " failed!" << std::endl;
return false;
}
//就可以进行文件内容的写入了
for(auto &item:results)
{
std::string outstring;
outstring += item.title;
outstring += SEP;
outstring += item.content;
outstring += SEP;
outstring += item.url;
outstring += '\n';
out.write(outstring.c_str(),outstring.size());
}
out.close();
return true;
}
6.3 解决小bug
bug所在:
因为ParseHtml接口中,解析数据后放入results中,results是一个vector容器,存放的是DocInfo_t类型而不是其指针类型。每次循环之后,我们都要push_back(doc),其本质是拷贝,效率低下,影响性能。因此我们使用move函数减少拷贝。
解决方案:
move:作为右值移动
将我们所要拷贝的对象,在地址空间层面上,让我们对象直接和容器当中的成员相关联,也就是说不会发生太多的拷贝
代码呈现:
bool PraseHtml(const std::vector<std::string> &file_list,std::vector<DocInfo_t> *results)
{
for(const std::string &file:file_list)
{
//1.读取文件,Read();
std::string result;
if(!ns_util::FileUtil::ReadFile(file, &result)){
continue;
}
DocInfo_t doc;
//2.解析指定的文件,提取title
if(!ParseTitle(result,&doc.title)){
continue;
}
//3. 解析指定的文件,提取content,就是去标签
if(!ParseContent(result,&doc.content)){
continue;
}
//4. 解析指定的文件路径,构建url
if(!ParseUrl(file,&doc.url)){
continue;
}
//5. 完成了解析任务,当前文档的相关结果都保存在了doc里面
// results->push_back(doc); //push_back()本质是拷贝,效率低
results->push_back(std::move(doc));//bug:由于results容器存的是DocInfo_t类型而不是其指针类型,所以push_back本质会发生拷贝,效率可能会比较低,我们使用move函数减少拷贝
//for debug
// ShowDoc(doc);//可以试试使用move后打印结果的影响
// break;//打印一份测试就行
}
return true;
}
6.4 结果
七、编写建立索引的模块 Index
首先创建 一个 Index.hpp 文件,用来编写 索引模块:该文件主要负责三件事
- 构建索引
- 正排索引
- 倒排索引
构建思路框图:
搜索引擎逻辑:
用户输入【关键字】搜索 → 【倒排索引】搜出 【倒排拉链】 → 倒排拉链中包含所有关键字有关的文档ID及其权重 → 【正派索引】文档ID,得到文档三元素 , 并按照权重排列呈现给用户。
7.1、节点设计
功能设计:
在【构建索引】模块时,我们要构建出 正排索引 和 倒排索引,正排索引是构建倒排索引的基础;通过给到的关键字,去倒排索引里查找出文档ID,再根据文档ID,找到对应的文档内容;所以在这个index模块中,就一定要包含两个节点结构,一个是文档信息的节点,一个是倒排对应的节点
代码呈现:
namespace ns_index
{
struct DocInfo //文档信息节点
{
std::string title; //文档的标题
std::string content; //文档对应的去标签后的内容
std::string url; //官网文档的url
uint64_t doc_id; //文档的ID
};
struct InvertedElem //倒排对应的节点
{
uint64_t doc_id; //文档ID
std::string word; //关键字(通过关键字可以找到对应的ID)
int weight; //权重---根据权重对文档进行排序展示
};
}
7.2 基本结构设计
7.2.1 Index类的基本框架
我们创建一个 Index类:主要用来构建索引模块,索引模块最大的两个部分当然是构建正排索引和构建倒排索引,其主要接口如下:
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <unordered_map>
namespace ns_index
{
struct DocInfo //文档信息节点
{
std::string title; //文档的标题
std::string content; //文档对应的去标签后的内容
std::string url; //官网文档的url
uint64_t doc_id; //文档的ID
};
// 一个【关键字】可能出现在 无数个 【文档】中 ,我们需要根据权重判断 文档的重要顺序
// 注意:只是一个关键字和文档的关系,我们会存在一个关键字 对应多个文档 -- 需要后面的 倒排拉链
struct InvertedElem //倒排对应的节点
{
uint64_t doc_id; //文档ID
std::string word; //关键字(通过关键字可以找到对应的ID)
int weight; //权重---根据权重对文档进行排序展示
};
// 倒排拉链 -- 一个关键字 可能存在于多个文档中,所以一个关键字对应了一组文档
typedef std::vector<InvertedElem> InvertedList;
class Index
{
private:
// 正排索引的数据结构采用数组,数组下标就是天然的文档ID
// 每一个数组里面存放一个 文档信息
std::vector<DocInfo> forward_index; //正排索引
// 一个【关键字】可能出现在 无数个 【文档】中 ,我们需要根据权重判断 文档的重要顺序
//倒排索引一定是一个关键字和一组(或者一个)InvertedElem对应[关键字和倒排拉链的映射关系]
std::unordered_map<std::string, InvertedList> inverted_index;
public:
Index(){}
~Index(){}
public:
//根据doc_id找到正排索引对应doc_id的文档内容
DocInfo* GetForwardIndex(uint64_t doc_id)
{
//...
return nullptr;
}
//根据倒排索引的关键字word,获得倒排拉链
InvertedList* GetInvertedList(const std::string &word)
{
//...
return nullptr;
}
//根据去标签,格式化后的文档,构建正排和倒排索引
//将数据源的路径:data/raw_html/raw.txt传给input即可,这个函数用来构建索引
bool BuildIndex(const std::string &input)
{
return true;
}
};
}
7.2.2 获取正排索引(GetForwardIndex)
GetForwardIndex函数:根据【正排索引】的 doc_id 找到文档内容
//根据doc_id找到正排索引对应doc_id的文档内容
DocInfo* GetForwardIndex(uint64_t doc_id)
{
//如果这个doc_id已经大于正排索引的元素个数,则索引失败
if(doc_id >= forward_index.size())
{
std::cout << "doc_id out range, error!" << std::endl;
return nullptr;
}
return &forward_index[doc_id];//否则返回相应doc_id的文档内容
}
7.2.3 获取倒排拉链(GetInvertedList)
GetInvertedList函数:根据倒排索引的关键字word,获得倒排拉链
//根据倒排索引的关键字word,获得倒排拉链
InvertedList* GetInvertedList(const std::string &word)
{
// word关键字不是在 unordered_map 中,直接去里面找对应的倒排拉链即可
auto iter = inverted_index.find(word);
if(iter == inverted_index.end()) // 判断是否越界
{
std::cerr << " have no InvertedList" << std::endl;
return nullptr;
}
// 返回 unordered_map 中的第二个元素--- 倒排拉链
return &(iter->second);
}
7.2.4 构建索引(BuildIndex)
构建索引:
构建索引的思路和用户使用搜索功能的过程正好相反。
BuildIndex函数:
- 根据 去标签,格式化后的.html 文档,构建 正排 和 倒排索引
- 在编写这部分代码时,稍微复杂一些,我们要构建索引,那我们应该是先把处理干净的文档读取上来,是按行读取,这样就能读到每个html文档;按行读上来每个html文档后,我们就可以开始构建正排索引和倒排索引,此时就要提供两个函数,分别为BuildForwardIndex(构建正排索引)和 BuildInvertedIndex(构建倒排索引)
//根据去标签,格式化后的文档,构建正排和倒排索引
//将数据源的路径:data/raw_html/raw.txt传给input即可,这个函数用来构建索引
bool BuildIndex(const std::string &input)
{
// 要构建索引,肯定先把我们之前处理好的 raw.txt 打开,按行处理(每一行就是一个.html 文件)
//在上面SaveHtml函数中,我们是以二进制的方式进行保存的,那么读取的时候也要按照二进制的方式读取,读取失败给出提示
std::ifstream in(input, std::ios::in | std::ios::binary);
if(!in.is_open())
{
std::cerr << "sory, " << input << " open error" << std::endl;
return false;
}
std::string line;
int count = 0;
while(std::getline(in, line))
{
DocInfo* doc = BuildForwardIndex(line);//构建正排索引
if(nullptr == doc)
{
std::cerr << "build " << line << " error" << std::endl;
continue;
}
BuildInvertedIndex(*doc);//有了正排索引才能构建倒排索引
count++;
if(count % 50 == 0)
{
std::cout << "当前已经建立的索引文档:" << count << "个" << std::endl;
}
}
return true;
}
7.2.5 构建正排索引(BuildForwardIndex)
BuildForwardIndex(构建正排索引):
在编写构建正排索引的代码前,我们要知道,在构建索引的函数中,我们是按行读取了每个html文件的(每个文件都是这种格式:title\3content\3url...)构建正排索引,就是将DocInfo结构体内的字段进行填充,这里我们就需要给一个字符串切分的函数,我们写到util.hpp中,这里我们又要引入一个新的方法——boost库当中的切分字符串函数split
#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <vector>
#include <boost/algorithm/string.hpp>
namespace ns_util
{
class FileUtil
{
public:
static bool ReadFile(const std::string &file_path, std::string *out)
{
std::ifstream in(file_path, std::ios::in);
if(!in.is_open())
{
std::cerr << "open file " << file_path << " error" << std::endl;
return false;
}
std::string line;
while(std::getline(in, line)) //如何理解getline读取到文件结束呢??getline的返回值是一个&,while(bool), 本质是因为重载了强制类型转化
{
*out += line;
}
in.close();
return true;
}
};
class StringUtil
{
public:
//切分字符串
static void Splist(const std::string &target, std::vector<std::string> *out, const std::string &sep)
{
//boost库中的split函数
boost::split(*out, target, boost::is_any_of(sep), boost::token_compress_on);
//第一个参数:表示你要将切分的字符串放到哪里
//第二个参数:表示你要切分的字符串
//第三个参数:表示分割符是什么,不管是多个还是一个
//第四个参数:它是默认可以不传,即切分的时候不压缩,不压缩就是保留空格
//如:字符串为aaaa\3\3bbbb\3\3cccc\3\3d
//如果不传第四个参数 结果为aaaa bbbb cccc d
//如果传第四个参数为boost::token_compress_on 结果为aaaabbbbccccd
//如果传第四个参数为boost::token_compress_off 结果为aaaa bbbb cccc d
}
};
}
构建正排索引的编写:
-
构建正排索引 将拿到的一行html文件传输进来,进行解析.
-
构建的正排索引,就是填充一个 DocInfo这个数据结构 ,然后将 DocInfo 插入 正排索引的 vector中即可.
// 构建正排索引 将拿到的一行html文件传输进来,进行解析
// 构建的正排索引,就是填充一个 DocInfo这个数据结构 ,然后将 DocInfo 插入 正排索引的 vector中即可
DocInfo* BuildForwardIndex(const std::string &line)
{
// 1. 解析 line ,字符串的切分 分为 DocInfo 中的结构
// 1. line -> 3 个 string (title , content , url)
std::vector<std::string> results;
std::string sep = "\3"; //行内分隔符
ns_util::StringUtil::Splist(line, &results, sep);//字符串切分
if(results.size() != 3)
{
return nullptr;
}
// 2.字符串进行填充到DocInfo
DocInfo doc;
doc.title = results[0];
doc.content = results[1];
doc.url = results[2];
// 第一次的话:正排索引的vector数组中是没有元素的,所以0作为第一次文档的ID
doc.doc_id = forward_index.size(); //先进行保存id,在插入,对应的id就是当前doc在vector中的下标
// 3.插入到正排索引的vector
forward_index.push_back(std::move(doc)); //使用move可以减少拷贝带来的效率降低
return &forward_index.back();
}
7.2.6 倒排索引的原理介绍
图解:
总的思路:
- 对 title 和 content 进行分词(使用cppjieba)
- 在分词的时候,必然会有某些词在 title 和 content 中出现过;我们这里还需要做一个处理,就是对每个词进行词频统计(你可想一下,你在搜索某个关键字的时候,为什么有些文档排在前面,而有些文档排在最后)这主要是词和文档的相关性(我们这里认为关键字出现在标题中的相关性高一些,出现在内容中的低一些,当然,关于相关性其实是比较复杂的,我们这里只考虑这些)
- 自定义相关性:我们有了词和文档的相关性的认识后,就要来自己设计这个相关性;我们把出现在title中的词,其权重更高,在content中,其权重低一些(如:让出现在title中的词的词频x10,出现在content中的词的词频x1,两者相加的结果称之为该词在整个文档中的权重)根据这个权重,我们就可以对所有文档进行权重排序,进行展示,权重高的排在前面展示,权重低的排在后面展示
7.2.7 cppjieba分词工具的安装和使用介绍
获取链接:GitHub - yanyiwu/cppjieba: "结巴"中文分词的C++版本
创建一个test目录,将我们 git clone 直接在 test 目录下进行:
查看 cppjieba 目录,里面包含如下:
我们待会儿需要用到的分词工具是在 include/cppjieba/jieba.hpp:
我们要在 test 目录下执行这个 demo.cpp,就要引入这个头文件,我们不能直接引入,需要使用软连接:
其次我们还要在 test 目录下执行这个 dict(词库)里面的内容,要引入这个头文件,我们不能直接引入,需要使用软连接:
7.2.8 引入cppjieba到项目中
那么接下来就要在我们的项目路径中,加入cppjieba下的Jieba.hpp,操作和上面的类似,这里我就不在操作了。直接看结果:
util.hpp代码:
#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <vector>
#include <boost/algorithm/string.hpp>
#include "cppjieba/Jieba.hpp" //引入头文件(确保你建立的没有错误才可以使用)
namespace ns_util
{
class FileUtil
{
public:
//输入文件名,将文件内容读取到out中
static bool ReadFile(const std::string &file_path, std::string *out)
{
// 读取 file_path(一个.html文件) 中的内容 -- 打开文件
std::ifstream in(file_path, std::ios::in);
//文件打开失败检查
if(!in.is_open())
{
std::cerr << "open file " << file_path << " error" << std::endl;
return false;
}
//读取文件内容
std::string line;
//while(bool),getline的返回值istream会重载操作符bool,读到文件尾eofset被设置并返回false
//如何理解getline读取到文件结束呢??getline的返回值是一个&,while(bool), 本质是因为重载了强制类型转化
while(std::getline(in, line)) // 每循环一次,读取的是文件的一行内容
{
*out += line; // 将文件内容保存在 *out 里面
}
in.close(); // 关掉文件
return true;
}
};
class StringUtil
{
public:
//切分字符串
static void Splist(const std::string &target, std::vector<std::string> *out, const std::string &sep)
{
//boost库中的split函数
boost::split(*out, target, boost::is_any_of(sep), boost::token_compress_on);
//第一个参数:表示你要将切分的字符串放到哪里
//第二个参数:表示你要切分的字符串
//第三个参数:表示分割符是什么,不管是多个还是一个
//第四个参数:它是默认可以不传,即切分的时候不压缩,不压缩就是保留空格
//如:字符串为aaaa\3\3bbbb\3\3cccc\3\3d
//如果不传第四个参数 结果为aaaa bbbb cccc d
//如果传第四个参数为boost::token_compress_on 结果为aaaabbbbccccd
//如果传第四个参数为boost::token_compress_off 结果为aaaa bbbb cccc d
}
};
//下面这5个是分词时所需要的词库