boost官网虽然提供了在线文档,但是没有一个方便的搜索入口,因此我基于这样的一个问题上,自主设计开发了一款基于boost文档的站内搜索引擎,可以让我们通过浏览器精准定位获取到我们所需要查找的信息内容,实现小型搜索引擎的功能。
介绍主要项目,更有利于在学习这个项目的时候更好的进行下去:
基于 boost 中的字符串切分, 封装一下
delimiter 表示分割符, 按照啥字符来切分.
理解 token_compress_off:
例如有个字符串: aaa\3bbb\3\3ccc
此时按照 \3 进行切分,
切分结果可能有两种风格:
1. 结果有三个部分, aaa bbb ccc token_compress_on 有分割符相邻时,
会压缩切分结果
2. 结果有四个部分, aaa bbb "" ccc token_compress_off
不会压缩切分结果的.
static void Split(const string &input, const string &delimiter,
vector<string> *output) {
boost::split(*output, input, boost::is_any_of(delimiter),
boost::token_compress_off);
}
searcher.h
之中,并且将其两个模块的功能放在同一个具体函数之中进行实现。namespace searcher
:searcher.h中索引模块使用到的函数API接口
struct DocInfo {
int64_t doc_id;//id在那个文档中出现
string title;//标题
string url;//url
string content;//正文
};
struct Weight {
// 该词在哪个文档中出现
int64_t doc_id;
// 对应的权重是多少
int weight;
// 词是啥
string word;
};
其中要定义一个叫做“倒排拉链”的东西typedef vector
,倒排拉链中是很多权重,用此来实现对倒排索引的哈希存储中kv键值对的v值类型
class Index {
private:
// 索引结构
正排索引, 数组下标就对应到 doc_id
vector<DocInfo> forward_index;
倒排索引, 使用一个 hash 表来表示这个映射关系
unordered_map<string, InvertedList> inverted_index;
public:
Index();
// 提供一些对外调用的函数
1. 查正排, 返回指针就可以使用 而NULL 表示无效结果的情况
const DocInfo* GetDocInfo(int64_t doc_id);
2. 查倒排
const InvertedList* GetInvertedList(const string& key);
3. 构建索引
bool Build(const string& input_path);
4. 分词函数
void CutWord(const string& input, vector<string>* output);
private:
DocInfo* BuildForward(const string& line);//字符串切分,使用\3将其行文本切分为3部分
void BuildInverted(const DocInfo& doc_info);//
cppjieba::Jieba jieba;
};
searcher.h中搜索模块所用到的函数API接口
class Searcher {
private:
Index* index;// 搜索过程依赖索引的. 就需要持有一个 Index 的指针.
public:
Searcher() : index(new Index()) {//构造函数
}
bool Init(const string& input_path);//初始化函数
bool Search(const string& query, string* results);//搜索函数
private:
string GenerateDesc(const string& content, const string& word);//在正文之后显示160字的详细描述
};
const char* const DICT_PATH = "../jieba_dict/jieba.dict.utf8";
const char* const HMM_PATH = "../jieba_dict/hmm_model.utf8";
const char* const USER_DICT_PATH = "../jieba_dict/user.dict.utf8";
const char* const IDF_PATH = "../jieba_dict/idf.utf8";
const char* const STOP_WORD_PATH = "../jieba_dict/stop_words.utf8";
Index::Index() : jieba(DICT_PATH, HMM_PATH, USER_DICT_PATH, IDF_PATH, STOP_WORD_P ATH) {
26 }
读取原始的html文档的内容,进行预处理操作:解析出一些重要的信息,文档标题,文档的URL,文档的正文(是去除原来的html标签,只保留正文)
因boost文档提供了两个版本,离线版本(下载)和在线版本(浏览器访问),所以为了能够在浏览器进行搜索的时候,直接展示出我们想要的内容,我通过基于离线版本分析文档页面的内容,为搜索功能提供支持,之后在浏览器点击进行搜索的时候,通过离线版本的倒排索引找到在线版的相关url信息,将其直接展示在浏览器上。
读取文档之前首先要定义好相关的变量结构体用来存放具体的数据,文档的标题,文档的正文,文档的url
创建一个重要的结构体, 表示一个文档(一个 HTML)
struct DocInfo {
文档的标题
string title;
文档的 url
string url;
文档的正文
string content;
};
我将预处理模块分为三个部分
1. 把input目录中所有的html路径都枚举出来:将文件传入枚举函数
fs::recursive_directory_iterator end_iter;
for (fs::recursive_directory_iterator iter(root_path); iter != end_iter;
++iter) {
当前的路径对应的是不是一个普通文件. 如果是目录, 直接跳过.
if (!fs::is_regular_file(*iter)) {
continue;
}
当前路径对应的文件是不是一个 html 文件. 如果是其他文件也跳过.
if (iter->path().extension() != ".html") {
continue;
}
把得到的路径加入到最终结果的 vector 中
file_list->push_back(iter->path().string());
}
2. 根据枚举出来的路径依次遍历读取每个html中内容,进行解析:打开枚举出路径的文件,先将内容一股脑地全部读出来之后使用for循环将每一个html取出来将其内容拿出来解析
负责从指定的路径中, 读取出文件的整体内容, 读到 output 这个 string 里
static bool Read(const string &input_path, string *output) {
std::ifstream file(input_path.c_str());
if (!file.is_open()) {
return false;
}
读取整个文件内容, 思路很简单, 只要按行读取就行了, 把读到的每行结果,
追加到 output 中即可
getline 功能就是读取文件中的一行.
如果读取成功, 就把内容放到了 line 中. 并返回 true
如果读取失败(读到文件末尾), 返回 false
string line;
while (std::getline(file, line)) {
*output += (line + "\n");
}
file.close();
return true;
}
读取完成之后就是对其内容进行解析,解析的话因为需要能够得到需要的正文,url,title标题这三部分,我将其解析过程分为四块来进行解析:
读取文件内容,一股脑的把文件内容都读取出来
直接调用common公共文件之中的Util.hpp文件之中的Read函数,来获取所有的文件内容;
根据文件内容解析出标题(html之中有着一个title标签)
将每一个读取出的html传入标题解析函数,对其进行解析,解析出来title后放入到doc_info->title之中/
查找标题的具体实现:要大概了解html的格式,会发现
标签引导开始的就是title内容,所以我们只要找到这个标签后,就能够确定后面的内容。
根据文件路径构造出对应在线文档URL
获取url的话我是根据本地路径获取到在线文档的路径
本地路径:…/data/input/html/thread.html
在线路径形如:https://www.boost.org/doc/libs/1_53_0/doc/html/thread.html
通过两个的对比,可以轻松的发现两者的差别指出,因此我把本地路径的后半部分截取出来, 拼装上在线路径的前缀就可以了
对文件中的内容进行去标签后所获取到的信息,作为doc_info中的content正文的内容
这也是这个第2个局部模块之中较为难的一个地方,首先是因为我们对于一些相关的标签不是特别了解,所以如果很多人不知道这个是什么的话,也可以先看一些html中的内容,就会清晰了(其实就是那些无关紧要的一些字符和标志)
最后将其去除掉标签的内容拿出来存放到我们实现创建好的目录文档之中,并且保证每一行对应一个原始的html
3. 把解析出的整体结果写入到输出文件中:直接使用ofstream类将解析出的最终数据结果放入输出文件之中,这一操作的话可以和第二步放在同一个循环之中进行实现
输入内容是解析后得到的行文本文件,通过读取这些内容,在内存中构造出一个正排索引和倒排索引,提供一些API供其他模块可以对其进行直接的调用
切分函数并将其存储进DocInfo之中构成正排索引
核心操作: 按照 \3 对 line 进行切分, 第一个部分就是标题, 第二个部分就是 url,第三个部分就是正文
DocInfo* Index::BuildForward(const string& line) {
1. 先把 line 按照 \3 切分成 3 个部分
vector<string> tokens;
common::Util::Split(line, "\3", &tokens);
if (tokens.size() != 3) {
如果切分结果不是 3 份, 就认为当前这一行是存在问题的,认为该文档构造失败.
return nullptr;
}
2. 把切分结果填充到 DocInfo 对象中
DocInfo doc_info;
doc_info.doc_id = forward_index.size();//当前读取文档的大小则就是其对应的id号
doc_info.title = tokens[0];//第一部分
doc_info.url = tokens[1];//第二部分
doc_info.content = tokens[2];//第三部分
forward_index.push_back(std::move(doc_info));//移动构造
3. 返回结果注意这里可能存在的野指针问题. C++ 中的经典错误,也是面试中的重要考点!!! //return &doc_info;
return &forward_index.back();
}
struct WordCnt {
int title_cnt;//标题出现的次数
int content_cnt;//正文出现的次数
WordCnt() : title_cnt(0), content_cnt(0) {//标题和正文初始都为0
}
};
将其和所需要找的词存放在哈希表中
01 针对标题进行分词
将标题传入分词函数,将其分词结果装入vector之中。
02 遍历分词结果统计每个次出现的次数
unordered_map[]有两个功能,key不存在就添加存在就修改,此处我们不考虑大小写的问题,我将其全部转换为小写来进行,使用boost标准库to_lower函数来完成。
for循环遍历整个vector容器,将其中每个日使用to_lower函数进行转换为小写,并把这个词出现的次数存放到标题词出现的次数变量中。
03 针对正文进行分词
将正文传入分词函数,将其分词结果装入vector之中
04 遍历分词结果统计每个词出现的次数
遍历分词的结果,将其转换为小写之后,统计其出现的次数,也就是使用map来进行查找,查找到了value++;
05根据统计结果,整合出Weight对象,并把结果更新到倒排索引中即可
构造Weight对象,将id序号赋给其权重结构体的id,计算权重,这里的话我使用的是标题出现的次数,出现一次我计为10,而在正文中出现一次,则计为1,之后将其出现的次数累加。
weight.weight = 10 * word_pair.second.title_cnt + word_pair.second.content_cnt;
weight.word = word_pair.first;
之后将其weight对象插入到倒排索引中去,因此需要先找到所对应的倒排拉链,然后将其追加到拉链末尾即可。
4. 分词
直接引用jieba函数 jieba.CutForSearch(input,*output);
函数来进行分词
1. 分词:先针对查询词进行分词(查询词可能比较长)
2. 触发:根据刚才的分词结果,查找倒排索引,找到那些文档是和当前的查询词相关的
3. 排序:把刚才搜索到的文档按照一定的规则进行排序,相关性越高的文档排的会越靠前
4. 构造结果:刚才触发出来的结果和排序的结果都是包含了一些文档id,而我们希望网页上显示的是标题,url,描述,因此就需要拿着文档id去查正排索引,把结果包装起来返回给发起请求的客户端
all_token_result.insert(all_token_result.end(), inverted_list->begin(), i nverted_list->end());
03 排序
把上面查找到的这些文档的倒排拉链合并在一起后就可以按照之前计算的每一个权重来进行降序排序(因为一般搜索引擎都是按照降序进行,如果我们需要使用升序,也可以使用仿函数来自己实现)
在这里我使用lambda来实现的
std::sort(all_token_result.begin(), all_token_result.end(), [](const Weight& w1, const Weight& w2) {
// 如果要实现升序排序, 就写成 w1.weight < w2.weight
// 如果要实现降序排序, 就写成 w1.weight > w2.weight
return w1.weight > w2.weight;
});
04 包装结果:把得到的这些倒排拉链中的文档id获取到后,去查正排
根据weight中的doc_id查正排,查找到之后把doc_info中的内容够造成最终预期的格式,JSON格式,使用jsoncpp这个库来实现json的操作
Json::Value results; // 这个 results 中包含了若干个搜索结果. 每个搜索结果就是一个 JSON 对象
循环遍历查正排,将查到的每一个结果包装成JSON对象
Json::Value result;
result["title"] = doc_info->title;
result["url"] = doc_info->url;
result["desc"] = GenerateDesc(doc_info->content, weight.word);
results.append(result);
最后一步,把得到的results这个JSON对象序列化成字符串,写入到output文件之中
05 160字的详细描述
根据正文找到word出现的位置,以该位置为中心,往前找60个字节,作为描述的起始位置,再从起始位置开始找160个字节,作为整个描述内容(需要注意的边界条件就是,前面不够60个或没在正文中出现只在标题中出现过,那就从0开始;如果后面内容不够了那就到末尾结束,如果后面内容显示不下,可以使用…省略号来表示[160这个数字大家可以随意改动])
http服务器,给外部提供服务
因为我更多的是学习的一些关于后端开发的知识和内容,所以对于http的一些内容我可能不是特别的熟悉,在了解http协议的基础上其大概的编程内容还都是从学校的选秀专业课计算机网络中学习到的,而我在整个模块中使用的cpphttplib,则不需要过多的关注HTTP服务器的实现细节,直接调用一些现成的函数即可,这对于大多数朋友来说应该都算是一个比较简捷的途径。
searcher::Searcher searcher;
bool ret = searcher.Init("../data/tmp/raw_input");//查看索引的路径是否存在
if (!ret) {
std::cout << "Searcher 初始化失败" << std::endl;
return 1;
}
Server server;
),这是httplib头文件中所包含的类,我们直接包含头文件按即可使用。server.Get("/searcher",[&searcher](const Request& req,Response& resp){
if (!req.has_param("query")) {//如果请求参数不存在的话返回空
resp.set_content("您发的请求参数错误", "text/plain; charset=utf-8");//设置响应数据
return;
}
string query = req.get_param_value("query");//请求参数
string results;
searcher.Search(query, &results);//将请求参数传入进行搜索
resp.set_content(results, "application/json; charset=utf-8");//将result搜索结果进行展示
});
以函数来进行响应数据的展示,在我们浏览器上访问某页面后加上一个/searcher的url,如果检查url中的路径,发现正确即会返回相应的响应数据(即我们访问后,浏览器会发送一个HTTP GET请求,将我们设置好的响应数据展示上去);
告诉服务器静态资源存放在wwwroot目录之中,之后通过服务器启动之后就可以通过http/127.0.0.1.10001/index.html来进行此页面的访问,当然了也不能够忘记了启动我们服务端的服务器,和我们socket编程中的listen设置被动套接字是一样的,直接使用server.listen("0.0.0.0",10001);
我也是设置了相关的query查询词,来表示我们所需要查询的内容,也就是说如果我们访问向filesystem的话,只需要在网页上输入127.0.0.1.10001/searcher?query=filesystem
就表示我们所需要查找的关键词是filesystem!
关于对html的实现,因为我自身没有学习过更多的关于html开发设计的内容,只有着一些在学校专业课中简单的设置基础,所以一些大的模块我也是模仿和百度别人的模块,之后将其改过来,做了一些相关的改正和调整,因此对于这一方面我没有办法给大家做过多的解析,因为好多知识我自己也不懂,所以我直接将其放在了GitHub上,里面的每一个注释我也是详细的写上去了。
对于搜索引擎项目来说,因基于boost文档的所以它的规模是比较小的,我们开发起来也是比较方便的,但是若是对于搜索引擎有着深度的兴趣的话,我们可以尝试更大一点的搜索引擎,那么我们就需要考虑到:
在整个项目之中对于我来说最难的部分应该就属于索引模块里的分词,构建正排索引和倒排索引这三个部分了,在一开始时候我不太清楚分词可以直接使用cppjieba其自带的分词来实现的,当时也是自己进行一些想法,最后看看发现自己也是傻的可以;
而重点的正排索引和倒排索引,在这一块的时候我也是真正的发挥了搜索一七年的作用,我直接搜索了一些关于搜索引擎的内容,设计开发思路从其中得到了关于正排索引和倒排索引的一些知识,才弥补了索引模块所欠缺的那些知识,到现在也是对其有着一个充分的认识。
正排索引:根据文档id查找相关的文档内容
倒排索引:根据文档内容查找相关的文档id
还有一个可能会出错的点,那就是对于在搜索模块和索引模块中多次出现的一些unordered_map结构,倒排拉链和vector等结构的来回使用,可能会让大家有着一些混淆,如果大家可以将其稍微的写一些或者画一下,那么就会大致明白他们之间的一个关系,并且有着深刻的记忆了,知道他们每一部分都是做什么,且是为何这么做,是为了存储什么的。
和我前面那个网络工具项目相比,这个的难度肯定是更大的,字数也是破万了,熬过了多少个夜晚才能够有这样的成绩,写到这里也算是我对整个项目的彻底完成了,真的可以说是非常的激动和欣慰了。
虽说网络工具相对简单,但那也是相抵搜索引擎来说简单,可作为底层实现的网络工具,它的每一个步骤都需要我们自己手动进行却显得会更加的繁杂,而在搜索引擎这个大的项目上面,它更多的接口和处理函数我们可以发现是现成的,我们可以借用他们的一些实例来了解,并且之后将其运用到自己的一些项目之中。
通过这一次的项目也是给了我一个全新的经验,让我明白一些项目其实没有我们所想的特别难,所有的成功都是一步步的走过来的,对于一些功能的实现我们可以更好的借用当前所拥有的基础和一些相关的模块来实现,并且一个完整的项目也是需要耗费更多的心血,在自己所不擅长的部分模块里,也应该多问几个为什么或者是多请教别人帮助,对于它才能够有更号的进步,当然也是希望这个项目可以帮助到和我一样在找工作的朋友,让你的秋招之路更加顺畅!(可以收藏起来慢慢研究哦)
项目的GitHub:SoEasy搜索引擎