目录
前言
首先,这个基于Boost库的搜索引擎项目,博主之前就已经做过了,但是由于各方面的原因没有记录这个博客,拖了很久。这次我想把这个项目好好的总结出来,希望看到这个项目博客的小伙伴可以点点关注和收藏。
一、项目的相关背景
1. 什么是Boost库
Boost库是C++的准标准库, 它提供了很多C++没有的功能,可以称之为是C++的后备力量。早期的开发者多为C++标准委员会的成员,一些Boost库也被纳入了C++11中(如:哈希、智能指针);这里大家可以去百度百科上搜索,一看便知。下面是boost的官网:
2. 什么是搜索引擎
对于搜索引擎,相信大家一定不陌生,如:百度、360、搜狗等,都是我们常用的搜索引擎。但是你想自己实现出一个和百度、360、搜狗一模一样哪怕是类似的搜索引擎,是非常非常困难的。我们可以看一下这些搜索引擎在搜索关键字的时候,给我们展示了哪些信息:
我们可以看到,基本上搜索引擎根据我们所给的关键字,搜出来的结果展示都是以,网页标题、网页内容摘要和跳转的网址组成的。但是它可能还有相应的照片、视频、广告,这些我们在设计基于Boost库的搜索引擎项目的时候,不考虑这些,它们属于扩展内容;
3. 为什么要做Boost搜索引擎
刚刚我们看到了boost的官网界面,我们可以对比一下cplusplus官网,看看有什么区别
可以看到,boost库是没有站内搜索框的,如果我们可以对boost库做一个站内搜索,向cplusplus一样,搜索一个关键字,就能够跳转到指定的网页,并显示出来。那么这个项目还是具有一定意义的。这也就是项目的背景。
其次,站内搜索的数据更加垂直,数据量其实更小。
二、搜索引擎的宏观原理
刚刚我们介绍完了基于Boost库的搜索引擎的项目背景后,相信大家有了一定的了解,大致上知道了这个项目是什么意思。但是我们还需要了解一下搜索引擎的宏观原理。接下来以下面的图为例,介绍一下其宏观原理。
原理图分析:
我们要实现出boost库的站内搜索引擎,红色虚线框内就是我们要实现的内容,总的分为客户端和服务器,详细分析如下:
- 我们从客户端想要获取到大学生的相关信息(呈现在网页上的样子就是:网页的标题+摘要+网址),首先我们构建的服务器就要有对应的数据存在,这些数据从何而来,我们可以进行全网的一个爬虫,将数据爬到我们的服务器的磁盘上,但是我们这个项目是不涉及任何爬虫程序的,我们可以直接将boost库对应版本的数据直接解压到我们对应文件里。
- 现在数据已经被我们放到了磁盘中了,接下来客户端要访问服务器,那么服务器首先要运行起来,服务器一旦运行起来,它首先要做的工作就是对磁盘中的这些数据进行去标签和数据清洗的动作,因为我们从boost库拿的数据其实就是对应文档html网页,但是我们需要的只是每个网页的标题+网页内容摘要+跳转的网址,所以才有了去标签和数据清洗(只拿我们想要的)。这样就可以直接跳过网址跳转到boost库相应文档的位置。
- 服务器完成了去标签和数据清洗之后,就需要对这些清洗后的数据建立索引(方便客户端快速查找);
- 当服务器所以的工作都完成之后,客户端就发起http请求,通过GET方法,上传搜索关键,服务器收到了会进行解析,通过客户端发来的关键字去检索已经构建好的索引,找到了相关的html后,就会将逐个的将每个网页的标题、摘要和网址拼接起来,构建出一个新的网页,响应给客户端;至此,客户就看到了相应的内容,点击网址就可以跳转到boost库相应的文档位置。
三、搜索引擎技术栈和项目环境
基于Boost库的搜索引擎项目所涉及的技术栈和项目环境如下:
技术栈:
- C/C++/C++11、STL、boost库、Jsoncpp、cppjieba、cpp-httplib
- html5、css、js、jQuery、Ajax
项目环境:
- Centos 7 云服务器
- vim/gcc/g++/Makefile
- vs2019 or vscode
技术栈和项目环境,有些你可能不了解,没关系,下面的代码编写中会有介绍,但是基本的技术栈:C/C++/C++11/STL 你是要熟悉的;项目环境 :云服务器、vim、vs这些你也是需要知道的。
四、正排索引 VS 倒排索引 —— 搜索引擎的具体原理
通过下面两个文档来解释一下正排索引和倒排索引
文档ID | 文档内容 |
---|---|
1 | 雷军买了四斤小米 |
2 | 雷军发布了小米手机 |
1. 正排索引(forword index)
正排索引:就是从文档ID找到文档内容(文档内的关键字)
正排索引是创建倒排索引的基础,有了正排索引之后,如何构建倒排索引呢?
我们要对目标文档进行分词,以上面的文档1/2为例,我们来进行分词演示:
- 文档1:雷军、买、四斤、小米、四斤小米
- 文档2:雷军、发布、小米、手机、小米手机
进行分词之后,就能够方便的建立倒排索引和查找。
我们可以看到,在文档1/2中,其中的 “了” 子被我们省去了,这是因为像:了,呢,吗 ,a,the等都是属于停止词,一般我们在分词的时候可以不考虑。那么什么是停止词呢?
停止词: 它是搜索引擎分词的一项技术,停止词就是没有意义的词。如:在一篇文章中,你可以发现有很多类似于了,呢,吗 ,a,the等(中文或英文中)都是停止词,因为频繁出现,如果我们在进行分词操作的时候,如果把这些停止词也算上,不仅会建立索引麻烦,而且会增加精确搜索的难度。
2. 倒排索引(inverted index)
刚刚我们说正排索引是创建倒排索引的基础,首先是要对文档进行分词操作;
倒排索引:就是根据文档内容的分词,整理不重复的各个关键字,对应联系到文档ID的方案
关键词(具有唯一性) | 文档ID |
---|---|
雷军 | 文档1,文档2 |
买 | 文档1 |
四斤 | 文档1 |
小米 | 文档1,文档2 |
四斤小米 | 文档1 |
发布 | 文档2 |
手机 | 文档2 |
小米手机 | 文档2 |
模拟一次查找的过程:
用户输入:小米 ---> 去倒排索引中查找关键字“小米” ---> 提取出文档ID【1,2】---> 去正排索引中,根据文档ID【1,2】找到文档内容 ---> 通过 [ 标题 + 内容摘要 + 网址 ] 的形式,构建响应结果 ---> 返回给用户小米相关的网页信息。
五、编写数据去标签和数据清洗模块 Parser
在编写Parser模块的之前,我们先将数据准备好,去boost官网下载最新版本的库,解压到Linux下,操作方法如下:
1. 数据准备
boost官网:https://www.boost.org/
进入官网后,是如下界面:
进入之后,你可以选择最新版本的下载,我之前下载的是1.78.0版本,这里我就不下载最新版本了,都是可以用的。
点击Download之后,我们选择这个进行下载:
下载好之后,我们先在linux下,创建一个名为Boost_Searcher目录,以后将会在这个目录下进行各种代码模块的编写以及存放各种数据,下面是创建过程:
接下来我们将下载好的1.78.0版本的boost库解压到Linux下,使用 rz 命令(用于文件传输),输入rz -E 命令后直接回车,找到boost,点击打开即可,你也可以直接将压缩包拖拽到命令行中;
效果如下:
此时,我们使用 tar xzf boost_1_78_0.tar.gz 进行解压,解压好后,我们进行查看
可以看到,解压好的boost,里面有这么所文件,但这么多文件并不是我们都需要的,我们需要的就是boost_1_78_0/doc/html目录下的html。为什么呢?结合下面的图:
上面的图就是boost库的操作方法,我们可以看到右下角的两个网页的网址,他们都是在doc/html目录下的文件,都是 .html。我们只要这个就可以了。后期通过地址进行拼接,达到跳转,就能来到这个网页。
---------------------------------------------------------------------------------------------------------------------------------
我们进入到Linux下的doc/html目录,看看里面有哪些东西:
可以看到,里面处理html为后缀的文件外,还有一些目录,但是我们只需要html文件,所以我们要进行数据清洗。只拿html文件。
对数据清洗之后,拿到的全都是html文件,此时还需要对html文件进行去标签处理,我们这里随便看一个html文件:
<> : html的标签,这个标签对我们进行搜索是没有价值的,需要去掉这些标签,一般标签都是成对出现的!但是也有单独出现的,我们也是不需要的。
我们的目标:把每个文档都去标签,然后写入到同一个文件中!每个文档内容不需要任何\n!文档和文档之间用 \3 进行区分。
类似:XXXXXXXXXXXXXXX\3YYYYYYYYYYYYYYYYYY\3ZZZZZZZZZZZZZZZZZZZZ\3
采用下面的方案:
- 写入文件中,一定要考虑下一次在读取的时候,也要方便操作!
- 类似:title\3content\3url \n title\3content\3url \n title\3content\3url \n ...
- 方便我们getline(ifsream, line),直接获取文档的全部内容:title\3content\3url
我们了解了大概的情况之后,我们来将我们所需要的数据源拷贝到data目录下的intput目录下:
最后,我们在data目录下的raw_html目录下创建有一个raw.txt文件,用来存储干净的数据文档
2. 编写parser模块
1. 基本结构设计
这里我是在vim下进行代码编写的,你可以选择vscode,但是需要连接一下云服务器,与Linux进行同步。
基本框架主要完成的工作如下:
- 将data/input/所有后缀为html的文件筛选出来
- 然后对筛选好的html文件进行解析(去标签),拆分出标题、内容、网址
- 最后将去标签后的所有html文件的标题、内容、网址按照 \3 作为分割符,每个文件再按照 \n 进行区分。写入到data/raw_html/raw.txt下
#include <iostream>
#include <vector>
#include <string>
//将数据源的路径 和 清理后干净文档的路径 径定义好
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中,方便后期进行一个一个的文件读取
if(!EnumFile(src_path, &files_list)) //EnumFile--枚举文件
{
std::cerr << "enum file name error! " << std::endl;
return 1;
}
// 第二步:按照files_list读取每个文件的内容,并进行解析
std::vector<DocInfo_t> results;
if(!ParseHtml(files_list, &results))//ParseHtml--解析html
{
std::cerr << "parse html error! " << std::endl;
return 2;
}
// 第三部:把解析完毕的各个文件的内容写入到output,按照 \3 作为每个文档的分隔符
if(!SaveHtml(results, output))//SaveHtml--保存html
{
std::cerr << "save html error! " << std::endl;
return 3;
}
return 0;
}
2. 细节实现
主要实现:枚举文件、解析html文件、保存html文件三个工作。
这三个工作完成是需要我们使用boost库当中的方法的,我们需要安装一下boost的开发库:
命令:sudo yum install -y boost-devel
下图就是我们接下来编写代码需要用到的boost库当中的filesystem方法。
枚举文件
//在原有的基础上添加这个头文件
#include <boost/filesystem.hpp>
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; // 定义了一个空的迭代器,用来进行判断递归结束
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:
cc=g++
parser:parser.cc
$(cc) -o $@ $^ -lboost_system -lboost_filesystem -std=c++11
.PHONY:clean
clean:
rm -f parser
接下来就可以make,然后运行了:
解析html文件
- 读取刚刚枚举好的文件
- 解析html文件中的title
- 解析html文件中的content
- 解析html文件中的路径,构建url
这里我们将这读取操作写到一个工具类中,包括后续有什么方法也可以写到这个里面,方便调用。创建一个util.hpp
util.hpp如下:
#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <vector>
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;
}
};
}
解析html文件:
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;
}
解析html的title:
在进行提取title的时候,我们可以看看html的代码。它显示标题的时候,是以<title>标题</title>构成的,我们只需要find(<title>)就能找到这个标签的左尖括号的位置,然后加上<title>的长度,此时就指向了标题的起始位置,同理,再去找到</title>的左尖括号,最后截取子串;
static bool ParseTitle(const std::string &file, std::string *title) { std::size_t begin = file.find("<title>"); if(begin == std::string::npos) { return false; } 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; }
解析html的content
解析内容的时候,我们采用一个简易的状态机来完成,状态机包括两种状态:LABLE(标签)和CONTENT(内容);
html的代码中标签都是这样的<>;起始肯定是标签,我们追个字符进行遍历判断,如果遇到“>”,表明下一个即将是内容了,我们将状态机置为CONTENT,接着将内容保存起来,如果此时遇到了“<”,表明到了标签了,我们再将状态机置为LABLE;不断的循环,知道遍历结束;
static bool ParseContent(const std::string &file, std::string *content) { //去标签,基于一个简易的状态机 enum status { LABLE, CONTENT }; enum status s = 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
在编写解析html的url的时候,我们需要注意,我们自己路径下的html的路径和官网上的路径是有对应关系的:
- 官网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
url_tail = [data/input(删除)] /accumulators.html -> url_tail = /accumulators.html;//将我们项目的路径data/input删除后得到/accumulators.html; 将 url_head + url_tail 得到 官网的urlstatic bool ParseUrl(const std::string &file_path, std::string *url) { std::string url_head = "https://www.boost.org/doc/libs/1_78_0/doc/html"; std::string url_tail = file_path.substr(src_path.size());//将data/input截取掉 *url = url_head + url_tail;//拼接 return true; }
我们不是已经定义好了两个路径嘛!源数据路径和清理后干净文档的路径;url_head这个比较简单,直接复制官网的。url_tail,我们可以将传过来的文件路径使用一个substr把data/input截取掉,保留剩下的,然后和url_head拼接起来。
保存html文件
说明一下,分隔符为什么使用‘\3’ :
\3在ASSCII码表中是不可以显示的字符,我们将title、content、url用\3进行区分,不会污染我们的文档,当然你也可以使用\4等
bool SaveHtml(const std::vector<DocInfo_t> &results, const std::string &output)
{
#define SEP '\3'//分割符---区分标题、内容和网址
// 按照二进制的方式进行写入
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 out_string;
out_string = item.title;//标题
out_string += SEP;//分割符
out_string += item.content;//内容
out_string += SEP;//分割符
out_string += item.url;//网址
out_string += '\n';//换行,表示区分每一个文件
out.write(out_string.c_str(), out_string.size());
}
out.close();
return true;
}
接下来我们做一下测试:运行代码后,查看我们data/raw_html/raw.txt文件,就如下图:
至此,我们的parser(去标签+数据清)模块就完成了,为了大家能够更好的理解,下面是一张关系图:
六、编写建立索引的模块 Index
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; //权重---根据权重对文档进行排序展示
};
}
说明一下:
在倒排对应的节点之中, 有doc_id、word和weight;我们可以通过word关键字找到对应的文档ID,并且我们有文档的信息节点,通过倒排找到的文档ID,就能够在文档信息节点中找到对应的文档所有内容;这两个节点都有doc_id,就像MySQL中外键,相当于两张表产生了关联;
2. 基本结构设计
1. Index类的基本框架
我们创建一个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; //权重---根据权重对文档进行排序展示
};
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)
{
//...
}
//根据倒排索引的关键字word,获得倒排拉链
InvertedList* GetInvertedList(const std::string &word)
{
//...
}
//根据去标签,格式化后的文档,构建正排和倒排索引
//将数据源的路径:data/raw_html/raw.txt传给input即可,这个函数用来构建索引
bool BuildIndex(const std::string &input)
{
}
}
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的文档内容
}
2. 获取倒排索引(GetInvertedList)
GetInvertedList函数:根据倒排索引的关键字word,获得倒排拉链(和上面类似)
//根据倒排索引的关键字word,获得倒排拉链
InvertedList* GetInvertedList(const std::string &word)
{
auto iter = inverted_index.find(word);
if(iter == inverted_index.end())
{
std::cerr << " have no InvertedList" << std::endl;
return nullptr;
}
return &(iter->second);
}
3. 建立索引(BuildIndex)
BuildIndex函数:根据去标签,格式化后的文档,构建正排和倒排索引
在编写这部分代码时,稍微复杂一些,我们要构建索引,那我们应该是先把处理干净的文档读取上来,是按行读取,这样就能读到每个html文档;按行读上来每个html文档后,我们就可以开始构建正排索引和倒排索引,此时就要提供两个函数,分别为BuildForwardIndex(构建正排索引)和 BuildInvertedIndex(构建倒排索引),基本的代码如下:
//根据去标签,格式化后的文档,构建正排和倒排索引
//将数据源的路径:data/raw_html/raw.txt传给input即可,这个函数用来构建索引
bool BuildIndex(const std::string &input)
{
//在上面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;
}
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
}
};
}
构建正排索引的编写:
//构建正排索引
DocInfo* BuildForwardIndex(const std::string &line)
{
// 1.解析line,字符串切分
// 将line中的内容且分为3段:原始为title\3content\3url\3
// 切分后: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];
doc.doc_id = forward_index.size(); //先进行保存id,在插入,对应的id就是当前doc在vector中的下标
// 3.插入到正排索引的vector
forward_index.push_back(std::move(doc)); //使用move可以减少拷贝带来的效率降低
return &forward_index.back();
}
6. 倒排索引的原理介绍
建立倒排的原理,我们之前只是单纯的说了一下,没有详细的说明如何实现,接下来我通过x张图来解释建立倒排索引的原理:
总的思路:
- 对title和content进行分词(使用cppjieba)
- 在分词的时候,必然会有某些词在title和content中出现过;我们这里还需要做一个处理,就是对每个词进行词频统计(你可想一下,你在搜索某个关键字的时候,为什么有些文档排在前面,而有些文档排在最后)这主要是词和文档的相关性(我们这里认为关键字出现在标题中的相关性高一些,出现在内容中的低一些,当然,关于相关性其实是比较复杂的,我们这里只考虑这些)
- 自定义相关性:我们有了词和文档的相关性的认识后,就要来自己设计这个相关性;我们把出现在title中的词,其权重更高,在content中,其权重低一些(如:让出现在title中的词的词频x10,出现在content中的词的词频x1,两者相加的结果称之为该词在整个文档中的权重)根据这个权重,我们就可以对所有文档进行权重排序,进行展示,权重高的排在前面展示,权重低的排在后面展示
伪代码操作演示:
如下是我们之前的基本结构代码
//倒排拉链节点 struct InvertedElem{ uint64_t doc_id; //文档的ID std::string word; //关键词 int weight; //权重 }; //倒排拉链 typedef std::vector<InvertedElem> InvertedList; //倒排索引一定是一个关键字和一组(个)InvertedElem对应[关键字和倒排拉链的映射关系] std::unordered_map<std::string, InvertedList> inverted_index; //文档信息节点 struct DocInfo{ std::string title; //文档的标题 std::string content; //文档对应的去标签之后的内容 std::string url; //官网文档url uint64_t doc_id; //文档的ID };
1. 需要对 title && content都要先分词 -- 使用jieba分词
- title: 吃/葡萄/吃葡萄(title_word)
- content:吃/葡萄/不吐/葡萄皮(content_word)
2. 词频统计统计词频,它是包含标题和内容的,我们就需要有一个结构体,来存储每一篇文档中每个词出现在title和content中的次数,伪代码如下://词频统计的结点 struct word_cnt{ title_cnt; //词在标题中出现的次数 content_cnt;//词在内容中出现的次数 }
统计这些次数之后,我们还需要将词频和关键词进行关联,文档中的每个词都要对应一个词频结构体,这样我们通过关键字就能找到其对应的词频结构体,通过这个结构体就能知道该关键字在文档中的title和content中分别出现了多少次,下一步就可以进行权重的计算。这里我们就可以使用数据结构unordered_map来进行存储。伪代码如下:
//关键字和词频结构体的映射 unordered_map<std::string, word_cnt> word_map; //范围for进行遍历,对title中的词进行词频统计 for(auto& word : title_word){ word_map[word].title_cnt++; //吃(1)/葡萄(1)/吃葡萄(1) } //范围for进行遍历,对content中的词进行词频统计 for(auto& word : content_word){ word_map[word].content_cnt++; //吃(1)/葡萄(1)/不吐(1)/葡萄皮(1) }
3. 自定义相关性知道了在文档中,标题和内容每个词出现的次数,接下来就需要我们自己来设计相关性了,伪代码如下://遍历刚才那个unordered_map<std::string, word_cnt> word_map; for(auto& word : word_map){ struct InvertedElem elem;//定义一个倒排拉链,然后填写相应的字段 elem.doc_id = 123; elem.word = word.first; elem.weight = 10*word.second.title_cnt + word.second.content_cnt ;//权重计算 inverted_index[word.first].push_back(elem);//最后保存到倒排索引的数据结构中 } //倒排索引结构如下: //std::unordered_map<std::string, InvertedList> inverted_index;
至此就是倒排索引比较完善的原理介绍和代码思路。
7. cppjieba分词工具的安装和使用介绍
获取链接:https://github.com/yanyiwu/cppjieba 里面有详细的教程
我这里是在GitHub上下载的解压包,然后自己解压的(可以使用git clone,主要因为太慢了,就直接下的压缩包),就解压好后基本环境的搭建,如下:
创建一个test目录用来解压好cppjieba,效果如下:
查看cppjieba-master目录,里面包含如下:
我们待会儿需要用到的分词工具是在include/cppjieba/jieba.hpp
首先,这是别人的写好的一个开源项目,里面会有这个测试代码,通常是在test目录下:
我们来做个分词演示,先将这个demo.cpp拷贝到我们的test目录下:
打开之后,就是一堆错误,主要原因是路径不对:
首先,从上图可以看到头文件的路径就不对,我们先来修改一下头文件的路径,它本身是要使用cppjieba/Jieba.hpp的,我们看一下这个头文件的具体路径:
路径是cppjieba-master/include/cppjieba/Jieba.hpp
我们要在test目录下执行这个demo.cpp,要引入这个头文件,我们不能直接引入,需要使用软连接:
软连接建立好后并修改demo.hpp的相应路径,再将该包的头文件包起来,再来查看demo.hpp是否还有错误:
我们编译后发现,limonp/Logging.hpp这个头文件没有:
此时,我们还是需要对这个头文件进行软连接,我们通过查找,发现有这么一个路径:
但是里面什么东西都没有,这是我在联系项目中出现的问题,经过我去GitHub查找一番后,发现它在另外一个压缩包里:
解压好如下:
此时,我们找一下limonp/Logging.hpp:
拷贝之后如下:
此时,再打开demo.hpp既没有任何问题了:(命令行参数的提示,不是错误,不考虑)
7~11行的路径中有个dict目录,在我们的test目录下是没有的,我还需要软连接:
我们将路径都完善之后,接下来,我们编译运行一下demo.hpp,看下效果:
可以看出,分词效果还是很不错的。那么接下来就要在我们的项目路径中,加入cppjieba下的Jieba.hpp,操作和上面的类似,这里我就不在操作了。直接看结果:
上面的操作做完之后,就可以在我们的项目中引入头文件,来使用cppjieba分词工具啦!!
8. 引入cppjieba到项目中
将软链接建立好之后,我们在util.hpp中编写一个jieba分词的类,主要是为了方便后期其他地方需要使用的时候,可以直接调用。
我们在util.hpp中创建一个 JiebaUtil的分词工具类,首先我们先看一下之前测试过的demo.cpp的代码:
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:
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
}
};
//下面这5个是分词时所需要的词库路径
const char* const DICT_PATH = "./dict/jieba.dict.utf8";
const char* const HMM_PATH = "./dict/hmm_model.utf8";
const char* const USER_DICT_PATH = "./dict/user.dict.utf8";
const char* const IDF_PATH = "./dict/idf.utf8";
const char* const STOP_WORD_PATH = "./dict/stop_words.utf8";
class JiebaUtil
{
private:
static cppjieba::Jieba jieba; //定义静态的成员变量(需要在类外初始化)
public:
static void CutString(const std::string &src, std::vector<std::string> *out)
{
//调用CutForSearch函数,第一个参数就是你要对谁进行分词,第二个参数就是分词后的结果存放到哪里
jieba.CutForSearch(src, *out);
}
};
//类外初始化,就是将上面的路径传进去,具体和它的构造函数是相关的,具体可以去看一下源代码
cppjieba::Jieba JiebaUtil::jieba(DICT_PATH, HMM_PATH, USER_DICT_PATH, IDF_PATH, STOP_WORD_PATH);
}
9. 构建倒排索引(BuildInvertedIndex)
BuildInvertedIndex(构建倒排索引):
构建倒排索引相对复杂一些,只要将上面倒排索引的原理和伪代码的思路;理解到位后,下面的代码就比较简单了。
//构建倒排索引
bool BuildInvertedIndex(const DocInfo &doc)
{
//词频统计结构体
struct word_cnt
{
int title_cnt;
int content_cnt;
word_cnt():title_cnt(0), content_cnt(0){}
};
std::unordered_map<std::string, word_cnt> word_map; //用来暂存词频的映射表
//对标题进行分词
std::vector<std::string> title_words;
ns_util::JiebaUtil::CutString(doc.title, &title_words);
//对标题进行词频统计
for(auto s : title_words)
{
boost::to_lower(s); // 将我们的分词进行统一转化成为小写的
word_map[s].title_cnt++;//如果存在就获取,不存在就新建
}
//对文档内容进行分词
std::vector<std::string> content_words;
ns_util::JiebaUtil::CutString(doc.content, &content_words);
//对文档内容进行词频统计
for(auto s : content_words)
{
boost::to_lower(s); // 将我们的分词进行统一转化成为小写的
word_map[s].content_cnt++;
}
#define X 10
#define Y 1
//最终构建倒排
for(auto &word_pair : word_map)
{
InvertedElem item;
item.doc_id = doc.doc_id; //倒排索引的id即文档id
item.word = word_pair.first;
item.weight = X * word_pair.second.title_cnt + Y * word_pair.second.content_cnt;
InvertedList& inverted_list = inverted_index[word_pair.first];
inverted_list.push_back(std::move(item));
}
return true;
}
七、编写搜索引擎模块 Searcher
1. 基本结构
我们已经完成了数据清洗、去标签和索引相关的工作,接下来就是要编写服务器所提供的服务,我们试想一下,服务器要做哪些工作:首先,我们的数据事先已经经过了数据清洗和去标签的,服务器运行起来之后,应该要先去构建索引,然后通过服务,索引我们在Searcher模块中实现两个函数,分别为InitSearcher()和Search(),代码如下:
#include "index.hpp"
namespace ns_searcher{
class Searcher{
private:
ns_index::Index *index; //供系统进行查找的索引
public:
Searcher(){}
~Searcher(){}
public:
void InitSearcher(const std::string &input)
{
//...
}
//query: 搜索关键字
//json_string: 返回给用户浏览器的搜索结果
void Search(const std::string &query, std::string *json_string)
{
//...
}
};
}
2. 初始化服务(InitSearcher)
服务器要去构建索引,本质上就是去构建一个Index对象,然后调用其内部的方法,我们知道构建正排索引和倒排索引本质就是将磁盘上的数据加载的内存,其数据量还是比较大的(可能本项目的数据量不是很大)。从这一点可以看出,假设创建了多个Index对象的话,其实是比较占内存的,我们这里就可以将这个Index类设计成为单例模式;关于单例模式是什么及代码框架(懒汉模式和饿汉模式)我这里不做详细介绍,不了解的小伙伴可以去看我
写的这篇博客:https://blog.csdn.net/sjsjnsjnn/article/details/126364511里面有详细的讲解,这里我直接给出Index的全部代码。
1. Index模块的单例设计
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <mutex>
#include <fstream>
#include <unordered_map>
#include "util.hpp"
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;
private:
Index(){} //这个一定要有函数体,不能delete
Index(const Index&) = delete;
Index& operator = (const Index&) = delete;
static Index* instance;
static std::mutex mtx;//C++互斥锁,防止多线程获取单例存在的线程安全问题
public:
~Index(){}
public:
//获取index单例
static Index* GetInstance()
{
if(nullptr == instance)// 双重判定空指针, 降低锁冲突的概率, 提高性能
{
mtx.lock();//加锁
if(nullptr == instance)
{
instance = new Index();//获取单例
}
mtx.unlock();//解锁
}
return instance;
}
//根据doc_id找到正排索引对应doc_id的文档内容
DocInfo* GetForwardIndex(uint64_t doc_id)
{
if(doc_id >= forward_index.size())
{
std::cout << "doc_id out range, error!" << std::endl;
return nullptr;
}
return &forward_index[doc_id];
}
//根据倒排索引的关键字word,获得倒排拉链
InvertedList* GetInvertedList(const std::string &word)
{
auto iter = inverted_index.find(word);
if(iter == inverted_index.end())
{
std::cerr << " have no InvertedList" << std::endl;
return nullptr;
}
return &(iter->second);
}
//根据去标签,格式化后的文档,构建正排和倒排索引
//将数据源的路径:data/raw_html/raw.txt传给input即可,这个函数用来构建索引
bool BuildIndex(const std::string &input)
{
//在上面SaveHtml函数中,我们是以二进制的方式进行保存的,那么读取的时候也要按照二进制的方式读取,读取失败给出提示
std::ifstream in(input, std::ios::in | std::ios::binary);
if(!in.is_open())
{
std::cerr << "sorry, " << input << " open error" << std::endl;
return false;
}
std::string line;
while(std::getline(in, line))
{
DocInfo* doc = BuildForwardIndex(line);//构建正排索引
if(nullptr == doc)
{
std::cerr << "build " << line << " error" << std::endl;
continue;
}
BuildInvertedIndex(*doc);//有了正排索引才能构建倒排索引
}
return true;
}
public:
//构建正排索引
DocInfo* BuildForwardIndex(const std::string &line)
{
// 1.解析line,字符串切分
// 将line中的内容且分为3段:原始为title\3content\3url\3
// 切分后: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];
doc.doc_id = forward_index.size(); //先进行保存id,在插入,对应的id就是当前doc在vector中的下标
// 3.插入到正排索引的vector
forward_index.push_back(std::move(doc)); //使用move可以减少拷贝带来的效率降低
return &forward_index.back();
}
//构建倒排索引
bool BuildInvertedIndex(const DocInfo &doc)
{
//词频统计结构体
struct word_cnt
{
int title_cnt;
int content_cnt;
word_cnt():title_cnt(0), content_cnt(0){}
};
std::unordered_map<std::string, word_cnt> word_map; //用来暂存词频的映射表
//对标题进行分词
std::vector<std::string> title_words;
ns_util::JiebaUtil::CutString(doc.title, &title_words);
//对标题进行词频统计
for(auto s : title_words)
{
boost::to_lower(s); // 将我们的分词进行统一转化成为小写的
word_map[s].title_cnt++;//如果存在就获取,不存在就新建
}
//对文档内容进行分词
std::vector<std::string> content_words;
ns_util::JiebaUtil::CutString(doc.content, &content_words);
//对文档内容进行词频统计
for(auto s : content_words)
{
boost::to_lower(s); // 将我们的分词进行统一转化成为小写的
word_map[s].content_cnt++;
}
#define X 10
#define Y 1
//最终构建倒排
for(auto &word_pair : word_map)
{
InvertedElem item;
item.doc_id = doc.doc_id; //倒排索引的id即文档id
item.word = word_pair.first;
item.weight = X * word_pair.second.title_cnt + Y * word_pair.second.content_cnt;
InvertedList& inverted_list = inverted_index[word_pair.first];
inverted_list.push_back(std::move(item));
}
return true;
}
};
Index* Index::instance = nullptr;
std::mutex Index::mtx;
}
2. 编写InitSearcher
#include "index.hpp"
namespace ns_searcher{
class Searcher{
private:
ns_index::Index *index; //供系统进行查找的索引
public:
Searcher(){}
~Searcher(){}
public:
//这里的input就是用户传过来的关键字,首先创建单例,然后构建索引
void InitSearcher(const std::string &input)
{
//1.获取或者创建index对象
index = ns_index::Index::GetInstance();
//2.根据index对象建立索引
index->BuildIndex(input);
}
};
}
3. 提供服务(Search)
对于提供服务,我们需要从四个方面入手,达到服务效果:
- 对用户的输入的关键字,我们首先要做的就是分词,只有分成不同的词之后,才能按照不同的词去找文档;
- 分词完毕后,我们就要去触发这些分词,本质就是查找建立好的正排索引和倒排索引;
- 我们的每个文档都是设置了权重字段的,我们就应该在触发分词之后,进行权重的降序排序,达到权重高的文档靠前,权重低的文档靠后;
- 根据排序完的结果,构建json串,用于网络传输。因为结构化的数据不便于网络传输,我们就需要使用一个工具(jsoncpp),它是用来将结构化的数据转为字节序(你可以理解为很长的字符串),jsoncpp可以进行序列化(将结构化的数据转换为字节序列,发生到网络)和反序列化(将网络中的字节序列转化为结构化的数据)
jsoncpp使用的效果如下图:
具体的使用方法会在下面有介绍。
1. 对用户关键字进行分词
为什么我们要对用户输入的关键字进行分词呢?
这也不难理解,虽然我们index模块中的正排索引中已经做了分词操作,这只能说明服务器已经将数据准备好了,按照不同的词和对应的文档分好类了;但是用户输入的关键字,我们依旧是要做分词操作的。设想一下,如果没有做分词,直接按照原始的关键字进行查找,给用户反馈的文档一定没有分词来的效果好,甚至有可能匹配不到文档。影响用户的体验。代码如下:
//query--->搜索关键字
//json_string--->返回给用户浏览器的搜索结果
void Search(const std::string &query, std::string *json_string)
{
//1.分词---对query按照Searcher的要求进行分词
std::vector<std::string> words; //用一个数组存储分词的结果
ns_util::JiebaUtil::CutString(query, &words);//分词操作
}
2. 触发分词,进行索引查找
分词完成以后,我们就应该按照分好的每个词(关键字)去获取倒排拉链,我们将获取上来的倒排拉链进行保存到vector当中,这也就是我们根据用户关键字所查找的结果,但是我们还需要考虑一个问题,用户输入的关键字进行分词了以后,有没有可能多个关键字对应的是同一个文档,如下图所示:
根据上面的图,我们首先想到的就是去重。其次,每个倒排拉链的结点都包含:doc_id、关键字和权重。既然显示了重复的文档,我们应该是只显示一个,那么这个最终显示的文档其权重就是几个文档之和,关键字就是几个文档的组合,那么我们可以定义一个新的结构体来保存查找后的倒排拉链,代码如下:
//该结构体是用来对重复文档去重的结点结构
struct InvertedElemPrint
{
uint64_t doc_id; //文档ID
int weight; //重复文档的权重之和
std::vector<std::string> words;//关键字的集合,我们之前的倒排拉链节点只能保存一个关键字
InvertedElemPrint():doc_id(0), weight(0){}
};
有了上面的铺垫,我们就可以来编写触发分词的代码了:
//query--->搜索关键字
//json_string--->返回给用户浏览器的搜索结果
void Search(const std::string &query, std::string *json_string)
{
//1.分词---对query按照Searcher的要求进行分词
std::vector<std::string> words; //用一个数组存储分词的结果
ns_util::JiebaUtil::CutString(query, &words);//分词操作
//2.触发---就是根据分词的各个"词",进行index查找,建立index是忽略大小写,所以搜索关键字也需要
std::vector<InvertedElemPrint> inverted_list_all; //用vector来保存
std::unordered_map<uint64_t, InvertedElemPrint> tokens_map;//用来去重
for(std::string word : words)//遍历分词后的每个词
{
boost::to_lower(word);//忽略大小写
ns_index::InvertedList* inverted_list = index->GetInvertedList(word);//获取倒排拉链
if(nullptr == inverted_list)
{
continue;
}
//遍历获取上来的倒排拉链
for(const auto &elem : *inverted_list)
{
auto &item = tokens_map[elem.doc_id];//插入到tokens_map中,key值如果相同,这修改value中的值
item.doc_id = elem.doc_id;
item.weight += elem.weight;//如果是重复文档,key不变,value中的权重累加
item.words.push_back(elem.word);//如果树重复文档,关键字会被放到vector中保存
}
}
//遍历tokens_map,将它存放到新的倒排拉链集合中(这部分数据就不存在重复文档了)
for(const auto &item : tokens_map)
{
inverted_list_all.push_back(std::move(item.second));
}
}
3. 按文档权重进行降序排序
对于排序,应该不难,我们直接使用C++库当中的sort函数,并搭配lambda表达式使用;当然你也可以自己写一个快排或者归并排序,按权重去排;
//query--->搜索关键字
//json_string--->返回给用户浏览器的搜索结果
void Search(const std::string &query, std::string *json_string)
{
//1.分词---对query按照Searcher的要求进行分词
std::vector<std::string> words; //用一个数组存储分词的结果
ns_util::JiebaUtil::CutString(query, &words);//分词操作
//2.触发---就是根据分词的各个"词",进行index查找,建立index是忽略大小写,所以搜索关键字也需要
std::vector<InvertedElemPrint> inverted_list_all; //用vector来保存
std::unordered_map<uint64_t, InvertedElemPrint> tokens_map;//用来去重
for(std::string word : words)//遍历分词后的每个词
{
boost::to_lower(word);//忽略大小写
ns_index::InvertedList* inverted_list = index->GetInvertedList(word);//获取倒排拉链
if(nullptr == inverted_list)
{
continue;
}
//遍历获取上来的倒排拉链
for(const auto &elem : *inverted_list)
{
auto &item = tokens_map[elem.doc_id];//插入到tokens_map中,key值如果相同,这修改value中的值
item.doc_id = elem.doc_id;
item.weight += elem.weight;//如果是重复文档,key不变,value中的权重累加
item.words.push_back(elem.word);//如果树重复文档,关键字会被放到vector中保存
}
}
//遍历tokens_map,将它存放到新的倒排拉链集合中(这部分数据就不存在重复文档了)
for(const auto &item : tokens_map)
{
inverted_list_all.push_back(std::move(item.second));
}
//3. 合并排序---汇总查找结果,按照相关性(weight)降序排序
std::sort(inverted_list_all.begin(), inverted_list_all.end(),\
[](const InvertedElemPrint &e1, const InvertedElemPrint &e2)
{return e1.weight > e2.weight;});
}
4. 根据排序结果构建json串
关于json的使用,我们首先需要在Linux下安装jsoncpp:sudo yum install -y jsoncpp-devel 这里我之前下载过了,已经是最新的版本了,你们只需要输入上面的指令,有这样的提示,就表明安装成功了。
如何使用:
- root对象:你可以理解为json数组;
- item1对象:就是json中value的对象,他可以保存kv值
- item2对象:就是json中value的对象,他可以保存kv值
- 将item1和item2 ,append到root中:你可以理解为将root这个大json数组,保存了两个子json
- 序列化的方式有两种:StyledWriter和FastWriter 两者的区别:1. 呈现的格式不一样;2. 在网络传输中FastWriter更快。
序列化方式1:StyledWriter
序列化方式2:FastWriter
有了基本的了解之后,我们开始编写正式的代码:
//query--->搜索关键字
//json_string--->返回给用户浏览器的搜索结果
void Search(const std::string &query, std::string *json_string)
{
//1.分词---对query按照Searcher的要求进行分词
std::vector<std::string> words; //用一个数组存储分词的结果
ns_util::JiebaUtil::CutString(query, &words);//分词操作
//2.触发---就是根据分词的各个"词",进行index查找,建立index是忽略大小写,所以搜索关键字也需要
std::vector<InvertedElemPrint> inverted_list_all; //用vector来保存
std::unordered_map<uint64_t, InvertedElemPrint> tokens_map;//用来去重
for(std::string word : words)//遍历分词后的每个词
{
boost::to_lower(word);//忽略大小写
ns_index::InvertedList* inverted_list = index->GetInvertedList(word);//获取倒排拉链
if(nullptr == inverted_list)
{
continue;
}
//遍历获取上来的倒排拉链
for(const auto &elem : *inverted_list)
{
auto &item = tokens_map[elem.doc_id];//插入到tokens_map中,key值如果相同,这修改value中的值
item.doc_id = elem.doc_id;
item.weight += elem.weight;//如果是重复文档,key不变,value中的权重累加
item.words.push_back(elem.word);//如果树重复文档,关键字会被放到vector中保存
}
}
//遍历tokens_map,将它存放到新的倒排拉链集合中(这部分数据就不存在重复文档了)
for(const auto &item : tokens_map)
{
inverted_list_all.push_back(std::move(item.second));
}
//3. 合并排序---汇总查找结果,按照相关性(weight)降序排序
std::sort(inverted_list_all.begin(), inverted_list_all.end(),\
[](const InvertedElemPrint &e1, const InvertedElemPrint &e2)
{return e1.weight > e2.weight;});
//4.构建---根据查找出来的结果,构建json串---jsoncpp
Json::Value root;
for(auto &item : inverted_list_all)
{
ns_index::DocInfo *doc = index->GetForwardIndex(item.doc_id);
if(nullptr == doc)
{
continue;
}
Json::Value elem;
elem["title"] = doc->title;
elem["desc"] = GetDesc(doc->content, item.words[0]); //content是文档去标签后的结果,但不是我们想要的,我们要的是一部分
elem["url"] = doc->url;
//调式
//elem["id"] = (int)item.doc_id;
//elem["weight"] = item.weight;
root.append(elem);
}
//Json::StyledWriter writer; //方便调试
Json::FastWriter writer;//调式没问题后使用这个
*json_string = writer.write(root);
}
在上述的代码中,我们构建出来的json串最后是要返回给用户的,对于内容,我们只需要一部分,而不是全部,所以我们还要实现一个 GetDesc 的函数:
std::string GetDesc(const std::string &html_content, const std::string &word)
{
//找到word(关键字)在html_content中首次出现的位置
//然后往前找50个字节(如果往前不足50字节,就从begin开始)
//往后找100个字节(如果往后不足100字节,就找到end即可)
//截取出这部分内容
const int prev_step = 50;
const int next_step = 100;
//1.找到首次出现
auto iter = std::search(html_content.begin(), html_content.end(), word.begin(), word.end(), [](int x, int y){
return (std::tolower(x) == std::tolower(y));
});
if(iter == html_content.end())
{
return "None1";
}
int pos = std::distance(html_content.begin(), iter);
//2.获取start和end位置
int start = 0;
int end = html_content.size() - 1;
//如果之前有50个字符,就更新开始位置
if(pos > start + prev_step) start = pos - prev_step;
if(pos < end - next_step) end = pos + next_step;
//3.截取子串,然后返回
if(start >= end) return "None2";
std::string desc = html_content.substr(start,end - start);
desc += "...";
return desc;
}
最后,我们来测试一下效果,编写debug.cc,这个文件和我们项目文件关联性不大,主要是用来调式(需要将上文代码中备注调式的代码放开):
#include "searcher.hpp"
#include <cstdio>
#include <iostream>
#include <string>
const std::string input = "data/raw_html/raw.txt";
int main()
{
ns_searcher::Searcher *search = new ns_searcher::Searcher();
search->InitSearcher(input); //初始化search,创建单例,并构建索引
std::string query; //自定义一个搜索关键字
std::string json_string; //用json串返回给我们
char buffer[1024];
while(true)
{
std::cout << "Please Enter You Search Query:"; //提示输入
fgets(buffer, sizeof(buffer) - 1, stdin); //读取
buffer[strlen(buffer)-1] = 0;
query = buffer;
search->Search(query, &json_string); //执行服务,对关键字分词->查找索引->按权重排序->构建json串->保存到json_string->返回给我们
std::cout << json_string << std::endl;//输出打印
}
return 0;
}
对应的Makefile:
运行结果如下:
我们输入搜索关键字:split
我们可以看到,效果很明显。我们复制第三个网址,查看一下权重是否一样:
当你再去查看其他网址,然后自己进行权重计算的时候,有时候会多一个或者少一个,我分析的原因就是,在对标题和内容进行分词的时候,产生的一些影响,但是大体上没有太大的问题。
测试完毕之后,那些测试可以删除或屏蔽
八、编写http_server模块
1. 引入cpp-httplib到项目中
下载zip,上传到服务器即可,这些操作在上面都演示过了。
cpp-httplib在使用的时候需要使用较新版本的gcc,centos 7下默认gcc 4.8.5,我们是需要升级的,这里我是已经升级过的,接下来我介绍一下升级gcc的方法。
升级gcc
- scl gcc devsettool 升级gcc(使用scl工具集来升级)
- 安装scl:sudo yum install centos-release-scl scl-utils-build
- 安装新版本gcc:sudo yum install -y devtoolset-7-gcc devtoolset-7-gcc-c++
启动新版的gcc:scl enable devtoolset-7 bash(注意这只是本次会话有效) 长期使用新版的gcc:修改 ~/. bash_profile 文件只要在这个文件下加上这个命令,启动服务器就会执行,gcc就是新版的了
我们将cpp-httplib放到项目中的test目录下,并解压好;
提示:这里你可以将test目录修改为thirdparty,这样从命名上更加直观,将test目录下的一些软连接删除(使用unlink命令),但是在Boost_Searcher目录下的软连接还要重新修改一下。
cpp-httplib有了之后,我们只需要使用这个目录下的httplib.h文件即可:
建立软连接到我们的项目路径下:
至此,我们就可以在我们的项目中使用了。
2. cpp-httplib的使用介绍
创建一个http_server.cc的文件,编写测试代码:
#include "cpp-httplib/httplib.h"
int main()
{
//创建一个Server对象,本质就是搭建服务端
httplib::Server svr;
// 这里注册用于处理 get 请求的函数,当收到对应的get请求时(请求hi时),程序会执行对应的函数(也就是lambda表达式)
svr.Get("/hi", [](const httplib::Request& req, httplib::Response& rsp){
//设置 get "hi" 请求返回的内容
rsp.set_content("hello world!", "text/plain; charset=utf-8");
});
// 绑定端口(8080),启动监听(0.0.0.0表示监听任意端口)
svr.listen("0.0.0.0", 8080);
return 0;
}
对应的Makefile:
我们直接编译运行http_server
打开浏览器,访问我们这个端口(如43.138.201:8081/hi),结果如下:
但是当我们访问43.138.201:8081时,却找不到对应的网页,
像我们访问百度时,www.baidu.com,百度会给一个首页,所有在我们的项目目录下呢,也需要一个首页。 (在项目路径下创建一个wwwroot目录,目录中包含一个index.html文件)
编写我们的首页,并修改我们的http_server.cc:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>boost搜索引擎</title>
</head>
<body>
<h1>欢迎来到我的世界</h1>
</body>
</html>
#include "cpp-httplib/httplib.h"
const std::string root_path = "./wwwroot";
int main()
{
//创建一个Server对象,本质就是搭建服务端
httplib::Server svr;
//访问首页
svr.set_base_dir(root_path.c_str());
// 这里注册用于处理 get 请求的函数,当收到对应的get请求时(请求hi时),程序会执行对应的函数(也就是lambda表达式)
svr.Get("/hi", [](const httplib::Request& req, httplib::Response& rsp){
//设置 get "hi" 请求返回的内容
rsp.set_content("hello world!", "text/plain; charset=utf-8");
});
// 绑定端口(8080),启动监听(0.0.0.0表示监听任意端口)
svr.listen("0.0.0.0", 8080);
return 0;
}
再次通过浏览器进行访问:
3. 正式编写http_server
#include "cpp-httplib/httplib.h"
#include "searcher.hpp"
const std::string input = "data/raw_html/raw.txt";
const std::string root_path = "./wwwroot";
int main()
{
ns_searcher::Searcher search;
search.InitSearcher(input);
//创建一个Server对象,本质就是搭建服务端
httplib::Server svr;
//访问首页
svr.set_base_dir(root_path.c_str());
// 这里注册用于处理 get 请求的函数,当收到对应的get请求时(请求s时),程序会执行对应的函数(也就是lambda表达式)
svr.Get("/s", [&search](const httplib::Request &req, httplib::Response &rsp){
//has_param:这个函数用来检测用户的请求中是否有搜索关键字,参数中的word就是给用户关键字取的名字(类似word=split)
if(!req.has_param("word")){
rsp.set_content("必须要有搜索关键字!", "text/plain; charset=utf-8");
return;
}
//获取用户输入的关键字
std::string word = req.get_param_value("word");
std::cout << "用户在搜索:" << word << std::endl;
//根据关键字,构建json串
std::string json_string;
search.Search(word, &json_string);
//设置 get "s" 请求返回的内容,返回的是根据关键字,构建json串内容
rsp.set_content(json_string, "application/json");
});
std::cout << "服务器启动成功......" << std::endl;
// 绑定端口(8080),启动监听(0.0.0.0表示监听任意端口)
svr.listen("0.0.0.0", 8080);
return 0;
}
此时我们编译运行我们的代码,先执行parser进行数据清洗,然后执行http_server,搭建服务,创建单例,构建索引,发生请求(根据用户输入的关键字,进行查找索引,构建json串),最后响应给用户
此时服务器启动成功,索引也建立完毕
此时,我们在浏览器进行访问(43.138.71.201:8080/s)
此时,我们在浏览器进行访问(43.138.71.201:8080/s?word=split)
最终,在浏览器上就显示出来了,到这里我们的后端内容大致上算是完成了,最后添加一个日志就可以了,如果你对前端不感兴趣,到这里就可以了。可以把日志功能的添加看一看
九、添加日志到项目中
我们创建一个log.hpp的头文件,需要添加日志的地方:index模块,searcher模块、http_server模块。代码如下:
#pragma once
#include <iostream>
#include <string>
#include <ctime>
#define NORMAL 1 //正常的
#define WARNING 2 //错误的
#define DEBUG 3 //bug
#define FATAL 4 //致命的
#define LOG(LEVEL, MESSAGE) log(#LEVEL, MESSAGE, __FILE__, __LINE__)
void log(std::string level, std::string message, std::string file, int line)
{
std::cout << "[" << level << "]" << "[" << time(nullptr) << "]" << "[" << message << "]" << "[" << file << " : " << line << "]" << std::endl;
}
/*
简单说明:
我们用宏来实现日志功能,其中LEVEL表明的是等级(有四种),
这里的#LEVEL的作用是:把一个宏参数变成对应的字符串(直接替换)
C语言中的预定义符号:
__FILE__:进行编译的源文件
__LINE__:文件的当前行号
补充几个:
__DATE__:文件被编译的日期
__TIME__:文件被编译的时间
__FUNCTION__:进行编译的函数
*/
你可以在你想要的地方进行添加
十、编写前端模块
前端模块,我做详细的解释,代码中都有注释,直接上代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="http://code.jquery.com/jquery-2.1.1.min.js"></script>
<title>boost 搜索引擎</title>
<style>
/*去掉网页中的所有内外边距,可以了解html的盒子模型*/
* {
margin: 0;
/* 设置外边距 */
padding: 0;
/* 设置内边距 */
}
/* 将我们的body内的内容100%和html的呈现吻合 */
html,
body {
height: 100%;
}
/* 以点开头的称为类选择器.container */
.container {
/* 设置div的宽度 */
width: 800px;
/* 通过设置外边距达到居中对其的目的 */
margin: 0px auto;
/* 设置外边距的上边距,保持元素和网页的上部距离 */
margin-top: 15px;
}
/* 复合选择器,选中container下的search */
.container .search {
/* 宽度与父标签保持一致 */
width: 100%;
/* 高度设置52px */
height: 50px;
}
/* 选中input标签,直接设置标签的属性,先要选中,标签选择器 */
/* input在进行高度设置的时候,没有考虑边框的问题 */
.container .search input {
/* 设置left浮动 */
float: left;
width: 600px;
height: 50px;
/* 设置边框属性,依次是边框的宽度、样式、颜色 */
border: 2px solid #CCC;
/* 去掉input输入框的右边框 */
border-right: none;
/* 设置内内边距,默认文字不要和左侧边框紧挨着 */
padding-left: 10px;
/* 设置input内部的字体的颜色和样式 */
color: #CCC;
color: #CCC;
font-size: 17px;
}
.container .search button {
/* 设置left浮动 */
float: left;
width: 150px;
height: 54px;
/* 设置button的背景颜色 #4e6ef2*/
background-color: #4e6ef2;
color: #FFF;
/* 设置字体的大小 */
font-size: 19px;
font-family: Georgia, 'Times New Roman', Times, serif 'Times New Roman', Times, serif;
}
.container .result {
width: 100%;
}
.container .result .item {
margin-top: 15px;
}
.container .result .item a {
/* 设置为块级元素,单独占一行 */
display: block;
text-decoration: none;
/* 设置a标签中的文字字体大小 */
font-size: 22px;
/* 设置字体的颜色 */
color: #4e6ef2;
}
.container .result .item a:hover {
/* 设置鼠标放在a之上的动态效果 */
text-decoration: underline;
}
.container .result .item p {
margin-top: 5px;
font-size: 16px;
font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif;
}
.container .result .item i {
/* 设置为块级元素,单独占一行 */
display: block;
/* 取消斜体风格 */
font-style: normal;
color: green;
}
</style>
</head>
<body>
<div class="container">
<div class="search">
<input type="text" value="输入搜索关键字...">
<button onclick="Search()">搜索一下</button>
</div>
<div class="result">
<!-- <div class="item">
<a href="#">这是标题</a>
<p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要</p>
<i>https://hao.360.com/?hj=llq7a</i>
</div>
<div class="item">
<a href="#">这是标题</a>
<p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要</p>
<i>https://hao.360.com/?hj=llq7a</i>
</div>
<div class="item">
<a href="#">这是标题</a>
<p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要</p>
<i>https://hao.360.com/?hj=llq7a</i>
</div>
<div class="item">
<a href="#">这是标题</a>
<p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要</p>
<i>https://hao.360.com/?hj=llq7a</i>
</div>
<div class="item">
<a href="#">这是标题</a>
<p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要</p>
<i>https://hao.360.com/?hj=llq7a</i>
</div>
<div class="item">
<a href="#">这是标题</a>
<p>这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要这是摘要</p>
<i>https://hao.360.com/?hj=llq7a</i>
</div> -->
</div>
</div>
<script>
function Search() {
// 是浏览器的一个弹出窗
// 1.提取数据,$可以理解为就是JQuery的别称
let query = $(".container .search input").val();
console.log("query = " + query); //console是浏览器对话框,可以用来进行查看js数据
// 2.发起http请求,ajax属于一个和后端进行数据交互的函数
$.ajax({
type: "GET",
url: "/s?word=" + query,
success: function (data) {
console.log(data);
BuildHtml(data);
}
});
}
function BuildHtml(data) {
// 获取html中的result标签
let result_lable = $(".container .result");
// 清空历史搜索结果
result_lable.empty();
for (let elem of data) {
console.log(elem.title);
console.log(elem.url);
let a_lable = $("<a>", {
text: elem.title,
href: elem.url,
// 跳转到新的页面
target: "_blank"
});
let p_lable = $("<p>", {
text: elem.desc
});
let i_lable = $("<p>", {
text: elem.url
});
let div_lable = $("<div>", {
class: "item"
});
a_lable.appendTo(div_lable);
p_lable.appendTo(div_lable);
i_lable.appendTo(div_lable);
div_lable.appendTo(result_lable);
}
}
</script>
</body>
</html>
最终演示:
将我们的项目部署到Linux上: nohup ./http_server > log/log.txt 2>&1 &
一些日志信息就会保存到log/log.txt中
十一、项目总结
关于项目总结,主要是针对项目的扩展
1. 建立整站搜索
- 我们搜索的内容是在boost库下的doc目录下的html文档,你可以将这个库建立搜索,也可以将所有的版本,但是成本是很高的,对单个版本的整站搜索还是可以完成的,取决于你服务器的配置。
2. 设计一个在线更新的方案,信号,爬虫,完成整个服务器的设计
- 我们在获取数据源的时候,是我们手动下载的,你可以学习一下爬虫,写个简单的爬虫程序。采用信号的方式去定期的爬取。
3. 不使用组件,而是自己设计一下对应的各种方案
- 我们在编写http_server的时候,是使用的组件,你可以自己设计一个简单的;
4. 在我们的搜索引擎中,添加竞价排名
- 我们在给用户反馈是,提供的是json串,显示到网页上,有title、content和url;就可以在构建json串时,你加上你的博客链接(将博客权重变高了,就能够显示在第一个)
5. 热次统计,智能显示搜索关键词(字典树,优先级队列)
6. 设置登陆注册,引入对mysql的使用